Skip to content

Commit 727e310

Browse files
erickzhaoClaude
andcommitted
fix: align API review "ready on" date with draft-aware open time
The API review check previously calculated its "ready on" date from `pr.created_at`, while the actual merge-gating logic (24-hour rule) used `getPROpenedTime` which accounts for draft→ready transitions. This caused PRs that were opened as drafts to display a stale "ready on" date. Extract `getPROpenedTime` into a shared utility and use it in `getPRReadyDate` so both the check display and the enforcement use the same draft-aware timestamp. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
1 parent cced5b8 commit 727e310

7 files changed

Lines changed: 109 additions & 41 deletions

spec/api-review-state.spec.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('api review', () => {
6262
removeLabel: vi.fn().mockReturnValue({ data: [] }),
6363
listLabelsOnIssue: vi.fn().mockReturnValue({ data: [] }),
6464
listComments: vi.fn().mockReturnValue({ data: [] }),
65+
listEventsForTimeline: vi.fn().mockReturnValue({ data: [] }),
6566
},
6667
checks: {
6768
listForRef: vi.fn().mockReturnValue({ data: { check_runs: [] } }),
@@ -108,9 +109,11 @@ describe('api review', () => {
108109
// Set created_at to yesterday.
109110
pull_request.created_at = new Date(+new Date() - 1000 * 60 * 60 * 24 * 2);
110111

112+
moctokit.rest.issues.listEventsForTimeline = vi.fn().mockReturnValue({ data: [] });
113+
111114
const expectedTime = pull_request.created_at.getTime() + MINIMUM_MINOR_OPEN_TIME;
112115
const expectedDate = new Date(expectedTime).toISOString().split('T')[0];
113-
const readyDate = getPRReadyDate(pull_request);
116+
const readyDate = await getPRReadyDate(moctokit, pull_request);
114117

115118
expect(readyDate).toEqual(expectedDate);
116119
});
@@ -121,9 +124,11 @@ describe('api review', () => {
121124
// Set created_at to yesterday.
122125
payload.created_at = new Date(+new Date() - 1000 * 60 * 60 * 24 * 2);
123126

127+
moctokit.rest.issues.listEventsForTimeline = vi.fn().mockReturnValue({ data: [] });
128+
124129
const expectedTime = payload.created_at.getTime() + MINIMUM_PATCH_OPEN_TIME;
125130
const expectedDate = new Date(expectedTime).toISOString().split('T')[0];
126-
const readyDate = getPRReadyDate(payload);
131+
const readyDate = await getPRReadyDate(moctokit, payload);
127132

128133
expect(readyDate).toEqual(expectedDate);
129134
});
@@ -134,8 +139,33 @@ describe('api review', () => {
134139
// Set created_at to yesterday.
135140
payload.created_at = new Date(+new Date() - 1000 * 60 * 60 * 24 * 2);
136141

142+
moctokit.rest.issues.listEventsForTimeline = vi.fn().mockReturnValue({ data: [] });
143+
137144
const expectedDate = payload.created_at.toISOString().split('T')[0];
138-
const readyDate = getPRReadyDate(payload);
145+
const readyDate = await getPRReadyDate(moctokit, payload);
146+
147+
expect(readyDate).toEqual(expectedDate);
148+
});
149+
150+
it('correctly returns PR ready date based on ready_for_review event when PR was a draft', async () => {
151+
const { pull_request } = loadFixture('api-review-state/pull_request.semver-minor.json');
152+
153+
// PR was created 10 days ago but marked ready 2 days ago.
154+
pull_request.created_at = new Date(+new Date() - 1000 * 60 * 60 * 24 * 10).toISOString();
155+
const readyForReviewTime = new Date(+new Date() - 1000 * 60 * 60 * 24 * 2);
156+
157+
moctokit.rest.issues.listEventsForTimeline = vi.fn().mockReturnValue({
158+
data: [
159+
{
160+
event: 'ready_for_review',
161+
created_at: readyForReviewTime.toISOString(),
162+
},
163+
],
164+
});
165+
166+
const expectedTime = readyForReviewTime.getTime() + MINIMUM_MINOR_OPEN_TIME;
167+
const expectedDate = new Date(expectedTime).toISOString().split('T')[0];
168+
const readyDate = await getPRReadyDate(moctokit, pull_request);
139169

140170
expect(readyDate).toEqual(expectedDate);
141171
});
@@ -531,6 +561,10 @@ describe('api review', () => {
531561
.get(`/repos/electron/electron/issues/${pull_request.number}/comments`)
532562
.reply(200, [c1]);
533563

564+
nock(GH_API)
565+
.get(`/repos/electron/electron/issues/${pull_request.number}/timeline`)
566+
.reply(200, []);
567+
534568
nock(GH_API)
535569
.post('/repos/electron/electron/check-runs', (body) => {
536570
expect(body).toMatchObject({
@@ -584,6 +618,10 @@ describe('api review', () => {
584618
.get(`/repos/electron/electron/issues/${pull_request.number}/comments`)
585619
.reply(200, []);
586620

621+
nock(GH_API)
622+
.get(`/repos/electron/electron/issues/${pull_request.number}/timeline`)
623+
.reply(200, []);
624+
587625
nock(GH_API)
588626
.get(`/repos/electron/electron/issues/${pull_request.number}/labels?per_page=100&page=1`)
589627
.reply(200, [
@@ -678,6 +716,10 @@ describe('api review', () => {
678716
.get(`/repos/electron/electron/issues/${pull_request.number}/comments`)
679717
.reply(200, []);
680718

719+
nock(GH_API)
720+
.get(`/repos/electron/electron/issues/${pull_request.number}/timeline`)
721+
.reply(200, []);
722+
681723
nock(GH_API)
682724
.get(`/repos/electron/electron/issues/${pull_request.number}/labels?per_page=100&page=1`)
683725
.reply(200, [

spec/fixtures/api-review-state/pull_request.api-skip-delay_label.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
"review_comment_url": "https://api.github.com/repos/electron/electron/pulls/comments{/number}",
3636
"comments_url": "https://api.github.com/repos/electron/electron/issues/26876/comments",
3737
"statuses_url": "https://api.github.com/repos/electron/electron/statuses/c6b1b7168ab850a47f856c4a30f7a441bede1117",
38+
"base": {
39+
"ref": "main",
40+
"repo": {
41+
"id": 9384267,
42+
"name": "electron",
43+
"full_name": "electron/electron",
44+
"owner": {
45+
"login": "electron"
46+
},
47+
"default_branch": "main"
48+
}
49+
},
3850
"head": {
3951
"label": "electron:fix-lint-js",
4052
"ref": "fix-lint-js",

spec/fixtures/api-review-state/pull_request.requested_review_label.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
],
4646
"base": {
4747
"repo": {
48+
"full_name": "electron/electron",
4849
"owner": {
4950
"login": "electron"
5051
}

spec/fixtures/api-review-state/pull_request.semver-patch.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
"review_comment_url": "https://api.github.com/repos/electron/electron/pulls/comments{/number}",
3636
"comments_url": "https://api.github.com/repos/electron/electron/issues/26876/comments",
3737
"statuses_url": "https://api.github.com/repos/electron/electron/statuses/c6b1b7168ab850a47f856c4a30f7a441bede1117",
38+
"base": {
39+
"ref": "main",
40+
"repo": {
41+
"id": 9384267,
42+
"name": "electron",
43+
"full_name": "electron/electron",
44+
"owner": {
45+
"login": "electron"
46+
},
47+
"default_branch": "main"
48+
}
49+
},
3850
"head": {
3951
"label": "electron:fix-lint-js",
4052
"ref": "fix-lint-js",

src/24-hour-rule.ts

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Context, Probot, ProbotOctokit } from 'probot';
1+
import { Context, Probot } from 'probot';
22

33
import {
44
NEW_PR_LABEL,
@@ -16,6 +16,9 @@ import { log } from './utils/log-util';
1616
import { addLabels, removeLabel } from './utils/label-utils';
1717
import { LogLevel } from './enums';
1818
import { PullRequest, Label, PullRequestLabeledEvent } from './types';
19+
import { getPROpenedTime } from './utils/pr-open-time-util';
20+
21+
export { getPROpenedTime };
1922

2023
const CHECK_INTERVAL = 1000 * 60 * 5;
2124

@@ -36,40 +39,6 @@ export const getMinimumOpenTime = (pr: PullRequest): number => {
3639
return MINIMUM_MAJOR_OPEN_TIME;
3740
};
3841

39-
/**
40-
* @param octokit - An Octokit instance
41-
* @returns a number representing the that cation should use as the
42-
* open time for the PR in milliseconds, taking draft status into account.
43-
*/
44-
export const getPROpenedTime = async (octokit: ProbotOctokit, pr: PullRequest): Promise<number> => {
45-
const [owner, repo] = pr.base.repo.full_name.split('/');
46-
47-
// Fetch PR timeline events.
48-
const { data: events } = await octokit.rest.issues.listEventsForTimeline({
49-
owner,
50-
repo,
51-
issue_number: pr.number,
52-
});
53-
54-
// Filter out all except 'Ready For Review' events.
55-
const readyForReviewEvents = events
56-
.filter((e) => e.event === 'ready_for_review')
57-
.sort((cA, cB) => {
58-
if (!('created_at' in cA) || !('created_at' in cB)) return 0;
59-
return new Date(cB.created_at).getTime() - new Date(cA.created_at).getTime();
60-
});
61-
62-
// If this PR was a draft PR previously, set its opened time as a function
63-
// of when it was most recently marked ready for review instead of when it was opened,
64-
// otherwise return the PR open date.
65-
const firstEvent = readyForReviewEvents[0];
66-
const validFirstEvent = firstEvent && 'created_at' in firstEvent;
67-
68-
return validFirstEvent
69-
? new Date(firstEvent.created_at).getTime()
70-
: new Date(pr.created_at).getTime();
71-
};
72-
7342
export const shouldPRHaveLabel = async (
7443
github: Context['octokit'],
7544
pr: PullRequest,

src/api-review-state.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { getEnvVar } from './utils/env-util';
2020
import { PullRequest, Label } from './types';
2121
import { GetResponseDataTypeFromEndpointMethod, Endpoints } from '@octokit/types';
2222
import { addLabels, removeLabel } from './utils/label-utils';
23+
import { getPROpenedTime } from './utils/pr-open-time-util';
2324

2425
type APIApprovalState =
2526
ReturnType<typeof addOrUpdateAPIReviewCheck> extends Promise<infer T> ? T : unknown;
@@ -45,8 +46,8 @@ export const hasAPIReviewRequestedLabel = (pr: PullRequest) =>
4546
* @returns a date corresponding to the time that must elapse before a PR requiring
4647
* API review is ready to be merged according to its semver label.
4748
*/
48-
export const getPRReadyDate = (pr: PullRequest) => {
49-
let readyTime = new Date(pr.created_at).getTime();
49+
export const getPRReadyDate = async (octokit: Context['octokit'], pr: PullRequest) => {
50+
let readyTime = await getPROpenedTime(octokit, pr);
5051

5152
if (pr.labels.some((l) => l.name === API_SKIP_DELAY_LABEL)) {
5253
log(
@@ -319,7 +320,7 @@ export async function addOrUpdateAPIReviewCheck(octokit: Context['octokit'], pr:
319320
output: {
320321
title: `${checkTitles[currentReviewLabel.name]} (${
321322
users.approved.length
322-
}/2 LGTMs - ready on ${getPRReadyDate(pr)})`,
323+
}/2 LGTMs - ready on ${await getPRReadyDate(octokit, pr)})`,
323324
summary: checkSummary,
324325
},
325326
});

src/utils/pr-open-time-util.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ProbotOctokit } from 'probot';
2+
import { PullRequest } from '../types';
3+
4+
/**
5+
* @param octokit - An Octokit instance
6+
* @returns a number representing the time that cation should use as the
7+
* open time for the PR in milliseconds, taking draft status into account.
8+
*/
9+
export const getPROpenedTime = async (octokit: ProbotOctokit, pr: PullRequest): Promise<number> => {
10+
const [owner, repo] = pr.base.repo.full_name.split('/');
11+
12+
const { data: events } = await octokit.rest.issues.listEventsForTimeline({
13+
owner,
14+
repo,
15+
issue_number: pr.number,
16+
});
17+
18+
const readyForReviewEvents = events
19+
.filter((e) => e.event === 'ready_for_review')
20+
.sort((cA, cB) => {
21+
if (!('created_at' in cA) || !('created_at' in cB)) return 0;
22+
return new Date(cB.created_at).getTime() - new Date(cA.created_at).getTime();
23+
});
24+
25+
const firstEvent = readyForReviewEvents[0];
26+
const validFirstEvent = firstEvent && 'created_at' in firstEvent;
27+
28+
return validFirstEvent
29+
? new Date(firstEvent.created_at).getTime()
30+
: new Date(pr.created_at).getTime();
31+
};

0 commit comments

Comments
 (0)