Skip to content

Commit d02bcee

Browse files
authored
[ci] create docs_pr_nudge.yaml (#17399)
1 parent 477209c commit d02bcee

File tree

1 file changed

+176
-0
lines changed

1 file changed

+176
-0
lines changed

.github/workflows/docs_pr_nudge.yaml

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
name: Documentation PR nudger
2+
3+
# Posts a comment to PR's labeled "documentation" in the following cases:
4+
# 1) The assignee is not promptly providing feedback for the iteration
5+
# 2) The author received feedback but hasn't acted on it for a long time
6+
# Skips weekends and first weeks of January and May.
7+
#
8+
# See https://ydb.tech/docs/en/contributor/documentation/review for more details
9+
10+
on:
11+
schedule:
12+
- cron: '0 0,6,12,18 * * *'
13+
14+
permissions:
15+
contents: read
16+
issues: write
17+
pull-requests: write
18+
19+
jobs:
20+
nudge:
21+
runs-on: ubuntu-latest
22+
env:
23+
AUTHOR_RESPONSE_THRESHOLD_HOURS: 24 # when to nudge reviewers after author
24+
REVIEWER_RESPONSE_THRESHOLD_DAYS: 7 # when to nudge author after reviewer
25+
REVIEWER_NUDGE_COOLDOWN_HOURS: 24 # min hours between reviewer‑nudges
26+
AUTHOR_NUDGE_COOLDOWN_DAYS: 7 # min days between author‑nudges
27+
steps:
28+
- uses: actions/github-script@v7
29+
30+
with:
31+
github-token: ${{ secrets.YDBOT_TOKEN }}
32+
script: |
33+
const { owner, repo } = context.repo;
34+
const authorThresholdH = Number(process.env.AUTHOR_RESPONSE_THRESHOLD_HOURS);
35+
const reviewerThresholdD = Number(process.env.REVIEWER_RESPONSE_THRESHOLD_DAYS);
36+
const reviewerCooldownH = Number(process.env.REVIEWER_NUDGE_COOLDOWN_HOURS);
37+
const authorCooldownD = Number(process.env.AUTHOR_NUDGE_COOLDOWN_DAYS);
38+
const BOT_MARKER = '<!-- docs-review-nudge -->';
39+
40+
const now = new Date();
41+
const day = now.getUTCDay();
42+
const date = now.getUTCDate(); // 1–31
43+
const month = now.getUTCMonth() + 1; // 1–12
44+
45+
// skip weekends, first week of May, first week of the year
46+
if (
47+
day === 0 || day === 6 || // 0=Sun,6=Sat
48+
(month === 5 && date <= 7) ||
49+
(month === 1 && date <= 7)
50+
) {
51+
console.log('Skip run: weekend or first week of May/year');
52+
return;
53+
}
54+
55+
const isWeekend = d => d.getUTCDay() === 0 || d.getUTCDay() === 6;
56+
57+
// sum only business‑hours between two dates
58+
function businessMsBetween(start, end) {
59+
let ms = 0;
60+
let cur = new Date(start);
61+
while (cur < end) {
62+
if (!isWeekend(cur)) ms += 3600e3;
63+
cur = new Date(cur.getTime() + 3600e3);
64+
}
65+
return ms;
66+
}
67+
68+
// count only business‑days between two dates
69+
function businessDaysBetween(start, end) {
70+
let days = 0;
71+
let cur = new Date(start);
72+
cur.setUTCHours(0,0,0,0);
73+
const endDay = new Date(end);
74+
endDay.setUTCHours(0,0,0,0);
75+
while (cur < endDay) {
76+
if (!isWeekend(cur)) days++;
77+
cur.setUTCDate(cur.getUTCDate() + 1);
78+
}
79+
return days;
80+
}
81+
82+
// 1) search open, non‑draft docs PRs
83+
const q = [
84+
`repo:${owner}/${repo}`,
85+
`type:pr`,
86+
`state:open`,
87+
`label:documentation`,
88+
`draft:false`
89+
].join(' ');
90+
const { data: { items: docsPRs } } = await github.rest.search.issuesAndPullRequests({ q, per_page: 100 });
91+
92+
// 2) fetch primary-docs-reviewers
93+
const members = await github.paginate(
94+
github.rest.teams.listMembersInOrg,
95+
{ org: owner, team_slug: 'primary-docs-reviewers', per_page: 100 }
96+
);
97+
const reviewerLogins = members.map(u => u.login);
98+
99+
for (const prItem of docsPRs) {
100+
const prNum = prItem.number;
101+
const pr = (await github.rest.pulls.get({ owner, repo, pull_number: prNum })).data;
102+
103+
// assign reviewer if none assigned
104+
if (!pr.assignees.some(a => reviewerLogins.includes(a.login))) {
105+
const pick = reviewerLogins[Math.floor(Math.random() * reviewerLogins.length)];
106+
await github.rest.issues.addAssignees({
107+
owner, repo, issue_number: prNum, assignees: [pick]
108+
});
109+
}
110+
111+
// collect all events
112+
const [comments, reviewComments, reviews, commits] = await Promise.all([
113+
github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNum, per_page: 100 }),
114+
github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: prNum, per_page: 100 }),
115+
github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: prNum, per_page: 100 }),
116+
github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: prNum, per_page: 100 }),
117+
]);
118+
119+
const author = pr.user.login;
120+
const assignees = pr.assignees.map(a => a.login);
121+
122+
const extract = (arr, whoFn, dateKey) =>
123+
arr.map(i => ({ who: whoFn(i), when: new Date(i[dateKey]) }))
124+
.filter(x => x.when);
125+
126+
const allEvents = [
127+
...extract(comments, c => c.user.login, 'created_at'),
128+
...extract(reviewComments, c => c.user.login, 'created_at'),
129+
...extract(reviews, r => r.user.login, 'submitted_at'),
130+
...extract(commits, c => c.author?.login || c.commit?.author?.name, 'commit.author.date'),
131+
{ who: author, when: new Date(pr.created_at) }
132+
];
133+
134+
const authorEvents = allEvents.filter(e => e.who === author).map(e => e.when);
135+
const reviewerEvents = allEvents.filter(e => assignees.includes(e.who)).map(e => e.when);
136+
137+
const lastAuthorActivity = authorEvents.length ? new Date(Math.max(...authorEvents)) : new Date(pr.created_at);
138+
const lastReviewerActivity = reviewerEvents.length ? new Date(Math.max(...reviewerEvents)) : new Date(0);
139+
140+
// last nudge time
141+
const nudgeTimes = comments.filter(c => c.body.includes(BOT_MARKER))
142+
.map(c => new Date(c.created_at));
143+
const lastNudgeTime = nudgeTimes.length ? new Date(Math.max(...nudgeTimes)) : new Date(0);
144+
145+
// RULE 1: nudge reviewers
146+
const msSinceAuthor = businessMsBetween(lastAuthorActivity, now);
147+
const hoursSinceAuthor= Math.floor(msSinceAuthor / 3600e3);
148+
const msSinceLastRevNudge = businessMsBetween(lastNudgeTime, now);
149+
150+
if (
151+
lastAuthorActivity > lastReviewerActivity &&
152+
msSinceAuthor > authorThresholdH * 3600e3 &&
153+
msSinceLastRevNudge > reviewerCooldownH * 3600e3
154+
) {
155+
await github.rest.issues.createComment({
156+
owner, repo, issue_number: prNum,
157+
body: `Hey ${assignees.map(a=>`@${a}`).join(', ')}, it has been ${hoursSinceAuthor} business‑hours since the author's last update, could you please review? ${BOT_MARKER}`
158+
});
159+
continue;
160+
}
161+
162+
// RULE 2: nudge author
163+
const daysSinceReviewer = businessDaysBetween(lastReviewerActivity, now);
164+
const daysSinceLastAuthNudge = businessDaysBetween(lastNudgeTime, now);
165+
166+
if (
167+
lastReviewerActivity > lastAuthorActivity &&
168+
daysSinceReviewer > reviewerThresholdD &&
169+
daysSinceLastAuthNudge > authorCooldownD
170+
) {
171+
await github.rest.issues.createComment({
172+
owner, repo, issue_number: prNum,
173+
body: `Heads‑up: it's been ${daysSinceReviewer} business‑days since a reviewer comment. @${author}, any updates? ${assignees.map(a=>`@${a}`).join(' ')}, please check the status with the author. ${BOT_MARKER}`
174+
});
175+
}
176+
}

0 commit comments

Comments
 (0)