Last active
March 27, 2021 03:39
-
-
Save andycmaj/2cd889fbe303a37991d8b1fb59277661 to your computer and use it in GitHub Desktop.
Merge/Pull Request State Machine for XState
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | |
}, | |
}, | |
} | |
); |
Thanks for sharing! This is fantastic!
no problem @with-heart lemme know if you have any thoughts/feedback!
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
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