diff --git a/packages/functions/transform/src/merge-request-metrics.ts b/packages/functions/transform/src/merge-request-metrics.ts index 42e9fd60f..d05a2aee0 100644 --- a/packages/functions/transform/src/merge-request-metrics.ts +++ b/packages/functions/transform/src/merge-request-metrics.ts @@ -342,7 +342,6 @@ function calculateMrSize(mergeRequestId: number, diffs: { stringifiedHunks: stri .reduce((a, b) => a + b, 0); } - return mrSize; } @@ -361,15 +360,16 @@ type MergeRequestData = { authorExternalId: extract.MergeRequest['authorExternalId'] } -type TimelineEventData = { +export type TimelineEventData = { type: extract.TimelineEvents['type']; timestamp: extract.TimelineEvents['timestamp']; actorId: extract.TimelineEvents['actorId']; data: extract.TimelineEvents['data']; } -type MergeRequestNoteData = { - createdAt: extract.MergeRequestNote['createdAt']; +export type MergeRequestNoteData = { + type: 'note'; + timestamp: extract.MergeRequestNote['createdAt']; authorExternalId: extract.MergeRequestNote['authorExternalId']; } @@ -411,12 +411,12 @@ async function selectExtractData(db: ExtractDatabase, extractMergeRequestId: num .all(); const mergeRequestNotesData = await db.select({ - createdAt: mergeRequestNotes.createdAt, + timestamp: mergeRequestNotes.createdAt, authorExternalId: mergeRequestNotes.authorExternalId, }) .from(mergeRequestNotes) .where(eq(mergeRequestNotes.mergeRequestId, extractMergeRequestId)) - .all() satisfies MergeRequestNoteData[]; + .all() satisfies Omit[]; const timelineEventsData = await db.select({ type: timelineEvents.type, @@ -431,7 +431,7 @@ async function selectExtractData(db: ExtractDatabase, extractMergeRequestId: num return { diffs: mergerRequestDiffsData, ...mergeRequestData || { mergeRequest: null }, - notes: mergeRequestNotesData, + notes: mergeRequestNotesData.map(note => ({ ...note, type: 'note' as const })), timelineEvents: timelineEventsData, ...repositoryData || { repository: null }, }; @@ -442,30 +442,27 @@ export type RunContext = { transformDatabase: TransformDatabase; }; -type TimelineMapKey = { +export type TimelineMapKey = { type: extract.TimelineEvents['type'] | 'note', timestamp: Date, - actorId: extract.TimelineEvents['actorId'] | extract.MergeRequestNote['authorExternalId'] | null, } + function setupTimeline(timelineEvents: TimelineEventData[], notes: MergeRequestNoteData[]) { const timeline = new Map(); - for (const timelineEvent of timelineEvents) { timeline.set({ type: timelineEvent.type, timestamp: timelineEvent.timestamp, - actorId: timelineEvent.actorId, }, timelineEvent); } for (const note of notes) { timeline.set({ type: 'note', - timestamp: note.createdAt, - actorId: note.authorExternalId, + timestamp: note.timestamp, }, note); } @@ -473,83 +470,144 @@ function setupTimeline(timelineEvents: TimelineEventData[], notes: MergeRequestN } -function runTimeline(extractMergeRequest: MergeRequestData, timelineEvents: TimelineEventData[], notes: MergeRequestNoteData[]) { +type calcTimelineArgs = { + authorExternalId: extract.MergeRequest['authorExternalId'], +} - const timelineMap = setupTimeline(timelineEvents, notes); - const timelineMapKeys = [...timelineMap.keys()]; +export function calculateTimeline(timelineMapKeys: TimelineMapKey[], timelineMap: Map, { authorExternalId }: calcTimelineArgs) { + + const commitedEvents = timelineMapKeys.filter(key => key.type === 'committed'); + commitedEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + const firstCommitEvent = commitedEvents[0] || null; + const lastCommitEvent = commitedEvents[commitedEvents.length - 1] || null; - //start coding at + const startedCodingAt = firstCommitEvent ? firstCommitEvent.timestamp : null; - const committedEvents = timelineMapKeys.filter(({ type }) => type === 'committed') as (TimelineMapKey & { type: 'committed' })[]; + const mergedEvents = timelineMapKeys.filter(key => key.type === 'merged'); + mergedEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - let startedCodingAt: Date | null = null; + const mergedAt = mergedEvents[0]?.timestamp || null; - if (committedEvents.length > 0) { + const readyForReviewEvents = timelineMapKeys.filter(key => key.type === 'ready_for_review' || key.type === 'review_requested'); + readyForReviewEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const lastReadyForReviewEvent = readyForReviewEvents[readyForReviewEvents.length - 1] || null; - for (const committedEvent of committedEvents) { - if (!startedCodingAt) { - startedCodingAt = committedEvent.timestamp; + const startedPickupAt = (() => { + if (lastCommitEvent === null && lastReadyForReviewEvent === null) { + return null; + } + if (lastReadyForReviewEvent === null && lastCommitEvent) { + // problematic code: everything below is problematic + const reviewedEventsBeforeLastCommitEvent = timelineMapKeys.filter(key => key.type === 'reviewed' && key.timestamp < lastCommitEvent.timestamp); + reviewedEventsBeforeLastCommitEvent.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const firstReviewedEventBeforeLastCommitEvent = reviewedEventsBeforeLastCommitEvent[0]; + if (firstReviewedEventBeforeLastCommitEvent) { + return [...commitedEvents].reverse().find(event => event.timestamp < firstReviewedEventBeforeLastCommitEvent.timestamp)?.timestamp || null; } - else if (committedEvent.timestamp.getTime() < startedCodingAt.getTime()) { - startedCodingAt = committedEvent.timestamp; + + return lastCommitEvent.timestamp; + } + if (lastReadyForReviewEvent && lastCommitEvent) { + // problematic code: there could be a commit between last commit and lastReadyForReviewEvent + const reviewedEventsAfterLastReadyForReviewEvent = timelineMapKeys.filter( + key => + key.type === 'reviewed' + && key.timestamp > lastReadyForReviewEvent.timestamp + && key.timestamp < lastCommitEvent.timestamp + ); + reviewedEventsAfterLastReadyForReviewEvent.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const firstReviewedEventAfterLastReadyForReviewEvent = reviewedEventsAfterLastReadyForReviewEvent[0] + + if (firstReviewedEventAfterLastReadyForReviewEvent) { + const temp = [...commitedEvents].reverse().find( + event => event.timestamp > lastReadyForReviewEvent.timestamp + && event.timestamp < firstReviewedEventAfterLastReadyForReviewEvent.timestamp + + )?.timestamp || null; + + if (temp) { + return temp; + } + return lastReadyForReviewEvent.timestamp; } + return lastReadyForReviewEvent.timestamp > lastCommitEvent.timestamp ? lastReadyForReviewEvent.timestamp : lastCommitEvent.timestamp; } - } + return null; + })(); - // start review at + let firstReviewedEvent = null; + let reviewed = false; + let reviewDepth = 0; - const reviewEvents = timelineMapKeys.filter(({ type }) => type === 'note' || type === 'reviewed' || type === 'commented') as (TimelineMapKey & { type: 'note' | 'reviewed' | 'commented' })[]; - let startedReviewAt: Date | null = null; + const noteEvents = timelineMapKeys.filter(key => key.type === 'note'); + for (const noteEvent of noteEvents) { + const eventData = timelineMap.get(noteEvent) as MergeRequestNoteData | undefined; + if (!eventData) { + console.error('note event data not found', noteEvent); + continue; + } - if (reviewEvents.length > 0) { - for (const reviewEvent of reviewEvents) { - if (!startedReviewAt && reviewEvent.actorId !== extractMergeRequest.authorExternalId) { - startedReviewAt = reviewEvent.timestamp; - } - if (startedReviewAt && reviewEvent.timestamp.getTime() < startedReviewAt.getTime()) { - startedReviewAt = reviewEvent.timestamp; - } + const afterStartedPickupAt = startedPickupAt ? noteEvent.timestamp > startedPickupAt : true; + const beforeMergedEvent = mergedAt ? noteEvent.timestamp < mergedAt : true; + const isAuthorReviewer = eventData.authorExternalId === authorExternalId; + if (afterStartedPickupAt && beforeMergedEvent && !isAuthorReviewer) { + reviewDepth++; } } - // start pickup at - - const convertToDraftEvents = timelineMapKeys.filter(({ type }) => type === 'convert_to_draft') as (TimelineMapKey & { type: 'convert_to_draft' })[]; - let lastConvertToDraftBeforeReview: Date | null = null; + const reviewedEvents = timelineMapKeys.filter(key => key.type === 'reviewed' && key.timestamp < (mergedAt || new Date())); + for (const reviewedEvent of reviewedEvents) { + const eventData = timelineMap.get(reviewedEvent); + if (!eventData) { + console.error('reviewed event data not found', reviewedEvent); + continue; + } + const res = extract.ReviewedEventSchema.safeParse((eventData as TimelineEventData).data); + if (!res.success) { + console.error(res.error); + continue; + } + const isValidState = res.data.state === 'approved' || res.data.state === 'changes_requested' || res.data.state === 'commented'; + const afterStartedPickupAt = startedPickupAt ? reviewedEvent.timestamp > startedPickupAt : true; + const beforeFirstReviewedEvent = firstReviewedEvent ? reviewedEvent.timestamp < firstReviewedEvent.timestamp : true; + const beforeMergedEvent = mergedAt ? reviewedEvent.timestamp < mergedAt : true; + const isAuthorReviewer = (eventData as TimelineEventData).actorId === authorExternalId; + + if (isValidState && afterStartedPickupAt && beforeMergedEvent && !isAuthorReviewer) { + reviewed = true; + reviewDepth++; + } - for (const convertToDraft of convertToDraftEvents) { - if ( - (!lastConvertToDraftBeforeReview || convertToDraft.timestamp.getTime() > lastConvertToDraftBeforeReview.getTime()) - && (!startedReviewAt || convertToDraft.timestamp.getTime() < startedReviewAt.getTime()) - ) { - lastConvertToDraftBeforeReview = convertToDraft.timestamp; + if (isValidState && afterStartedPickupAt && beforeFirstReviewedEvent && !isAuthorReviewer) { + reviewed = true; + firstReviewedEvent = reviewedEvent; } } - let startedPickupAt: Date | null = null; - const initialPickupEvents = timelineMapKeys.filter(({ type, timestamp }) => type === 'ready_for_review' || type === 'review_requested' - && (!lastConvertToDraftBeforeReview || timestamp.getTime() > lastConvertToDraftBeforeReview.getTime()) - && (!startedReviewAt || timestamp.getTime() < startedReviewAt.getTime())) as (TimelineMapKey & { type: 'ready_for_review' | 'review_requested' })[]; + return { + startedCodingAt, + startedPickupAt, + startedReviewAt: firstReviewedEvent ? firstReviewedEvent.timestamp : null, + mergedAt, + reviewed, + reviewDepth, + }; +} - for (const pickupEvent of initialPickupEvents) { - if (!startedPickupAt || pickupEvent.timestamp.getTime() < startedPickupAt.getTime()) { - startedPickupAt = pickupEvent.timestamp; - } - } - if (startedReviewAt && !startedPickupAt) { - for (const committedEvent of committedEvents) { - if (!startedPickupAt && committedEvent.timestamp.getTime() < startedReviewAt.getTime()) startedPickupAt = committedEvent.timestamp; - if (startedPickupAt - && committedEvent.timestamp.getTime() > startedPickupAt.getTime() - && committedEvent.timestamp.getTime() < startedReviewAt.getTime()) startedPickupAt = committedEvent.timestamp; - } - } - if (startedReviewAt && !startedPickupAt) { - startedPickupAt = extractMergeRequest.openedAt; - } +function runTimeline(mergeRequestData: MergeRequestData, timelineEvents: TimelineEventData[], notes: MergeRequestNoteData[]) { + const timelineMap = setupTimeline(timelineEvents, notes); + const timelineMapKeys = [...timelineMap.keys()]; + + const { startedCodingAt, startedReviewAt, startedPickupAt, reviewed, reviewDepth } = calculateTimeline( + timelineMapKeys, + timelineMap, + { + authorExternalId: mergeRequestData.authorExternalId, + }); // TODO: can this be optimized with the map ? const approved = timelineEvents.find(ev => ev.type === 'reviewed' && (JSON.parse(ev.data as string) as extract.ReviewedEvent).state === 'approved') !== undefined; @@ -558,13 +616,14 @@ function runTimeline(extractMergeRequest: MergeRequestData, timelineEvents: Time startedCodingAt, startedReviewAt, startedPickupAt, - reviewed: startedReviewAt !== null, + reviewed, + reviewDepth, approved, - reviewDepth: reviewEvents.length, - }; + } } + export async function run(extractMergeRequestId: number, ctx: RunContext) { const extractData = await selectExtractData(ctx.extractDatabase, extractMergeRequestId); diff --git a/packages/functions/transform/src/timeline.test.ts b/packages/functions/transform/src/timeline.test.ts new file mode 100644 index 000000000..a8ba6bf5d --- /dev/null +++ b/packages/functions/transform/src/timeline.test.ts @@ -0,0 +1,1385 @@ +import { describe, expect, test } from '@jest/globals'; +import { calculateTimeline } from './merge-request-metrics'; +import type { MergeRequestNoteData, TimelineEventData, TimelineMapKey } from './merge-request-metrics'; + +const pr1 = { + "authorExternalId": 2, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Trnmc@dontemailme.com", + "committerName": "Mr. Uzybq", + "committedDate": 10 + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Trnmc@dontemailme.com", + "committerName": "Mr. Duiph", + "committedDate": 20 + } + }, + { + "type": "merged", + "timestamp": 30, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 40, + "actorId": 1 + } + ] +}; + +const pr1Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(20), + startedReviewAt: null, + mergedAt: new Date(30), + reviewed: false, + reviewDepth: 0 +}; + + +const pr5 = { + "authorExternalId": 2, + "timeline": [] +}; + +const pr5Expected = { + startedCodingAt: null, + startedPickupAt: null, + startedReviewAt: null, + mergedAt: null, + reviewed: false, + reviewDepth: 0 +}; + + +const pr83 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Jyetw", + "committedDate": 10 + } + }, + { + "type": "merged", + "timestamp": 20, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 30, + "actorId": 1 + } + ] +}; + +const pr83Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(10), + startedReviewAt: null, + mergedAt: new Date(20), + reviewed: false, + reviewDepth: 0 +}; + +const pr74 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Tqsas", + "committedDate": 10 + } + }, + { + "type": "reviewed", + "timestamp": 20, + "actorId": 4, + "data": { + "state": "approved" + } + }, + { + "type": "merged", + "timestamp": 30, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 40, + "actorId": 1 + }, + { + "type": "reviewed", + "timestamp": 50, + "actorId": 3, + "data": { + "state": "approved" + } + } + ] +}; + +const pr74Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(10), + startedReviewAt: new Date(20), + mergedAt: new Date(30), + reviewed: true, + reviewDepth: 1 +}; + +const pr39 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Pqsiy", + "committedDate": 20 + } + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Txxub", + "committedDate": 30 + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Jrehm", + "committedDate": 40 + } + }, + { + "type": "convert_to_draft", + "timestamp": 50, + "actorId": 1 + }, + { + "type": "committed", + "timestamp": 60, + "actorId": null, + "data": { + "committerEmail": "Fehyq@dontemailme.com", + "committerName": "Mr. Zimwu", + "committedDate": 60 + } + }, + { + "type": "committed", + "timestamp": 70, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Exgph", + "committedDate": 70 + } + }, + { + "type": "committed", + "timestamp": 80, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Zxqfu", + "committedDate": 80 + } + }, + { + "type": "committed", + "timestamp": 90, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Uinov", + "committedDate": 90 + } + }, + { + "type": "committed", + "timestamp": 100, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Tlygx", + "committedDate": 100 + } + }, + { + "type": "committed", + "timestamp": 110, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Kbofh", + "committedDate": 110 + } + }, + { + "type": "committed", + "timestamp": 120, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Gabrn", + "committedDate": 120 + } + }, + { + "type": "reviewed", + "timestamp": 10, + "actorId": 139872861, + "data": { + "state": "pending" + } + }, + { + "type": "committed", + "timestamp": 130, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Cgfyi", + "committedDate": 130 + } + }, + { + "type": "committed", + "timestamp": 140, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Yfleg", + "committedDate": 140 + } + }, + { + "type": "committed", + "timestamp": 150, + "actorId": null, + "data": { + "committerEmail": "Fehyq@dontemailme.com", + "committerName": "Mr. Jqbqc", + "committedDate": 150 + } + }, + { + "type": "ready_for_review", + "timestamp": 160, + "actorId": 1 + }, + { + "type": "merged", + "timestamp": 170, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 180, + "actorId": 1 + } + ] +}; + +const pr39Expected = { + startedCodingAt: new Date(20), + startedPickupAt: new Date(160), + startedReviewAt: null, + mergedAt: new Date(170), + reviewed: false, + reviewDepth: 0 +}; + +const pr143 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Gugot", + "committedDate": 10 + } + }, + { + "type": "convert_to_draft", + "timestamp": 20, + "actorId": 1 + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Ugoev", + "committedDate": 30 + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Vzniz", + "committedDate": 40 + } + }, + { + "type": "committed", + "timestamp": 50, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Jgwlo", + "committedDate": 50 + } + }, + { + "type": "committed", + "timestamp": 60, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Savet", + "committedDate": 60 + } + }, + { + "type": "committed", + "timestamp": 70, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Vlpol", + "committedDate": 70 + } + }, + { + "type": "committed", + "timestamp": 80, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Lytya", + "committedDate": 80 + } + }, + { + "type": "committed", + "timestamp": 90, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Ilqqe", + "committedDate": 90 + } + }, + { + "type": "committed", + "timestamp": 100, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Cjoov", + "committedDate": 100 + } + }, + { + "type": "committed", + "timestamp": 110, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Byusa", + "committedDate": 110 + } + }, + { + "type": "committed", + "timestamp": 120, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Sufac", + "committedDate": 120 + } + }, + { + "type": "committed", + "timestamp": 130, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Jmsle", + "committedDate": 130 + } + }, + { + "type": "commented", + "timestamp": 140, + "actorId": 1 + }, + { + "type": "commented", + "timestamp": 150, + "actorId": 2 + }, + { + "type": "ready_for_review", + "timestamp": 160, + "actorId": 1 + }, + { + "type": "reviewed", + "timestamp": 170, + "actorId": 1, + "data": { + "state": "commented" + } + }, + { + "type": "reviewed", + "timestamp": 200, + "actorId": 4, + "data": { + "state": "commented" + } + }, + { + "type": "committed", + "timestamp": 190, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Agatz", + "committedDate": 190 + } + }, + { + "type": "committed", + "timestamp": 220, + "actorId": null, + "data": { + "committerEmail": "Rzgen@dontemailme.com", + "committerName": "Mr. Ixokh", + "committedDate": 220 + } + }, + { + "type": "commented", + "timestamp": 230, + "actorId": 1 + }, + { + "type": "commented", + "timestamp": 240, + "actorId": 2, + }, + { + "type": "reviewed", + "timestamp": 250, + "actorId": 3, + "data": { + "state": "approved" + } + }, + { + "type": "reviewed", + "timestamp": 260, + "actorId": 4, + "data": { + "state": "approved" + } + }, + { + "type": "merged", + "timestamp": 270, + "actorId": 4 + }, + { + "type": "closed", + "timestamp": 280, + "actorId": 4 + }, + { + "type": "note", + "createdAt": "2023-08-30T17:21:04.000Z", + "authorExternalId": 1, + "timestamp": 180 + }, + { + "type": "note", + "createdAt": "2023-08-31T08:11:15.000Z", + "authorExternalId": 4, + "timestamp": 210 + } + ] +}; + +const pr143Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(160), + startedReviewAt: new Date(200), + mergedAt: new Date(270), + reviewed: true, + reviewDepth: 4, +}; + + +const pr20 = { + "authorExternalId": 2, + "timeline": [ + { + "type": "commented", + "timestamp": 10, + "actorId": 2 + } + ] +}; + +const pr20Expected = { + startedCodingAt: null, + startedPickupAt: null, + startedReviewAt: null, + mergedAt: null, + reviewed: false, + reviewDepth: 0 +}; + +const pr73 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Hhcvn", + "committedDate": 10 + } + }, + { + "type": "convert_to_draft", + "timestamp": 20, + "actorId": 1 + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Ckktp", + "committedDate": 30 + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Wlpzl", + "committedDate": 40 + } + }, + { + "type": "ready_for_review", + "timestamp": 50, + "actorId": 1 + }, + { + "type": "reviewed", + "timestamp": 60, + "actorId": 3, + "data": { + "state": "approved" + } + }, + { + "type": "committed", + "timestamp": 70, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Rhwdv", + "committedDate": 70 + } + }, + { + "type": "committed", + "timestamp": 80, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Hibtu", + "committedDate": 80 + } + }, + { + "type": "reviewed", + "timestamp": 90, + "actorId": 4, + "data": { + "state": "approved" + } + }, + { + "type": "merged", + "timestamp": 100, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 110, + "actorId": 1 + } + ] +}; + +const pr73Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(50), + startedReviewAt: new Date(60), + mergedAt: new Date(100), + reviewed: true, + reviewDepth: 2 +}; + +const pr99 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Rltpc", + "committedDate": 10 + } + }, + { + "type": "review_requested", + "timestamp": 20, + "actorId": 3, + "data": { + "requestedReviewerId": 5, + "requestedReviewerName": "Mr. Wldts" + } + }, + { + "type": "review_requested", + "timestamp": 30, + "actorId": 3, + "data": { + "requestedReviewerId": 4, + "requestedReviewerName": "Mr. Ghoie" + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Mzpby", + "committedDate": 40 + } + }, + { + "type": "reviewed", + "timestamp": 50, + "actorId": 5, + "data": { + "state": "approved" + } + }, + { + "type": "merged", + "timestamp": 60, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 70, + "actorId": 1 + } + ] +}; + +const pr99Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(40), + startedReviewAt: new Date(50), + mergedAt: new Date(60), + reviewed: true, + reviewDepth: 1 +}; + +const pr100 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Cqevr", + "committedDate": 10 + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Msmpt@dontemailme.com", + "committerName": "Mr. Pxhck", + "committedDate": 20 + } + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Hvbgh", + "committedDate": 30 + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Ypeeg", + "committedDate": 40 + } + }, + { + "type": "reviewed", + "timestamp": 50, + "actorId": 3, + "data": { + "state": "approved" + } + }, + { + "type": "commented", + "timestamp": 60, + "actorId": 1 + }, + { + "type": "committed", + "timestamp": 70, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Gowhm", + "committedDate": 70 + } + }, + { + "type": "merged", + "timestamp": 80, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 90, + "actorId": 1 + } + ] +}; + +const pr100Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(40), + startedReviewAt: new Date(50), + mergedAt: new Date(80), + reviewed: true, + reviewDepth: 1 +}; + +const pr101 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Siynx", + "committedDate": 10 + } + }, + { + "type": "review_requested", + "timestamp": 20, + "actorId": 3, + "data": { + "requestedReviewerId": 5, + "requestedReviewerName": "Mr. Okmbb" + } + }, + { + "type": "reviewed", + "timestamp": 30, + "actorId": 5, + "data": { + "state": "approved" + } + }, + { + "type": "merged", + "timestamp": 40, + "actorId": 5 + }, + { + "type": "closed", + "timestamp": 50, + "actorId": 5 + } + ] +}; + +const pr101Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(20), + startedReviewAt: new Date(30), + mergedAt: new Date(40), + reviewed: true, + reviewDepth: 1 +}; + + +const pr312 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Imvlz", + "committedDate": 10 + } + }, + { + "type": "reviewed", + "timestamp": 20, + "actorId": 5, + "data": { + "state": "approved" + } + }, + { + "type": "reviewed", + "timestamp": 30, + "actorId": 4, + "data": { + "state": "approved" + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Cosgs", + "committedDate": 40 + } + }, + { + "type": "merged", + "timestamp": 50, + "actorId": 3 + }, + { + "type": "closed", + "timestamp": 60, + "actorId": 3 + } + ] +}; + +const pr312Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(10), + startedReviewAt: new Date(20), + mergedAt: new Date(50), + reviewed: true, + reviewDepth: 2 +}; + + +const pr93 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Evtdq", + "committedDate": 10 + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Sqmvt", + "committedDate": 20 + } + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Cvzwu", + "committedDate": 30 + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Yxgml", + "committedDate": 40 + } + }, + { + "type": "committed", + "timestamp": 50, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Qubjs", + "committedDate": 50 + } + }, + { + "type": "committed", + "timestamp": 60, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Pqtik", + "committedDate": 60 + } + }, + { + "type": "committed", + "timestamp": 70, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Dakrr", + "committedDate": 70 + } + }, + { + "type": "committed", + "timestamp": 80, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Llwuh", + "committedDate": 80 + } + }, + { + "type": "review_requested", + "timestamp": 90, + "actorId": 3, + "data": { + "requestedReviewerId": 5, + "requestedReviewerName": "Mr. Nbydb" + } + }, + { + "type": "review_requested", + "timestamp": 100, + "actorId": 3, + "data": { + "requestedReviewerId": 1, + "requestedReviewerName": "Mr. Pvpuh" + } + }, + { + "type": "committed", + "timestamp": 110, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Nnycv", + "committedDate": 110 + } + }, + { + "type": "merged", + "timestamp": 120, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 130, + "actorId": 1 + } + ] +}; + +const pr93Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(110), + startedReviewAt: null, + mergedAt: new Date(120), + reviewed: false, + reviewDepth: 0 +}; + + +const pr173 = { + "authorExternalId": 2, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Pcabj", + "committedDate": 10 + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Rdhmw", + "committedDate": 20 + } + }, + { + "type": "merged", + "timestamp": 30, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 40, + "actorId": 1 + } + ] +}; + +const pr173Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(20), + startedReviewAt: null, + mergedAt: new Date(30), + reviewed: false, + reviewDepth: 0 +}; + +const pr41 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "review_requested", + "timestamp": 10, + "actorId": 3, + "data": { + "requestedReviewerId": 1, + "requestedReviewerName": "Mr. Fydck" + } + }, + { + "type": "closed", + "timestamp": 20, + "actorId": 3 + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Dteim", + "committedDate": 30 + } + }, + { + "type": "merged", + "timestamp": 40, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 50, + "actorId": 1 + } + ] +}; + +const pr41Expected = { + startedCodingAt: new Date(30), + startedPickupAt: new Date(30), + startedReviewAt: null, + mergedAt: new Date(40), + reviewed: false, + reviewDepth: 0 +}; + + +const pr193 = { + "authorExternalId": 3, + "timeline": [ + { + "type": "review_requested", + "timestamp": 10, + "actorId": 3, + "data": { + "requestedReviewerId": 1, + "requestedReviewerName": "Mr. Ysiqq" + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Bivxr@dontemailme.com", + "committerName": "Mr. Mffqi", + "committedDate": 20 + } + }, + { + "type": "reviewed", + "timestamp": 30, + "actorId": 1, + "data": { + "state": "approved" + } + }, + { + "type": "committed", + "timestamp": 40, + "actorId": null, + "data": { + "committerEmail": "Lozxg@dontemailme.com", + "committerName": "Mr. Tmnhl", + "committedDate": 40 + } + }, + { + "type": "merged", + "timestamp": 50, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 60, + "actorId": 1 + } + ] +}; + +const pr193Expected = { + startedCodingAt: new Date(20), + startedPickupAt: new Date(20), + startedReviewAt: new Date(30), + mergedAt: new Date(50), + reviewed: true, + reviewDepth: 1 +}; + +const pr270 = { + "authorExternalId": 1, + "timeline": [ + { + "type": "committed", + "timestamp": 10, + "actorId": null, + "data": { + "committerEmail": "Lbfvu@dontemailme.com", + "committerName": "Mr. Ywdbo", + "committedDate": 10 + } + }, + { + "type": "committed", + "timestamp": 20, + "actorId": null, + "data": { + "committerEmail": "Lbfvu@dontemailme.com", + "committerName": "Mr. Ennxr", + "committedDate": 20 + } + }, + { + "type": "committed", + "timestamp": 30, + "actorId": null, + "data": { + "committerEmail": "Lbfvu@dontemailme.com", + "committerName": "Mr. Mrzuv", + "committedDate": 30 + } + }, + { + "type": "merged", + "timestamp": 40, + "actorId": 1 + }, + { + "type": "closed", + "timestamp": 50, + "actorId": 1 + }, + { + "type": "reviewed", + "timestamp": 60, + "actorId": 5, + "data": { + "state": "approved" + } + }, + { + "type": "commented", + "timestamp": 70, + "actorId": 3 + } + ] +}; + +const pr270Expected = { + startedCodingAt: new Date(10), + startedPickupAt: new Date(30), + startedReviewAt: null, + mergedAt: new Date(40), + reviewed: false, + reviewDepth: 0, +}; + +const fixtures = [ + ['pr1', { pr: pr1, expected: pr1Expected }], + ['pr5', { pr: pr5, expected: pr5Expected }], + ['pr20', { pr: pr20, expected: pr20Expected }], + ['pr39', { pr: pr39, expected: pr39Expected }], + ['pr41', { pr: pr41, expected: pr41Expected }], + ['pr73', { pr: pr73, expected: pr73Expected }], + ['pr74', { pr: pr74, expected: pr74Expected }], + ['pr83', { pr: pr83, expected: pr83Expected }], + ['pr93', { pr: pr93, expected: pr93Expected }], + ['pr99', { pr: pr99, expected: pr99Expected }], + ['pr100', { pr: pr100, expected: pr100Expected }], + ['pr101', { pr: pr101, expected: pr101Expected }], + ['pr143', { pr: pr143, expected: pr143Expected }], + ['pr173', { pr: pr173, expected: pr173Expected }], + ['pr193', { pr: pr193, expected: pr193Expected }], + ['pr270', { pr: pr270, expected: pr270Expected }], + ['pr312', { pr: pr312, expected: pr312Expected }], +] as [string, { pr: typeof pr1 | typeof pr5 | typeof pr143, expected: typeof pr1Expected | typeof pr5Expected | typeof pr143Expected }][] + + +describe("timelines", () => { + describe.each(fixtures)("%s", (_name, { pr, expected }) => { + + const authorExternalId = pr.authorExternalId; + + + const map = new Map(); + + for (const ev of pr.timeline) { + map.set({ type: ev.type, timestamp: new Date(ev.timestamp) }, ev); + } + + const keys = [...map.keys()]; + + const result = calculateTimeline(keys as unknown as TimelineMapKey[], map as Map, { authorExternalId }); + + const { startedCodingAt, startedPickupAt, startedReviewAt, mergedAt, reviewed, reviewDepth } = result; + + const getTime = (date: Date | null) => date?.getTime() || null; + + test('startedCodingAt', () => { + expect(getTime(startedCodingAt)).toEqual(getTime(expected.startedCodingAt )); + }); + + test('startedPickupAt', () => { + expect(getTime(startedPickupAt)).toEqual(getTime(expected.startedPickupAt)); + }); + + test('startedReviewAt', () => { + expect(getTime(startedReviewAt)).toEqual(getTime(expected.startedReviewAt)); + }); + + test('mergedAt', () => { + expect(getTime(mergedAt)).toEqual(getTime(expected.mergedAt)); + }); + + test('reviewed', () => { + expect(reviewed).toEqual(expected.reviewed); + }); + + test('reviewDepth', () => { + expect(reviewDepth).toEqual(expected.reviewDepth); + }); + + }); +});