Skip to content

Commit 02d213a

Browse files
authored
Use repository labels for work-queue Issue status (#67223)
1 parent aa7c2ec commit 02d213a

17 files changed

Lines changed: 360 additions & 297 deletions

‎.changeset/major-work-queue-issue-labels.md‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/aw/work-queue.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ for installation; writer-restriction automation remains deferred.
5353

5454
## Mirror admitted Work with Issues
5555

56-
`tools.work-queue.issues: true` mirrors admitted Work with the `work` label.
57-
An object may set `label` and a pre-provisioned native organization
58-
`status-field`. Require installed projector authority: only protected hooks
56+
`tools.work-queue.issues: true` mirrors admitted Work with purple `work` and
57+
`work: <status>` labels. An object may customize the tracking label and status
58+
label prefix with `label`. Require installed projector authority: only protected hooks
5959
project their own admissions/original Claims. Immutable `backing_issue` binds
6060
one Work per Issue. Agents never write mirrors; human edits never establish
6161
Result. Pre-existing Issues need exact installed `backing_issues` grants.

‎actions/setup/js/work_queue_claim_scope_checks.cjs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,13 @@ function registerTests({ describe, it }) {
322322
for (const role of ["dispatcher", "worker"]) {
323323
process.env.GH_AW_WORK_QUEUE_ROLE = role;
324324
write({ ...snapshot, role });
325-
for (const message of messages) assert.throws(() => scope.normalizeRuntimeMessage(message));
326-
await assert.rejects(manager.processMessages(handlers, messages));
325+
if (role === "dispatcher") {
326+
assert.deepEqual(scope.normalizeRuntimeMessage(messages[0]), messages[0]);
327+
assert.throws(() => scope.normalizeRuntimeMessage(messages[1]), /unassigned dispatcher/);
328+
} else {
329+
for (const message of messages) assert.throws(() => scope.normalizeRuntimeMessage(message));
330+
await assert.rejects(manager.processMessages(handlers, messages));
331+
}
327332
assert.equal(writes, 3);
328333
}
329334
} finally {

‎actions/setup/js/work_queue_issue_api.cjs‎

Lines changed: 78 additions & 96 deletions
Large diffs are not rendered by default.

‎actions/setup/js/work_queue_issue_messages.cjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,20 @@ function issueBody(state, work, origin, config, branch) {
4646
admission_link_label: admission.run_id ? "View the admitting run" : "View the admission receipt",
4747
ledger_url: ledgerUrl,
4848
tracking_label: config.label,
49-
projection_note: config["status-field"] ? "Progress is projected into the configured native Issue status field when synchronization is available." : "The live summary carries its current status.",
49+
projection_note: "Progress is projected into a status label; the live summary links to the checked ledger.",
5050
},
5151
origin
5252
);
5353
}
5454

