Skip to content

Commit f6e54a8

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 f6e54a8

8 files changed

Lines changed: 25400 additions & 0 deletions

File tree

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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import {Octokit} from '@octokit/rest';
4+
import {ANGULAR_ROBOT, getAuthTokenFor, revokeActiveInstallationToken} from '../../../../github-actions/utils.js';
5+
6+
const STALE_DAYS = 28;
7+
8+
export async function closeStaleDraftPrs(github: Octokit, repo: string): Promise<void> {
9+
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!`;
10+
11+
const threshold = new Date();
12+
threshold.setDate(threshold.getDate() - STALE_DAYS);
13+
const thresholdIso = threshold.toISOString();
14+
15+
const repositoryName = `${context.repo.owner}/${repo}`;
16+
const query = `repo:${repositoryName} is:pr is:draft is:open updated:<${thresholdIso} sort:updated-asc`;
17+
core.info('Stale Draft PR Query: ' + query);
18+
19+
let closeCount = 0;
20+
// We look at 100 at a time to avoid handling too many PRs in one go.
21+
// With each batch of 100 we'll eventually burn down the list of all stale draft PRs.
22+
const prResponse = await github.search.issuesAndPullRequests({
23+
q: query,
24+
per_page: 100,
25+
});
26+
27+
core.info(`Stale Draft PR Query found ${prResponse.data.total_count} items`);
28+
29+
if (!prResponse.data.items.length) {
30+
core.info(`No draft PRs to close`);
31+
return;
32+
}
33+
34+
core.info(`Attempting to close up to ${prResponse.data.items.length} draft PR(s)`);
35+
core.startGroup('Closing stale draft PRs');
36+
37+
for (const item of prResponse.data.items) {
38+
if (!item.pull_request) continue;
39+
40+
try {
41+
await github.request('POST /graphql', {
42+
query: `
43+
mutation CloseStalePR($id: ID!, $body: String!) {
44+
addComment(input: {subjectId: $id, body: $body}) {
45+
clientMutationId
46+
}
47+
closePullRequest(input: {pullRequestId: $id}) {
48+
pullRequest {
49+
state
50+
}
51+
}
52+
}
53+
`,
54+
variables: {
55+
id: item.node_id,
56+
body: message,
57+
},
58+
});
59+
60+
++closeCount;
61+
} catch (error: unknown) {
62+
const e = error as Error & {request?: unknown};
63+
core.warning(`Unable to close draft PR ${repositoryName}#${item.number}: ${e.message}`);
64+
if (typeof e.request === 'object') {
65+
core.error(JSON.stringify(e.request, null, 2));
66+
}
67+
}
68+
}
69+
70+
core.endGroup();
71+
core.info(`Closed ${closeCount} stale draft PR(s)`);
72+
}
73+
74+
async function main() {
75+
const github = new Octokit({auth: await getAuthTokenFor(ANGULAR_ROBOT)});
76+
try {
77+
const repos = core.getMultilineInput('repos', {required: true, trimWhitespace: true});
78+
await core.group('Repos being cleaned:', async () =>
79+
repos.forEach((repo) => core.info(`- ${repo}`)),
80+
);
81+
for (const repo of repos) {
82+
await closeStaleDraftPrs(github, repo);
83+
}
84+
} catch (error: any) {
85+
core.debug(error.message);
86+
core.setFailed(error.message);
87+
} finally {
88+
await revokeActiveInstallationToken(github);
89+
}
90+
}
91+
92+
main().catch((err) => {
93+
console.error(err);
94+
core.setFailed('Failed with the above error');
95+
});

0 commit comments

Comments
 (0)