From 6ebfd4b4e356eb8ed128c6b18942f6a03e4b2145 Mon Sep 17 00:00:00 2001 From: Anis Benabdallah Date: Wed, 28 Oct 2026 16:27:10 -0400 Subject: [PATCH] chore(discord-bot): stop sending assignment, approved-review and stale-work messages --- .github/discord-bot/github.mjs | 4 - .github/discord-bot/handlers/issues.mjs | 58 ------- .github/discord-bot/handlers/pull_request.mjs | 22 +-- .github/discord-bot/reminders.mjs | 143 ------------------ .github/workflows/discord-notify.yml | 3 +- .github/workflows/discord-reminders.yml | 19 +-- 6 files changed, 9 insertions(+), 240 deletions(-) delete mode 100644 .github/discord-bot/reminders.mjs diff --git a/.github/discord-bot/github.mjs b/.github/discord-bot/github.mjs index 4251183..92b212f 100644 --- a/.github/discord-bot/github.mjs +++ b/.github/discord-bot/github.mjs @@ -150,10 +150,6 @@ async function listIssues(owner, repo, query) { return out } -export async function listOpenIssues(owner, repo) { - return listIssues(owner, repo, 'state=open') -} - /** Every issue filed against one milestone — closed ones included, for the ratio. */ export async function listMilestoneIssues( owner, diff --git a/.github/discord-bot/handlers/issues.mjs b/.github/discord-bot/handlers/issues.mjs index fa3291f..79b31d9 100644 --- a/.github/discord-bot/handlers/issues.mjs +++ b/.github/discord-bot/handlers/issues.mjs @@ -59,61 +59,6 @@ async function announceUnblocked(owner, repo, issue, { trigger, block }) { }) } -/** Someone got assigned — tell them whether they can actually start. */ -async function onAssigned(owner, repo, issue, payload) { - const target = payload.assignee?.login - const block = await getBlockState(owner, repo, issue) - const blocking = await countBlocking(owner, repo, issue.number) - - const blockingField = { - name: 'Blocking', - value: blockingFieldValue(blocking), - inline: true, - } - - if (!block.blocked) { - const urgency = blocking - ? ` Worth prioritising — it's ${blockingPhrase(blocking)}.` - : '' - await sendDiscord({ - content: `${mention(target)} — you're assigned to #${issue.number} and nothing is blocking it. Ready to work.${urgency}`, - pingLogins: [target], - embed: { - title: `#${issue.number} ${clamp(issue.title, 200)}`, - url: issue.html_url, - color: COLORS.ready, - description: clamp(issue.body, 300) || '_No description._', - fields: [ - blockingField, - { name: 'Repo', value: `${owner}/${repo}`, inline: true }, - ], - timestamp: new Date().toISOString(), - }, - }) - return - } - - await sendDiscord({ - content: `${mention(target)} — you're assigned to #${issue.number}, but it's blocked. Nothing to do yet; I'll ping you the moment it clears.`, - pingLogins: [target], - embed: { - title: `#${issue.number} ${clamp(issue.title, 200)}`, - url: issue.html_url, - color: COLORS.blocked, - fields: [ - { - name: 'Waiting on', - value: block.openBlockers.length - ? clamp(formatBlockerList(block.openBlockers), 800) - : 'a blocking label', - }, - blockingField, - ], - timestamp: new Date().toISOString(), - }, - }) -} - /** A closed issue can free everything downstream of it. */ async function onClosed(owner, repo, issue) { const dependents = await findDependents(owner, repo, issue.number) @@ -136,9 +81,6 @@ export async function handleIssues(payload) { const repo = repository.name switch (action) { - case 'assigned': - return onAssigned(owner, repo, issue, payload) - case 'closed': return onClosed(owner, repo, issue) diff --git a/.github/discord-bot/handlers/pull_request.mjs b/.github/discord-bot/handlers/pull_request.mjs index f5ba21f..ae1754f 100644 --- a/.github/discord-bot/handlers/pull_request.mjs +++ b/.github/discord-bot/handlers/pull_request.mjs @@ -141,34 +141,24 @@ export async function handlePullRequest(payload) { } } -/** A submitted review is news for the PR author, not the reviewer. */ +/** A request for changes is news for the PR author, not the reviewer. */ export async function handlePullRequestReview(payload) { if (payload.action !== 'submitted') return const { review, pull_request: pr, repository } = payload + // Approvals and plain comments are not announced; only requested changes are. + if (String(review.state ?? '').toLowerCase() !== 'changes_requested') return + const author = pr.user?.login const reviewer = review.user?.login - const line = { - approved: { - text: `${mention(author)} — ${mention(reviewer)} approved #${pr.number}. Good to merge.`, - color: COLORS.unblocked, - }, - changes_requested: { - text: `${mention(author)} — ${mention(reviewer)} requested changes on #${pr.number}.`, - color: COLORS.review, - }, - }[String(review.state ?? '').toLowerCase()] - - if (!line) return // plain comments would just be noise - await sendDiscord({ review: true, - content: line.text, + content: `${mention(author)} — ${mention(reviewer)} requested changes on #${pr.number}.`, // The reviewer is pinged as well as the author: they asked for a change and will // want the thread in their notifications when the revision lands. pingLogins: [...new Set([author, reviewer].filter(Boolean))], - embed: prEmbed(pr, repository, line.color, [ + embed: prEmbed(pr, repository, COLORS.review, [ { name: 'Review', value: clamp(review.body, 500) || '_No comment left._', diff --git a/.github/discord-bot/reminders.mjs b/.github/discord-bot/reminders.mjs deleted file mode 100644 index ad6d71c..0000000 --- a/.github/discord-bot/reminders.mjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Scheduled nudge for work that has gone quiet. - * - * Run on a cron by .github/workflows/discord-reminders.yml. Posts one digest that - * pings each person with their open, assigned, unblocked issues that have seen no - * recent activity. - * - * Deliberately silent about: - * - blocked issues — nagging someone about work they cannot start is noise - * - unassigned issues — there is nobody to remind - * - recently touched work — an issue commented on this morning is not stale - * - * Silence is the intended output when everyone is current. - */ -import { assertConfig, mention } from './config.mjs' -import { COLORS, clamp, sendDiscord } from './discord.mjs' -import { blockingPhrase, countBlocking, getBlockState } from './blocking.mjs' -import { listOpenIssues } from './github.mjs' - -assertConfig() - -const repoFullName = process.env.GITHUB_REPOSITORY -if (!repoFullName) { - console.error('::error::GITHUB_REPOSITORY is not set.') - process.exit(1) -} -const [owner, repo] = repoFullName.split('/') - -const staleDays = Number(process.env.STALE_DAYS || 2) -const now = Date.now() -const daysSince = (iso) => (now - new Date(iso).getTime()) / 86_400_000 - -const issues = await listOpenIssues(owner, repo) -console.log(`[reminders] ${issues.length} open issues in ${repoFullName}`) - -/** login -> issues that person could be working on right now but has not touched. */ -const byAssignee = new Map() -let skippedBlocked = 0 -let skippedFresh = 0 -let skippedUnassigned = 0 -let unreadable = 0 - -for (const issue of issues) { - const assignees = (issue.assignees ?? []).map((a) => a.login).filter(Boolean) - if (assignees.length === 0) { - skippedUnassigned++ - continue - } - - const idle = daysSince(issue.updated_at) - if (idle < staleDays) { - skippedFresh++ - continue - } - - // One unreachable issue must not sink the whole digest. - let block - try { - block = await getBlockState(owner, repo, issue) - } catch (err) { - console.warn(`[reminders] skipping #${issue.number}: ${err.message}`) - unreadable++ - continue - } - if (block.blocked) { - skippedBlocked++ - continue - } - - const blocking = (await countBlocking(owner, repo, issue.number)) ?? 0 - - for (const login of assignees) { - if (!byAssignee.has(login)) byAssignee.set(login, []) - byAssignee.get(login).push({ issue, idle, blocking }) - } -} - -console.log( - `[reminders] ${byAssignee.size} person(s) with stale work; ` + - `skipped ${skippedUnassigned} unassigned, ${skippedFresh} recently active, ${skippedBlocked} blocked` + - (unreadable > 0 ? `, ${unreadable} unreadable` : ''), -) - -if (byAssignee.size === 0) { - console.log('[reminders] nothing to nag about — staying quiet') - process.exit(0) -} - -// People holding up the most work lead the digest; idle time breaks ties. -const heldUp = (rows) => rows.reduce((n, r) => n + r.blocking, 0) -const sorted = [...byAssignee.entries()].sort((a, b) => { - const diff = heldUp(b[1]) - heldUp(a[1]) - if (diff !== 0) return diff - return ( - Math.max(...b[1].map((x) => x.idle)) - Math.max(...a[1].map((x) => x.idle)) - ) -}) - -const fields = sorted.slice(0, 25).map(([login, rows]) => { - const held = heldUp(rows) - return { - name: `@${login} — ${rows.length} open${held > 0 ? `, ${blockingPhrase(held)}` : ''}`, - value: clamp( - rows - .sort((a, b) => b.blocking - a.blocking || b.idle - a.idle) - .map( - ({ issue, idle, blocking }) => - `[#${issue.number}](${issue.html_url}) ${String(issue.title).slice(0, 44)}` + - ` — quiet ${Math.floor(idle)}d${blocking > 0 ? ` · ⛔ ${blocking}` : ''}`, - ) - .join('\n'), - 1000, - ), - } -}) - -const total = sorted.reduce((n, [, rows]) => n + rows.length, 0) -const totalBlocking = sorted.reduce((n, [, rows]) => n + heldUp(rows), 0) - -await sendDiscord({ - content: - `${sorted.map(([login]) => mention(login)).join(' ')} — reminder: ` + - `${total} issue${total === 1 ? '' : 's'} still open with no activity in ${staleDays}+ days` + - (totalBlocking > 0 ? `, together ${blockingPhrase(totalBlocking)}.` : '.'), - pingLogins: sorted.map(([login]) => login), - embed: { - title: 'Open work that has gone quiet', - url: `https://github.com/${repoFullName}/issues`, - color: COLORS.review, - description: - 'Everything here is assigned, unblocked, and workable — it just has not moved. ' + - 'Close it, comment with where it stands, or unassign yourself if it is not yours any more.\n\n' + - '⛔ marks how many other issues are waiting on that one.', - fields, - footer: { - text: - skippedBlocked > 0 - ? `${skippedBlocked} blocked issue(s) left out — nothing to do on those yet.` - : 'Blocked and unassigned issues are left out.', - }, - timestamp: new Date().toISOString(), - }, -}) diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml index 72a1dec..81043f3 100644 --- a/.github/workflows/discord-notify.yml +++ b/.github/workflows/discord-notify.yml @@ -2,7 +2,8 @@ name: Discord notifications on: issues: - types: [assigned, closed, unlabeled, edited] + # No `assigned` on purpose: taking an issue is not announced on Discord. + types: [closed, unlabeled, edited] # pull_request_target (not pull_request) so notifications also work for PRs opened # from forks, which are not given access to secrets. Safe here because the job only # ever runs code from the base branch — it never checks out the PR's own code. diff --git a/.github/workflows/discord-reminders.yml b/.github/workflows/discord-reminders.yml index 095693c..7817e9d 100644 --- a/.github/workflows/discord-reminders.yml +++ b/.github/workflows/discord-reminders.yml @@ -3,15 +3,10 @@ name: Discord reminders on: schedule: # 13:00 UTC = 09:00 Montreal (EDT) / 08:00 (EST), every second day of the month. - # Carries two digests: work that has gone quiet, and sprints past their due date. + # Chases sprints past their due date. - cron: '0 13 */2 * *' # Lets you run it on demand from the Actions tab without waiting for the schedule. workflow_dispatch: - inputs: - stale_days: - description: 'Remind about issues with no activity for this many days' - required: false - default: '2' permissions: issues: read @@ -29,19 +24,7 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Send reminder digest - run: node .github/discord-bot/reminders.mjs - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - DISCORD_USER_MAP: ${{ secrets.DISCORD_USER_MAP }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STALE_DAYS: ${{ inputs.stale_days || '2' }} - BLOCKED_LABELS: blocked - - # Runs even if the digest above failed: the two are independent, and a late - # sprint is exactly the thing you do not want silently dropped. - name: Chase overdue sprints - if: always() run: node .github/discord-bot/sprint.mjs env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}