Repository navigation
v0.1 ask4help lesson #57
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Guard Unassigned PRs | ||
|
Check warning on line 1 in .github/workflows/guard_unassigned_prs.yml
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, reopened, edited, synchronize] | ||
| # One run per PR at a time, newest wins (avoids duplicate comments/labels). | ||
| concurrency: | ||
| group: pr-guard-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
| jobs: | ||
| check-assignee: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| pull-requests: write | ||
| issues: write | ||
| steps: | ||
| # SECURITY: pull_request_target runs with write permissions. | ||
| # Never add actions/checkout of the PR head (or run any PR code) here. | ||
| - uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| // ---- Config ------------------------------------------------- | ||
| const MAX_FILES = 3; // small fix: max files touched | ||
| const MAX_CHANGED_LINES = 20; // small fix: max added+removed lines | ||
| const SMALL_FIX_EXTENSIONS = ['.md', '.mdx']; | ||
| const LABEL = 'needs-issue'; | ||
| const STALE_LABEL = 'stale'; // set by the stale workflow | ||
| const MARKER = '<!-- pr-guard -->'; | ||
| // ------------------------------------------------------------- | ||
| const { owner, repo, number } = context.issue; | ||
| const pr = context.payload.pull_request; | ||
| const author = pr.user.login; | ||
| const currentLabels = (pr.labels ?? []).map(l => l.name); | ||
| // Ignore closed PRs (edited also fires on closed ones) | ||
| if (pr.state !== 'open') return; | ||
| // 1. Maintainers and bots bypass | ||
| if ( | ||
| pr.user.type === 'Bot' || | ||
| ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) | ||
| ) return; | ||
| const footer = | ||
| `\n\nThis PR is labelled \`${LABEL}\` and will be closed automatically ` + | ||
| `after about a week if this isn't resolved. Editing the PR description ` + | ||
| `re-runs this check.`; | ||
| // Returns null if the PR is fine, otherwise a message for the author | ||
| async function findProblem() { | ||
| // 2. Small edits to existing docs bypass (typos, bug fixes) | ||
| const files = await github.paginate(github.rest.pulls.listFiles, { | ||
| owner, repo, pull_number: number, per_page: 100, | ||
| }); | ||
| const totalChanges = files.reduce((sum, f) => sum + f.changes, 0); | ||
| const isSmallFix = | ||
| files.length <= MAX_FILES && | ||
| totalChanges <= MAX_CHANGED_LINES && | ||
| files.every(f => | ||
| f.status === 'modified' && | ||
| SMALL_FIX_EXTENSIONS.some(ext => f.filename.endsWith(ext)) | ||
| ); | ||
| if (isSmallFix) return null; | ||
| // 3. Everything else needs a linked issue assigned to the author | ||
| const intro = | ||
| `Hi @${author}! Thanks for contributing 🙌\n\n` + | ||
| `Small fixes to existing lessons (up to ${MAX_FILES} Markdown files and ` + | ||
| `${MAX_CHANGED_LINES} changed lines) are welcome directly. ` + | ||
| `This PR is larger or adds new files, so it needs a linked issue.\n\n`; | ||
| const match = (pr.body ?? '').match( | ||
| /\b(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+#(\d+)/i | ||
| ); | ||
| if (!match) { | ||
| return intro + | ||
| `Please reference an issue in the PR description (e.g. \`Closes #12\`) ` + | ||
| `that is assigned to you.` + footer; | ||
| } | ||
| const issueNum = parseInt(match[1], 10); | ||
| let issue; | ||
| try { | ||
| ({ data: issue } = await github.rest.issues.get({ | ||
| owner, repo, issue_number: issueNum, | ||
| })); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| return intro + `Issue #${issueNum} could not be found.` + footer; | ||
| } | ||
| if (issue.pull_request) { | ||
| return intro + | ||
| `#${issueNum} is a pull request, not an issue. ` + | ||
| `Please link the issue you were assigned.` + footer; | ||
| } | ||
| if (!issue.assignees.some(a => a.login === author)) { | ||
| return intro + | ||
| `Issue #${issueNum} is not assigned to you. ` + | ||
| `Please comment on it to claim it first!` + footer; | ||
| } | ||
| return null; | ||
| } | ||
| const problem = await findProblem(); | ||
| // Find our previous comment (so we update instead of spamming) | ||
| const comments = await github.paginate(github.rest.issues.listComments, { | ||
| owner, repo, issue_number: number, per_page: 100, | ||
| }); | ||
| const existing = comments.find( | ||
| c => c.user.login === 'github-actions[bot]' && c.body?.includes(MARKER) | ||
| ); | ||
| // ---- All good: clean up any earlier warning ------------------- | ||
| if (!problem) { | ||
| for (const name of [LABEL, STALE_LABEL]) { | ||
| if (!currentLabels.includes(name)) continue; | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: number, name, | ||
| }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| } | ||
| } | ||
| if (existing) { | ||
| await github.rest.issues.deleteComment({ | ||
| owner, repo, comment_id: existing.id, | ||
| }); | ||
| } | ||
| return; | ||
| } | ||
| // ---- Problem: label + comment, but don't close ---------------- | ||
| try { | ||
| await github.rest.issues.createLabel({ | ||
| owner, repo, name: LABEL, color: 'd93f0b', | ||
| description: 'PR needs a linked issue assigned to the author', | ||
| }); | ||
| } catch (e) { | ||
| if (e.status !== 422) throw e; // 422 = label already exists | ||
| } | ||
| if (!currentLabels.includes(LABEL)) { | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: number, labels: [LABEL], | ||
| }); | ||
| } | ||
| const body = `${MARKER}\n${problem}`; | ||
| if (!existing) { | ||
| await github.rest.issues.createComment({ | ||
| owner, repo, issue_number: number, body, | ||
| }); | ||
| } else if (existing.body !== body) { | ||
| await github.rest.issues.updateComment({ | ||
| owner, repo, comment_id: existing.id, body, | ||
| }); | ||
| } | ||