Skip to content

Instantly share code, notes, and snippets.

@andycmaj
Last active March 27, 2021 03:39
Show Gist options
  • Save andycmaj/2cd889fbe303a37991d8b1fb59277661 to your computer and use it in GitHub Desktop.
Save andycmaj/2cd889fbe303a37991d8b1fb59277661 to your computer and use it in GitHub Desktop.
Merge/Pull Request State Machine for XState
import { DateTime } from 'luxon';
import { Machine, assign } from 'xstate';
type Maybe<T> = T | null | undefined;
export type AnyCodeReviewActivity =
| CodeReviewCreatedActivity
| CodeReviewRequestedActivity
| CodeReviewMergedActivity
| CodeReviewDiscussedActivity
| CodeReviewViewedActivity
| CodeReviewSubmittedActivity
| CodeReviewClosedActivity
| UserMentionedActivity;
export type MergeRequestResolution = 'Merged' | 'Closed';
export type ParallelStates = 'Reviews' | 'Discussions';
export type MergeRequestState =
| 'Open'
// just opened, and reviewers requested
| 'OpenPendingReview'
// discussions exist, but no reviews. author needs to respond
| 'DiscussedPendingAuthorComment'
// discussions exist, but no reviews. reviewer needs to respond
| 'DiscussedPendingReviewerComment'
// reviewed with Changes Requested outcome. author needs to make changes
| 'ReviewedPendingAuthorChanges'
// reviewed with Changes Requested outcome. author needs to respond to reviewer comment
| 'ReviewedPendingAuthorComment'
// reviewed with Changes Requested outcome. reviewer needs to respond to author's comments
| 'ReviewedPendingReviewerComment'
// reviewed with Approved outcome. author or someone else needs to merge
| 'ApprovedPendingMerge'
| 'Closed';
export type DiscussionState = 'Discussed';
export type CodeReviewState = 'Requested' | 'Rejected' | 'Approved';
interface StateSchema {
states: {
// eslint-disable-next-line @typescript-eslint/ban-types
['Reviews']: {
states: {
[key in MergeRequestState];
};
};
['Discussions']: {
states: {
[key in DiscussionState];
};
};
};
}
// The events that the machine handles
type StateEvent = AnyCodeReviewActivity;
// debugging xstate types
// type TCM<TContext, TEvent extends EventObject> = {
// [K in TEvent['type']]?: TransitionConfigOrTarget<
// TContext,
// TEvent extends {
// type: K;
// }
// ? TEvent
// : never
// >;
// } & {
// ''?: TransitionConfigOrTarget<TContext, TEvent>;
// } & {
// '*'?: TransitionConfigOrTarget<TContext, TEvent>;
// };
// type TEST = TCM<StateMachineContext, StateEvent>;
// type T2 = TEST['code_review_submitted'];
export interface StateMachineContext {
finalApprovalActivity: Maybe<CodeReviewSubmittedActivity>;
resolution?: MergeRequestResolution;
codeReviewStates: Record<
number,
{
state: CodeReviewState;
lastUpdated: DateTime;
requestCount: number;
}
>;
comments: Array<CodeReviewDiscussedActivity>;
mentionStates: Array<{
mentionActivityId: number;
threadId: number;
mentionedUserId: number;
hasReplyByMentionedUser: boolean;
}>;
}
const updateCodeReviewStatesOnSubmitted: (
context: StateMachineContext,
event: CodeReviewSubmittedActivity
) => StateMachineContext['codeReviewStates'] = (context, event) => ({
...context.codeReviewStates,
[event.actorId]: {
...context.codeReviewStates[event.actorId],
state: event.data.outcome === 'approved' ? 'Approved' : 'Rejected',
lastUpdated: normalizeTimestamp(event.timestamp),
},
});
const updateCodeReviewStatesOnRequested: (
context: StateMachineContext,
event: CodeReviewRequestedActivity
) => StateMachineContext['codeReviewStates'] = (context, event) => ({
...context.codeReviewStates,
[event.aboutUserId]: {
requestCount:
event.aboutUserId in context.codeReviewStates
? context.codeReviewStates[event.aboutUserId].requestCount + 1
: 1,
state: 'Requested',
lastUpdated: normalizeTimestamp(event.timestamp),
},
});
const updateFinalApprovalActivity: (
_,
event: CodeReviewSubmittedActivity
) => CodeReviewSubmittedActivity = (_, event) => event;
const findMentionBeingRepliedTo = (
mentionStates: StateMachineContext['mentionStates'],
event: CodeReviewDiscussedActivity
): Maybe<ElementType<StateMachineContext['mentionStates']>> => {
// this comment is a reply to a mention if there is an earlier mention for
// the commenting user in the current thread which has not yet been replied
// to.
return (
mentionStates.find(
state =>
!state.hasReplyByMentionedUser &&
state.mentionedUserId === event.actorId &&
state.threadId === event.data.threadStartActivityId
) ?? null
);
};
// https://xstate.js.org/viz/?gist=2347aa48160b6486cd6e52b651dc956d
export const mergeRequestStateMachine = Machine<
StateMachineContext,
StateSchema,
StateEvent
>(
{
id: 'MergeRequestState',
context: {
finalApprovalActivity: null,
codeReviewStates: {},
comments: [],
mentionStates: [],
},
type: 'parallel',
states: {
Discussions: {
initial: 'Discussed',
states: {
Discussed: {
on: {
code_review_discussed: {
type: 'history',
actions: assign(
// Add this latest comment and update any un-replied-to mentions
// that this comment was a reply to
(context, event: CodeReviewDiscussedActivity) => {
const mentionBeingRepliedTo = findMentionBeingRepliedTo(
context.mentionStates,
event
);
return {
comments: [...context.comments, event],
mentionStates: context.mentionStates.map(state =>
state === mentionBeingRepliedTo
? {
...state,
hasReplyByMentionedUser: true,
}
: state
),
};
}
),
},
user_mentioned: {
type: 'history',
actions: assign(
// Add new mentionState
(context, event: UserMentionedActivity) => {
const mentionCommentActivity = context.comments.find(
activity =>
activity.data.commentLink === event.data.commentLink
);
if (!mentionCommentActivity) {
return context;
}
// Add to mention map as not-yet-replied-to
return {
mentionStates: [
...context.mentionStates,
{
mentionActivityId: event.id,
// aboutUserId is guaranteed to be present
mentionedUserId: event.aboutUserId,
threadId:
mentionCommentActivity.data.threadStartActivityId ??
mentionCommentActivity.id,
hasReplyByMentionedUser: false,
},
],
};
}
),
},
},
},
},
},
Reviews: {
initial: 'Open',
states: {
Open: {
on: {
code_review_requested: {
target: 'OpenPendingReview',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnRequested,
}),
},
},
},
OpenPendingReview: {
on: {
code_review_merged: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Merged',
}),
},
],
code_review_closed: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Closed',
}),
},
],
code_review_requested: {
type: 'history',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnRequested,
}),
},
code_review_submitted: [
{
target: 'ApprovedPendingMerge',
cond: 'noOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
finalApprovalActivity: updateFinalApprovalActivity,
}),
},
{
target: 'ReviewedPendingAuthorChanges',
cond: 'anyOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
}),
},
],
},
},
DiscussedPendingAuthorComment: {},
DiscussedPendingReviewerComment: {},
ReviewedPendingAuthorChanges: {
on: {
code_review_merged: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Merged',
}),
},
],
code_review_closed: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Closed',
}),
},
],
code_review_requested: {
type: 'history',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnRequested,
}),
},
code_review_submitted: [
{
target: 'ApprovedPendingMerge',
cond: 'noOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
finalApprovalActivity: updateFinalApprovalActivity,
}),
},
{
target: 'ReviewedPendingAuthorChanges',
cond: 'anyOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
}),
},
],
},
},
ReviewedPendingAuthorComment: {},
ReviewedPendingReviewerComment: {},
ApprovedPendingMerge: {
on: {
code_review_merged: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Merged',
}),
},
],
code_review_closed: [
{
target: 'Closed',
actions: assign({
resolution: (_, __) => 'Closed',
}),
},
],
code_review_requested: {
// don't change state if already ApprovedPendingMerge
// and re-requested
type: 'history',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnRequested,
}),
},
code_review_submitted: [
{
target: 'ApprovedPendingMerge',
cond: 'noOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
finalApprovalActivity: updateFinalApprovalActivity,
}),
},
{
target: 'ReviewedPendingAuthorChanges',
cond: 'anyOutstandingRejections',
actions: assign({
codeReviewStates: updateCodeReviewStatesOnSubmitted,
finalApprovalActivity: () => null,
}),
},
],
},
},
Closed: {
type: 'final',
},
},
},
},
},
{
guards: {
anyOutstandingRejections: (
context,
event: CodeReviewSubmittedActivity
) => {
const codeReviewStates = {
...context.codeReviewStates,
[event.actorId]:
event.data.outcome === 'approved' ? 'Approved' : 'Rejected',
};
return Object.values(codeReviewStates).includes('Rejected');
},
noOutstandingRejections: (
context,
event: CodeReviewSubmittedActivity
) => {
const codeReviewStates = {
...context.codeReviewStates,
[event.actorId]:
event.data.outcome === 'approved' ? 'Approved' : 'Rejected',
};
return !Object.values(codeReviewStates).includes('Rejected');
},
},
}
);
@andycmaj
Copy link
Author

