From 75811305d043148e0e517d87b98a84722c21d113 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:04:49 -0400 Subject: [PATCH] ci: require a #openab-pty Discord discussion link on community PRs Same rule as openabdev/openab: a PR without a Discord discussion URL gets the closing-soon label and a comment, and close-stale-prs.yml closes it after 24 hours. Bots and maintainers (write access to this repo) are exempt. Uses GITHUB_TOKEN and repo permission instead of openab's GitHub App and team, which this repo does not have. pull_request_target is safe here: nothing from the PR is checked out or run. --- .github/workflows/close-stale-prs.yml | 66 +++++++++++ .github/workflows/pr-discussion-check.yml | 133 ++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 .github/workflows/close-stale-prs.yml create mode 100644 .github/workflows/pr-discussion-check.yml diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml new file mode 100644 index 0000000..b71ef31 --- /dev/null +++ b/.github/workflows/close-stale-prs.yml @@ -0,0 +1,66 @@ +name: Close Stale closing-soon PRs + +# Companion to pr-discussion-check.yml (same rule as openabdev/openab): a PR that +# has carried `closing-soon` for more than a day is closed with a comment. +# pr-discussion-check removes the label as soon as a Discord link is added, so +# only PRs that never got one are closed. + +on: + schedule: + - cron: '0 */6 * * *' # every 6 hours + workflow_dispatch: + +permissions: + contents: read + +jobs: + close-stale: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + steps: + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const label = 'closing-soon'; + const staleDays = 1; + const cutoff = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000); + + const prs = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'open', + per_page: 100 + }); + + for (const pr of prs) { + if (!pr.labels.some(l => l.name === label)) continue; + + // When the label was (last) added. + const events = await github.paginate(github.rest.issues.listEvents, { + ...context.repo, + issue_number: pr.number, + per_page: 100 + }); + const labelEvent = events + .filter(e => e.event === 'labeled' && e.label?.name === label) + .pop(); + if (!labelEvent) continue; + + const labeledAt = new Date(labelEvent.created_at); + if (labeledAt > cutoff) continue; + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pr.number, + body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} day without a Discord Discussion URL.\n\nIf you'd like to continue working on this, please add the link and reopen, or submit a new PR and link to this one.` + }); + + await github.rest.pulls.update({ + ...context.repo, + pull_number: pr.number, + state: 'closed' + }); + + console.log(`Closed PR #${pr.number} (labeled ${labeledAt.toISOString()})`); + } diff --git a/.github/workflows/pr-discussion-check.yml b/.github/workflows/pr-discussion-check.yml new file mode 100644 index 0000000..3b16d00 --- /dev/null +++ b/.github/workflows/pr-discussion-check.yml @@ -0,0 +1,133 @@ +name: PR Discussion URL Check + +# Mirrors openabdev/openab's rule: every PR must link the Discord discussion it +# came from. Without one the PR is labelled `closing-soon`, commented on, and +# closed by close-stale-prs.yml after 24 hours. +# +# Differences from openab, because this repo has no GitHub App secret and no +# team attached: it uses the built-in GITHUB_TOKEN, and it exempts maintainers +# by their permission on this repo (write/maintain/admin) instead of membership +# of openabdev/openab-maintainers, which GITHUB_TOKEN cannot read. +# +# pull_request_target runs the workflow from the base branch with a write token. +# It is safe here because nothing from the PR is checked out or executed: the +# script only reads the PR body and author from the event payload. + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: pr-discussion-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + steps: + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const labels = pr.labels.map(l => l.name); + const marker = ''; + const label = 'closing-soon'; + // Where community PRs are discussed. Set CHANNEL_URL to the channel's + // own link (https://discord.com/channels//) to make the + // comment clickable and to require links into that channel only. + const CHANNEL_NAME = '#openab-pty'; + const CHANNEL_URL = ''; + + const discordLink = /https:\/\/(?:discord\.com|discordapp\.com)\/channels\/(\d+)\/(\d+)/g; + const links = [...body.matchAll(discordLink)]; + let hasDiscordUrl = links.length > 0; + if (hasDiscordUrl && CHANNEL_URL) { + const [, guild, channel] = CHANNEL_URL.match(/channels\/(\d+)\/(\d+)/) || []; + hasDiscordUrl = links.some(m => m[1] === guild && m[2] === channel); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: pr.number, + per_page: 100 + }); + const old = comments.find(c => c.body && c.body.includes(marker)); + + const clear = async () => { + if (old) { + await github.rest.issues.deleteComment({ ...context.repo, comment_id: old.id }); + } + if (labels.includes(label)) { + try { + await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: label }); + } catch (e) { if (e.status !== 404) throw e; } + } + }; + + // Exempt bot-authored PRs (dependabot, release bots). + if (pr.user.type === 'Bot') { + console.log(`Skipping discussion check for bot PR by ${pr.user.login}`); + await clear(); + return; + } + + // Exempt maintainers: anyone with write access to this repository. + let permission = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: pr.user.login + }); + permission = data.permission; + } catch (e) { + if (e.status !== 404) throw e; + } + if (['admin', 'maintain', 'write'].includes(permission)) { + console.log(`Skipping discussion check for maintainer ${pr.user.login} (${permission})`); + await clear(); + return; + } + + if (!hasDiscordUrl) { + if (!labels.includes(label)) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: pr.number, + labels: [label] + }); + } + + const msg = [ + marker, + '> [!CAUTION]', + `> This PR is missing a link to its discussion in the **${CHANNEL_NAME}** Discord channel.`, + '> This PR will be **automatically closed in 24 hours** if the link is not added.', + '', + `All community PRs must link the ${CHANNEL_NAME} Discord discussion where the change was agreed, so the direction is aligned before implementation.` + + (CHANNEL_URL ? ` Start one here: ${CHANNEL_URL}` : ''), + '', + 'Please edit the PR description to include a link like:', + '```', + 'Discord Discussion URL: https://discord.com/channels///', + '```' + ].join('\n'); + + if (!old) { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pr.number, + body: msg + }); + } + + core.setFailed(`PR body is missing a ${CHANNEL_NAME} Discord Discussion URL`); + } else { + await clear(); + }