You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/.github/workflows/sync-labels-to-board.yml

251 lines
9.9 KiB

name: Sync Labels to Project Board
# Fires when a board: or priority: label is added/removed (or an issue closes).
# Maps labels -> Project V2 board fields (OffbandMesh org project #2) via GraphQL,
# so agents never call `gh project` commands directly.
# Adapted from the DifferentWire/Unfocused canonical (GH #59 board backfill).
#
# Requires repo secret PROJECT_PAT: a classic PAT with `project` + `repo` scope
# (the default GITHUB_TOKEN cannot write an org-level project).
on:
issues:
types: [labeled, unlabeled, closed]
jobs:
# -- Closed issue -> board:done, strip any stale board:* label, set Status --
#
# The `sync` job below only runs on labeled/unlabeled, and a label added with
# the default GITHUB_TOKEN does not cascade to trigger it. So the close path
# must be self-contained: enforce board:* mutual exclusivity AND set the
# project Status field itself, using PROJECT_PAT (which can write the org
# project). Fixes #447 (closed cards left in their old column).
auto-label-closed:
runs-on: ubuntu-latest
if: github.event.action == 'closed'
steps:
- name: Mark closed issue Done (strip stale board:*, set Status)
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PROJECT_PAT }}
script: |
const PROJECT_ID = 'PVT_kwDOEXsS3c4BaleY';
const STATUS_FIELD_ID = 'PVTSSF_lADOEXsS3c4BaleYzhVcBOA';
const DONE_OPTION_ID = '72944195'; // board:done
const issueNumber = context.payload.issue.number;
const issueNodeId = context.payload.issue.node_id;
const labels = context.payload.issue.labels.map(l => l.name);
// 1. Strip any board:* label that is not board:done (mutual exclusion).
const stale = labels.filter(l => l.startsWith('board:') && l !== 'board:done');
for (const lbl of stale) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: lbl,
});
core.info(`Removed stale label: ${lbl}`);
} catch (e) {
core.warning(`Could not remove "${lbl}": ${e.message}`);
}
}
// 2. Ensure board:done is present.
if (!labels.includes('board:done')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['board:done'],
});
core.info('Added board:done');
}
// 3. Set the project Status field to Done directly (do not rely on a
// label-add cascade the GITHUB_TOKEN path cannot trigger).
const addResult = await github.graphql(`
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item { id }
}
}`, { projectId: PROJECT_ID, contentId: issueNodeId });
const itemId = addResult.addProjectV2ItemById.item.id;
await github.graphql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) { projectV2Item { id } }
}`, {
projectId: PROJECT_ID,
itemId: itemId,
fieldId: STATUS_FIELD_ID,
optionId: DONE_OPTION_ID,
});
core.info(`#${issueNumber} Status -> Done`);
# -- Sync board/priority labels to project fields --
sync:
runs-on: ubuntu-latest
if: >-
(github.event.action == 'labeled' || github.event.action == 'unlabeled') && (
startsWith(github.event.label.name, 'board:') ||
startsWith(github.event.label.name, 'priority:')
)
steps:
- name: Sync label to project board
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PROJECT_PAT }}
script: |
// -- Project field IDs (OffbandMesh org project #2) --
const PROJECT_ID = 'PVT_kwDOEXsS3c4BaleY';
const STATUS_FIELD_ID = 'PVTSSF_lADOEXsS3c4BaleYzhVcBOA';
const STATUS_OPTIONS = {
'board:backlog': '74e6ff08',
'board:todo': 'd85ffaa6',
'board:ready': 'e23e8854',
'board:in-progress': 'a406c68f',
'board:testing': 'ead78e5a',
'board:deferred': '0bb62da8',
'board:done': '72944195',
};
const PRIORITY_FIELD_ID = 'PVTSSF_lADOEXsS3c4BaleYzhVcBUc';
const PRIORITY_OPTIONS = {
'priority:P0': '59562f1e',
'priority:P1': 'a76f6d06',
'priority:P2': '2b16020d',
'priority:P3': 'd5740bf3',
};
const action = context.payload.action;
const triggerLabel = context.payload.label.name;
const issueNumber = context.payload.issue.number;
const issueNodeId = context.payload.issue.node_id;
const issueLabels = context.payload.issue.labels.map(l => l.name);
core.info(`Action "${action}" label "${triggerLabel}" on #${issueNumber}`);
let triggerPrefix;
if (triggerLabel.startsWith('board:')) triggerPrefix = 'board:';
else if (triggerLabel.startsWith('priority:')) triggerPrefix = 'priority:';
else {
core.info('Not a board/priority label — skipping');
return;
}
if (action === 'unlabeled') {
const options = triggerPrefix === 'board:' ? STATUS_OPTIONS : PRIORITY_OPTIONS;
const surviving = issueLabels.find(l => options[l]);
if (!surviving) {
core.info(`No remaining ${triggerPrefix} label — nothing to sync`);
return;
}
core.info(`Unlabeled "${triggerLabel}", surviving: "${surviving}" — syncing`);
}
if (action === 'labeled') {
const conflicting = issueLabels.filter(
l => l.startsWith(triggerPrefix) && l !== triggerLabel
);
for (const oldLabel of conflicting) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: oldLabel,
});
core.info(`Removed conflicting label: ${oldLabel}`);
} catch (e) {
core.warning(`Could not remove label "${oldLabel}": ${e.message}`);
}
}
}
const addMutation = `
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {
projectId: $projectId
contentId: $contentId
}) {
item { id }
}
}
`;
let itemId;
try {
const addResult = await github.graphql(addMutation, {
projectId: PROJECT_ID,
contentId: issueNodeId,
});
itemId = addResult.addProjectV2ItemById.item.id;
core.info(`Project item: ${itemId}`);
} catch (e) {
core.setFailed(`Failed to add #${issueNumber} to project: ${e.message}`);
return;
}
const updateMutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`;
const boardLabel = triggerPrefix === 'board:'
? (action === 'labeled'
? triggerLabel
: issueLabels.find(l => STATUS_OPTIONS[l]))
: issueLabels.find(l => l.startsWith('board:') && STATUS_OPTIONS[l]);
const priorityLabel = triggerPrefix === 'priority:'
? (action === 'labeled'
? triggerLabel
: issueLabels.find(l => PRIORITY_OPTIONS[l]))
: issueLabels.find(l => l.startsWith('priority:') && PRIORITY_OPTIONS[l]);
if (boardLabel) {
try {
await github.graphql(updateMutation, {
projectId: PROJECT_ID,
itemId: itemId,
fieldId: STATUS_FIELD_ID,
optionId: STATUS_OPTIONS[boardLabel],
});
core.info(`Status -> ${boardLabel}`);
} catch (e) {
core.setFailed(`Failed to set status: ${e.message}`);
}
}
if (priorityLabel) {
try {
await github.graphql(updateMutation, {
projectId: PROJECT_ID,
itemId: itemId,
fieldId: PRIORITY_FIELD_ID,
optionId: PRIORITY_OPTIONS[priorityLabel],
});
core.info(`Priority -> ${priorityLabel}`);
} catch (e) {
core.setFailed(`Failed to set priority: ${e.message}`);
}
}
core.info(`Done syncing #${issueNumber}`);

Powered by TurnKey Linux.