Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/aw/work-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ for installation; writer-restriction automation remains deferred.

## Mirror admitted Work with Issues

`tools.work-queue.issues: true` mirrors admitted Work with the `work` label.
An object may set `label` and a pre-provisioned native organization
`status-field`. Require installed projector authority: only protected hooks
`tools.work-queue.issues: true` mirrors admitted Work with purple `work` and
`work: <status>` labels. An object may customize the tracking label and status
label prefix with `label`. Require installed projector authority: only protected hooks
project their own admissions/original Claims. Immutable `backing_issue` binds
one Work per Issue. Agents never write mirrors; human edits never establish
Result. Pre-existing Issues need exact installed `backing_issues` grants.
Expand Down
120 changes: 46 additions & 74 deletions actions/setup/js/work_queue_issue_api.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,80 +9,54 @@ const MAX_PROJECTION_TARGETS = 25;
const MAX_PROJECTION_MUTATIONS = 50;
const LABELS_PAGE_SIZE = 100;
const STATUSES = ["Queued", "Blocked", "Assigned", "Running", "Verifying", "Needs review", "Done", "Needs attention", "Cancelled"];
const FIELD_SELECTION = `__typename ... on IssueFieldText { id name } ... on IssueFieldNumber { id name }
... on IssueFieldDate { id name } ... on IssueFieldMultiSelect { id name }
... on IssueFieldSingleSelect { id name options { id name } }`;
const VALUE_SELECTION = `... on IssueFieldSingleSelectValue { optionId name field { ... on IssueFieldSingleSelect { id } } }`;
const ISSUE_SELECTION = `id databaseId number state viewerCanSetFields repository { id databaseId nameWithOwner } author { login }
labels(first:${LABELS_PAGE_SIZE}) { nodes { id name } pageInfo { hasNextPage endCursor } }
issueFieldValues(first:${MAX_PROJECTION_TARGETS}) { nodes { ${VALUE_SELECTION} } pageInfo { hasNextPage endCursor } }`;
const ISSUE_SELECTION = `id databaseId number state repository { id databaseId nameWithOwner } author { login }
labels(first:${LABELS_PAGE_SIZE}) { nodes { id name } pageInfo { hasNextPage endCursor } }`;
const QUEUE_LABEL_COLOR = "7057FF";

