diff --git a/.github/workflows/pr-merged-done.yml b/.github/workflows/pr-merged-done.yml deleted file mode 100644 index 494378c..0000000 --- a/.github/workflows/pr-merged-done.yml +++ /dev/null @@ -1,243 +0,0 @@ -# Reusable workflow: when a PR is merged, move all issues it closes -# to "Done" on the org-level Project Board (Mininglamp-OSS / project #2). -# -# Triggered via `workflow_call` from per-repo caller workflows. Requires a -# PROJECT_TOKEN secret with `projects:write` (org-level PAT or GitHub App token). -# -# Known limitations: -# - Only "Closes/Fixes/Resolves #N" in the PR body is scanned; commit message keywords are not. -# - Cross-repo closing references (e.g. owner/other-repo#N) are not yet supported. -name: PR Merged → Board Done - -on: - workflow_call: - secrets: - PROJECT_TOKEN: - required: true - -permissions: {} - -jobs: - move-to-done: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Move closed issues to Done - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - with: - github-token: ${{ secrets.PROJECT_TOKEN }} - script: | - const ORG = 'Mininglamp-OSS'; - const PROJECT_NUMBER = 2; - - const pr = context.payload.pull_request; - if (!pr) { - console.log('No pull_request payload; nothing to do.'); - return; - } - - // Guard: only act on merged PRs (not closed-without-merge) - if (!pr.merged) { - console.log(`PR #${pr.number} was closed without merge; skipping.`); - return; - } - - // Guard: only act on merges into the default branch - const defaultBranch = context.payload.repository?.default_branch || 'main'; - if (pr.base.ref !== defaultBranch) { - console.log(`PR #${pr.number} merged into '${pr.base.ref}', not default branch '${defaultBranch}'; skipping.`); - return; - } - - const body = pr.body || ''; - - // Extract issue numbers from "Closes/Fixes/Resolves #N" or full URL form. - // Case-insensitive; supports plural forms (Closes/Closed/Fixes/Fixed/Resolves/Resolved). - // URL form must point to the same owner/repo as the PR. - const repoOwner = context.repo.owner; - const repoName = context.repo.repo; - const urlPrefix = `https://github.com/${repoOwner}/${repoName}/issues/`.toLowerCase(); - - const keywordRe = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s*(?:#(\d+)|https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+))/gi; - - const issueNumbers = new Set(); - let m; - while ((m = keywordRe.exec(body)) !== null) { - if (m[1]) { - issueNumbers.add(Number(m[1])); - } else if (m[2] && m[3] && m[4]) { - // Only accept URLs pointing at this PR's repo (matches GitHub's own auto-close behavior). - if (m[2].toLowerCase() === repoOwner.toLowerCase() - && m[3].toLowerCase() === repoName.toLowerCase()) { - issueNumbers.add(Number(m[4])); - } - } - } - - if (issueNumbers.size === 0) { - console.log('PR body contains no Closes/Fixes/Resolves keywords; skipping.'); - return; - } - console.log(`Found issue references: ${[...issueNumbers].join(', ')}`); - - // 1. Resolve project id and locate the Status field + Done option id. - const projectQuery = ` - query($org: String!, $number: Int!) { - organization(login: $org) { - projectV2(number: $number) { - id - field(name: "Status") { - ... on ProjectV2SingleSelectField { - id - options { id name } - } - } - } - } - } - `; - let projectResp; - try { - projectResp = await github.graphql(projectQuery, { - org: ORG, - number: PROJECT_NUMBER, - }); - } catch (err) { - core.setFailed(`GraphQL project query failed: ${err.message}`); - return; - } - const project = projectResp.organization?.projectV2; - if (!project) { - core.setFailed(`Project ${ORG}/#${PROJECT_NUMBER} not found or token lacks access.`); - return; - } - const statusField = project.field; - if (!statusField || !statusField.options) { - core.setFailed('Status field not found on project.'); - return; - } - // Normalize: strip emoji and punctuation, trim, lowercase — so "✅ Done" matches "done" - const normalize = s => s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').trim(); - const DONE_ALIASES = new Set(['done', '已完成', '完了', 'completed', '完成']); - const doneOption = statusField.options.find(o => DONE_ALIASES.has(normalize(o.name))); - if (!doneOption) { - const optionNames = statusField.options.map(o => o.name).join(', '); - core.setFailed(`No Done option found (tried aliases: ${[...DONE_ALIASES].join(', ')}). Available options: ${optionNames}`); - return; - } - const projectId = project.id; - const fieldId = statusField.id; - const doneOptionId = doneOption.id; - - // 2. Page through every item on the project, building issue-number → item-id map - // (scoped to the current repo to avoid collisions across repos). - const itemsQuery = ` - query($projectId: ID!, $cursor: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - content { - __typename - ... on Issue { - number - repository { name owner { login } } - } - ... on PullRequest { - number - repository { name owner { login } } - } - } - } - } - } - } - } - `; - - const itemByIssue = new Map(); - let prItemId = null; - let cursor = null; - do { - let page; - try { - page = await github.graphql(itemsQuery, { projectId, cursor }); - } catch (err) { - core.setFailed(`GraphQL items pagination failed: ${err.message}`); - return; - } - const conn = page.node.items; - for (const item of conn.nodes) { - const c = item.content; - if (c && c.__typename === 'Issue' - && c.repository?.owner?.login?.toLowerCase() === repoOwner.toLowerCase() - && c.repository?.name?.toLowerCase() === repoName.toLowerCase()) { - itemByIssue.set(c.number, item.id); - } else if (c && c.__typename === 'PullRequest' - && c.number === pr.number - && c.repository?.owner?.login?.toLowerCase() === repoOwner.toLowerCase() - && c.repository?.name?.toLowerCase() === repoName.toLowerCase()) { - prItemId = item.id; - } - } - cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null; - } while (cursor); - - // 3. For each referenced issue, set Status → Done. - const updateMutation = ` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, - itemId: $itemId, - fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - `; - - let mutationFailures = 0; - for (const issueNumber of issueNumbers) { - const itemId = itemByIssue.get(issueNumber); - if (!itemId) { - console.log(`Issue #${issueNumber} not on project board; skipping.`); - continue; - } - try { - await github.graphql(updateMutation, { - projectId, - itemId, - fieldId, - optionId: doneOptionId, - }); - console.log(`Issue #${issueNumber} → Done`); - } catch (err) { - mutationFailures++; - console.log(`Failed to update issue #${issueNumber}: ${err.message}`); - } - } - - // 4. Also move the PR itself to Done if it's on the board. - if (!prItemId) { - console.log(`PR #${pr.number} not on project board; skipping.`); - } else { - try { - await github.graphql(updateMutation, { - projectId, - itemId: prItemId, - fieldId, - optionId: doneOptionId, - }); - console.log(`PR #${pr.number} → Done`); - } catch (err) { - mutationFailures++; - console.log(`Failed to update PR #${pr.number}: ${err.message}`); - } - } - - if (mutationFailures > 0) { - core.setFailed(`${mutationFailures} issue update(s) failed; see log above.`); - }