-
-
Notifications
You must be signed in to change notification settings - Fork 360
Expand file tree
/
Copy pathdraft-pr-policy.js
More file actions
177 lines (152 loc) · 7.65 KB
/
Copy pathdraft-pr-policy.js
File metadata and controls
177 lines (152 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// Draft PRs get a longer inactivity window than ready PRs, and only checking
// the keep-alive box in the bot's own comment resets the clock -- a
// CI-triggered push or unrelated comment shouldn't make an abandoned draft
// look "fresh". Checking a box is also easier to discover and use than
// remembering an exact phrase to comment.
'use strict';
const LABEL = "draft-stale";
const STALE_DAYS = 60;
const KEEPALIVE_MARKER = "<!-- draft-stale-keepalive -->";
const KEEPALIVE_CHECKBOX = "- [ ] Still working on this -- check this box to keep the draft open";
const KEEPALIVE_CHECKED_RE = /-\s*\[[xX]\]\s*Still working on this/;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const daysSince = (isoDate) => (Date.now() - new Date(isoDate).getTime()) / MS_PER_DAY;
async function ensureLabel(github, owner, repo) {
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
} catch (err) {
if (err.status !== 404) throw err;
await github.rest.issues.createLabel({
owner,
repo,
name: LABEL,
color: "5319e7",
description: "Draft PR with no activity past the draft staleness window",
});
}
}
async function findKeepAliveComment(github, owner, repo, issue_number) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const marked = comments.filter((c) => c.body?.includes(KEEPALIVE_MARKER));
return marked.length ? marked[marked.length - 1] : null; // most recent stale episode's comment
}
// pr.updated_at is bumped by metadata-only changes (reviewer requested/removed, labels,
// milestone, assignee, etc.), which would let a draft dodge the staleness check forever
// without any real work happening. Use the latest commit/comment/review activity instead.
// Bot comments are excluded — they represent automated activity, not real human progress.
//
// One exception: a checked keepalive checkbox (see KEEPALIVE_CHECKBOX) IS real human
// activity, even though it's an edit to the bot's own comment rather than a new one of
// the human's own — GitHub lets any collaborator toggle a task-list checkbox in-place
// without changing the comment's author. Without counting it here, confirming via the
// checkbox would remove the label and post "Thanks for confirming" without ever moving
// the underlying clock, so the very next scheduled run would see the same stale
// last-activity timestamp and immediately re-flag it — contradicting the checkbox's own
// promise that checking it "resets this" (PR #6326 thrashed the label on a ~1-2 day loop
// once its true last activity fell behind the 60-day window and only the checkbox, never
// a new commit or comment, was being used to confirm it).
async function lastRealActivityAt(github, owner, repo, pr) {
const timestamps = [new Date(pr.created_at).getTime()];
const commits = await github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: pr.number, per_page: 100 });
for (const c of commits) {
const date = c.commit?.committer?.date || c.commit?.author?.date;
if (date) timestamps.push(new Date(date).getTime());
}
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
for (const c of comments) {
if (c.user?.type !== "Bot") {
timestamps.push(new Date(c.created_at).getTime());
} else if (c.body?.includes(KEEPALIVE_MARKER) && KEEPALIVE_CHECKED_RE.test(c.body)) {
timestamps.push(new Date(c.updated_at).getTime());
}
}
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100 });
for (const r of reviews) {
if (r.submitted_at && r.user?.type !== "Bot") timestamps.push(new Date(r.submitted_at).getTime());
}
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: pr.number, per_page: 100 });
for (const rc of reviewComments) {
if (rc.user?.type !== "Bot") timestamps.push(new Date(rc.created_at).getTime());
}
return Math.max(...timestamps);
}
// Returns true and removes the label if the PR has a keepalive signal:
// (a) the checkbox in the bot's keepalive comment is checked, OR
// (b) a non-bot human posted a comment after the keepalive comment was created.
// Case (b) handles external contributors who lack write access to edit the bot's comment.
async function processStaleSignals(github, owner, repo, pr) {
const keepAliveComment = await findKeepAliveComment(github, owner, repo, pr.number);
const checkboxChecked = keepAliveComment && KEEPALIVE_CHECKED_RE.test(keepAliveComment.body);
let humanCommentAfterStale = false;
if (!checkboxChecked && keepAliveComment) {
const staleDate = new Date(keepAliveComment.created_at);
const allComments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
for (const c of allComments) {
if (c.user?.type !== "Bot" && new Date(c.created_at) > staleDate) {
humanCommentAfterStale = true;
break;
}
}
}
if (checkboxChecked || humanCommentAfterStale) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL }).catch(() => {});
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: "Thanks for confirming — removing the stale label.",
});
return true;
}
return false;
}
async function runDraftPolicy({ github, context, core }) {
const { owner, repo } = context.repo;
await ensureLabel(github, owner, repo);
// Fast path for issue_comment: only check the PR that received the comment.
// This avoids scanning all open PRs on every comment event.
if (context.eventName === "issue_comment") {
const issue = context.payload.issue;
// issue_comment fires for issues too; skip non-PRs (PRs have a pull_request field).
if (!issue?.pull_request) return;
let prData;
try {
({ data: prData } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number }));
} catch (e) {
return;
}
if (!prData.draft) return;
const labelNames = prData.labels.map((l) => l.name);
if (!labelNames.includes(LABEL)) return;
const removed = await processStaleSignals(github, owner, repo, prData);
core.info(`draft-pr-policy: PR #${issue.number} — ${removed ? "un-staled" : "still stale"} (issue_comment trigger)`);
return;
}
// Full scan path for schedule / workflow_dispatch.
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
const drafts = prs.filter((pr) => pr.draft);
for (const pr of drafts) {
const labelNames = pr.labels.map((l) => l.name);
if (!labelNames.includes(LABEL)) {
const lastActivity = await lastRealActivityAt(github, owner, repo, pr);
if (daysSince(lastActivity) >= STALE_DAYS) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] });
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body:
`${KEEPALIVE_MARKER}\n` +
`This draft has had no activity for ${STALE_DAYS} days and has been marked \`${LABEL}\`.\n\n` +
`${KEEPALIVE_CHECKBOX}\n\n` +
`Checking the box or posting a new comment resets this — a commit or other automated update alone won't. ` +
`Otherwise it will be flagged for maintainer review.`,
});
}
continue;
}
await processStaleSignals(github, owner, repo, pr);
}
core.info(`draft-pr-policy: checked ${drafts.length} draft PR(s)`);
}
module.exports = { runDraftPolicy };