Skip to content

Commit 31a2dcd

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 31a2dcd

7 files changed

Lines changed: 25366 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: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
6+
export async function closeStaleDraftPrs(github: Octokit, repo: string): Promise<void> {
7+
const days = parseInt(core.getInput('days', {required: false}) || '28', 10);
8+
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!`;
9+
10+
const threshold = new Date();
11+
threshold.setDate(threshold.getDate() - days);
12+
const thresholdIso = threshold.toISOString();
13+
14+
const repositoryName = `${context.repo.owner}/${repo}`;
15+
const query = `repo:${repositoryName} is:pr is:draft is:open updated:<${thresholdIso} sort:updated-asc`;
16+
core.info('Stale Draft PR Query: ' + query);
17+
18+
let closeCount = 0;
19+
const prResponse = await github.search.issuesAndPullRequests({
20+
q: query,
21+
per_page: 100,
22+
});
23+
24+
core.info(`Stale Draft PR Query found ${prResponse.data.total_count} items`);
25+
26+
if (!prResponse.data.items.length) {
27+
core.info(`No draft PRs to close`);
28+
return;
29+
}
30+
31+
core.info(`Attempting to close up to ${prResponse.data.items.length} draft PR(s)`);
32+
core.startGroup('Closing stale draft PRs');
33+
34+
for (const item of prResponse.data.items) {
35+
if (!item.pull_request) continue;
36+
37+
try {
38+
core.info(`Closing stale draft PR #${item.number}`);
39+
40+
await github.request('POST /graphql', {
41+
query: `
42+
mutation CloseStalePR($id: ID!, $body: String!) {
43+
addComment(input: {subjectId: $id, body: $body}) {
44+
clientMutationId
45+
}
46+
closePullRequest(input: {pullRequestId: $id}) {
47+
pullRequest {
48+
state
49+
}
50+
}
51+
}
52+
`,
53+
variables: {
54+
id: item.node_id,
55+
body: message,
56+
},
57+
});
58+
59+
++closeCount;
60+
} catch (error: unknown) {
61+
const e = error as Error & {request?: unknown};
62+
core.warning(`Unable to close draft PR ${repositoryName}#${item.number}: ${e.message}`);
63+
if (typeof e.request === 'object') {
64+
core.error(JSON.stringify(e.request, null, 2));
65+
}
66+
}
67+
}
68+
69+
core.endGroup();
70+
core.info(`Closed ${closeCount} stale draft PR(s)`);
71+
}
72+
73+
async function main() {
74+
const token = await getAuthTokenFor(ANGULAR_LOCK_BOT, {org: 'angular'});
75+
const repo = context.repo.repo;
76+
77+
try {
78+
const github = new Octokit({auth: token});
79+
await closeStaleDraftPrs(github, repo);
80+
} catch (error: any) {
81+
core.debug(error.message);
82+
core.setFailed(error.message);
83+
} finally {
84+
await revokeActiveInstallationToken(token);
85+
}
86+
}
87+
88+
// Only run if the action is executed in a repository with is in the Angular org. This is in place
89+
// to prevent the action from actually running in a fork of a repository with this action set up.
90+
// Runs triggered via 'workflow_dispatch' are also allowed to run.
91+
if (context.repo.owner === 'angular' || context.eventName === 'workflow_dispatch') {
92+
main().catch((e: Error) => {
93+
console.error(e);
94+
core.setFailed(e.message);
95+
});
96+
} else {
97+
core.warning(
98+
'The Stale Draft PR Cleanup was skipped as this action is only meant to run ' +
99+
'in repos belonging to the Angular organization.',
100+
);
101+
}

0 commit comments

Comments
 (0)