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

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