name: Sync Project Status to Labels on: projects_v2_item: types: [edited] # Prevent race conditions when status changes rapidly. # Key by project item (node_id) so updates for the same card serialize. concurrency: group: status-sync-${{ github.event.projects_v2_item.node_id }} cancel-in-progress: true jobs: sync_status: runs-on: ubuntu-latest permissions: issues: write steps: - uses: actions/github-script@v8 with: # Use PAT/App token because Projects GraphQL often requires project scope. # GITHUB_TOKEN is repo-scoped and may not access org Projects. 【4-75ee64】【5-e66679】 github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} script: | const item = context.payload.projects_v2_item; const changes = context.payload.changes || {}; // 1) Logging project id so that we can filter out by project in next revision. console.log(`Processing issue from project: ${item.project_node_id}`); // 2) Only act on Issues if (item.content_type !== "Issue") return; // 3) Map project Status values to labels const labelMap = { "Planned": "status:planned", "In Progress": "status:in-progress", "In Review": "status:in-review", "Done": "status:done" }; const allStatusLabels = Object.values(labelMap); // 4) Fast path: If this edit is a Status change and the payload includes "to.name", use it. // Some payloads include field_value.to with { name, ... } for single-select fields. 【6-4092ed】【3-e3ddba】 let statusValue = null; const fv = changes.field_value; if (fv && fv.field_name === "Status" && fv.to && fv.to.name) { statusValue = fv.to.name; console.log(`Fast path: Status changed to "${statusValue}"`); } // 5) Otherwise, query GraphQL once to get both Issue number and Status field value. if (!statusValue) { try { const result = await github.graphql( `query($id: ID!) { node(id: $id) { ... on ProjectV2Item { content { ... on Issue { number } } fieldValues(first: 50) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2SingleSelectField { name } } } } } } } }`, { id: item.node_id } ); const node = result?.node; const values = node?.fieldValues?.nodes ?? []; statusValue = values.find(v => v.field?.name === "Status")?.name; // If no status found, nothing to do. if (!statusValue) { console.log("No Status field value found in project item"); return; } // Fetch issue number from GraphQL content if present var issue_number = node?.content?.number; if (!issue_number) { console.error("Could not extract issue number from GraphQL response"); return; } } catch (graphqlError) { console.error(`GraphQL query failed: ${graphqlError.message}`); throw graphqlError; } } else { // If we used fast-path for status, we still need issue_number: try { const result = await github.graphql( `query($id: ID!) { node(id: $id) { ... on Issue { number } } }`, { id: item.content_node_id } ); var issue_number = result?.node?.number; } catch (graphqlError) { console.error(`Failed to fetch issue number: ${graphqlError.message}`); throw graphqlError; } if (!issue_number) return; } const targetLabel = labelMap[statusValue]; if (!targetLabel) { console.warn(`Status "${statusValue}" has no mapped label. Skipping.`); return; } console.log(`Mapped status "${statusValue}" to label "${targetLabel}"`); // 6) Get existing labels let issue; try { const response = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number }); issue = response.data; } catch (restError) { console.error(`Failed to fetch issue #${issue_number}: ${restError.message}`); throw restError; } const existingLabels = issue.labels.map(l => l.name); // If already correct, exit (reduces churn) if (existingLabels.includes(targetLabel) && existingLabels.filter(l => allStatusLabels.includes(l)).length === 1) { console.log(`Issue #${issue_number} already has correct label "${targetLabel}". No changes needed.`); return; } // 7) Avoid "remove then add" partial failure by using setLabels once. // This preserves all non-status labels and ensures exactly one status label. const nextLabels = existingLabels .filter(l => !allStatusLabels.includes(l)) .concat([targetLabel]); const removedLabels = existingLabels.filter(l => allStatusLabels.includes(l) && l !== targetLabel); try { await github.rest.issues.setLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number, labels: nextLabels }); console.log(`Updated issue #${issue_number}: removed [${removedLabels.join(", ")}], added "${targetLabel}"`); } catch (updateError) { console.error(`Failed to update labels for issue #${issue_number}: ${updateError.message}`); throw updateError; }