mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
159 lines
6.4 KiB
YAML
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;
|
|
}
|
|
|