aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorIvan Blinkov <ivan@ydb.tech>2025-04-20 22:20:52 +0700
committerGitHub <noreply@github.com>2025-04-20 17:20:52 +0200
commitd02bceec59d9a4ed6e19c62f45027f29970dfc8c (patch)
tree18b2040b9cc91f556a906fd2f6e21008a83fc621
parent477209ca6236e7290f2622bf3382d8794fd56cc4 (diff)
downloadydb-d02bceec59d9a4ed6e19c62f45027f29970dfc8c.tar.gz
[ci] create docs_pr_nudge.yaml (#17399)
-rw-r--r--.github/workflows/docs_pr_nudge.yaml176
1 files changed, 176 insertions, 0 deletions
diff --git a/.github/workflows/docs_pr_nudge.yaml b/.github/workflows/docs_pr_nudge.yaml
new file mode 100644
index 00000000000..23a41513e6b
--- /dev/null
+++ b/.github/workflows/docs_pr_nudge.yaml
@@ -0,0 +1,176 @@
+name: Documentation PR nudger
+
+# Posts a comment to PR's labeled "documentation" in the following cases:
+# 1) The assignee is not promptly providing feedback for the iteration
+# 2) The author received feedback but hasn't acted on it for a long time
+# Skips weekends and first weeks of January and May.
+#
+# See https://ydb.tech/docs/en/contributor/documentation/review for more details
+
+on:
+ schedule:
+ - cron: '0 0,6,12,18 * * *'
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ nudge:
+ runs-on: ubuntu-latest
+ env:
+ AUTHOR_RESPONSE_THRESHOLD_HOURS: 24 # when to nudge reviewers after author
+ REVIEWER_RESPONSE_THRESHOLD_DAYS: 7 # when to nudge author after reviewer
+ REVIEWER_NUDGE_COOLDOWN_HOURS: 24 # min hours between reviewer‑nudges
+ AUTHOR_NUDGE_COOLDOWN_DAYS: 7 # min days between author‑nudges
+ steps:
+ - uses: actions/github-script@v7
+
+ with:
+ github-token: ${{ secrets.YDBOT_TOKEN }}
+ script: |
+ const { owner, repo } = context.repo;
+ const authorThresholdH = Number(process.env.AUTHOR_RESPONSE_THRESHOLD_HOURS);
+ const reviewerThresholdD = Number(process.env.REVIEWER_RESPONSE_THRESHOLD_DAYS);
+ const reviewerCooldownH = Number(process.env.REVIEWER_NUDGE_COOLDOWN_HOURS);
+ const authorCooldownD = Number(process.env.AUTHOR_NUDGE_COOLDOWN_DAYS);
+ const BOT_MARKER = '<!-- docs-review-nudge -->';
+
+ const now = new Date();
+ const day = now.getUTCDay();
+ const date = now.getUTCDate(); // 1–31
+ const month = now.getUTCMonth() + 1; // 1–12
+
+ // skip weekends, first week of May, first week of the year
+ if (
+ day === 0 || day === 6 || // 0=Sun,6=Sat
+ (month === 5 && date <= 7) ||
+ (month === 1 && date <= 7)
+ ) {
+ console.log('Skip run: weekend or first week of May/year');
+ return;
+ }
+
+ const isWeekend = d => d.getUTCDay() === 0 || d.getUTCDay() === 6;
+
+ // sum only business‑hours between two dates
+ function businessMsBetween(start, end) {
+ let ms = 0;
+ let cur = new Date(start);
+ while (cur < end) {
+ if (!isWeekend(cur)) ms += 3600e3;
+ cur = new Date(cur.getTime() + 3600e3);
+ }
+ return ms;
+ }
+
+ // count only business‑days between two dates
+ function businessDaysBetween(start, end) {
+ let days = 0;
+ let cur = new Date(start);
+ cur.setUTCHours(0,0,0,0);
+ const endDay = new Date(end);
+ endDay.setUTCHours(0,0,0,0);
+ while (cur < endDay) {
+ if (!isWeekend(cur)) days++;
+ cur.setUTCDate(cur.getUTCDate() + 1);
+ }
+ return days;
+ }
+
+ // 1) search open, non‑draft docs PRs
+ const q = [
+ `repo:${owner}/${repo}`,
+ `type:pr`,
+ `state:open`,
+ `label:documentation`,
+ `draft:false`
+ ].join(' ');
+ const { data: { items: docsPRs } } = await github.rest.search.issuesAndPullRequests({ q, per_page: 100 });
+
+ // 2) fetch primary-docs-reviewers
+ const members = await github.paginate(
+ github.rest.teams.listMembersInOrg,
+ { org: owner, team_slug: 'primary-docs-reviewers', per_page: 100 }
+ );
+ const reviewerLogins = members.map(u => u.login);
+
+ for (const prItem of docsPRs) {
+ const prNum = prItem.number;
+ const pr = (await github.rest.pulls.get({ owner, repo, pull_number: prNum })).data;
+
+ // assign reviewer if none assigned
+ if (!pr.assignees.some(a => reviewerLogins.includes(a.login))) {
+ const pick = reviewerLogins[Math.floor(Math.random() * reviewerLogins.length)];
+ await github.rest.issues.addAssignees({
+ owner, repo, issue_number: prNum, assignees: [pick]
+ });
+ }
+
+ // collect all events
+ const [comments, reviewComments, reviews, commits] = await Promise.all([
+ github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNum, per_page: 100 }),
+ github.paginate(github.rest.pulls.listReviewComments, { owner, repo, pull_number: prNum, per_page: 100 }),
+ github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: prNum, per_page: 100 }),
+ github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: prNum, per_page: 100 }),
+ ]);
+
+ const author = pr.user.login;
+ const assignees = pr.assignees.map(a => a.login);
+
+ const extract = (arr, whoFn, dateKey) =>
+ arr.map(i => ({ who: whoFn(i), when: new Date(i[dateKey]) }))
+ .filter(x => x.when);
+
+ const allEvents = [
+ ...extract(comments, c => c.user.login, 'created_at'),
+ ...extract(reviewComments, c => c.user.login, 'created_at'),
+ ...extract(reviews, r => r.user.login, 'submitted_at'),
+ ...extract(commits, c => c.author?.login || c.commit?.author?.name, 'commit.author.date'),
+ { who: author, when: new Date(pr.created_at) }
+ ];
+
+ const authorEvents = allEvents.filter(e => e.who === author).map(e => e.when);
+ const reviewerEvents = allEvents.filter(e => assignees.includes(e.who)).map(e => e.when);
+
+ const lastAuthorActivity = authorEvents.length ? new Date(Math.max(...authorEvents)) : new Date(pr.created_at);
+ const lastReviewerActivity = reviewerEvents.length ? new Date(Math.max(...reviewerEvents)) : new Date(0);
+
+ // last nudge time
+ const nudgeTimes = comments.filter(c => c.body.includes(BOT_MARKER))
+ .map(c => new Date(c.created_at));
+ const lastNudgeTime = nudgeTimes.length ? new Date(Math.max(...nudgeTimes)) : new Date(0);
+
+ // RULE 1: nudge reviewers
+ const msSinceAuthor = businessMsBetween(lastAuthorActivity, now);
+ const hoursSinceAuthor= Math.floor(msSinceAuthor / 3600e3);
+ const msSinceLastRevNudge = businessMsBetween(lastNudgeTime, now);
+
+ if (
+ lastAuthorActivity > lastReviewerActivity &&
+ msSinceAuthor > authorThresholdH * 3600e3 &&
+ msSinceLastRevNudge > reviewerCooldownH * 3600e3
+ ) {
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNum,
+ 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}`
+ });
+ continue;
+ }
+
+ // RULE 2: nudge author
+ const daysSinceReviewer = businessDaysBetween(lastReviewerActivity, now);
+ const daysSinceLastAuthNudge = businessDaysBetween(lastNudgeTime, now);
+
+ if (
+ lastReviewerActivity > lastAuthorActivity &&
+ daysSinceReviewer > reviewerThresholdD &&
+ daysSinceLastAuthNudge > authorCooldownD
+ ) {
+ await github.rest.issues.createComment({
+ owner, repo, issue_number: prNum,
+ 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}`
+ });
+ }
+ }