Files
agent-framework/.github/workflows/project-status-sync.yml
2026-05-11 17:30:51 -07:00

159 lines
6.4 KiB
YAML

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;
}