async function ensureTrackingLabel(github, repositoryId, label, name) {
async function ensureLabel(github, repositoryId, label, name) {
if (label) {
if (label.name !== name || typeof label.id !== "string") throw queueError("projection_label_pending", "tracking label identity is invalid");
if (label.color?.toUpperCase() !== QUEUE_LABEL_COLOR) {
const response = await github.graphql("mutation WorkQueueLabelColor($input:UpdateLabelInput!) { updateLabel(input:$input) { label { id name color } } }", {
input: { id: label.id, color: QUEUE_LABEL_COLOR },
request: { retries: 0, timeout: 15000 },
});
if (response?.updateLabel?.label?.id !== label.id || response.updateLabel.label.name !== name || response.updateLabel.label.color?.toUpperCase() !== QUEUE_LABEL_COLOR)
throw queueError("projection_label_pending", "work queue label color update is uncertain");
}
return label.id;
}
const response = await github.graphql("mutation WorkQueueTrackingLabel($input:CreateLabelInput!) { createLabel(input:$input) { label { id name } } }", {
input: { repositoryId, name, color: "808080", description: "Git-backed Work queue tracking" },
const response = await github.graphql("mutation WorkQueueTrackingLabel($input:CreateLabelInput!) { createLabel(input:$input) { label { id name color } } }", {
input: { repositoryId, name, color: QUEUE_LABEL_COLOR, description: "Git-backed Work queue" },
request: { retries: 0, timeout: 15000 },
});
if (!response?.createLabel?.label?.id || response.createLabel.label.name !== name) throw queueError("projection_label_pending", "tracking label creation is uncertain");
if (!response?.createLabel?.label?.id || response.createLabel.label.name !== name || response.createLabel.label.color?.toUpperCase() !== QUEUE_LABEL_COLOR)
throw queueError("projection_label_pending", "work queue label creation is uncertain");
return response.createLabel.label.id;
}

function statusLabelName(config, status) {
return `${config.label}: ${status}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot no spaces in label names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated status labels now use lowercase hyphenated names without spaces, including whitespace-normalized prefixes. Added test coverage and updated the reference docs in c02d3f9.

}

async function ensureStatusLabel(github, repositoryId, config, status) {
const name = statusLabelName(config, status);
const response = await github.graphql("query WorkQueueStatusLabel($repositoryId:ID!,$name:String!) { node(id:$repositoryId) { ... on Repository { id label(name:$name) { id name color } } } }", { repositoryId, name });
if (response?.node?.id !== repositoryId) throw queueError("projection_label_pending", "status label target is inaccessible");
return ensureLabel(github, repositoryId, response.node.label, name);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented duplicate-label recovery: on an already-taken response, the projector re-queries the status label and verifies/recolors it. Added a concurrent provisioning test. Fixed in 3664808.

}

async function discoverTarget(github, repository, config, firstPage = undefined) {
const [owner, name] = repository.split("/");
/** @type {string | null} */
let cursor = null;
let field;
let repositoryId;
let label;
try {
for (let page = 0; page < 16; page++) {
const fieldQuery = config["status-field"] ? `issueFields(first:${MAX_PROJECTION_TARGETS},after:$cursor) { nodes { ${FIELD_SELECTION} } pageInfo { hasNextPage endCursor } }` : "";
const result =
page === 0 && firstPage
? { repository: firstPage }
: await github.graphql(
`query WorkQueueIssueTarget($owner:String!,$name:String!,$label:String!${config["status-field"] ? ",$cursor:String" : ""}) {
repository(owner:$owner,name:$name) { id nameWithOwner label(name:$label) { id name } ${fieldQuery} }
}`,
{ owner, name, label: config.label, ...(config["status-field"] ? { cursor } : {}) }
);
const>
if (!target || target.nameWithOwner !== repository) throw queueError("projection_target_unavailable", "Issue target is inaccessible or transferred");
repositoryId = target.id;
label = target.label;
if (!config["status-field"]) return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label) };
const fields = target.issueFields;
if (!Array.isArray(fields?.nodes)) throw queueError("projection_field_pending", "native organization Issue fields are inaccessible");
for (const node of fields.nodes) {
if (node.name !== config["status-field"]) continue;
if (field || node.__typename !== "IssueFieldSingleSelect" || !Array.isArray(node.options)) throw queueError("projection_field_pending", "status field is ambiguous or not single-select");
const options = new Map();
for (const option of node.options) {
if (options.has(option.name) || typeof option.id !== "string") throw queueError("projection_field_pending", "status options are ambiguous");
options.set(option.name, option.id);
}
if (STATUSES.some(status => !options.has(status))) throw queueError("projection_field_pending", "status field is missing required options");
field = { id: node.id, options };
}
if (fields.pageInfo?.hasNextPage === false) {
if (!field) throw queueError("projection_field_pending", "configured native status field is missing or inaccessible");
return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label), field };
}
const next = fields.pageInfo?.endCursor;
if (typeof next !== "string" || !next || next === cursor) throw queueError("projection_field_pending", "status field discovery is incomplete");
cursor = next;
}
throw queueError("projection_field_pending", "status field discovery pagination exhausted");
} catch (error) {
if (error.code !== "projection_field_pending") throw error;
return { repositoryId, labelId: await ensureTrackingLabel(github, repositoryId, label, config.label), fieldPending: error.message };
}
const result = firstPage
? { repository: firstPage }
: await github.graphql("query WorkQueueIssueTarget($owner:String!,$name:String!,$label:String!) { repository(owner:$owner,name:$name) { id nameWithOwner label(name:$label) { id name color } } }", { owner, name, label: config.label });
const>
if (!target || target.nameWithOwner !== repository) throw queueError("projection_target_unavailable", "Issue target is inaccessible or transferred");
return { repositoryId: target.id, labelId: await ensureLabel(github, target.id, target.label, config.label) };
}

function issuePreflightQuery(targets, fields) {
function issuePreflightQuery(targets) {
if (targets.length > MAX_PROJECTION_TARGETS) throw queueError("projection_limit", `at most ${MAX_PROJECTION_TARGETS} projection targets`);
const variables = {};
const declarations = [];
Expand All @@ -94,7 +68,7 @@ function issuePreflightQuery(targets, fields) {
if (!Number.isSafeInteger(number) || number < 1 || number > 2147483647) throw queueError("projection_target_unavailable", "Issue number cannot be represented by GraphQL");
declarations.push(`$o${index}:String!,$r${index}:String!,$n${index}:Int!`);
Object.assign(variables, { [`o${index}`]: owner, [`r${index}`]: name, [`n${index}`]: number });
selections.push(`i${index}: repository(owner:$o${index},name:$r${index}) { issue(number:$n${index}) { ${fields ? ISSUE_SELECTION : ISSUE_SELECTION.replace(/issueFieldValues[\s\S]*$/, "")} } }`);
selections.push(`i${index}: repository(owner:$o${index},name:$r${index}) { issue(number:$n${index}) { ${ISSUE_SELECTION} } }`);
}
for (const [handle, id] of Object.entries(target.comments || {})) {
const alias = `c${index}_${selections.length}`;
Expand All @@ -109,7 +83,7 @@ function issuePreflightQuery(targets, fields) {
}

function issueReadQuery(targets, config, repository) {
const read = issuePreflightQuery(targets, !!config["status-field"]);
const read = issuePreflightQuery(targets);
const repositories = [...new Set(targets.map(target => target.resource?.repository || repository))];
const repositoryAliases = new Map();
read.declarations.push("$trackingLabel:String!");
Expand All @@ -120,16 +94,15 @@ function issueReadQuery(targets, config, repository) {
repositoryAliases.set(repository, alias);
read.declarations.push(`$${alias}Owner:String!,$${alias}Name:String!`);
Object.assign(read.variables, { [`${alias}Owner`]: owner, [`${alias}Name`]: name });
read.selections.push(`${alias}: repository(owner:$${alias}Owner,name:$${alias}Name) { id nameWithOwner label(name:$trackingLabel) { id name }
${config["status-field"] ? `issueFields(first:${MAX_PROJECTION_TARGETS}) { nodes { ${FIELD_SELECTION} } pageInfo { hasNextPage endCursor } }` : ""} }`);
read.selections.push(`${alias}: repository(owner:$${alias}Owner,name:$${alias}Name) { id nameWithOwner label(name:$trackingLabel) { id name color } }`);
}
return { ...read, repositoryAliases };
}

/** @param {{fields?: boolean, response?: Record<string, any>}} [options] */
/** @param {{response?: Record<string, any>}} [options] */
async function preflightIssues(github, targets, options = {}) {
const { fields = true, response } = options;
const read = issuePreflightQuery(targets, fields);
const { response } = options;
const read = issuePreflightQuery(targets);
if (!read.selections.length) return new Map();
const result = response || (await github.graphql(`query WorkQueueIssuePreflight(${read.declarations.join(",")}) { ${read.selections.join("\n")} }`, read.variables));
const issues = new Map();
Expand All @@ -146,7 +119,6 @@ async function preflightIssues(github, targets, options = {}) {
)
throw queueError("projection_target_unavailable", "backing Issue was deleted, transferred, or changed identity");
if (!Array.isArray(issue.labels?.nodes)) throw queueError("projection_target_unavailable", "Issue labels are inaccessible");
if (fields && !Array.isArray(issue.issueFieldValues?.nodes)) throw queueError("projection_field_pending", "Issue field values are inaccessible");
issue.comments = new Map();
for (const [handle, alias] of Object.entries(target.commentAliases || {})) {
const comment = result?.[alias];
Expand All @@ -156,19 +128,19 @@ async function preflightIssues(github, targets, options = {}) {
}
issues.set(target.work_id, issue);
}
await completeIssueConnections(github, [...issues.values()], fields);
await completeIssueConnections(github, [...issues.values()]);
return issues;
}

async function completeIssueConnections(github, issues, fields) {
async function completeIssueConnections(github, issues) {
const seen = new Map();
for (let page = 0; page < 16; page++) {
const declarations = [];
const selections = [];
const variables = {};
const pending = [];
for (const [index, issue] of issues.entries()) {
for (const connection of fields ? ["labels", "issueFieldValues"] : ["labels"]) {
for (const connection of ["labels"]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L143: yagni: generic connection loop and dynamic property bookkeeping now serve only ["labels"]. Inline label pagination and retain just issue/alias pending state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the generic connection loop with inline labels-only pagination, keeping just issue/alias pending state. Fixed in 3664808.

const values = issue[connection];
if (values?.pageInfo?.hasNextPage === false) continue;
const cursor = values?.pageInfo?.endCursor;
Expand All @@ -180,9 +152,7 @@ async function completeIssueConnections(github, issues, fields) {
const alias = `p${pending.length}`;
declarations.push(`$${alias}:ID!,$${alias}Cursor:String!`);
Object.assign(variables, { [alias]: issue.id, [`${alias}Cursor`]: cursor });
selections.push(
`${alias}: node(id:$${alias}) { ... on Issue { id repository { id } ${connection}(first:${connection === "labels" ? LABELS_PAGE_SIZE : MAX_PROJECTION_TARGETS},after:$${alias}Cursor) { nodes { ${connection === "labels" ? "id name" : VALUE_SELECTION} } pageInfo { hasNextPage endCursor } } } }`
);
selections.push(`${alias}: node(id:$${alias}) { ... on Issue { id repository { id } ${connection}(first:${LABELS_PAGE_SIZE},after:$${alias}Cursor) { nodes { id name } pageInfo { hasNextPage endCursor } } } }`);
pending.push({ issue, connection, alias });
}
}
Expand Down Expand Up @@ -269,6 +239,8 @@ module.exports = {
LABELS_PAGE_SIZE,
STATUSES,
ISSUE_SELECTION,
statusLabelName,
ensureStatusLabel,
discoverTarget,
preflightIssues,
mutateIssues,
Expand Down
8 changes: 4 additions & 4 deletions actions/setup/js/work_queue_issue_messages.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,20 @@ function issueBody(state, work, origin, config, branch) {
admission_link_label: admission.run_id ? "View the admitting run" : "View the admission receipt",
ledger_url: ledgerUrl,
tracking_label: config.label,
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.",
projection_note: "Progress is projected into a status label; the live summary links to the checked ledger.",
},
origin
);
}

function renderSummary(values, run, status, nativeField) {
const statusSection = nativeField ? "" : renderTemplateFromFile(getPromptPath("work_queue_issue_status.md"), { status }).trimEnd();
function renderSummary(values, run, status) {
const statusSection = renderTemplateFromFile(getPromptPath("work_queue_issue_status.md"), { status }).trimEnd();
return renderWorkQueueMessage(
"work_queue_issue_summary.md",
{
...values,
status_section: statusSection,
progress_note: nativeField ? "Progress is tracked in the native Issue status field. The links below follow the checked ledger." : PROGRESS_NOTES[status],
progress_note: PROGRESS_NOTES[status],
},
run
);
Expand Down
Loading
Loading