55-
function renderSummary(values, run, status, nativeField) {
56-
const statusSection = nativeField ? "" : renderTemplateFromFile(getPromptPath("work_queue_issue_status.md"), { status }).trimEnd();
55+
function renderSummary(values, run, status) {
56+
const statusSection = renderTemplateFromFile(getPromptPath("work_queue_issue_status.md"), { status }).trimEnd();
5757
return renderWorkQueueMessage(
5858
"work_queue_issue_summary.md",
5959
{
6060
...values,
6161
status_section: statusSection,
62-
progress_note: nativeField ? "Progress is tracked in the native Issue status field. The links below follow the checked ledger." : PROGRESS_NOTES[status],
62+
progress_note: PROGRESS_NOTES[status],
6363
},
6464
run
6565
);

‎actions/setup/js/work_queue_issue_pacing.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function wrapGithubClient(githubClient, { sleep = delay => new Promise(resolve =
3131
if (key === "graphql")
3232
return async (query, variables) => {
3333
const mutation = /^\s*mutation/.test(query);
34-
const cost = Math.max(1, [...query.matchAll(/:\s*(?:createIssue|addComment|updateIssueComment|setIssueFieldValue|addLabelsToLabelable|closeIssue|createRef|deleteRef)\(/g)].length);
34+
const cost = Math.max(1, [...query.matchAll(/:\s*(?:createIssue|addComment|updateIssueComment|setIssueFieldValue|addLabelsToLabelable|removeLabelsFromLabelable|closeIssue|createRef|deleteRef)\(/g)].length);
3535
return invoke(mutation, cost, () => target.graphql(query, { ...variables, request: { ...variables?.request, retries: 0 } }));
3636
};
3737
if (key === "rest")

‎actions/setup/js/work_queue_issue_pacing.test.cjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ describe("Issue projection pacing", () => {
3535
expect(calls[1][1].request).toEqual({ retries: 0, timeout: 123 });
3636
});
3737

38+
it("paces a batch of label removals by mutation count", async () => {
39+
const timer = clock();
40+
const { client } = wrapGithubClient({ graphql: async () => ({ value: true }) }, timer);
41+
const removals = Array.from({ length: 50 }, (_, index) => `r${index}: removeLabelsFromLabelable(input:$r${index}) { clientMutationId }`).join(" ");
42+
await client.graphql(`mutation { ${removals} }`, {});
43+
expect(timer.delays).toEqual([50000]);
44+
});
45+
3846
it("preserves REST method receivers and paces only writes", async () => {
3947
const timer = clock();
4048
const issues = {

‎actions/setup/js/work_queue_issues.cjs‎

Lines changed: 43 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const { dependencyStatus } = require("./work_queue_graph.cjs");
1111
const { appendCommit, newRequest, proposedCommitId, replayTransactions } = require("./work_queue_replay.cjs");
1212
const { readCheckedQueue, writeCheckedFiles } = require("./work_queue_checked_transport.cjs");
1313
const { withProjectionLocks, projectionNonce } = require("./work_queue_issue_coordination.cjs");
14-
const { MAX_PROJECTION_TARGETS, LABELS_PAGE_SIZE, discoverTarget, preflightIssues, mutateIssues, issueResource, ISSUE_SELECTION, issueReadQuery } = require("./work_queue_issue_api.cjs");
14+
const { MAX_PROJECTION_TARGETS, LABELS_PAGE_SIZE, STATUSES, statusLabelName, ensureStatusLabel, discoverTarget, preflightIssues, mutateIssues, issueResource, ISSUE_SELECTION, issueReadQuery } = require("./work_queue_issue_api.cjs");
1515
const { wrapGithubClient } = require("./work_queue_issue_pacing.cjs");
1616
const { isStagedMode } = require("./safe_output_helpers.cjs");
1717
const { validateJournal } = require("./work_queue_issue_journal.cjs");
@@ -20,9 +20,9 @@ const { issueBody, renderSummary, renderClaim, runLink } = require("./work_queue
2020
function issuesConfiguration(value) {
2121
if (value === undefined || value === false) return null;
2222
if (value === true) return { label: "work" };
23-
closed(value, [], ["label", "status-field"], "work-queue issues");
23+
closed(value, [], ["label"], "work-queue issues");
2424
for (const [key, field] of Object.entries(value)) {
25-
if (typeof field !== "string" || !field.trim() || field.includes("${{") || Buffer.byteLength(field) > 256 || /[\x00-\x1f\x7f]/.test(field))
25+
if (typeof field !== "string" || !field.trim() || field.includes("${{") || Buffer.byteLength(field) > 33 || /[\x00-\x1f\x7f]/.test(field))
2626
throw queueError("projection_invalid", `${SAFE_OUTPUT_E001}: ${key} must be a nonblank bounded literal`);
2727
}
2828
return { label: "work", ...value };
@@ -77,7 +77,7 @@ function jobDiagnostics(value) {
7777
.map(([name, job]) => `Native job \`${name}\`: **${job.result}**. This does not establish Work cancellation or Result.`);
7878
}
7979

80-
function summaryBody(state, work, field, at, diagnostics = [], branch = "work-queue", run = undefined) {
80+
function summaryBody(state, work, at, diagnostics = [], branch = "work-queue", run = undefined) {
8181
const lines = [...diagnostics];
8282
const claimHistory = [];
8383
const outcomes = [];
@@ -112,8 +112,7 @@ function summaryBody(state, work, field, at, diagnostics = [], branch = "work-qu
112112
diagnostics: lines.join("\n"),
113113
},
114114
origin,
115-
workIssueStatus(state, work, at),
116-
!!field
115+
workIssueStatus(state, work, at)
117116
);
118117
}
119118

@@ -207,20 +206,23 @@ async function projectBatch(options, initial, origin, assignment, config, target
207206
const journals = new Map();
208207
const pending = [];
209208
const discovery = new Map();
209+
const statusLabels = new Map();
210210
for (const target of targets) {
211211
const path = journalPath(target.work_id);
212212
const journal = current.journal.get(path) || { version: 1, work_id: target.work_id, comments: {} };
213213
const repository = target.resource?.repository || origin.repository;
214214
validateJournal(journal, current.state, target.work_id, repository);
215215
journals.set(path, structuredClone(journal));
216+
const work = current.state.works.get(target.work_id);
216217
if (!discovery.has(repository)) discovery.set(repository, await discoverTarget(options.githubClient, repository, config, current.nativeResponse?.[issueRead.repositoryAliases.get(repository)]));
217-
if (discovery.get(repository).fieldPending) pending.push({ work_id: target.work_id, reason: `queue committed; field sync pending: ${discovery.get(repository).fieldPending}` });
218+
const status = workIssueStatus(current.state, work, options.now ?? Date.now());
219+
const statusKey = `${repository}:${status}`;
220+
if (!statusLabels.has(statusKey)) statusLabels.set(statusKey, await ensureStatusLabel(options.githubClient, discovery.get(repository).repositoryId, config, status));
218221
if (!target.resource && journal.create?.resource) {
219222
assertProjectionAuthority(current.state, journal.create.origin, target.work_id, journal.create.ref, journal.create.resource.repository, journal.create.authority_claim_id);
220223
target.resource = journal.create.resource;
221224
}
222225
target.comments = {};
223-
const work = current.state.works.get(target.work_id);
224226
if (work.issue_summary) target.comments.summary = work.issue_summary;
225227
for (const claimId of target.claim_ids) {
226228
const claim = current.state.claims.get(claimId);
@@ -231,7 +233,7 @@ async function projectBatch(options, initial, origin, assignment, config, target
231233
}
232234
}
233235
const samePreflight = targets.every((target, index) => canonical(target.resource || null) === canonical(preflight[index].resource || null) && canonical(target.comments) === canonical(preflight[index].comments));
234-
const issues = await preflightIssues(options.githubClient, targets, { fields: !!config["status-field"], response: samePreflight ? current.nativeResponse : undefined });
236+
const issues = await preflightIssues(options.githubClient, targets, { response: samePreflight ? current.nativeResponse : undefined });
235237
for (const target of targets) {
236238
const receipt = journals.get(journalPath(target.work_id)).create;
237239
if (receipt?.node_id && receipt.node_id !== issues.get(target.work_id)?.id) throw queueError("projection_journal_invalid", "native Issue receipt node identity disagrees with preflight");
@@ -265,10 +267,9 @@ async function projectBatch(options, initial, origin, assignment, config, target
265267
repositoryId: discovered.repositoryId,
266268
title: `Work ${work.work_id}`,
267269
body: issueBody(current.state, work, origin, config, current.branch),
268-
labelIds: [discovered.labelId],
269-
...(discovered.field ? { issueFields: [{ fieldId: discovered.field.id, singleSelectOptionId: discovered.field.options.get(status) }] } : {}),
270+
labelIds: [discovered.labelId, statusLabels.get(`${origin.repository}:${status}`)],
270271
},
271-
selection: `issue { ${config["status-field"] ? ISSUE_SELECTION : ISSUE_SELECTION.replace(/issueFieldValues[\s\S]*$/, "")} }`,
272+
selection: `issue { ${ISSUE_SELECTION} }`,
272273
});
273274
}
274275
if (creates.length) {
@@ -300,16 +301,9 @@ async function projectBatch(options, initial, origin, assignment, config, target
300301
journal.create.resource = resource;
301302
journal.create.node_id = issue.id;
302303
issues.set(target.work_id, { ...issue, comments: new Map() });
303-
const discovered = discovery.get(resource.repository);
304-
if (
305-
discovered.field &&
306-
(!issue.viewerCanSetFields ||
307-
issue.issueFieldValues?.pageInfo?.hasNextPage !== false ||
308-
!issue.issueFieldValues.nodes?.some(
309-
value => value.field?.id === discovered.field.id && value.optionId === discovered.field.options.get(workIssueStatus(current.state, current.state.works.get(target.work_id), options.now ?? Date.now()))
310-
))
311-
)
312-
pending.push({ work_id: target.work_id, reason: "queue committed; field sync pending: initial field application was not verified" });
304+
const status = workIssueStatus(current.state, current.state.works.get(target.work_id), options.now ?? Date.now());
305+
if (issue.labels?.pageInfo?.hasNextPage !== false || !issue.labels.nodes?.some(label => label.id === statusLabels.get(`${resource.repository}:${status}`)))
306+
pending.push({ work_id: target.work_id, reason: "queue committed; initial status label application was not verified" });
313307
operations.push(bindingOperation(target, origin.ref, resource));
314308
}
315309
current = await publishProjection(options, current, actor, journals, operations);
@@ -326,12 +320,12 @@ async function projectBatch(options, initial, origin, assignment, config, target
326320
if (canonical(work.backing_issue || work.issue_link || null) !== canonical(target.resource)) throw queueError("projection_scope_changed", "new native receipt has no checked backing binding");
327321
}
328322
}
329-
return projectLinkedIssues(options, current, actor, origin, config, targets, journals, issues, discovery, pending, creates.length > 0);
323+
return projectLinkedIssues(options, current, actor, origin, config, targets, journals, issues, discovery, statusLabels, pending, creates.length > 0);
330324
};
331325
return resources.length ? (options.withProjectionLocks || withProjectionLocks)({ ...options, repositoryId: current.repositoryId, head: current.sha, keys: resources }, project) : project();
332326
}
333327

334-
async function projectLinkedIssues(options, current, actor, origin, config, targets, journals, issues, discovery, pending, created) {
328+
async function projectLinkedIssues(options, current, actor, origin, config, targets, journals, issues, discovery, statusLabels, pending, created) {
335329
const edits = [];
336330
const newComments = [];
337331
const bindings = [];
@@ -342,33 +336,30 @@ async function projectLinkedIssues(options, current, actor, origin, config, targ
342336
const journal = journals.get(journalPath(target.work_id));
343337
assertProjectionAuthority(current.state, actor, work.work_id, origin.ref, target.resource.repository, target.authority_claim_id);
344338
const discovered = discovery.get(target.resource.repository);
345-
if (!issue.labels.nodes.some(label => label.id === discovered.labelId))
339+
const status = workIssueStatus(current.state, work, options.now ?? Date.now());
340+
const desiredStatusId = statusLabels.get(`${target.resource.repository}:${status}`);
341+
const missing = [discovered.labelId, desiredStatusId].filter(id => !issue.labels.nodes.some(label => label.id === id));
342+
if (missing.length)
346343
edits.push({
347344
key: target.work_id,
348345
name: "addLabelsToLabelable",
349346
type: "AddLabelsToLabelableInput",
350-
input: { labelableId: issue.id, labelIds: [discovered.labelId] },
347+
input: { labelableId: issue.id, labelIds: missing },
351348
selection: `labelable { id ... on Issue { labels(first:${LABELS_PAGE_SIZE}) { nodes { id } pageInfo { hasNextPage } } } }`,
349+
expectedLabels: missing,
350+
});
351+
const obsolete = issue.labels.nodes.filter(label => STATUSES.some(candidate => candidate !== status && label.name === statusLabelName(config, candidate))).map(label => label.id);
352+
if (obsolete.length)
353+
edits.push({
354+
key: target.work_id,
355+
name: "removeLabelsFromLabelable",
356+
type: "RemoveLabelsFromLabelableInput",
357+
input: { labelableId: issue.id, labelIds: obsolete },
358+
selection: `labelable { id ... on Issue { labels(first:${LABELS_PAGE_SIZE}) { nodes { id } pageInfo { hasNextPage } } } }`,
359+
removedLabels: obsolete,
352360
});
353-
const status = workIssueStatus(current.state, work, options.now ?? Date.now());
354-
if (discovered.field) {
355-
if (!issue.viewerCanSetFields) {
356-
pending.push({ work_id: target.work_id, reason: "queue committed; field sync pending: projector token cannot set fields" });
357-
} else if (!issue.issueFieldValues.nodes.some(value => value.field?.id === discovered.field.id && value.optionId === discovered.field.options.get(status))) {
358-
edits.push({
359-
key: target.work_id,
360-
name: "setIssueFieldValue",
361-
type: "SetIssueFieldValueInput",
362-
input: { issueId: issue.id, issueFields: [{ fieldId: discovered.field.id, singleSelectOptionId: discovered.field.options.get(status) }] },
363-
selection: `issue { id issueFieldValues(first:${MAX_PROJECTION_TARGETS}) { nodes { ... on IssueFieldSingleSelectValue { optionId field { ... on IssueFieldSingleSelect { id } } } } pageInfo { hasNextPage } } }`,
364-
expected: { field: discovered.field.id, option: discovered.field.options.get(status) },
365-
});
366-
}
367-
}
368361
const diagnostics = jobDiagnostics(options.jobResults ?? process.env.GH_AW_WORK_QUEUE_JOB_RESULTS);
369-
if (discovered.fieldPending) diagnostics.push(`Native status field sync pending: ${discovered.fieldPending}.`);
370-
else if (discovered.field && !issue.viewerCanSetFields) diagnostics.push("Native status field sync pending: projector token cannot set fields.");
371-
const bodies = new Map([["summary", summaryBody(current.state, work, !!config["status-field"], options.now ?? Date.now(), diagnostics, current.branch, origin)]]);
362+
const bodies = new Map([["summary", summaryBody(current.state, work, options.now ?? Date.now(), diagnostics, current.branch, origin)]]);
372363
for (const claimId of target.claim_ids) bodies.set(claimId, claimBody(current.state, current.state.claims.get(claimId), options.now ?? Date.now(), diagnostics));
373364
for (const [key, body] of bodies) {
374365
const comment = issue.comments.get(key);
@@ -416,17 +407,14 @@ async function projectLinkedIssues(options, current, actor, origin, config, targ
416407
recordUncertainReceipt(result, batch, pending, "Issue closure returned no exact native receipt");
417408
continue;
418409
}
419-
if (result.operation.name === "addLabelsToLabelable" && (result.value.labelable?.id !== result.operation.input.labelableId || !result.value.labelable?.labels?.nodes?.some(label => label.id === result.operation.input.labelIds[0])))
420-
pending.push({ work_id: result.operation.key, reason: "queue committed; tracking label application was not verified" });
421-
if (result.operation.expected) {
422-
const values = result.value.issue?.issueFieldValues;
423-
if (
424-
result.value.issue?.id !== result.operation.input.issueId ||
425-
values?.pageInfo?.hasNextPage !== false ||
426-
!values.nodes?.some(value => value.field?.id === result.operation.expected.field && value.optionId === result.operation.expected.option)
427-
)
428-
pending.push({ work_id: result.operation.key, reason: "queue committed; field sync pending: mutation did not apply the selected option" });
429-
}
410+
if (
411+
(result.operation.expectedLabels || result.operation.removedLabels) &&
412+
(result.value.labelable?.id !== result.operation.input.labelableId ||
413+
result.value.labelable?.labels?.pageInfo?.hasNextPage !== false ||
414+
result.operation.expectedLabels?.some(id => !result.value.labelable.labels.nodes.some(label => label.id === id)) ||
415+
result.operation.removedLabels?.some(id => result.value.labelable.labels.nodes.some(label => label.id === id)))
416+
)
417+
pending.push({ work_id: result.operation.key, reason: "queue committed; status label synchronization was not verified" });
430418
if (result.operation.handle) {
431419
const node = result.value.commentEdge?.node;
432420
const target = targets.find(target => target.work_id === result.operation.key);

0 commit comments

Comments
 (0)