b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
245 lines
11 KiB
YAML
245 lines
11 KiB
YAML
# Mirrors an issue's triage state onto the "Auto-Triage" Projects v2 board.
|
|
#
|
|
# Reactive and decoupled from the triage bot: it watches label changes and,
|
|
# whenever a `bot:repro` (trigger) or `triage/*` (state) label is added or
|
|
# removed, recomputes the issue's canonical triage state from its CURRENT
|
|
# labels and sets the board's "Triage State" single-select field. The triage
|
|
# workflows just move labels as they always have; this never writes back to
|
|
# them.
|
|
#
|
|
# It also keeps board membership in sync with the issue's open/closed state:
|
|
# closing an issue ARCHIVES its card (it leaves the active board views but
|
|
# stays recoverable in the project's archive, since triage is done once the
|
|
# issue is closed); reopening unarchives and re-syncs it if it still carries
|
|
# triage labels.
|
|
#
|
|
# Auth: mints an emdashbot App installation token (same APP_ID / APP_PRIVATE_KEY
|
|
# secrets the other bot workflows use). The App needs organization "Projects:
|
|
# Read and write" for the project mutations.
|
|
|
|
name: Triage project sync
|
|
|
|
on:
|
|
issues:
|
|
types: [labeled, unlabeled, closed, reopened]
|
|
workflow_dispatch: # one-shot backfill of issues already carrying triage labels
|
|
|
|
concurrency:
|
|
# Serialize per issue so rapid reproducing -> reproduced transitions don't race.
|
|
group: triage-project-sync-${{ github.event.issue.number || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
sync:
|
|
runs-on: ubuntu-latest
|
|
# Skip PRs (issues.labeled fires for PRs too); always run the manual backfill.
|
|
if: github.event_name == 'workflow_dispatch' || github.event.issue.pull_request == null
|
|
steps:
|
|
- name: Generate app token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
with:
|
|
app-id: ${{ secrets.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
# owner + repositories: org-scoped for Projects, but limited to this
|
|
# one repo. permission-*: least privilege, just what the sync needs
|
|
# (org Projects write; issues read for the workflow_dispatch backfill).
|
|
owner: ${{ github.repository_owner }}
|
|
repositories: ${{ github.event.repository.name }}
|
|
permission-organization-projects: write
|
|
permission-issues: read
|
|
|
|
- name: Sync triage state to project
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
with:
|
|
github-token: ${{ steps.app-token.outputs.token }}
|
|
script: |
|
|
const PROJECT_NUMBER = 3;
|
|
const FIELD_NAME = "Triage State";
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
|
|
// Triage label -> board option name.
|
|
const STATE_BY_LABEL = {
|
|
"bot:repro": "Queued",
|
|
"triage/reproducing": "Reproducing",
|
|
"triage/reproduced": "Reproduced",
|
|
"triage/by-design": "By design",
|
|
"triage/awaiting-reporter": "Awaiting reporter",
|
|
"triage/verified": "Verified",
|
|
"triage/not-reproduced": "Not reproduced",
|
|
"triage/failed": "Failed",
|
|
"triage/skipped": "Skipped",
|
|
};
|
|
// When several are present, the most advanced/terminal one wins.
|
|
const PRECEDENCE = [
|
|
"triage/verified",
|
|
"triage/awaiting-reporter",
|
|
"triage/reproduced",
|
|
"triage/by-design",
|
|
"triage/not-reproduced",
|
|
"triage/failed",
|
|
"triage/skipped",
|
|
"triage/reproducing",
|
|
"bot:repro",
|
|
];
|
|
const pickState = (labels) => {
|
|
for (const l of PRECEDENCE) if (labels.includes(l)) return STATE_BY_LABEL[l];
|
|
return null;
|
|
};
|
|
const labelNames = (iss) =>
|
|
(iss.labels || []).map((l) => (typeof l === "string" ? l : l.name));
|
|
|
|
// Resolve the project, the single-select field, and its options.
|
|
const projData = await github.graphql(
|
|
`query($owner:String!, $num:Int!, $field:String!) {
|
|
organization(login:$owner) {
|
|
projectV2(number:$num) {
|
|
id
|
|
field(name:$field) {
|
|
... on ProjectV2SingleSelectField { id options { id name } }
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
{ owner, num: PROJECT_NUMBER, field: FIELD_NAME },
|
|
);
|
|
const project = projData.organization.projectV2;
|
|
if (!project || !project.field) {
|
|
core.setFailed(`Project #${PROJECT_NUMBER} or field "${FIELD_NAME}" not found`);
|
|
return;
|
|
}
|
|
const fieldId = project.field.id;
|
|
const optionId = (name) => project.field.options.find((o) => o.name === name)?.id;
|
|
|
|
// Archive an issue's card on THIS board, if present. An issue can
|
|
// belong to several projects; match on the project NODE ID, not the
|
|
// number -- Projects v2 numbers are per-owner, so a foreign project
|
|
// could also be #3 and a number match would target the wrong item.
|
|
// Archiving is reversible (unlike delete) and hides the card from
|
|
// active board views. Idempotent -- returns false when the issue
|
|
// has no card here, and re-archiving an archived item is a no-op.
|
|
const archiveCard = async (number) => {
|
|
const data = await github.graphql(
|
|
`query($owner:String!, $repo:String!, $number:Int!) {
|
|
repository(owner:$owner, name:$repo) {
|
|
issue(number:$number) {
|
|
projectItems(first:100) { nodes { id project { id } } }
|
|
}
|
|
}
|
|
}`,
|
|
{ owner, repo, number },
|
|
);
|
|
const nodes = data.repository?.issue?.projectItems?.nodes || [];
|
|
const item = nodes.find((n) => n.project?.id === project.id);
|
|
if (!item) {
|
|
// first:100 isn't paginated; warn if we hit the cap so a silent
|
|
// miss on an issue attached to 100+ projects is at least visible.
|
|
if (nodes.length === 100) {
|
|
core.warning(`#${number}: on 100+ projects; could not confirm board membership`);
|
|
}
|
|
return false;
|
|
}
|
|
await github.graphql(
|
|
`mutation($project:ID!, $item:ID!) {
|
|
archiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
|
|
}`,
|
|
{ project: project.id, item: item.id },
|
|
);
|
|
return true;
|
|
};
|
|
|
|
// Add the issue to the board (idempotent) and set its Triage State.
|
|
const upsertState = async (iss) => {
|
|
const stateName = pickState(labelNames(iss));
|
|
if (!stateName) {
|
|
core.info(`#${iss.number}: no triage label; skipping`);
|
|
return;
|
|
}
|
|
const optId = optionId(stateName);
|
|
if (!optId) {
|
|
core.warning(`#${iss.number}: no board option named "${stateName}"`);
|
|
return;
|
|
}
|
|
// Idempotent: returns the existing item if the issue is already added.
|
|
const added = await github.graphql(
|
|
`mutation($project:ID!, $content:ID!) {
|
|
addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } }
|
|
}`,
|
|
{ project: project.id, content: iss.node_id },
|
|
);
|
|
const itemId = added.addProjectV2ItemById.item.id;
|
|
// addProjectV2ItemById returns an EXISTING item as-is -- including
|
|
// when it was archived on close -- so unarchive to bring a
|
|
// reopened issue's card back onto the active board. No-op for
|
|
// items that are already active.
|
|
await github.graphql(
|
|
`mutation($project:ID!, $item:ID!) {
|
|
unarchiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
|
|
}`,
|
|
{ project: project.id, item: itemId },
|
|
);
|
|
await github.graphql(
|
|
`mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!) {
|
|
updateProjectV2ItemFieldValue(input:{
|
|
projectId:$project, itemId:$item, fieldId:$field,
|
|
value:{ singleSelectOptionId:$opt }
|
|
}) { projectV2Item { id } }
|
|
}`,
|
|
{ project: project.id, item: itemId, field: fieldId, opt: optId },
|
|
);
|
|
core.info(`#${iss.number} -> ${stateName}`);
|
|
};
|
|
|
|
// Build the work list.
|
|
let issues = [];
|
|
if (context.eventName === "workflow_dispatch") {
|
|
const all = await github.paginate(github.rest.issues.listForRepo, {
|
|
owner,
|
|
repo,
|
|
state: "all",
|
|
per_page: 100,
|
|
});
|
|
issues = all.filter(
|
|
(iss) =>
|
|
!iss.pull_request &&
|
|
labelNames(iss).some((l) => l === "bot:repro" || l.startsWith("triage/")),
|
|
);
|
|
} else {
|
|
const iss = context.payload.issue;
|
|
if (!iss || iss.pull_request) return;
|
|
issues = [iss];
|
|
}
|
|
|
|
// Per-issue try/catch so the bulk backfill reconciles every issue
|
|
// even if one fails (e.g. a transient GraphQL error). Single-issue
|
|
// event runs still fail loudly via setFailed below, since the next
|
|
// event would otherwise be the only chance to recover.
|
|
let failures = 0;
|
|
for (const iss of issues) {
|
|
try {
|
|
// Closed issues never belong on the board: triage is finished
|
|
// once the issue is closed (however it was closed). Handling it
|
|
// here -- rather than only on the `closed` event -- means label
|
|
// changes on an already-closed issue, and the dispatch backfill,
|
|
// also reconcile, so the board self-heals to "open triaged
|
|
// issues only". Reopening hits the else-branch with state "open"
|
|
// and is re-added below if it still carries triage labels.
|
|
if ((iss.state || "").toLowerCase() === "closed") {
|
|
const archived = await archiveCard(iss.number);
|
|
core.info(`#${iss.number}: closed; ${archived ? "archived" : "not on board"}`);
|
|
continue;
|
|
}
|
|
await upsertState(iss);
|
|
} catch (e) {
|
|
failures++;
|
|
core.warning(`#${iss.number}: sync failed: ${e.message}`);
|
|
}
|
|
}
|
|
if (failures > 0 && context.eventName !== "workflow_dispatch") {
|
|
core.setFailed(`${failures} issue(s) failed to sync`);
|
|
}
|