diff options
author | Ivan Blinkov <ivan@ydb.tech> | 2025-04-20 22:20:52 +0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-04-20 17:20:52 +0200 |
commit | d02bceec59d9a4ed6e19c62f45027f29970dfc8c (patch) | |
tree | 18b2040b9cc91f556a906fd2f6e21008a83fc621 | |
parent | 477209ca6236e7290f2622bf3382d8794fd56cc4 (diff) | |
download | ydb-d02bceec59d9a4ed6e19c62f45027f29970dfc8c.tar.gz |
[ci] create docs_pr_nudge.yaml (#17399)
-rw-r--r-- | .github/workflows/docs_pr_nudge.yaml | 176 |
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}` + }); + } + } |