@crewbie/cli 0.1.0-alpha.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +506 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +84 -0
- package/dist/config.js +178 -0
- package/dist/config.js.map +1 -0
- package/dist/core.d.ts +31 -0
- package/dist/core.js +186 -0
- package/dist/core.js.map +1 -0
- package/dist/execution/attribution.d.ts +10 -0
- package/dist/execution/attribution.js +102 -0
- package/dist/execution/attribution.js.map +1 -0
- package/dist/execution/cancel.d.ts +3 -0
- package/dist/execution/cancel.js +59 -0
- package/dist/execution/cancel.js.map +1 -0
- package/dist/execution/capabilities.d.ts +22 -0
- package/dist/execution/capabilities.js +101 -0
- package/dist/execution/capabilities.js.map +1 -0
- package/dist/execution/controls.d.ts +51 -0
- package/dist/execution/controls.js +257 -0
- package/dist/execution/controls.js.map +1 -0
- package/dist/execution/dispatch.d.ts +69 -0
- package/dist/execution/dispatch.js +831 -0
- package/dist/execution/dispatch.js.map +1 -0
- package/dist/execution/fix.d.ts +3 -0
- package/dist/execution/fix.js +267 -0
- package/dist/execution/fix.js.map +1 -0
- package/dist/execution/github.d.ts +15 -0
- package/dist/execution/github.js +43 -0
- package/dist/execution/github.js.map +1 -0
- package/dist/execution/merge.d.ts +15 -0
- package/dist/execution/merge.js +133 -0
- package/dist/execution/merge.js.map +1 -0
- package/dist/execution/planning-approval.d.ts +33 -0
- package/dist/execution/planning-approval.js +227 -0
- package/dist/execution/planning-approval.js.map +1 -0
- package/dist/execution/pr-review.d.ts +86 -0
- package/dist/execution/pr-review.js +339 -0
- package/dist/execution/pr-review.js.map +1 -0
- package/dist/execution/review-loop.d.ts +63 -0
- package/dist/execution/review-loop.js +463 -0
- package/dist/execution/review-loop.js.map +1 -0
- package/dist/execution/test-feature.d.ts +29 -0
- package/dist/execution/test-feature.js +240 -0
- package/dist/execution/test-feature.js.map +1 -0
- package/dist/execution/watch.d.ts +12 -0
- package/dist/execution/watch.js +34 -0
- package/dist/execution/watch.js.map +1 -0
- package/dist/memory/context.d.ts +14 -0
- package/dist/memory/context.js +82 -0
- package/dist/memory/context.js.map +1 -0
- package/dist/memory/improvement.d.ts +25 -0
- package/dist/memory/improvement.js +168 -0
- package/dist/memory/improvement.js.map +1 -0
- package/dist/memory/launch.d.ts +9 -0
- package/dist/memory/launch.js +65 -0
- package/dist/memory/launch.js.map +1 -0
- package/dist/memory/runner.d.ts +4 -0
- package/dist/memory/runner.js +74 -0
- package/dist/memory/runner.js.map +1 -0
- package/dist/memory/state.d.ts +10 -0
- package/dist/memory/state.js +42 -0
- package/dist/memory/state.js.map +1 -0
- package/dist/presentation.d.ts +19 -0
- package/dist/presentation.js +128 -0
- package/dist/presentation.js.map +1 -0
- package/dist/reporting/dashboard.d.ts +2 -0
- package/dist/reporting/dashboard.js +106 -0
- package/dist/reporting/dashboard.js.map +1 -0
- package/dist/reporting/pr-usage.d.ts +24 -0
- package/dist/reporting/pr-usage.js +179 -0
- package/dist/reporting/pr-usage.js.map +1 -0
- package/dist/reporting/records.d.ts +28 -0
- package/dist/reporting/records.js +152 -0
- package/dist/reporting/records.js.map +1 -0
- package/dist/setup/agents.d.ts +8 -0
- package/dist/setup/agents.js +83 -0
- package/dist/setup/agents.js.map +1 -0
- package/dist/setup/assessment.d.ts +31 -0
- package/dist/setup/assessment.js +75 -0
- package/dist/setup/assessment.js.map +1 -0
- package/dist/setup/auto-loaded.d.ts +17 -0
- package/dist/setup/auto-loaded.js +49 -0
- package/dist/setup/auto-loaded.js.map +1 -0
- package/dist/setup/copilot-version.d.ts +7 -0
- package/dist/setup/copilot-version.js +39 -0
- package/dist/setup/copilot-version.js.map +1 -0
- package/dist/setup/copilot.d.ts +22 -0
- package/dist/setup/copilot.js +164 -0
- package/dist/setup/copilot.js.map +1 -0
- package/dist/setup/init.d.ts +41 -0
- package/dist/setup/init.js +315 -0
- package/dist/setup/init.js.map +1 -0
- package/dist/setup/install.d.ts +12 -0
- package/dist/setup/install.js +211 -0
- package/dist/setup/install.js.map +1 -0
- package/dist/setup/instruction-quality.d.ts +25 -0
- package/dist/setup/instruction-quality.js +258 -0
- package/dist/setup/instruction-quality.js.map +1 -0
- package/dist/setup/inventory.d.ts +26 -0
- package/dist/setup/inventory.js +78 -0
- package/dist/setup/inventory.js.map +1 -0
- package/dist/setup/onboarding.d.ts +40 -0
- package/dist/setup/onboarding.js +472 -0
- package/dist/setup/onboarding.js.map +1 -0
- package/dist/setup/package.d.ts +2 -0
- package/dist/setup/package.js +8 -0
- package/dist/setup/package.js.map +1 -0
- package/dist/setup/planning-workflow.d.ts +3 -0
- package/dist/setup/planning-workflow.js +250 -0
- package/dist/setup/planning-workflow.js.map +1 -0
- package/dist/setup/repository-map.d.ts +34 -0
- package/dist/setup/repository-map.js +89 -0
- package/dist/setup/repository-map.js.map +1 -0
- package/dist/setup/review.d.ts +8 -0
- package/dist/setup/review.js +137 -0
- package/dist/setup/review.js.map +1 -0
- package/dist/setup/team.d.ts +18 -0
- package/dist/setup/team.js +172 -0
- package/dist/setup/team.js.map +1 -0
- package/dist/setup/templates.d.ts +11 -0
- package/dist/setup/templates.js +494 -0
- package/dist/setup/templates.js.map +1 -0
- package/dist/setup/terminal.d.ts +12 -0
- package/dist/setup/terminal.js +11 -0
- package/dist/setup/terminal.js.map +1 -0
- package/dist/setup/update.d.ts +6 -0
- package/dist/setup/update.js +71 -0
- package/dist/setup/update.js.map +1 -0
- package/dist/specification/batch.d.ts +50 -0
- package/dist/specification/batch.js +127 -0
- package/dist/specification/batch.js.map +1 -0
- package/dist/specification/plan-checks.d.ts +6 -0
- package/dist/specification/plan-checks.js +73 -0
- package/dist/specification/plan-checks.js.map +1 -0
- package/dist/specification/planning.d.ts +26 -0
- package/dist/specification/planning.js +507 -0
- package/dist/specification/planning.js.map +1 -0
- package/dist/specification/prose.d.ts +1 -0
- package/dist/specification/prose.js +15 -0
- package/dist/specification/prose.js.map +1 -0
- package/dist/tracking/ado.d.ts +19 -0
- package/dist/tracking/ado.js +128 -0
- package/dist/tracking/ado.js.map +1 -0
- package/dist/tracking/github.d.ts +8 -0
- package/dist/tracking/github.js +112 -0
- package/dist/tracking/github.js.map +1 -0
- package/dist/tracking/issues.d.ts +28 -0
- package/dist/tracking/issues.js +180 -0
- package/dist/tracking/issues.js.map +1 -0
- package/dist/tracking/native.d.ts +5 -0
- package/dist/tracking/native.js +30 -0
- package/dist/tracking/native.js.map +1 -0
- package/dist/tracking/refs.d.ts +14 -0
- package/dist/tracking/refs.js +56 -0
- package/dist/tracking/refs.js.map +1 -0
- package/dist/tracking/sources.d.ts +5 -0
- package/dist/tracking/sources.js +29 -0
- package/dist/tracking/sources.js.map +1 -0
- package/docs/operations.md +1114 -0
- package/examples/batch.json +27 -0
- package/examples/runs.json +22 -0
- package/package.json +50 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { PLANNING_LABEL, reviewerFor } from "../config.js";
|
|
2
|
+
import { GitHubError, hash, integer, record, string } from "../core.js";
|
|
3
|
+
import { batchDigest, issueBody, requireApproval, taskMetadata } from "../specification/batch.js";
|
|
4
|
+
import { isWriter } from "../tracking/github.js";
|
|
5
|
+
import { createLedger, listLedger } from "../tracking/refs.js";
|
|
6
|
+
import { approvedIn, ensureLabels, hasApproval, managedIssues, setStatus } from "../tracking/issues.js";
|
|
7
|
+
import { verifySources } from "../tracking/sources.js";
|
|
8
|
+
import { attributePull } from "./attribution.js";
|
|
9
|
+
import { launchMemory } from "../memory/launch.js";
|
|
10
|
+
import { cloudTasks } from "../tracking/native.js";
|
|
11
|
+
import { checkLaunchModels, copilotStartFailure, launchAllowance, listCopilotModels, modelRejection, rejectedLaunchModels, reserveLaunch, RESTART_LABEL, RESTART_MARKER, withDispatchLock } from "./controls.js";
|
|
12
|
+
import { autoMerge, markReady } from "./merge.js";
|
|
13
|
+
import { requestReview, trustedReview } from "./pr-review.js";
|
|
14
|
+
export { withDispatchLock } from "./controls.js";
|
|
15
|
+
export function renderDispatchResult(work, config) {
|
|
16
|
+
if (!work.length)
|
|
17
|
+
return [
|
|
18
|
+
"# Crewbie dispatch", "",
|
|
19
|
+
"No managed implementation tasks were found. No agents were started.", "",
|
|
20
|
+
"`crewbie:ready-for-planning` requests a plan; it is not an implementation task or execution approval.",
|
|
21
|
+
config.planning?.enabled
|
|
22
|
+
? "Hosted planning is enabled. Check the separate Crewbie planning workflow for labelled requirement issues, then review its plan before approving implementation."
|
|
23
|
+
: "Hosted planning is disabled. Enable it explicitly through reviewed init (including its generated workflow), commit the setup, then reapply the ready-for-planning label to request a potentially billable plan.",
|
|
24
|
+
"Alternatively, approve and publish an implementation batch with the local coordinator. Dispatch acts only on managed tasks with current human execution approval.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
const cell = (text) => text.replaceAll("|", "\\|").replace(/\r?\n/g, " ");
|
|
27
|
+
return ["# Crewbie dispatch", "", `Reconciled ${work.length} managed task(s). Reconciliation does not imply every task started.`, "",
|
|
28
|
+
"| Issue | State | Explanation |", "| --- | --- | --- |",
|
|
29
|
+
...work.map((item) => `| #${item.issue.number} | ${item.state} | ${cell(item.reason)} |`)].join("\n");
|
|
30
|
+
}
|
|
31
|
+
export { cloudTasks } from "../tracking/native.js";
|
|
32
|
+
export async function selectNativeTask(client, config, issue, matches) {
|
|
33
|
+
if (matches.length < 2)
|
|
34
|
+
return matches[0];
|
|
35
|
+
const comments = await client.list(`/repos/${config.repository}/issues/${issue}/comments`);
|
|
36
|
+
const candidates = new Set();
|
|
37
|
+
for (const comment of comments) {
|
|
38
|
+
if (!await isWriter(client, config.repository, comment.user) || comment.created_at !== comment.updated_at)
|
|
39
|
+
continue;
|
|
40
|
+
const marker = /^<!-- crewbie-continuation:([A-Za-z0-9+/=]+) -->$/.exec(String(comment.body));
|
|
41
|
+
if (!marker?.[1])
|
|
42
|
+
continue;
|
|
43
|
+
const receipt = record(JSON.parse(Buffer.from(marker[1], "base64").toString("utf8")), "continuation receipt");
|
|
44
|
+
if (!Array.isArray(receipt.previous) || typeof receipt.task !== "string")
|
|
45
|
+
continue;
|
|
46
|
+
const ids = [...receipt.previous, receipt.task];
|
|
47
|
+
if (new Set(ids).size !== matches.length || ids.length !== matches.length || !matches.every((task) => ids.includes(task.id)))
|
|
48
|
+
continue;
|
|
49
|
+
if (!matches.filter((task) => task.id !== receipt.task).every((task) => ["completed", "failed", "timed_out", "cancelled"].includes(String(task.state))))
|
|
50
|
+
continue;
|
|
51
|
+
candidates.add(receipt.task);
|
|
52
|
+
}
|
|
53
|
+
if (candidates.size !== 1)
|
|
54
|
+
return undefined;
|
|
55
|
+
return matches.find((task) => candidates.has(String(task.id)));
|
|
56
|
+
}
|
|
57
|
+
async function receiptOf(client, comment, config) {
|
|
58
|
+
if (!await isWriter(client, config.repository, comment.user) || comment.created_at !== comment.updated_at)
|
|
59
|
+
return null;
|
|
60
|
+
const marker = /^<!-- crewbie-continuation:([A-Za-z0-9+/=]+) -->$/.exec(String(comment.body));
|
|
61
|
+
if (!marker?.[1])
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const receipt = record(JSON.parse(Buffer.from(marker[1], "base64").toString("utf8")), "continuation receipt");
|
|
65
|
+
return typeof receipt.task === "string" && Array.isArray(receipt.previous) ? { task: receipt.task, previous: receipt.previous.map(String) } : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** A continuation whose task is not yet linked to the PR is still starting; the earlier completed task must not count as the outcome. */
|
|
72
|
+
export async function pendingContinuation(client, comments, config, matches) {
|
|
73
|
+
let latest = null;
|
|
74
|
+
for (const comment of [...comments].reverse()) {
|
|
75
|
+
latest = await receiptOf(client, comment, config);
|
|
76
|
+
if (latest)
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return latest && !matches.some((task) => task.id === latest.task) ? latest.task : null;
|
|
80
|
+
}
|
|
81
|
+
function copilotAssigned(issue) {
|
|
82
|
+
return Array.isArray(issue.assignees) && issue.assignees.some((value) => {
|
|
83
|
+
const assignee = record(value, "assignee");
|
|
84
|
+
return ["copilot-swe-agent[bot]", "copilot-swe-agent", "Copilot"].includes(String(assignee.login));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const COPILOT_LOGINS = ["copilot-swe-agent[bot]", "copilot-swe-agent", "Copilot"];
|
|
88
|
+
const byCopilot = (pr) => pr.user !== null && COPILOT_LOGINS.includes(String(record(pr.user, "PR author").login));
|
|
89
|
+
/**
|
|
90
|
+
* The Copilot PR for a task issue. Default-branch PRs are found through GitHub's closing references; GitHub does not link
|
|
91
|
+
* PRs into a feature branch, so those are found through the issue's cross-references and must target that branch.
|
|
92
|
+
*/
|
|
93
|
+
export async function linkedPull(client, repository, issue, branch) {
|
|
94
|
+
if (branch)
|
|
95
|
+
return branchPull(client, repository, issue, branch);
|
|
96
|
+
const pulls = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const [owner, name] = repository.split("/");
|
|
99
|
+
let after = null;
|
|
100
|
+
for (let page = 0; page < 100; page++) {
|
|
101
|
+
const response = record(await client.request("POST", "/graphql", {
|
|
102
|
+
query: `query($owner:String!,$name:String!,$number:Int!,$after:String) {
|
|
103
|
+
repository(owner:$owner,name:$name) { issue(number:$number) {
|
|
104
|
+
closedByPullRequestsReferences(first:100,includeClosedPrs:true,after:$after) {
|
|
105
|
+
nodes { number repository { nameWithOwner } } pageInfo { hasNextPage endCursor }
|
|
106
|
+
}
|
|
107
|
+
} }
|
|
108
|
+
}`,
|
|
109
|
+
variables: { owner, name, number: integer(issue, "issue number"), after },
|
|
110
|
+
}), "closing PR references");
|
|
111
|
+
if (response.errors !== undefined && (!Array.isArray(response.errors) || response.errors.length)) {
|
|
112
|
+
throw new Error("GitHub could not establish closing PR references. Check repository permissions before reconciling work.");
|
|
113
|
+
}
|
|
114
|
+
const data = record(response.data, "closing reference data");
|
|
115
|
+
const repo = record(data.repository, "closing reference repository");
|
|
116
|
+
const item = record(repo.issue, "closing reference issue");
|
|
117
|
+
const connection = record(item.closedByPullRequestsReferences, "closing PR connection");
|
|
118
|
+
if (!Array.isArray(connection.nodes))
|
|
119
|
+
throw new Error("GitHub returned invalid closing PR references.");
|
|
120
|
+
for (const raw of connection.nodes) {
|
|
121
|
+
const target = record(raw, "closing PR");
|
|
122
|
+
if (string(record(target.repository, "PR repository").nameWithOwner, "PR repository name").toLowerCase() !== repository.toLowerCase())
|
|
123
|
+
continue;
|
|
124
|
+
const number = integer(target.number, "closing PR number");
|
|
125
|
+
if (seen.has(number))
|
|
126
|
+
continue;
|
|
127
|
+
seen.add(number);
|
|
128
|
+
const pr = record(await client.request("GET", `/repos/${repository}/pulls/${number}`), "pull request");
|
|
129
|
+
if (byCopilot(pr))
|
|
130
|
+
pulls.push(pr);
|
|
131
|
+
}
|
|
132
|
+
const info = record(connection.pageInfo, "closing reference page");
|
|
133
|
+
if (typeof info.hasNextPage !== "boolean")
|
|
134
|
+
throw new Error("Closing-reference pagination is unverified.");
|
|
135
|
+
if (!info.hasNextPage)
|
|
136
|
+
break;
|
|
137
|
+
const next = string(info.endCursor, "closing reference cursor");
|
|
138
|
+
if (next === after || page === 99)
|
|
139
|
+
throw new Error("Closing-reference pagination could not complete safely.");
|
|
140
|
+
after = next;
|
|
141
|
+
}
|
|
142
|
+
return pickPull(pulls, issue);
|
|
143
|
+
}
|
|
144
|
+
async function branchPull(client, repository, issue, branch) {
|
|
145
|
+
const pulls = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const event of await client.list(`/repos/${repository}/issues/${integer(issue, "issue number")}/timeline`)) {
|
|
148
|
+
if (event.event !== "cross-referenced" || event.source === null || event.source === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
const source = record(event.source, "cross-reference source").issue;
|
|
151
|
+
const url = source && typeof source === "object" ? source.pull_request?.url : undefined;
|
|
152
|
+
const match = typeof url === "string" ? /\/repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/.exec(url) : null;
|
|
153
|
+
if (!match || match[1].toLowerCase() !== repository.toLowerCase())
|
|
154
|
+
continue;
|
|
155
|
+
const number = integer(Number(match[2]), "cross-referencing PR");
|
|
156
|
+
if (seen.has(number))
|
|
157
|
+
continue;
|
|
158
|
+
seen.add(number);
|
|
159
|
+
const pr = record(await client.request("GET", `/repos/${repository}/pulls/${number}`), "pull request");
|
|
160
|
+
if (byCopilot(pr) && record(pr.base, "PR base").ref === branch)
|
|
161
|
+
pulls.push(pr);
|
|
162
|
+
}
|
|
163
|
+
return pickPull(pulls, issue);
|
|
164
|
+
}
|
|
165
|
+
function pickPull(pulls, issue) {
|
|
166
|
+
// A restart leaves the earlier closed, unmerged PR linked; the current PR is the one that is open or merged.
|
|
167
|
+
const current = pulls.length > 1 ? pulls.filter((pr) => pr.state === "open" || pr.merged_at) : pulls;
|
|
168
|
+
let candidates = current.length || !pulls.length ? current : [pulls.reduce((a, b) => integer(b.number, "PR") > integer(a.number, "PR") ? b : a)];
|
|
169
|
+
// Another task's PR can mention this issue with a closing keyword (for example a review quoting "closes #61"). The earliest
|
|
170
|
+
// merged PR completed the issue; among open PRs, the one titled for this issue is the task's own.
|
|
171
|
+
const merged = candidates.filter((pr) => pr.merged_at).sort((a, b) => String(a.merged_at).localeCompare(String(b.merged_at)));
|
|
172
|
+
if (candidates.length > 1 && merged.length)
|
|
173
|
+
candidates = [merged[0]];
|
|
174
|
+
const titled = candidates.filter((pr) => new RegExp(`#${integer(issue, "issue number")}(?!\\d)`).test(String(pr.title ?? "")));
|
|
175
|
+
if (candidates.length > 1 && titled.length === 1)
|
|
176
|
+
candidates = titled;
|
|
177
|
+
const closing = candidates.filter((pr) => new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}(?!\\d)`, "i").test(String(pr.body ?? "")));
|
|
178
|
+
if (candidates.length > 1 && closing.length === 1)
|
|
179
|
+
candidates = closing;
|
|
180
|
+
if (candidates.length > 1)
|
|
181
|
+
throw new Error(`Issue #${issue} has multiple candidate agent PRs. Reconcile before dispatch.`);
|
|
182
|
+
return candidates[0] ?? null;
|
|
183
|
+
}
|
|
184
|
+
/** Closed issues updated within this window are still inspected, so a session that outlives its issue keeps its slot. */
|
|
185
|
+
const RECENTLY_CLOSED_MS = 24 * 60 * 60 * 1000;
|
|
186
|
+
/**
|
|
187
|
+
* Only open managed issues, recently closed ones and issues a scoped run names are inspected in full. Older closed
|
|
188
|
+
* issues are read only when an open task depends on them, so each run stays proportional to active work.
|
|
189
|
+
*/
|
|
190
|
+
export async function inspectWork(client, config, knownIssues = [], now = new Date()) {
|
|
191
|
+
const all = await managedIssues(client, config.repository, "open");
|
|
192
|
+
const refs = await listLedger(client, config.repository, "claims/");
|
|
193
|
+
const claims = new Set();
|
|
194
|
+
for (const raw of refs) {
|
|
195
|
+
const match = /^claims\/(\d+)$/.exec(raw.name);
|
|
196
|
+
if (!match)
|
|
197
|
+
throw new Error(`Unexpected claim ref: ${raw.ref}`);
|
|
198
|
+
claims.add(integer(Number(match[1]), "claimed issue number"));
|
|
199
|
+
}
|
|
200
|
+
const recent = claims.size ? await managedIssues(client, config.repository, "closed", new Date(now.getTime() - RECENTLY_CLOSED_MS)) : [];
|
|
201
|
+
all.push(...recent.filter((issue) => claims.has(integer(issue.number, "issue number"))));
|
|
202
|
+
for (const number of new Set(knownIssues.map((number) => integer(number, "published issue number")))) {
|
|
203
|
+
if (!all.some((issue) => issue.number === number)) {
|
|
204
|
+
all.push(record(await client.request("GET", `/repos/${config.repository}/issues/${number}`), "published issue"));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
all.sort((a, b) => integer(a.number, "issue number") - integer(b.number, "issue number"));
|
|
208
|
+
const result = [];
|
|
209
|
+
let telemetry;
|
|
210
|
+
for (const issue of all) {
|
|
211
|
+
const body = String(issue.body ?? "");
|
|
212
|
+
const metadata = taskMetadata(body);
|
|
213
|
+
// Tasks published before feature branches are finished by hand.
|
|
214
|
+
if (!metadata && body.includes("<!-- crewbie-task:"))
|
|
215
|
+
continue;
|
|
216
|
+
if (!metadata)
|
|
217
|
+
throw new Error(`Managed issue #${issue.number} lacks task metadata.`);
|
|
218
|
+
const number = integer(issue.number, "issue number");
|
|
219
|
+
const claim = claims.has(number);
|
|
220
|
+
const comments = await client.list(`/repos/${config.repository}/issues/${number}/comments`);
|
|
221
|
+
const approved = await approvedIn(client, comments, config, issue);
|
|
222
|
+
let pr = claim ? await linkedPull(client, config.repository, number, metadata.branch) : null;
|
|
223
|
+
const restart = [...comments].reverse().find((comment) => String(comment.body ?? "").includes(RESTART_MARKER));
|
|
224
|
+
// After a restart, an earlier closed, unmerged PR belongs to the ended attempt.
|
|
225
|
+
if (pr && restart && pr.state === "closed" && !pr.merged_at && String(restart.created_at) > String(pr.created_at))
|
|
226
|
+
pr = null;
|
|
227
|
+
let sessionComplete = false;
|
|
228
|
+
let sessionEnded = false;
|
|
229
|
+
let nativeTask;
|
|
230
|
+
let state = !approved ? "blocked" : claim ? "running" : "ready";
|
|
231
|
+
let reason = !approved ? "Missing current human execution approval." : claim ? "Launch already claimed; awaiting or reconciling its session/PR." : "Approved; dependencies will be checked.";
|
|
232
|
+
if (claim && !pr && copilotStartFailure(comments)) {
|
|
233
|
+
state = "failed";
|
|
234
|
+
sessionEnded = true;
|
|
235
|
+
reason = `Copilot reported it could not start this task; no session ran, so it does not count as a task attempt. Add the ${RESTART_LABEL} label to relaunch it.`;
|
|
236
|
+
}
|
|
237
|
+
else if (claim && !pr && !copilotAssigned(issue)) {
|
|
238
|
+
state = "blocked";
|
|
239
|
+
reason = "Launch was claimed but neither Copilot assignment nor a linked PR is visible. Inspect the outcome before retrying.";
|
|
240
|
+
}
|
|
241
|
+
if (pr?.merged_at) {
|
|
242
|
+
state = "done";
|
|
243
|
+
reason = `Merged into ${metadata.branch}.`;
|
|
244
|
+
}
|
|
245
|
+
else if (pr) {
|
|
246
|
+
telemetry ??= cloudTasks(client, config.repository);
|
|
247
|
+
const snapshot = await telemetry;
|
|
248
|
+
const matches = snapshot.tasks.filter((task) => Array.isArray(task.artifacts) && task.artifacts.some((raw) => {
|
|
249
|
+
const artifact = record(raw, "task artifact");
|
|
250
|
+
return artifact.provider === "github" && artifact.type === "pull" && artifact.data !== undefined
|
|
251
|
+
&& typeof pr.id === "number" && record(artifact.data, "artifact data").id === pr.id;
|
|
252
|
+
}));
|
|
253
|
+
nativeTask = await pendingContinuation(client, comments, config, matches) ? undefined : await selectNativeTask(client, config, number, matches);
|
|
254
|
+
sessionComplete = nativeTask?.state === "completed";
|
|
255
|
+
const failed = nativeTask !== undefined && ["failed", "timed_out", "cancelled"].includes(String(nativeTask.state));
|
|
256
|
+
// An open PR may still receive an authorized continuation, so only closure releases a failed session's slot.
|
|
257
|
+
sessionEnded = failed && (pr.state === "closed" || issue.state === "closed");
|
|
258
|
+
state = sessionComplete ? "review" : failed ? "failed" : "running";
|
|
259
|
+
reason = sessionComplete ? "Cloud task completed; linked PR awaits human review."
|
|
260
|
+
: failed ? `Cloud task ${String(nativeTask?.state)}; inspect its saved work before an explicitly authorized continuation.`
|
|
261
|
+
: snapshot.warning ?? "Linked PR exists, but its cloud session is active or unverified; capacity remains reserved.";
|
|
262
|
+
}
|
|
263
|
+
if (!pr?.merged_at && (pr?.state === "closed" || issue.state === "closed")) {
|
|
264
|
+
state = "failed";
|
|
265
|
+
reason = `Closed without a merged prerequisite PR.${claim && !sessionComplete ? sessionEnded ? ` ${reason}` : ` Native completion remains unverified or unsuccessful; capacity stays reserved. ${reason}` : ""}`;
|
|
266
|
+
}
|
|
267
|
+
const role = config.roles.find((role) => role.id === metadata.task.owner);
|
|
268
|
+
if (!claim && (!role || role.model !== metadata.task.model)) {
|
|
269
|
+
state = "blocked";
|
|
270
|
+
reason = "Owner/model policy changed; reapproval required.";
|
|
271
|
+
}
|
|
272
|
+
if (!Array.isArray(issue.labels))
|
|
273
|
+
throw new Error("Issue labels are missing.");
|
|
274
|
+
const ownerLabels = issue.labels.map((label) => typeof label === "string" ? label : String(record(label, "label").name))
|
|
275
|
+
.filter((label) => label.startsWith("crewbie:owner:"));
|
|
276
|
+
if (!claim && (ownerLabels.length !== 1 || ownerLabels[0] !== `crewbie:owner:${metadata.task.owner}`)) {
|
|
277
|
+
state = "blocked";
|
|
278
|
+
reason = "Owner label does not match the approved specialist.";
|
|
279
|
+
}
|
|
280
|
+
if (!approved && state !== "failed") {
|
|
281
|
+
state = "blocked";
|
|
282
|
+
reason = "Issue content no longer has current human execution approval.";
|
|
283
|
+
}
|
|
284
|
+
result.push({ issue, metadata, state, approved, claimed: claim, sessionComplete, ...(sessionEnded ? { sessionEnded } : {}), reason, ...(pr ? { pull: pr } : {}), ...(nativeTask ? { nativeTask } : {}) });
|
|
285
|
+
}
|
|
286
|
+
const key = (batch, digest, id) => `${batch}/${digest}/${id}`;
|
|
287
|
+
const present = new Set(result.map((item) => key(item.metadata.batch, item.metadata.batchDigest, item.metadata.task.id)));
|
|
288
|
+
const missing = new Set(result.flatMap((item) => item.metadata.task.dependsOn.map((id) => key(item.metadata.batch, item.metadata.batchDigest, id))).filter((id) => !present.has(id)));
|
|
289
|
+
if (missing.size) {
|
|
290
|
+
for (const issue of await managedIssues(client, config.repository, "closed")) {
|
|
291
|
+
const metadata = taskMetadata(String(issue.body ?? ""));
|
|
292
|
+
const id = metadata && key(metadata.batch, metadata.batchDigest, metadata.task.id);
|
|
293
|
+
if (!metadata || !id || !missing.has(id))
|
|
294
|
+
continue;
|
|
295
|
+
missing.delete(id);
|
|
296
|
+
const merged = !!(await linkedPull(client, config.repository, integer(issue.number, "prerequisite issue"), metadata.branch))?.merged_at;
|
|
297
|
+
result.push({ issue, metadata, state: merged ? "done" : "failed", approved: false, claimed: false, sessionComplete: false,
|
|
298
|
+
reason: merged ? "Closed; linked Copilot PR merged." : "Closed without a merged prerequisite PR." });
|
|
299
|
+
if (!missing.size)
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
export function batchWork(work, batch) {
|
|
306
|
+
requireApproval(batch);
|
|
307
|
+
if (!batch.approval?.execute)
|
|
308
|
+
throw new Error("Watching requires execution approval.");
|
|
309
|
+
const digest = batchDigest(batch);
|
|
310
|
+
const expected = new Set(batch.tasks.map((task) => task.id));
|
|
311
|
+
const selected = work.filter((item) => item.metadata.batch === batch.id && item.metadata.batchDigest === digest && expected.has(item.metadata.task.id));
|
|
312
|
+
if (selected.length !== batch.tasks.length)
|
|
313
|
+
throw new Error(`Published batch is incomplete or duplicated (${selected.length} records for ${batch.tasks.length} tasks); reconcile before dispatch.`);
|
|
314
|
+
for (const task of batch.tasks) {
|
|
315
|
+
const matches = selected.filter((item) => item.metadata.task.id === task.id);
|
|
316
|
+
const item = matches[0];
|
|
317
|
+
if (matches.length !== 1 || !item || item.metadata.batchDigest !== digest || !item.approved
|
|
318
|
+
|| item.issue.title !== task.title || item.issue.body !== issueBody(batch, task)) {
|
|
319
|
+
throw new Error(`Published task ${task.id} differs from the approved batch; stop for reconciliation.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return selected;
|
|
323
|
+
}
|
|
324
|
+
export function eligible(work, maxActive, batchId) {
|
|
325
|
+
// Replanned batches reuse batch/task ids; the approved digest keeps each revision's graph separate.
|
|
326
|
+
const keyOf = (item, id = item.metadata.task.id) => `${item.metadata.batch}/${item.metadata.batchDigest}/${id}`;
|
|
327
|
+
const byKey = new Map();
|
|
328
|
+
for (const item of work) {
|
|
329
|
+
const key = keyOf(item);
|
|
330
|
+
if (byKey.has(key))
|
|
331
|
+
throw new Error(`Duplicate task ${item.metadata.batch}/${item.metadata.task.id}.`);
|
|
332
|
+
byKey.set(key, item);
|
|
333
|
+
}
|
|
334
|
+
const visiting = new Set(), visited = new Set();
|
|
335
|
+
function visit(item) {
|
|
336
|
+
const key = keyOf(item);
|
|
337
|
+
if (visiting.has(key))
|
|
338
|
+
throw new Error(`Dependency cycle at ${item.metadata.batch}/${item.metadata.task.id}.`);
|
|
339
|
+
if (visited.has(key))
|
|
340
|
+
return;
|
|
341
|
+
// Finished work no longer waits on anything, and its own prerequisites may not have been loaded.
|
|
342
|
+
if (item.state === "done" || item.state === "failed") {
|
|
343
|
+
visited.add(key);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
visiting.add(key);
|
|
347
|
+
for (const id of item.metadata.task.dependsOn) {
|
|
348
|
+
const dependency = byKey.get(keyOf(item, id));
|
|
349
|
+
if (!dependency)
|
|
350
|
+
throw new Error(`Missing prerequisite ${id}.`);
|
|
351
|
+
visit(dependency);
|
|
352
|
+
// Review tasks, like every other task, start from the merged work on the feature branch.
|
|
353
|
+
if (!item.claimed && dependency.state !== "done") {
|
|
354
|
+
item.state = "blocked";
|
|
355
|
+
item.reason = `Waiting for ${id} to merge into ${item.metadata.branch}.`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
visiting.delete(key);
|
|
359
|
+
visited.add(key);
|
|
360
|
+
}
|
|
361
|
+
for (const item of work)
|
|
362
|
+
visit(item);
|
|
363
|
+
const active = work.filter((item) => item.claimed && item.state !== "done" && item.sessionComplete !== true && item.sessionEnded !== true).length;
|
|
364
|
+
return work.filter((item) => item.approved && !item.claimed && item.state === "ready" && (batchId === undefined || item.metadata.batch === batchId))
|
|
365
|
+
.sort((a, b) => a.metadata.task.priority - b.metadata.task.priority || a.metadata.task.id.localeCompare(b.metadata.task.id))
|
|
366
|
+
.slice(0, Math.max(0, maxActive - active));
|
|
367
|
+
}
|
|
368
|
+
export async function dispatch(client, config, ado, scope, discoverModels = listCopilotModels) {
|
|
369
|
+
return withDispatchLock(client, config, () => dispatchLocked(client, config, ado, scope, discoverModels));
|
|
370
|
+
}
|
|
371
|
+
async function dispatchLocked(client, config, ado, scope, discoverModels) {
|
|
372
|
+
await ensureLabels(client, config);
|
|
373
|
+
const work = await inspectWork(client, config, scope?.issueNumbers);
|
|
374
|
+
const scoped = scope?.batch ? batchWork(work, scope.batch) : work;
|
|
375
|
+
const selected = eligible(work, config.maxActive, scope?.batch?.id);
|
|
376
|
+
for (const item of selected) {
|
|
377
|
+
const allowance = await launchAllowance(client, config, item.metadata, integer(item.issue.number, "issue"));
|
|
378
|
+
const blocked = allowance.blocked ?? (allowance.issueUsed > 0 ? "Initial launch already reserved; inspect its outcome instead of retrying." : null);
|
|
379
|
+
if (blocked) {
|
|
380
|
+
item.state = "blocked";
|
|
381
|
+
item.reason = blocked;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const candidates = selected.filter((item) => item.state === "ready");
|
|
385
|
+
// A model the cloud agent rejects blocks only its own tasks; the rest of the batch still launches.
|
|
386
|
+
const rejected = candidates.length ? await rejectedLaunchModels(await discoverModels(), candidates.map((item) => item.metadata.task), config, client) : new Set();
|
|
387
|
+
for (const item of candidates)
|
|
388
|
+
if (rejected.has(item.metadata.task.model)) {
|
|
389
|
+
item.state = "blocked";
|
|
390
|
+
item.reason = modelRejection(item.metadata.task.model, item.metadata.task.owner);
|
|
391
|
+
}
|
|
392
|
+
const launchable = candidates.filter((item) => item.state === "ready");
|
|
393
|
+
for (const item of scoped) {
|
|
394
|
+
await setStatus(client, config.repository, item.issue, item.state);
|
|
395
|
+
if (item.state === "review" && item.sessionComplete && item.pull && item.nativeTask?.custom_agent) {
|
|
396
|
+
await attributePull(client, config, item.pull, item.nativeTask, item.metadata.task.owner, item.metadata.task.model, integer(item.issue.number, "execution issue"));
|
|
397
|
+
}
|
|
398
|
+
if (item.state === "review" && item.sessionComplete && item.pull?.state === "open") {
|
|
399
|
+
try {
|
|
400
|
+
item.reason = await afterSession(client, config, item);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
item.reason = `Ready/review/merge step stopped: ${error instanceof Error ? error.message : "request failed"}`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const repo = record(await client.request("GET", `/repos/${config.repository}`), "repository");
|
|
408
|
+
const branch = string(repo.default_branch, "default branch");
|
|
409
|
+
const branchInfo = record(await client.request("GET", `/repos/${config.repository}/branches/${encodeURIComponent(branch)}`), "branch");
|
|
410
|
+
const sha = string(record(branchInfo.commit, "commit").sha, "base SHA");
|
|
411
|
+
for (const item of launchable) {
|
|
412
|
+
const issue = integer(item.issue.number, "issue number");
|
|
413
|
+
let fresh;
|
|
414
|
+
try {
|
|
415
|
+
fresh = await freshLaunchable(client, config, item, sha, ado);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
const message = error instanceof Error ? error.message : "Source verification failed.";
|
|
419
|
+
if (!/Source changed|requirements changed/i.test(message))
|
|
420
|
+
throw error;
|
|
421
|
+
item.state = "blocked";
|
|
422
|
+
item.reason = `Source verification failed: ${message}`;
|
|
423
|
+
await setStatus(client, config.repository, item.issue, "blocked");
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const allowance = await launchAllowance(client, config, item.metadata, issue);
|
|
427
|
+
if (allowance.blocked) {
|
|
428
|
+
item.state = "blocked";
|
|
429
|
+
item.reason = allowance.blocked;
|
|
430
|
+
await setStatus(client, config.repository, fresh, "blocked");
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const base = await ensureBranch(client, config, item.metadata.branch, sha);
|
|
434
|
+
const memory = await launchMemory(client, config, item.metadata.task.owner, base);
|
|
435
|
+
await reserveLaunch(client, config, item.metadata, issue, sha, true);
|
|
436
|
+
// Atomic remote claim prevents a second workflow from launching the same issue.
|
|
437
|
+
await createLedger(client, config.repository, `claims/${issue}`, sha);
|
|
438
|
+
await assign(client, config, item, fresh, base, memory);
|
|
439
|
+
}
|
|
440
|
+
await restarts(client, config, work, sha, discoverModels, ado);
|
|
441
|
+
await featurePulls(client, config, work, branch);
|
|
442
|
+
return work;
|
|
443
|
+
}
|
|
444
|
+
/** Creates a plan's feature branch from the default branch the first time one of its tasks launches. */
|
|
445
|
+
async function ensureBranch(client, config, branch, sha) {
|
|
446
|
+
// The git refs API takes the ref path with literal slashes; the branch name was validated as crewbie/<slug>.
|
|
447
|
+
const path = `/repos/${config.repository}/git/ref/heads/${branch}`;
|
|
448
|
+
try {
|
|
449
|
+
await client.request("GET", path);
|
|
450
|
+
return branch;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (!(error instanceof GitHubError && error.status === 404))
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
await client.request("POST", `/repos/${config.repository}/git/refs`, { ref: `refs/heads/${branch}`, sha });
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (!(error instanceof GitHubError && error.status === 422))
|
|
461
|
+
throw error;
|
|
462
|
+
await client.request("GET", path);
|
|
463
|
+
}
|
|
464
|
+
return branch;
|
|
465
|
+
}
|
|
466
|
+
export const FEATURE_MARKER = "<!-- crewbie-feature:";
|
|
467
|
+
const FEATURE_TASKS_START = "<!-- crewbie-feature-tasks:start -->";
|
|
468
|
+
const FEATURE_TASKS_END = "<!-- crewbie-feature-tasks:end -->";
|
|
469
|
+
/**
|
|
470
|
+
* Once every task of a feature plan merged into its branch, Crewbie opens one PR to the default branch that closes all of
|
|
471
|
+
* the plan's issues. The Crewbie reviewer (when enabled) reviews each new head; only a human merges it.
|
|
472
|
+
*/
|
|
473
|
+
async function featurePulls(client, config, work, base) {
|
|
474
|
+
const prefix = `/repos/${config.repository}`;
|
|
475
|
+
const plans = new Map();
|
|
476
|
+
for (const item of work) {
|
|
477
|
+
const key = `${item.metadata.batch}/${item.metadata.batchDigest}`;
|
|
478
|
+
plans.set(key, [...plans.get(key) ?? [], item]);
|
|
479
|
+
}
|
|
480
|
+
for (const items of plans.values()) {
|
|
481
|
+
if (items.some((item) => item.state !== "done"))
|
|
482
|
+
continue;
|
|
483
|
+
const { batch, branch } = items[0].metadata;
|
|
484
|
+
const owner = config.repository.split("/")[0];
|
|
485
|
+
const pulls = (await client.list(`${prefix}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(base)}`))
|
|
486
|
+
.sort((a, b) => integer(b.number, "PR") - integer(a.number, "PR"));
|
|
487
|
+
let feature = pulls.find((pr) => pr.state === "open" || pr.merged_at);
|
|
488
|
+
let note;
|
|
489
|
+
if (feature?.merged_at)
|
|
490
|
+
note = `Feature PR #${String(feature.number)} merged into ${base}.`;
|
|
491
|
+
else if (!feature && pulls.length)
|
|
492
|
+
note = `Feature PR #${String(pulls[0].number)} was closed without merging; reopen it to continue.`;
|
|
493
|
+
else {
|
|
494
|
+
let opened = "";
|
|
495
|
+
if (!feature) {
|
|
496
|
+
try {
|
|
497
|
+
feature = record(await client.request("POST", `${prefix}/pulls`, { title: await featureTitle(client, config, batch), head: branch, base, body: featureBody(config, batch, branch, await currentPulls(client, config, items)) }), "feature PR");
|
|
498
|
+
opened = `Opened feature PR #${String(feature.number)}. ${await requestRequesters(client, config, feature, items)}`;
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error instanceof GitHubError && error.status === 422) {
|
|
502
|
+
note = `${branch} has nothing to merge into ${base}, or GitHub refused the feature PR.`;
|
|
503
|
+
setNote(items, branch, note);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
const current = typeof feature.body === "string" ? feature.body : "";
|
|
511
|
+
const body = hasManagedTaskSet(current, items) ? current : updateFeatureBody(config, batch, await currentPulls(client, config, items), current);
|
|
512
|
+
if (feature.state === "open" && body !== current) {
|
|
513
|
+
feature = record(await client.request("PATCH", `${prefix}/pulls/${integer(feature.number, "feature PR")}`, { body }), "feature PR");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
note = opened + await featureReview(client, config, feature, branch);
|
|
517
|
+
}
|
|
518
|
+
setNote(items, branch, note);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/** Asks whoever labeled the PRD for planning to review its feature PR; GitHub refuses the PR's own author, who already owns it. */
|
|
522
|
+
async function requestRequesters(client, config, pull, items) {
|
|
523
|
+
const author = typeof pull.user === "object" && pull.user !== null ? pull.user.login : undefined;
|
|
524
|
+
const people = new Set();
|
|
525
|
+
for (const number of sourceIssues(config, items)) {
|
|
526
|
+
let events;
|
|
527
|
+
try {
|
|
528
|
+
events = await client.list(`/repos/${config.repository}/issues/${String(number)}/events`);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
if (error instanceof GitHubError && error.status === 404)
|
|
532
|
+
continue;
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
const labeled = events.filter((event) => event.event === "labeled" && typeof event.label === "object" && event.label !== null
|
|
536
|
+
&& event.label.name === PLANNING_LABEL).at(-1);
|
|
537
|
+
if (labeled && await isWriter(client, config.repository, labeled.actor))
|
|
538
|
+
people.add(String(record(labeled.actor, "label actor").login));
|
|
539
|
+
}
|
|
540
|
+
people.delete(String(author));
|
|
541
|
+
if (!people.size)
|
|
542
|
+
return "";
|
|
543
|
+
const reviewers = [...people].sort();
|
|
544
|
+
try {
|
|
545
|
+
await client.request("POST", `/repos/${config.repository}/pulls/${integer(pull.number, "feature PR")}/requested_reviewers`, { reviewers });
|
|
546
|
+
return `Requested review from ${reviewers.map((login) => `@${login}`).join(", ")}. `;
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
if (error instanceof GitHubError && error.status === 422)
|
|
550
|
+
return "";
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Task PRs as they are now: a specialist often finalizes its PR body seconds before the merge this run just made. */
|
|
555
|
+
async function currentPulls(client, config, items) {
|
|
556
|
+
return Promise.all(items.map(async (item) => item.pull
|
|
557
|
+
? { ...item, pull: record(await client.request("GET", `/repos/${config.repository}/pulls/${integer(item.pull.number, "task PR")}`), "task PR") }
|
|
558
|
+
: item));
|
|
559
|
+
}
|
|
560
|
+
function setNote(items, branch, note) { for (const item of items)
|
|
561
|
+
item.reason = `Merged into ${branch}. ${note}`; }
|
|
562
|
+
async function featureTitle(client, config, batch) {
|
|
563
|
+
const source = /^issue-(\d+)$/.exec(batch)?.[1];
|
|
564
|
+
if (source) {
|
|
565
|
+
try {
|
|
566
|
+
return `Crewbie feature: ${string(record(await client.request("GET", `/repos/${config.repository}/issues/${source}`), "source issue").title, "source title")} (#${source})`;
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (!(error instanceof GitHubError && error.status === 404))
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return `Crewbie feature: ${batch}`;
|
|
574
|
+
}
|
|
575
|
+
export function featureBody(config, batch, branch, items) {
|
|
576
|
+
const reviewer = reviewerFor(config);
|
|
577
|
+
return [
|
|
578
|
+
`Every task of plan \`${batch}\` merged into \`${branch}\`. Check out that branch to test the whole feature, then merge this PR yourself; Crewbie never merges it.${reviewer ? ` crewbie-${reviewer.role} reviews each new head.` : ""} Comment \`/crewbie fix\` to have the specialists address a changes-requested review.`,
|
|
579
|
+
"", managedFeatureBlock(config, items),
|
|
580
|
+
"", `${FEATURE_MARKER}${batch} -->`,
|
|
581
|
+
].join("\n");
|
|
582
|
+
}
|
|
583
|
+
function managedFeatureBlock(config, items) {
|
|
584
|
+
const sorted = [...items].sort((a, b) => integer(a.issue.number, "issue") - integer(b.issue.number, "issue"));
|
|
585
|
+
const checks = sorted.filter((item) => item.pull).map((item) => {
|
|
586
|
+
const reported = taskChecks(item.pull);
|
|
587
|
+
return `### #${String(item.issue.number)} (PR #${String(item.pull.number)})\n${reported ?? "_No Checks section in the task PR._"}`;
|
|
588
|
+
});
|
|
589
|
+
return [
|
|
590
|
+
FEATURE_TASKS_START,
|
|
591
|
+
"## Tasks",
|
|
592
|
+
...sorted.map((item) => `- #${String(item.issue.number)} ${String(item.issue.title)}${item.pull ? ` (#${String(item.pull.number)})` : ""}`),
|
|
593
|
+
...(checks.length ? ["", "## Checks", "Each task PR's own Checks section, as its specialist reported it. The reviewer also reads this head's CI check runs from GitHub.", "", ...checks] : []),
|
|
594
|
+
"", ...sorted.map((item) => `Closes #${String(item.issue.number)}`),
|
|
595
|
+
...sourceIssues(config, items).map((number) => `Closes #${String(number)}`),
|
|
596
|
+
FEATURE_TASKS_END,
|
|
597
|
+
].join("\n");
|
|
598
|
+
}
|
|
599
|
+
const TASK_CHECKS_CHARACTERS = 1500;
|
|
600
|
+
/** GitHub's closing keywords with any issue reference form: #N, owner/repo#N or an issue URL. */
|
|
601
|
+
const CLOSING = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s*(?:[\w.-]+\/[\w.-]+)?#\d+|\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s*https?:\/\/\S+\/issues\/\d+/i;
|
|
602
|
+
/** The "## Checks" section of a task PR body, without closing keywords or comments that would change the feature PR's meaning. */
|
|
603
|
+
export function taskChecks(pull) {
|
|
604
|
+
const lines = (typeof pull.body === "string" ? pull.body : "").replace(/<!--[\s\S]*?-->/g, "").split(/\r?\n/);
|
|
605
|
+
const start = lines.findIndex((line) => /^#{2,3}\s+(?:Checks|Tests?|Verification)\b/i.test(line.trim()));
|
|
606
|
+
if (start === -1)
|
|
607
|
+
return null;
|
|
608
|
+
const rest = lines.slice(start + 1);
|
|
609
|
+
const end = rest.findIndex((line) => /^#{1,3}\s/.test(line.trim()));
|
|
610
|
+
const text = (end === -1 ? rest : rest.slice(0, end)).filter((line) => !CLOSING.test(line)).join("\n").trim();
|
|
611
|
+
if (!text)
|
|
612
|
+
return null;
|
|
613
|
+
return text.length > TASK_CHECKS_CHARACTERS ? `${text.slice(0, TASK_CHECKS_CHARACTERS).trimEnd()}… (truncated; see PR #${String(pull.number)})` : text;
|
|
614
|
+
}
|
|
615
|
+
function taskIssueSet(items) {
|
|
616
|
+
return new Set(items.map((item) => integer(item.issue.number, "issue")));
|
|
617
|
+
}
|
|
618
|
+
function bodyCloses(body) {
|
|
619
|
+
return new Set([...body.matchAll(/\bCloses\s+#(\d+)\b/gi)].map((match) => Number(match[1])));
|
|
620
|
+
}
|
|
621
|
+
function hasManagedTaskSet(body, items) {
|
|
622
|
+
const closes = bodyCloses(body);
|
|
623
|
+
for (const issue of taskIssueSet(items))
|
|
624
|
+
if (!closes.has(issue))
|
|
625
|
+
return false;
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
export function updateFeatureBody(config, batch, items, current) {
|
|
629
|
+
if (hasManagedTaskSet(current, items))
|
|
630
|
+
return current;
|
|
631
|
+
const block = managedFeatureBlock(config, items);
|
|
632
|
+
const start = current.indexOf(FEATURE_TASKS_START), end = current.indexOf(FEATURE_TASKS_END);
|
|
633
|
+
if (start >= 0 && end > start)
|
|
634
|
+
return `${current.slice(0, start)}${block}${current.slice(end + FEATURE_TASKS_END.length)}`;
|
|
635
|
+
const missing = [...taskIssueSet(items)].filter((issue) => !bodyCloses(current).has(issue)).sort((a, b) => a - b).map((issue) => `Closes #${issue}`);
|
|
636
|
+
const addition = missing.length ? `\n${missing.join("\n")}` : "";
|
|
637
|
+
const marker = `${FEATURE_MARKER}${batch} -->`;
|
|
638
|
+
const index = current.indexOf(marker);
|
|
639
|
+
if (index >= 0)
|
|
640
|
+
return `${current.slice(0, index).trimEnd()}${addition}\n\n${current.slice(index)}`;
|
|
641
|
+
return `${current.trimEnd()}${addition}\n\n${marker}`;
|
|
642
|
+
}
|
|
643
|
+
function sourceIssues(config, items) {
|
|
644
|
+
const prefix = `https://github.com/${config.repository}/issues/`;
|
|
645
|
+
const tasks = new Set(items.map((item) => integer(item.issue.number, "issue")));
|
|
646
|
+
const numbers = items.flatMap((item) => item.metadata.sources.map((source) => source.uri))
|
|
647
|
+
.filter((uri) => uri.startsWith(prefix) && /^\d+$/.test(uri.slice(prefix.length))).map((uri) => Number(uri.slice(prefix.length)));
|
|
648
|
+
return [...new Set(numbers)].filter((number) => !tasks.has(number)).sort((a, b) => a - b);
|
|
649
|
+
}
|
|
650
|
+
async function featureReview(client, config, pull, branch) {
|
|
651
|
+
const number = integer(pull.number, "feature PR");
|
|
652
|
+
const reviewer = reviewerFor(config);
|
|
653
|
+
const human = `Test ${branch}, then merge feature PR #${number} yourself.`;
|
|
654
|
+
if (!reviewer)
|
|
655
|
+
return human;
|
|
656
|
+
const head = string(record(pull.head, "feature PR head").sha, "feature PR head SHA");
|
|
657
|
+
const review = await trustedReview(client, config, number, head);
|
|
658
|
+
if (!review)
|
|
659
|
+
return `${await requestReview(client, config, number, head)} ${human}`;
|
|
660
|
+
if (review.verdict === "changes")
|
|
661
|
+
return `crewbie-${reviewer.role} requested changes on ${head.slice(0, 7)}; push fixes to ${branch} for a fresh review, or merge feature PR #${number} yourself if you disagree.`;
|
|
662
|
+
return `crewbie-${reviewer.role} found no blocking issues on ${head.slice(0, 7)}${review.partial ? " (partial review)" : ""}. ${human}`;
|
|
663
|
+
}
|
|
664
|
+
function labelsOf(issue) {
|
|
665
|
+
if (!Array.isArray(issue.labels))
|
|
666
|
+
throw new Error("Issue labels are missing.");
|
|
667
|
+
return issue.labels.map((label) => typeof label === "string" ? label : String(record(label, "label").name));
|
|
668
|
+
}
|
|
669
|
+
const activeSessions = (work) => work.filter((other) => other.claimed && other.state !== "done" && other.sessionComplete !== true && other.sessionEnded !== true).length;
|
|
670
|
+
/** After a completed session Crewbie marks the task PR ready and merges it once its checks and task review pass. */
|
|
671
|
+
async function afterSession(client, config, item) {
|
|
672
|
+
const pull = item.pull;
|
|
673
|
+
const lead = await markReady(client, pull) ? "Crewbie marked the PR ready for review. " : "";
|
|
674
|
+
// Copilot asks the assigning person to review every finished PR; task PRs receive a Crewbie review, not a human request.
|
|
675
|
+
const people = (Array.isArray(pull.requested_reviewers) ? pull.requested_reviewers : []).map((user) => String(record(user, "requested reviewer").login));
|
|
676
|
+
if (people.length)
|
|
677
|
+
await client.request("DELETE", `/repos/${config.repository}/pulls/${integer(pull.number, "PR number")}/requested_reviewers`, { reviewers: people });
|
|
678
|
+
const outcome = await autoMerge(client, config, integer(pull.number, "PR number"), string(record(pull.head, "PR head").sha, "PR head SHA"));
|
|
679
|
+
if (outcome.merged) {
|
|
680
|
+
item.state = "done";
|
|
681
|
+
await setStatus(client, config.repository, item.issue, "done");
|
|
682
|
+
}
|
|
683
|
+
return lead + outcome.reason;
|
|
684
|
+
}
|
|
685
|
+
async function freshLaunchable(client, config, item, sha, ado) {
|
|
686
|
+
const issue = integer(item.issue.number, "issue number");
|
|
687
|
+
await verifySources(item.metadata.sources, client, config, ado);
|
|
688
|
+
const fresh = record(await client.request("GET", `/repos/${config.repository}/issues/${issue}`), "current issue");
|
|
689
|
+
if (fresh.body !== item.issue.body || fresh.title !== item.issue.title || fresh.state !== "open" || !await hasApproval(client, config, fresh)) {
|
|
690
|
+
throw new Error(`Issue #${issue} changed during dispatch. Nothing was launched for this issue.`);
|
|
691
|
+
}
|
|
692
|
+
await client.request("GET", `/repos/${config.repository}/contents/.github/agents/crewbie-${item.metadata.task.owner}.agent.md?ref=${sha}`);
|
|
693
|
+
return fresh;
|
|
694
|
+
}
|
|
695
|
+
async function assign(client, config, item, fresh, branch, memory) {
|
|
696
|
+
const issue = integer(fresh.number, "issue number");
|
|
697
|
+
const task = item.metadata.task;
|
|
698
|
+
try {
|
|
699
|
+
const assigned = record(await client.request("POST", `/repos/${config.repository}/issues/${issue}/assignees`, {
|
|
700
|
+
assignees: ["copilot-swe-agent[bot]"],
|
|
701
|
+
agent_assignment: {
|
|
702
|
+
target_repo: config.repository, base_branch: branch, custom_agent: `crewbie-${task.owner}`, model: task.model,
|
|
703
|
+
custom_instructions: `Implement only issue #${issue}. Approved task fingerprint: ${hash(String(fresh.body))}. If the issue changes from this approved scope, stop for reapproval. Approved task:\n${task.body}\n${memory}\nLink the PR with Closes #${issue}. Identify Specialist: crewbie-${task.owner} in the PR description. Report the memory paths/revisions read. Before handoff, add only non-obvious gotchas (one or two lines each, with a link) to .crewbie/team/${task.owner}/hot.md on this branch, as the shared working rules describe; this is always in scope. Record any new cross-role choice in .crewbie/decisions/hot.md the same way. Put downstream contracts in the PR Handoff section, not memory. Keep the PR concise, with ## What changed, ## Why and ## Checks sections (actual checks and risks). Requested model: ${task.model}; report observed model only with runtime evidence.`,
|
|
704
|
+
},
|
|
705
|
+
}), "assignment response");
|
|
706
|
+
if (!copilotAssigned(assigned))
|
|
707
|
+
throw new Error("GitHub did not confirm Copilot among the assignees. The assignment may have been ignored; check push access and cloud-agent entitlement.");
|
|
708
|
+
if (assigned.number !== issue || assigned.title !== fresh.title || assigned.body !== fresh.body)
|
|
709
|
+
throw new Error("Issue scope changed during assignment. Inspect the session and obtain reapproval.");
|
|
710
|
+
item.state = "running";
|
|
711
|
+
item.claimed = true;
|
|
712
|
+
item.reason = "Assignment requested; effective profile/model remain subject to runtime verification.";
|
|
713
|
+
await setStatus(client, config.repository, fresh, "running");
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
throw new Error(`Issue #${issue} has a persistent launch claim. Assignment outcome may be unknown; inspect GitHub before any manual retry. ${error instanceof Error ? error.message : "Request failed."}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Why a restart request cannot relaunch this task, or null when its previous session verifiably ended. */
|
|
720
|
+
export function restartProblem(item) {
|
|
721
|
+
if (item.issue.state !== "open")
|
|
722
|
+
return "the issue is closed. Reopen it first if the work is still wanted.";
|
|
723
|
+
if (!item.approved)
|
|
724
|
+
return "the issue no longer has current human execution approval.";
|
|
725
|
+
if (!item.claimed)
|
|
726
|
+
return "it was never launched; dispatch starts it when it is ready.";
|
|
727
|
+
if (item.pull?.merged_at)
|
|
728
|
+
return "its PR is already merged.";
|
|
729
|
+
if (item.pull?.state === "open")
|
|
730
|
+
return "it has an open PR. Push fixes to it, or close it and add the label again.";
|
|
731
|
+
if (item.sessionEnded !== true)
|
|
732
|
+
return "its previous session is not verified as ended, so a relaunch could run twice.";
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
async function restarts(client, config, work, sha, discoverModels, ado) {
|
|
736
|
+
const prefix = `/repos/${config.repository}`;
|
|
737
|
+
for (const item of work.filter((item) => labelsOf(item.issue).includes(RESTART_LABEL))) {
|
|
738
|
+
const issue = integer(item.issue.number, "issue number");
|
|
739
|
+
const consume = async (message) => {
|
|
740
|
+
await client.request("DELETE", `${prefix}/issues/${issue}/labels/${encodeURIComponent(RESTART_LABEL)}`);
|
|
741
|
+
await client.request("POST", `${prefix}/issues/${issue}/comments`, { body: message });
|
|
742
|
+
};
|
|
743
|
+
const events = await client.list(`${prefix}/issues/${issue}/events`);
|
|
744
|
+
const labeled = [...events].reverse().find((event) => event.event === "labeled" && event.label !== null && event.label !== undefined
|
|
745
|
+
&& record(event.label, "label").name === RESTART_LABEL);
|
|
746
|
+
if (!labeled || !await isWriter(client, config.repository, labeled.actor)) {
|
|
747
|
+
item.reason = "Restart refused: the label was not applied by a user with write access.";
|
|
748
|
+
await consume(`Crewbie did not restart this task: the \`${RESTART_LABEL}\` label must be applied by a user with write access.`);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const problem = restartProblem(item);
|
|
752
|
+
if (problem) {
|
|
753
|
+
item.reason = `Restart refused: ${problem}`;
|
|
754
|
+
await consume(`Crewbie did not restart this task: ${problem}`);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const active = activeSessions(work);
|
|
758
|
+
if (active >= config.maxActive) {
|
|
759
|
+
item.reason = `Restart requested; waiting for a free session slot (${active}/${config.maxActive}).`;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const allowance = await launchAllowance(client, config, item.metadata, issue);
|
|
763
|
+
if (allowance.blocked) {
|
|
764
|
+
item.reason = `Restart refused: ${allowance.blocked}`;
|
|
765
|
+
await consume(`Crewbie did not restart this task: ${allowance.blocked}`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
await checkLaunchModels(await discoverModels(), [item.metadata.task], config, client);
|
|
769
|
+
const fresh = await freshLaunchable(client, config, item, sha, ado);
|
|
770
|
+
// Copilot starts on a new assignment event; an earlier assignment from the ended attempt would suppress it.
|
|
771
|
+
// The API shows the bot as "Copilot" but only removes it by its account login.
|
|
772
|
+
if (copilotAssigned(fresh)) {
|
|
773
|
+
const after = record(await client.request("DELETE", `${prefix}/issues/${issue}/assignees`, { assignees: ["copilot-swe-agent[bot]"] }), "unassign response");
|
|
774
|
+
if (copilotAssigned(after))
|
|
775
|
+
throw new Error(`Issue #${issue}: GitHub did not remove the earlier Copilot assignment, so a new one would not start a session. No attempt was used; unassign Copilot, then run dispatch again.`);
|
|
776
|
+
}
|
|
777
|
+
const base = await ensureBranch(client, config, item.metadata.branch, sha);
|
|
778
|
+
const memory = await launchMemory(client, config, item.metadata.task.owner, base);
|
|
779
|
+
const reserved = await reserveLaunch(client, config, item.metadata, issue, sha);
|
|
780
|
+
await client.request("DELETE", `${prefix}/issues/${issue}/labels/${encodeURIComponent(RESTART_LABEL)}`);
|
|
781
|
+
await client.request("POST", `${prefix}/issues/${issue}/comments`, {
|
|
782
|
+
body: `Crewbie restart requested by @${String(record(labeled.actor, "actor").login)}: attempt ${reserved.taskUsed + 1} of ${reserved.maxAttemptsPerTask} for this task. The previous session had ended.\n${RESTART_MARKER}${reserved.taskUsed + 1} -->`,
|
|
783
|
+
});
|
|
784
|
+
item.sessionEnded = false;
|
|
785
|
+
await assign(client, config, item, { ...fresh, assignees: [] }, base, memory);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
export async function preflight(client, config, batchId, discoverModels = listCopilotModels, ado) {
|
|
789
|
+
const work = await inspectWork(client, config);
|
|
790
|
+
const candidates = eligible(work, config.maxActive, batchId);
|
|
791
|
+
const models = candidates.length ? await discoverModels() : [];
|
|
792
|
+
const prefix = `/repos/${config.repository}`;
|
|
793
|
+
const repo = record(await client.request("GET", prefix), "repository");
|
|
794
|
+
const branch = string(repo.default_branch, "default branch");
|
|
795
|
+
const pending = new Map();
|
|
796
|
+
const tasks = [];
|
|
797
|
+
for (const item of work.filter((item) => !batchId || item.metadata.batch === batchId)) {
|
|
798
|
+
const issue = integer(item.issue.number, "issue");
|
|
799
|
+
const allowance = await launchAllowance(client, config, item.metadata, issue);
|
|
800
|
+
let reason = allowance.blocked ?? item.reason;
|
|
801
|
+
let ready = candidates.includes(item) && !allowance.blocked;
|
|
802
|
+
if (ready && allowance.issueUsed > 0) {
|
|
803
|
+
ready = false;
|
|
804
|
+
reason = "Initial launch already reserved; inspect its outcome instead of retrying.";
|
|
805
|
+
}
|
|
806
|
+
let profileRevision = null;
|
|
807
|
+
if (ready) {
|
|
808
|
+
await checkLaunchModels(models, [item.metadata.task], config);
|
|
809
|
+
await verifySources(item.metadata.sources, client, config, ado);
|
|
810
|
+
const profile = record(await client.request("GET", `${prefix}/contents/.github/agents/crewbie-${item.metadata.task.owner}.agent.md?ref=${encodeURIComponent(branch)}`), "specialist profile");
|
|
811
|
+
if (profile.type !== "file")
|
|
812
|
+
throw new Error("Specialist profile is not a regular file.");
|
|
813
|
+
profileRevision = string(profile.sha, "specialist profile revision");
|
|
814
|
+
const queued = pending.get(allowance.batch) ?? 0;
|
|
815
|
+
if (allowance.used + queued >= allowance.maxLaunchesPerBatch) {
|
|
816
|
+
ready = false;
|
|
817
|
+
reason = "Batch allowance is reserved by earlier candidates in this preview.";
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
pending.set(allowance.batch, queued + 1);
|
|
821
|
+
reason = "Current approval, dependencies, source and account model verified; cloud runtime acceptance remains unverified.";
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else if (item.state === "ready" && !candidates.includes(item) && !allowance.blocked)
|
|
825
|
+
reason = "Waiting for repository capacity.";
|
|
826
|
+
tasks.push({ issue, title: item.metadata.task.title, specialist: `crewbie-${item.metadata.task.owner}`, model: item.metadata.task.model,
|
|
827
|
+
approved: item.approved, ready, reason, profileRevision, allowance });
|
|
828
|
+
}
|
|
829
|
+
return { repository: config.repository, branch, tasks, notice: "Read-only snapshot; launch rechecks policy and reserves allowance under the repository lock. No session started. Account catalog availability does not prove cloud-runtime acceptance." };
|
|
830
|
+
}
|
|
831
|
+
//# sourceMappingURL=dispatch.js.map
|