Skip to content

Commit 3e10147

Browse files
feat(github-actions): add stale draft PR action
This adds a new behavior to the lock-closed local-action. It automatically closes draft PRs with no activity from the PR author over the past 4 weeks.
1 parent 3a72c83 commit 3e10147

7 files changed

Lines changed: 25594 additions & 0 deletions

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ github-actions/saucelabs/set-saucelabs-env.js
1717
github-actions/labeling/issue/main.js
1818
github-actions/slash-commands/main.js
1919
github-actions/unified-status-check/main.js
20+
github-actions/stale-cleanup/main.js
2021
bazel/map-size-tracking/test/size-golden.json
2122

2223
**/test/goldens/**/*.md
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
load("//tools:defaults.bzl", "esbuild_checked_in", "jasmine_test", "ts_project")
2+
3+
package(default_visibility = ["//github-actions/stale-cleanup:__subpackages__"])
4+
5+
ts_project(
6+
name = "lib",
7+
srcs = glob(
8+
["lib/*.ts"],
9+
exclude = ["lib/*.spec.ts"],
10+
),
11+
tsconfig = "//github-actions:tsconfig",
12+
deps = [
13+
"//github-actions:node_modules/@actions/core",
14+
"//github-actions:node_modules/@actions/github",
15+
"//github-actions:node_modules/@octokit/rest",
16+
"//github-actions:node_modules/@types/node",
17+
"//github-actions:utils",
18+
],
19+
)
20+
21+
ts_project(
22+
name = "test_lib",
23+
testonly = True,
24+
srcs = glob(["lib/*.spec.ts"]),
25+
tsconfig = "//github-actions:tsconfig_test",
26+
deps = [
27+
":lib",
28+
"//github-actions:node_modules/@actions/core",
29+
"//github-actions:node_modules/@actions/github",
30+
"//github-actions:node_modules/@octokit/rest",
31+
"//github-actions:node_modules/@types/jasmine",
32+
"//github-actions:node_modules/@types/node",
33+
"//github-actions:utils",
34+
],
35+
)
36+
37+
jasmine_test(
38+
name = "test",
39+
data = [
40+
":test_lib",
41+
],
42+
env = {
43+
"GITHUB_REPOSITORY": "angular/angular",
44+
},
45+
)
46+
47+
esbuild_checked_in(
48+
name = "main",
49+
srcs = [
50+
":lib",
51+
],
52+
entry_point = "lib/main.ts",
53+
format = "esm",
54+
platform = "node",
55+
target = "node22",
56+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name: 'Stale Draft PR Cleanup'
2+
description: 'Automatically closes draft PRs that have been inactive for a specific number of days.'
3+
inputs:
4+
days:
5+
description: 'Number of days a draft PR must be inactive before being closed'
6+
required: false
7+
default: '28'
8+
runs:
9+
using: 'node22'
10+
main: 'main.js'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {closeStaleDraftPrs} from './main.js';
2+
3+
describe('closeStaleDraftPrs', () => {
4+
it('should be a function', () => {
5+
expect(typeof closeStaleDraftPrs).toBe('function');
6+
});
7+
});
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import {Octokit} from '@octokit/rest';
4+
import {ANGULAR_LOCK_BOT, getAuthTokenFor, revokeActiveInstallationToken} from '../../utils.js';
5+
import {setTimeout as setTimeoutPromise} from 'timers/promises';
6+
7+
export async function closeStaleDraftPrs(github: Octokit, repo: string): Promise<void> {
8+
const days = parseInt(core.getInput('days', {required: false}) || '28', 10);
9+
const message = `This draft PR is being closed because it has been stale for ${days} days and has seen no activity from you. If you'd like to see this change land, you can re-open this PR. Thank you for being an Angular contributor!`;
10+
11+
const threshold = new Date();
12+
threshold.setDate(threshold.getDate() - days);
13+
const thresholdIso = threshold.toISOString();
14+
const thresholdDateString = thresholdIso.split('T')[0];
15+
16+
const repositoryName = `angular/${repo}`;
17+
const query = `repo:${repositoryName}+is:pr+is:draft+is:open+created:<${thresholdDateString}+sort:updated-asc`;
18+
core.info('Stale Draft PR Query: ' + query);
19+
20+
let closeCount = 0;
21+
const prResponse = await github.search.issuesAndPullRequests({
22+
q: query,
23+
per_page: 100,
24+
});
25+
26+
core.info(`Stale Draft PR Query found ${prResponse.data.total_count} items`);
27+
28+
if (!prResponse.data.items.length) {
29+
core.info(`No draft PRs to close`);
30+
return;
31+
}
32+
33+
core.info(`Attempting to close up to ${prResponse.data.items.length} draft PR(s)`);
34+
core.startGroup('Closing stale draft PRs');
35+
36+
interface PrActivityResponse {
37+
repository: {
38+
pullRequest: {
39+
isDraft: boolean;
40+
state: 'OPEN';
41+
updatedAt: string;
42+
author: {
43+
login: string;
44+
};
45+
commits: {
46+
nodes: {
47+
commit: {
48+
authoredDate: string;
49+
};
50+
}[];
51+
};
52+
comments: {
53+
nodes: {
54+
author: {
55+
login: string;
56+
} | null;
57+
createdAt: string;
58+
}[];
59+
};
60+
reviews: {
61+
nodes: {
62+
author: {
63+
login: string;
64+
} | null;
65+
comments: {
66+
nodes: {
67+
author: {
68+
login: string;
69+
} | null;
70+
createdAt: string;
71+
}[];
72+
};
73+
}[];
74+
};
75+
} | null;
76+
};
77+
}
78+
79+
for (const item of prResponse.data.items) {
80+
if (!item.pull_request) continue;
81+
82+
try {
83+
if (item.updated_at && new Date(item.updated_at) < threshold) {
84+
core.info(
85+
`PR #${item.number} has had no activity from anyone since ${item.updated_at}, closing immediately.`,
86+
);
87+
} else {
88+
const queryStr = `
89+
query GetPrActivity($owner: String!, $repo: String!, $prNumber: Int!) {
90+
repository(owner: $owner, name: $repo) {
91+
pullRequest(number: $prNumber) {
92+
isDraft
93+
state
94+
updatedAt
95+
author {
96+
login
97+
}
98+
commits(last: 1) {
99+
nodes {
100+
commit {
101+
authoredDate
102+
}
103+
}
104+
}
105+
comments(last: 100) {
106+
nodes {
107+
author {
108+
login
109+
}
110+
createdAt
111+
}
112+
}
113+
reviews(last: 100) {
114+
nodes {
115+
author {
116+
login
117+
}
118+
comments(last: 100) {
119+
nodes {
120+
author {
121+
login
122+
}
123+
createdAt
124+
}
125+
}
126+
}
127+
}
128+
}
129+
}
130+
}
131+
`;
132+
133+
const response = (await github.request('POST /graphql', {
134+
query: queryStr,
135+
variables: {
136+
owner: 'angular',
137+
repo,
138+
prNumber: item.number,
139+
},
140+
})) as {data: PrActivityResponse};
141+
142+
const pr = response.data.repository.pullRequest;
143+
144+
if (!pr || !pr.isDraft) {
145+
core.info(`Skipping PR #${item.number}, no longer a draft`);
146+
continue;
147+
}
148+
149+
const author = pr.author.login;
150+
151+
const latestCommitDate = new Date(pr.commits.nodes[0]?.commit.authoredDate || 0);
152+
if (latestCommitDate > threshold) {
153+
core.info(`Skipping PR #${item.number}, recent commit found from ${latestCommitDate}`);
154+
continue;
155+
}
156+
157+
const recentComment = pr.comments.nodes.find(
158+
(c) => c.author?.login === author && new Date(c.createdAt) > threshold,
159+
);
160+
161+
if (recentComment) {
162+
core.info(`Skipping PR #${item.number}, recent issue comment found from author`);
163+
continue;
164+
}
165+
166+
let recentReviewCommentFound = false;
167+
for (const review of pr.reviews.nodes) {
168+
const comment = review.comments.nodes.find(
169+
(c) => c.author?.login === author && new Date(c.createdAt) > threshold,
170+
);
171+
if (comment) {
172+
recentReviewCommentFound = true;
173+
break;
174+
}
175+
}
176+
177+
if (recentReviewCommentFound) {
178+
core.info(`Skipping PR #${item.number}, recent review comment found from author`);
179+
continue;
180+
}
181+
}
182+
183+
core.info(`Closing stale draft PR #${item.number}`);
184+
185+
await github.issues.createComment({
186+
owner: 'angular',
187+
repo,
188+
issue_number: item.number,
189+
body: message,
190+
});
191+
192+
await github.pulls.update({
193+
owner: 'angular',
194+
repo,
195+
pull_number: item.number,
196+
state: 'closed',
197+
});
198+
199+
await setTimeoutPromise(250);
200+
++closeCount;
201+
} catch (error: unknown) {
202+
const e = error as Error & {request?: unknown};
203+
core.warning(`Unable to close draft PR angular/${repo}#${item.number}: ${e.message}`);
204+
if (typeof e.request === 'object') {
205+
core.error(JSON.stringify(e.request, null, 2));
206+
}
207+
}
208+
}
209+
210+
core.endGroup();
211+
core.info(`Closed ${closeCount} stale draft PR(s)`);
212+
}
213+
214+
async function main() {
215+
const token = await getAuthTokenFor(ANGULAR_LOCK_BOT, {org: 'angular'});
216+
const repo = context.repo.repo;
217+
218+
try {
219+
const github = new Octokit({auth: token});
220+
await closeStaleDraftPrs(github, repo);
221+
} catch (error: any) {
222+
core.debug(error.message);
223+
core.setFailed(error.message);
224+
} finally {
225+
await revokeActiveInstallationToken(token);
226+
}
227+
}
228+
229+
// Only run if the action is executed in a repository with is in the Angular org. This is in place
230+
// to prevent the action from actually running in a fork of a repository with this action set up.
231+
// Runs triggered via 'workflow_dispatch' are also allowed to run.
232+
if (context.repo.owner === 'angular' || context.eventName === 'workflow_dispatch') {
233+
main().catch((e: Error) => {
234+
console.error(e);
235+
core.setFailed(e.message);
236+
});
237+
} else {
238+
core.warning(
239+
'The Stale Draft PR Cleanup was skipped as this action is only meant to run ' +
240+
'in repos belonging to the Angular organization.',
241+
);
242+
}

0 commit comments

Comments
 (0)