Update readme instructions #2
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: Pull request automation (target) | |
| on: | |
| pull_request_target: | |
| types: [opened, reopened, synchronize, edited, closed] | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| env: | |
| INSTRUCTOR_GITHUB_USERNAME: ${{ vars.INSTRUCTOR_GITHUB_USERNAME }} | |
| jobs: | |
| pr: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ vars.PROJECTS_APP_ID }} | |
| private-key: ${{ secrets.PROJECTS_APP_PRIVATE_KEY }} | |
| - name: Check PR rules + move linked issues | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const instructor = (process.env.INSTRUCTOR_GITHUB_USERNAME || "").trim(); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| function uniq(arr) { return Array.from(new Set(arr)); } | |
| async function addPrComment(body) { | |
| await github.rest.issues.createComment({ | |
| owner, repo, | |
| issue_number: context.payload.pull_request.number, | |
| body, | |
| }); | |
| } | |
| async function addPrLabel(label) { | |
| const existing = (context.payload.pull_request.labels || []).map(l => l.name); | |
| if (existing.includes(label)) return; | |
| await github.rest.issues.addLabels({ | |
| owner, repo, | |
| issue_number: context.payload.pull_request.number, | |
| labels: [label], | |
| }); | |
| } | |
| async function removePrLabelSafe(label) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, | |
| issue_number: context.payload.pull_request.number, | |
| name: label, | |
| }); | |
| } catch {} | |
| } | |
| async function removeInstructorReviewerIfRequested() { | |
| if (!instructor) return; | |
| const req = (context.payload.pull_request.requested_reviewers || []).map(u => (u.login || "").toLowerCase()); | |
| if (!req.includes(instructor.toLowerCase())) return; | |
| await github.rest.pulls.removeRequestedReviewers({ | |
| owner, repo, | |
| pull_number: context.payload.pull_request.number, | |
| reviewers: [instructor], | |
| }); | |
| } | |
| async function requestInstructorReviewer() { | |
| if (!instructor) return; | |
| try { | |
| await github.rest.pulls.requestReviewers({ | |
| owner, repo, | |
| pull_number: context.payload.pull_request.number, | |
| reviewers: [instructor], | |
| }); | |
| } catch {} | |
| } | |
| async function getSingleRepoProjectV2() { | |
| const q = ` | |
| query($owner:String!, $repo:String!) { | |
| repository(owner:$owner, name:$repo) { | |
| projectsV2(first: 10) { nodes { id title } } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(q, { owner, repo }); | |
| const nodes = res.repository.projectsV2.nodes || []; | |
| if (nodes.length !== 1) throw new Error(`Repository must have exactly 1 linked Projects v2 project, found ${nodes.length}.`); | |
| return nodes[0]; | |
| } | |
| async function getStatusFieldConfig(projectId) { | |
| const q = ` | |
| query($projectId:ID!) { | |
| node(id:$projectId) { | |
| ... on ProjectV2 { | |
| fields(first: 100) { | |
| nodes { | |
| ... on ProjectV2SingleSelectField { | |
| id name options { id name } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(q, { projectId }); | |
| const fields = res.node.fields.nodes || []; | |
| const status = fields.find(f => f.name === "Status"); | |
| if (!status) throw new Error(`Project is missing a single-select field named "Status".`); | |
| return { fieldId: status.id, options: status.options || [] }; | |
| } | |
| function findSingleOptionId(options, needleLower) { | |
| const matches = options.filter(o => (o.name || "").toLowerCase().includes(needleLower)); | |
| if (matches.length !== 1) throw new Error(`Expected exactly 1 Status option containing "${needleLower}", found ${matches.length}.`); | |
| return matches[0].id; | |
| } | |
| async function getProjectItemIdForIssue(issueId, projectId) { | |
| const q = ` | |
| query($issueId:ID!) { | |
| node(id:$issueId) { | |
| ... on Issue { | |
| projectItems(first: 50) { nodes { id project { id } } } | |
| } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(q, { issueId }); | |
| const nodes = res.node.projectItems.nodes || []; | |
| const match = nodes.find(n => n.project.id === projectId); | |
| return match ? match.id : null; | |
| } | |
| async function addIssueToProject(projectId, issueNodeId) { | |
| const m = ` | |
| mutation($projectId:ID!, $contentId:ID!) { | |
| addProjectV2ItemById(input:{ projectId:$projectId, contentId:$contentId }) { | |
| item { id } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(m, { projectId, contentId: issueNodeId }); | |
| return res.addProjectV2ItemById.item.id; | |
| } | |
| async function setStatus(projectId, itemId, fieldId, optionId) { | |
| const m = ` | |
| mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) { | |
| updateProjectV2ItemFieldValue(input:{ | |
| projectId:$projectId, | |
| itemId:$itemId, | |
| fieldId:$fieldId, | |
| value:{ singleSelectOptionId:$optionId } | |
| }) { projectV2Item { id } } | |
| } | |
| `; | |
| await github.graphql(m, { projectId, itemId, fieldId, optionId }); | |
| } | |
| async function getClosingIssues(prNodeId) { | |
| const q = ` | |
| query($prId:ID!) { | |
| node(id:$prId) { | |
| ... on PullRequest { | |
| closingIssuesReferences(first: 10) { | |
| nodes { | |
| id | |
| number | |
| assignees(first: 50) { nodes { login } } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(q, { prId: prNodeId }); | |
| return res.node.closingIssuesReferences.nodes || []; | |
| } | |
| const action = context.payload.action; | |
| const pr = context.payload.pull_request; | |
| // Branch: PR closed without merge -> move linked issues "in review" back to "in progress" | |
| if ( | |
| action === "closed" && | |
| pr.merged === false && | |
| !(context.actor && context.actor.endsWith('[bot]')) | |
| ) { | |
| const issues = await getClosingIssues(pr.node_id); | |
| if (issues.length === 0) return; | |
| const project = await getSingleRepoProjectV2(); | |
| const status = await getStatusFieldConfig(project.id); | |
| const inProgressId = findSingleOptionId(status.options, "in progress"); | |
| for (const iss of issues) { | |
| let itemId = await getProjectItemIdForIssue(iss.id, project.id); | |
| if (!itemId) itemId = await addIssueToProject(project.id, iss.id); | |
| await setStatus(project.id, itemId, status.fieldId, inProgressId); | |
| } | |
| return; | |
| } | |
| // Only handle opened/updated-ish events here | |
| if (!["opened", "reopened", "synchronize", "edited"].includes(action)) return; | |
| const issues = await getClosingIssues(pr.node_id); | |
| // 1-2) Must link an issue | |
| if (issues.length === 0) { | |
| await addPrLabel("pr-missing-linked-issue"); | |
| await addPrComment( | |
| [ | |
| `Hi @${pr.user?.login || ""}, this PR is not linked to any issue.`, | |
| "Please link it by editing the PR description and adding something like:", | |
| "", | |
| "```", | |
| "Closes #123", | |
| "```", | |
| ].join("\n") | |
| ); | |
| await removeInstructorReviewerIfRequested(); | |
| return; | |
| } | |
| // 3) PR creator must be assigned to ALL linked issues | |
| const prCreator = (pr.user?.login || "").toLowerCase(); | |
| const notAssigned = issues | |
| .filter(iss => { | |
| const assignees = (iss.assignees.nodes || []).map(a => (a.login || "").toLowerCase()); | |
| return !assignees.includes(prCreator); | |
| }) | |
| .map(iss => `#${iss.number}`); | |
| if (notAssigned.length > 0) { | |
| await github.rest.pulls.update({ | |
| owner, repo, | |
| pull_number: pr.number, | |
| state: "closed", | |
| }); | |
| await addPrComment( | |
| [ | |
| `Hi @${pr.user?.login || ""}, your PR is trying to close these issues:`, | |
| notAssigned.map(x => `- ${x}`).join("\n"), | |
| "", | |
| "However, **you don't have permission to close them**, because you are not assigned to them. Make sure you are logged in as the correct GitHub user and that your team claimed the issue first.", | |
| "", | |
| `For security reasons, this PR will now be closed. If you believe this is an error, please ping @${instructor}.` | |
| ].join("\n") | |
| ); | |
| return; | |
| } | |
| // 4) Warn if multiple issues | |
| if (issues.length > 1) { | |
| await addPrComment( | |
| "Warning: this PR links multiple issues. You should try to close only 1 issue in each PR." | |
| ); | |
| } | |
| // 5) Add instructor reviewer | |
| await requestInstructorReviewer(); | |
| // 6) Move linked issues to "in review" | |
| const project = await getSingleRepoProjectV2(); | |
| const status = await getStatusFieldConfig(project.id); | |
| const inReviewId = findSingleOptionId(status.options, "in review"); | |
| for (const iss of issues) { | |
| let itemId = await getProjectItemIdForIssue(iss.id, project.id); | |
| if (!itemId) itemId = await addIssueToProject(project.id, iss.id); | |
| await setStatus(project.id, itemId, status.fieldId, inReviewId); | |
| } | |
| // 7) Remove missing-linked-issue label if present | |
| await removePrLabelSafe("pr-missing-linked-issue"); |