chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
# 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user