Skip to content

Commit 8c018d2

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 8c018d2

File tree

8 files changed

+25405
-0
lines changed

8 files changed

+25405
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
2+
load("//tools:defaults.bzl", "esbuild_checked_in", "ts_project")
3+
4+
package(default_visibility = ["//.github/local-actions/stale-cleanup:__subpackages__"])
5+
6+
npm_link_all_packages()
7+
8+
ts_project(
9+
name = "lib",
10+
srcs = glob(
11+
["lib/*.ts"],
12+
),
13+
tsconfig = "//.github/local-actions:tsconfig",
14+
deps = [
15+
"//.github/local-actions/stale-cleanup:node_modules/@actions/core",
16+
"//.github/local-actions/stale-cleanup:node_modules/@actions/github",
17+
"//.github/local-actions/stale-cleanup:node_modules/@octokit/rest",
18+
"//.github/local-actions/stale-cleanup:node_modules/@types/node",
19+
"//github-actions:utils",
20+
],
21+
)
22+
23+
esbuild_checked_in(
24+
name = "main",
25+
srcs = [
26+
":lib",
27+
],
28+
entry_point = "lib/main.ts",
29+
format = "esm",
30+
platform = "node",
31+
target = "node24",
32+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: 'Stale Draft PR Cleanup'
2+
description: 'Automatically closes draft PRs that have been inactive for a specific number of days.'
3+
author: 'Angular'
4+
inputs:
5+
angular-robot-key:
6+
description: 'The private key for the Angular Robot Github app.'
7+
required: true
8+
repos:
9+
description: |
10+
The repositories in which to clean up stale draft PRs. The organization name is derived from
11+
the context in where the action runs.
12+
required: true
13+
runs:
14+
using: 'node24'
15+
main: 'main.js'
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import {Octokit} from '@octokit/rest';
4+
import {
5+
ANGULAR_ROBOT,
6+
getAuthTokenFor,
7+
revokeActiveInstallationToken,
8+
} from '../../../../github-actions/utils.js';
9+
10+
const STALE_DAYS = 28;
11+
12+
export async function closeStaleDraftPrs(github: Octokit, repo: string): Promise<void> {
13+
const message = `This draft PR is being closed because it has been stale for ${STALE_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!`;
14+
15+
const threshold = new Date();
16+
threshold.setDate(threshold.getDate() - STALE_DAYS);
17+
const thresholdIso = threshold.toISOString();
18+
19+
const repositoryName = `${context.repo.owner}/${repo}`;
20+
const query = `repo:${repositoryName} is:pr is:draft is:open updated:<${thresholdIso} sort:updated-asc`;
21+
core.info('Stale Draft PR Query: ' + query);
22+
23+
let closeCount = 0;
24+
// We look at 100 at a time to avoid handling too many PRs in one go.
25+
// With each batch of 100 we'll eventually burn down the list of all stale draft PRs.
26+
const prResponse = await github.search.issuesAndPullRequests({
27+
q: query,
28+
per_page: 100,
29+
});
30+
31+
core.info(`Stale Draft PR Query found ${prResponse.data.total_count} items`);
32+
33+
if (!prResponse.data.items.length) {
34+
core.info(`No draft PRs to close`);
35+
return;
36+
}
37+
38+
core.info(`Attempting to close up to ${prResponse.data.items.length} draft PR(s)`);
39+
core.startGroup('Closing stale draft PRs');
40+
41+
for (const item of prResponse.data.items) {
42+
if (!item.pull_request) continue;
43+
44+
try {
45+
await github.request('POST /graphql', {
46+
query: `
47+
mutation CloseStalePR($id: ID!, $body: String!) {
48+
addComment(input: {subjectId: $id, body: $body}) {
49+
clientMutationId
50+
}
51+
closePullRequest(input: {pullRequestId: $id}) {
52+
pullRequest {
53+
state
54+
}
55+
}
56+
}
57+
`,
58+
variables: {
59+
id: item.node_id,
60+
body: message,
61+
},
62+
});
63+
64+
++closeCount;
65+
} catch (error: unknown) {
66+
const e = error as Error & {request?: unknown};
67+
core.warning(`Unable to close draft PR ${repositoryName}#${item.number}: ${e.message}`);
68+
if (typeof e.request === 'object') {
69+
core.error(JSON.stringify(e.request, null, 2));
70+
}
71+
}
72+
}
73+
74+
core.endGroup();
75+
core.info(`Closed ${closeCount} stale draft PR(s)`);
76+
}
77+
78+
async function main() {
79+
const github = new Octokit({auth: await getAuthTokenFor(ANGULAR_ROBOT)});
80+
try {
81+
const repos = core.getMultilineInput('repos', {required: true, trimWhitespace: true});
82+
await core.group('Repos being cleaned:', async () =>
83+
repos.forEach((repo) => core.info(`- ${repo}`)),
84+
);
85+
for (const repo of repos) {
86+
await closeStaleDraftPrs(github, repo);
87+
}
88+
} catch (error: any) {
89+
core.debug(error.message);
90+
core.setFailed(error.message);
91+
} finally {
92+
await revokeActiveInstallationToken(github);
93+
}
94+
}
95+
96+
main().catch((err) => {
97+
console.error(err);
98+
core.setFailed('Failed with the above error');
99+
});

0 commit comments

Comments
 (0)