andycmaj commented Mar 25, 2021

This is the state machine we at botany.io use for modeling the VERY stateful and complex pull request workflow.
The event object shapes (what does a CodeReviewRequestedActivity look like??) are partially missing here, but the event TYPES, eg. code_review_requested, should give you a good enough sense of the event's shape.

It's a parallel state machine because I consider each discussion thread in a PR to kinda be its own sub-state machine.

XState Visualizer: https://xstate.js.org/viz/?code=01c78732bc9d6fe527e2&gist=96d4acea8c5cfccdd7723516cc3c7a8f

image

const mergeRequestStateMachine = Machine(
  {
    id: 'MergeRequestState',
    context: {
      finalApprovalActivity: null,
      codeReviewStates: {},
      comments: [],
      mentionStates: [],
    },
    type: 'parallel',
    states: {
      Discussions: {
        initial: 'Discussed',
        states: {
          Discussed: {
            on: {
              code_review_discussed: {
                type: 'history',
              },
              user_mentioned: {
                type: 'history',
              },
            },
          },
        },
      },
      Reviews: {
        initial: 'Open',
        states: {
          Open: {
            on: {
              code_review_requested: {
                target: 'OpenPendingReview',
              },
            },
          },
          OpenPendingReview: {
            on: {
              code_review_merged: [
                {
                  target: 'Closed',
                },
              ],
              code_review_closed: [
                {
                  target: 'Closed',
                },
              ],
              code_review_requested: {
                type: 'history',
              },
              code_review_submitted: [
                {
                  target: 'ApprovedPendingMerge',
                  cond: 'noOutstandingRejections',
                },
                {
                  target: 'ReviewedPendingAuthorChanges',
                  cond: 'anyOutstandingRejections',
                },
              ],
            },
          },
          DiscussedPendingAuthorComment: {},
          DiscussedPendingReviewerComment: {},
          ReviewedPendingAuthorChanges: {
            on: {
              code_review_merged: [
                {
                  target: 'Closed',
                },
              ],
              code_review_closed: [
                {
                  target: 'Closed',
                },
              ],
              code_review_requested: {
                type: 'history',
              },
              code_review_submitted: [
                {
                  target: 'ApprovedPendingMerge',
                  cond: 'noOutstandingRejections',
                },
                {
                  target: 'ReviewedPendingAuthorChanges',
                  cond: 'anyOutstandingRejections',
                },
              ],
            },
          },
          ReviewedPendingAuthorComment: {},
          ReviewedPendingReviewerComment: {},
          ApprovedPendingMerge: {
            on: {
              code_review_merged: [
                {
                  target: 'Closed',
                  actions: assign({
                    resolution: (_, __) => 'Merged',
                  }),
                },
              ],
              code_review_closed: [
                {
                  target: 'Closed',
                },
              ],
              code_review_requested: {
                // don't change state if already ApprovedPendingMerge
                // and re-requested
                type: 'history',
              },
              code_review_submitted: [
                {
                  target: 'ApprovedPendingMerge',
                  cond: 'noOutstandingRejections',
                },
                {
                  target: 'ReviewedPendingAuthorChanges',
                  cond: 'anyOutstandingRejections',
                },
              ],
            },
          },
          Closed: {
            type: 'final',
          },
        },
      },
    },
  },
  {
    guards: {
      anyOutstandingRejections: (
        context,
        event
      ) => {
       return true
      },
      noOutstandingRejections: (
        context,
        event
      ) => {
        return true
      },
    },
  }
);

@with-heart
Copy link

Thanks for sharing! This is fantastic!

@andycmaj
Copy link
Author

no problem @with-heart lemme know if you have any thoughts/feedback!

@andycmaj
Copy link
Author

FWIW this is in use @ https://botany.io

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment