@uchibeke/gallivant 0.2.0 → 0.3.0
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/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/control.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
2
|
// Local operator requests. UI and CLI enqueue; the daemon is the only consumer.
|
|
2
3
|
// One outstanding request per entry avoids competing resumes and needs no second runner.
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -5,7 +6,7 @@ import { needsAgentResume } from "./network_recovery.mjs";
|
|
|
5
6
|
import { resolveHandle, STATES } from "./state.mjs";
|
|
6
7
|
import { readDaemon, assertDaemonUsable } from "./daemon.mjs";
|
|
7
8
|
import { nowIso } from "./time.mjs";
|
|
8
|
-
import { NEEDS_LABEL, READY_LABEL } from "./
|
|
9
|
+
import { NEEDS_LABEL, READY_LABEL } from "./host_contract.mjs";
|
|
9
10
|
import { adapterFor } from "./harness.mjs";
|
|
10
11
|
import { executionRunner, unsubmittedInitialDispatch, PROVIDER_STATES } from "./execution.mjs";
|
|
11
12
|
import { requestRunCancellation } from "./run.mjs";
|
|
@@ -163,21 +164,28 @@ export async function applyControl(ctx, id) {
|
|
|
163
164
|
}
|
|
164
165
|
const runner = executionRunner(ctx, id);
|
|
165
166
|
if (runner.current()?.running) return false;
|
|
167
|
+
if (["message", "resume"].includes(request.action)) {
|
|
168
|
+
const health = ctx.providerHealth?.get(entry.harness, ctx.config.harness[entry.harness]?.account || "operator");
|
|
169
|
+
if (health?.pending) {
|
|
170
|
+
if (request.message !== "Waiting for the provider readiness check.") ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, message: "Waiting for the provider readiness check." } } : {});
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
166
174
|
ctx.state.update(id, { control: { ...request, status: "applying" } });
|
|
167
175
|
try {
|
|
168
176
|
if (entry.state === STATES.done) throw new Error("The task was closed or merged before this request ran.");
|
|
169
177
|
if (entry.pr) {
|
|
170
|
-
const pr = await ctx.
|
|
178
|
+
const pr = await hostApi(ctx).prView(entry.repo, entry.pr);
|
|
171
179
|
if (pr.state !== "OPEN") throw new Error("The PR is no longer open.");
|
|
172
180
|
}
|
|
173
181
|
let run = null, initialLaunch = false;
|
|
174
182
|
if (request.action === "recheck") {
|
|
175
183
|
if (needsAgentResume(entry)) throw Object.assign(new Error("The agent failure requires resume, not PR recheck."), { code: "AGENT_RESUME_REQUIRED" });
|
|
176
184
|
if (executionResultCheckpoint(runner)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
|
|
177
|
-
await ctx.
|
|
178
|
-
await ctx.
|
|
185
|
+
await hostApi(ctx).removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
186
|
+
await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
|
|
179
187
|
ctx.state.update(id, {
|
|
180
|
-
state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null,
|
|
188
|
+
state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null, review_next_at: 0,
|
|
181
189
|
last: { ...entry.last, event: "operator requested PR recheck" }, updated_at: nowIso(ctx.clock),
|
|
182
190
|
});
|
|
183
191
|
// Preserve budgets, dispatch markers and the brief. Only the normal tick judges readiness.
|
|
@@ -187,13 +195,20 @@ export async function applyControl(ctx, id) {
|
|
|
187
195
|
run = result.run;
|
|
188
196
|
initialLaunch = result.initialLaunch === true;
|
|
189
197
|
// A cosmetic label failure after spawning must not invite another resume.
|
|
190
|
-
if (entry.pr) await ctx.
|
|
198
|
+
if (entry.pr) await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
|
|
191
199
|
}
|
|
192
200
|
finish({ status: "applied", run, message: initialLaunch ? "Started the initial run after repairing launch prerequisites." : request.action === "message"
|
|
193
201
|
? "Started the next run in the same session with your message."
|
|
194
202
|
: run ? "Resumed the same agent session." : "Watching PR checks and review again." });
|
|
195
203
|
return request.action !== "recheck"; // A recheck continues through normal judgement in this same tick.
|
|
196
204
|
} catch (e) {
|
|
205
|
+
// PROVIDER_UNAVAILABLE is raised only before launch. Preserve the exact
|
|
206
|
+
// request when a due probe races with preparation; do not replay an
|
|
207
|
+
// ambiguous dispatch or turn a failed/auth check into automatic retries.
|
|
208
|
+
if (e.code === "PROVIDER_UNAVAILABLE" && e.retryableProviderPending === true) {
|
|
209
|
+
ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, status: "queued", message: "Waiting for the provider readiness check." } } : {});
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
197
212
|
ctx.log.warn(`${id}: operator ${request.action} failed: ${e.message}`);
|
|
198
213
|
// Subprocess errors can carry credentials. Details stay in the local daemon log.
|
|
199
214
|
finish({ status: "failed", message: controlFailure(e) });
|
package/lib/credential.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseRemote, repositoryIdentity } from "./repository.mjs";
|
|
1
2
|
// The one place a GitHub credential for a run is resolved.
|
|
2
3
|
//
|
|
3
4
|
// The defect this exists for. A run is sandboxed: it cannot reach the operator's macOS keyring, their Linux secret
|
|
@@ -46,15 +47,11 @@ export const SOURCES = Object.freeze({
|
|
|
46
47
|
* The host a remote URL's credential belongs to. `gh` can be logged in to an enterprise host as well as
|
|
47
48
|
* github.com, and the token for github.com is the wrong one for `github.acme.com`.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* The strict repository parser supplies the host for every accepted transport. Malformed input throws
|
|
51
|
+
* without reflecting URL userinfo. The host adapter decides which transport may actually use a token.
|
|
51
52
|
*/
|
|
52
53
|
export function hostFromRemote(url) {
|
|
53
|
-
|
|
54
|
-
const m = s.match(/^https?:\/\/(?:[^@/]*@)?([a-z0-9.-]+)/i);
|
|
55
|
-
if (m) return m[1].toLowerCase();
|
|
56
|
-
const ssh = s.match(/^(?:ssh:\/\/)?(?:[^@/]+@)([a-z0-9.-]+)[:/]/i);
|
|
57
|
-
return ssh ? ssh[1].toLowerCase() : "github.com";
|
|
54
|
+
return parseRemote(url).host;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
/** True when a host is github.com itself rather than an enterprise install. */
|
|
@@ -111,6 +108,7 @@ export function noCredentialMessage(host = "github.com") {
|
|
|
111
108
|
* putting a real token anywhere near a fixture.
|
|
112
109
|
*/
|
|
113
110
|
export async function resolveCredential(config, { host = "github.com", env = process.env, exec = execFileAsyncQuiet } = {}) {
|
|
111
|
+
if (host.toLowerCase() === "bitbucket.org") throw new Error("GitHub credentials cannot authorize Bitbucket; resolve its selected credential through the host registry");
|
|
114
112
|
// A configured file is an authority boundary, not merely the first candidate.
|
|
115
113
|
// Missing, unreadable or blank must not silently widen to an ambient login.
|
|
116
114
|
if (config?.github_token_file) {
|
|
@@ -134,12 +132,11 @@ export const describe = ({ token, source, host }) =>
|
|
|
134
132
|
token ? `${source}${isDotCom(host) ? "" : ` for ${host}`} (${token.length} characters)` : "none";
|
|
135
133
|
|
|
136
134
|
/**
|
|
137
|
-
* The host a checkout's credential belongs to, from its
|
|
138
|
-
*
|
|
139
|
-
* git operations will say so.
|
|
135
|
+
* The host a checkout's credential belongs to, from its verified origin. Unreadable origins throw before
|
|
136
|
+
* any credential is resolved; they never fall back to another host.
|
|
140
137
|
*/
|
|
141
138
|
export async function hostForWorktree(git, dir) {
|
|
142
|
-
|
|
139
|
+
return hostFromRemote(await git.originOf(dir));
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
/**
|
|
@@ -154,8 +151,17 @@ export async function hostForWorktree(git, dir) {
|
|
|
154
151
|
* from the config alone, so a test that wants a credential sets `github_token_file` and one that wants none sets
|
|
155
152
|
* nothing, and no test needs a `gh` on the machine.
|
|
156
153
|
*/
|
|
157
|
-
export async function resolveCredentialFor(ctx, { worktree = null } = {}) {
|
|
158
|
-
|
|
154
|
+
export async function resolveCredentialFor(ctx, { worktree = null, repo = null } = {}) {
|
|
155
|
+
let repository = repo ? repositoryIdentity(repo) : null;
|
|
156
|
+
if (worktree && ctx.git?.originOf) {
|
|
157
|
+
const actual = parseRemote(await ctx.git.originOf(worktree));
|
|
158
|
+
if (repository && actual.remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout host/repository mismatch; restore the bound origin before resolving credentials");
|
|
159
|
+
repository = actual;
|
|
160
|
+
}
|
|
161
|
+
repository ??= repositoryIdentity("owner/name");
|
|
162
|
+
if (ctx.hosts) return ctx.hosts.credential(repository);
|
|
159
163
|
const resolve = ctx.credential ?? (opts => resolveCredential(ctx.config, opts));
|
|
160
|
-
|
|
164
|
+
const resolved = await resolve({ host: repository.host });
|
|
165
|
+
if (resolved?.host && resolved.host !== repository.host) throw new Error("Credential host mismatch");
|
|
166
|
+
return { ...resolved, host: repository.host, ...(repo || worktree ? { repository: repository.path } : {}) };
|
|
161
167
|
}
|
package/lib/deliverable.mjs
CHANGED
|
@@ -35,6 +35,8 @@ export const provenance = (framework = null) => ({ ...(framework ? { framework }
|
|
|
35
35
|
|
|
36
36
|
const RECEIPT_RE = new RegExp("^```(?:" + RECEIPT_TAGS.join("|") + ")\\s*\\n([\\s\\S]*?)\\n```[ \t]*\\r?(?=\\n|$)", "gm");
|
|
37
37
|
const receiptMatches = body => [...String(body || "").matchAll(RECEIPT_RE)];
|
|
38
|
+
/** A cross-host handoff must not carry the source task's machine receipt. */
|
|
39
|
+
export const withoutReceipt = body => String(body || "").replace(RECEIPT_RE, "");
|
|
38
40
|
export const publicTaskId = taskId => "task-" + createHash("sha256").update(String(taskId)).digest("hex").slice(0, 24);
|
|
39
41
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
40
42
|
// The hosted route limits the whole JSON body to 256 KiB, including the passport and JSON escaping.
|
package/lib/events.mjs
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
// The rules for what counts as an event, written as pure functions over the data `gh` returns, so every rule in
|
|
2
2
|
// PRD 6.8 has a unit test and the tick only orchestrates.
|
|
3
3
|
//
|
|
4
|
-
// Inputs are
|
|
5
|
-
import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./
|
|
4
|
+
// Inputs are normalized host records; legacy event aliases remain readable for saved integrations.
|
|
5
|
+
import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./host_contract.mjs";
|
|
6
6
|
import { hasMarker } from "./public-format.mjs";
|
|
7
7
|
import { isAfter, isAtOrAfter, later, floorSecond } from "./time.mjs";
|
|
8
8
|
|
|
9
|
-
const FAILING = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
|
|
10
|
-
const GREEN = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
11
9
|
const COMMAND = /^\s*(?:\/fix\b|@(?:work|gallivant)\s+fix\b)/i;
|
|
12
10
|
export const isFixCommand = body => COMMAND.test(String(body ?? ""));
|
|
13
11
|
|
|
14
12
|
export function makeTrust(config, ownLogin) {
|
|
15
13
|
if (!ownLogin) throw new Error("trust rules need the daemon's own login");
|
|
16
14
|
const trusted = new Set(config.trusted_authors.map(normalizeLogin));
|
|
17
|
-
const reviewer = config.reviewer ? normalizeLogin(config.reviewer.login) : null;
|
|
15
|
+
const reviewer = config.reviewer ? normalizeLogin(config.reviewer.actor_id ?? config.reviewer.login) : null;
|
|
18
16
|
const own = normalizeLogin(ownLogin);
|
|
19
17
|
return {
|
|
20
18
|
isTrusted: a => trusted.has(a.login) && !a.bot,
|
|
21
|
-
isReviewer: a => reviewer !== null && a.bot && a.login === reviewer,
|
|
19
|
+
isReviewer: a => reviewer !== null && (config.reviewer.actor_id || a.bot) && a.login === reviewer && a.login !== own,
|
|
22
20
|
isOwn: a => a.login === own,
|
|
23
21
|
};
|
|
24
22
|
}
|
|
@@ -42,6 +40,27 @@ export const eventKey = e => `${e.kind}:${e.id}`;
|
|
|
42
40
|
*
|
|
43
41
|
* Editing an item later does not make it new again: the comparison is on `created_at`, never on `updated_at`.
|
|
44
42
|
*/
|
|
43
|
+
const recordId = record => record.id ?? record.eventId;
|
|
44
|
+
const currentEvent = (record, head) => !record.deleted && !record.resolved && (record.headBinding !== "exact" || record.headSha === head);
|
|
45
|
+
|
|
46
|
+
// PR-wide unresolved tasks/change requests stay blocking even after their one
|
|
47
|
+
// fix round. An unbound finding must not be silently mistaken for a clean head.
|
|
48
|
+
export function requiredFeedback({ reviews = [], inline = [], trust, headSha }) {
|
|
49
|
+
return [...reviews, ...inline].filter(r => !r.deleted && !r.resolved && !(r.replyTo && trust.isOwn(actor(r.actor ?? r.user))) &&
|
|
50
|
+
(r.required || trust.isTrusted(actor(r.actor ?? r.user)) || trust.isReviewer(actor(r.actor ?? r.user))) &&
|
|
51
|
+
(r.required || r.headBinding === "exact" && !r.headSha) &&
|
|
52
|
+
(r.headBinding !== "exact" || !r.headSha || r.headSha === headSha));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readFeedback(host, repo, pr, inlineSince, commentSince) {
|
|
56
|
+
if (pr.feedback !== undefined) {
|
|
57
|
+
const f = pr.feedback;
|
|
58
|
+
if (!f || f.complete !== true || f.headSha !== pr.headRefOid || ![f.reviews, f.inline, f.comments].every(Array.isArray)) throw new Error("Incomplete or mismatched feedback snapshot; preserve cursor and retry");
|
|
59
|
+
return [f.reviews, f.inline, f.comments];
|
|
60
|
+
}
|
|
61
|
+
return Promise.all([host.reviews(repo, pr.number), host.inlineComments(repo, pr.number, inlineSince), host.issueComments(repo, pr.number, commentSince)]);
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust }) {
|
|
46
65
|
const since = entry.cursor.since;
|
|
47
66
|
const dispatched = new Set(entry.cursor.dispatched || []);
|
|
@@ -50,33 +69,33 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
50
69
|
|
|
51
70
|
// Reviews our own login submitted, so an inline note hanging off one is recognised as the harness's own comment on
|
|
52
71
|
// its own work rather than as feedback on it.
|
|
53
|
-
const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.user))).map(r => r
|
|
72
|
+
const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.actor ?? r.user))).map(r => recordId(r)));
|
|
54
73
|
|
|
55
74
|
const inlineByReview = new Map();
|
|
56
75
|
for (const c of inline) {
|
|
57
|
-
const rid = c.pull_request_review_id;
|
|
76
|
+
const rid = (c.reviewId ?? c.pull_request_review_id);
|
|
58
77
|
if (!inlineByReview.has(rid)) inlineByReview.set(rid, []);
|
|
59
78
|
inlineByReview.get(rid).push(c);
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
for (const r of reviews) {
|
|
63
|
-
if (!isNew(r.submitted_at, "review", r
|
|
64
|
-
const a = actor(r.user);
|
|
65
|
-
const attached = (inlineByReview.get(r
|
|
82
|
+
if (!currentEvent(r, pr.headRefOid) || r.headBinding === "unbound" || !isNew((r.at ?? r.submitted_at), "review", recordId(r))) continue;
|
|
83
|
+
const a = actor(r.actor ?? r.user);
|
|
84
|
+
const attached = (inlineByReview.get(recordId(r)) || []).filter(c => currentEvent(c, pr.headRefOid) && (c.headBinding !== "exact" || isNew(c.at ?? c.created_at, "inline", recordId(c))) && !(c.replyTo ?? c.in_reply_to_id));
|
|
66
85
|
const qualifies = (trust.isReviewer(a) && (r.state === "CHANGES_REQUESTED" || (r.state === "COMMENTED" && attached.length > 0)))
|
|
67
86
|
|| (trust.isTrusted(a) && r.state === "CHANGES_REQUESTED");
|
|
68
87
|
if (!qualifies) continue;
|
|
69
|
-
out.push({ kind: "review", id: r
|
|
88
|
+
out.push({ kind: "review", id: recordId(r), reviewId: recordId(r), author: a.login, state: r.state, body: r.body || "", at: (r.at ?? r.submitted_at) });
|
|
70
89
|
for (const c of attached) out.push(inlineEvent(c, a.login));
|
|
71
|
-
inlineByReview.delete(r
|
|
90
|
+
inlineByReview.delete(recordId(r));
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
// Inline comments not attached to a dispatched review: standalone trusted comments, or later comments on a review
|
|
75
94
|
// that was already handled. Replies by our own login are the harness's thread replies.
|
|
76
95
|
for (const list of inlineByReview.values()) {
|
|
77
96
|
for (const c of list) {
|
|
78
|
-
if (!isNew(c.created_at, "inline", c
|
|
79
|
-
const a = actor(c.user);
|
|
97
|
+
if (!currentEvent(c, pr.headRefOid) || !isNew((c.at ?? c.created_at), "inline", recordId(c))) continue;
|
|
98
|
+
const a = actor(c.actor ?? c.user);
|
|
80
99
|
// Our own login writes in two capacities and they have to be told apart, because on a one-operator install the
|
|
81
100
|
// operator's login IS the daemon's: config.md says `trusted_authors` is where your own login belongs. The
|
|
82
101
|
// operator leaving a standalone inline comment is the main feedback channel and must dispatch. The harness
|
|
@@ -84,17 +103,17 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
84
103
|
// carrying `in_reply_to_id`, and as inline notes hanging off a review of its own, which is what
|
|
85
104
|
// `gh pr review --comment` produces. Both are the daemon's own work explaining itself, and dispatching a round
|
|
86
105
|
// to answer it is the loop feeding itself.
|
|
87
|
-
if (trust.isOwn(a) && (c.in_reply_to_id || ownReviews.has(c.pull_request_review_id))) continue;
|
|
106
|
+
if (trust.isOwn(a) && ((c.replyTo ?? c.in_reply_to_id) || ownReviews.has((c.reviewId ?? c.pull_request_review_id)))) continue;
|
|
88
107
|
if (!(trust.isTrusted(a) || trust.isReviewer(a))) continue;
|
|
89
108
|
out.push(inlineEvent(c, a.login));
|
|
90
109
|
}
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
for (const c of comments) {
|
|
94
|
-
if (!isNew(c.created_at, "command", c
|
|
95
|
-
const a = actor(c.user);
|
|
113
|
+
if (!currentEvent(c, pr.headRefOid) || c.replyTo && trust.isOwn(actor(c.actor ?? c.user)) || !isNew((c.at ?? c.created_at), "command", recordId(c)) || isOwnComment(c.body)) continue;
|
|
114
|
+
const a = actor(c.actor ?? c.user);
|
|
96
115
|
if (!trust.isTrusted(a) || !isFixCommand(c.body)) continue;
|
|
97
|
-
out.push({ kind: "command", id: c
|
|
116
|
+
out.push({ kind: "command", id: recordId(c), author: a.login, body: c.body.replace(COMMAND, "").trim(), at: (c.at ?? c.created_at) });
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
if (pr.headRefOid && pr.headRefOid !== entry.cursor.checks_sha) {
|
|
@@ -106,36 +125,30 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
|
|
|
106
125
|
}
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
out.
|
|
110
|
-
|
|
128
|
+
const unique = [...new Map(out.map(e => [e.kind === "check" ? `check:${e.name}` : eventKey(e), e])).values()];
|
|
129
|
+
unique.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
|
|
130
|
+
return { events: unique, newest: unique.length ? unique[unique.length - 1].at : null };
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
const inlineEvent = (c, login) => ({
|
|
114
|
-
kind: "inline", id: c
|
|
115
|
-
path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: c.created_at,
|
|
134
|
+
kind: "inline", id: recordId(c), reviewId: (c.reviewId ?? c.pull_request_review_id) ?? null, author: login,
|
|
135
|
+
path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: (c.at ?? c.created_at),
|
|
116
136
|
});
|
|
117
137
|
|
|
118
138
|
/** CheckRun and StatusContext entries that failed, with the Actions run id when the URL carries one. */
|
|
119
139
|
export function failingChecks(rollup = []) {
|
|
120
|
-
|
|
121
|
-
for (const c of rollup || []) {
|
|
122
|
-
if (c.__typename === "StatusContext") {
|
|
123
|
-
if (["FAILURE", "ERROR"].includes(c.state)) out.push({ name: c.context, url: c.targetUrl, runId: runIdFrom(c.targetUrl), at: c.createdAt });
|
|
124
|
-
} else if (c.status === "COMPLETED" && FAILING.has(c.conclusion)) {
|
|
125
|
-
out.push({ name: c.workflowName ? `${c.workflowName} / ${c.name}` : c.name, url: c.detailsUrl, runId: runIdFrom(c.detailsUrl), at: c.completedAt });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
140
|
+
return (rollup || []).filter(c => c.outcome === "failed").map(c => ({ name: c.name, url: c.url, runId: c.runId, at: c.updatedAt }));
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
export function isGreen(rollup = []) {
|
|
135
|
-
const items = rollup || [];
|
|
136
|
-
return items.every(c => c.__typename === "StatusContext" ? c.state === "SUCCESS" : c.status === "COMPLETED" && GREEN.has(c.conclusion));
|
|
143
|
+
/** Every complete check concluded green. Empty evidence is subject to the shared grace period. */
|
|
144
|
+
export function isGreen(rollup = [], required = []) {
|
|
145
|
+
return Array.isArray(rollup) && required.every(name => rollup.some(c => c.name === name)) && rollup.every(c => c.outcome === "passed");
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
/** Newest verdict first, with stable exact-head sequence numbers breaking timestamp ties. */
|
|
149
|
+
export const compareReviewRecency = (a, b) => Date.parse(b.at ?? b.submitted_at) - Date.parse(a.at ?? a.submitted_at) ||
|
|
150
|
+
(a.headBinding === "exact" && b.headBinding === "exact" && Number.isSafeInteger(a.sequence) && Number.isSafeInteger(b.sequence) ? b.sequence - a.sequence : 0);
|
|
151
|
+
|
|
139
152
|
/**
|
|
140
153
|
* The reviewer's verdict since the last request: "none" when no reviewer is configured, "waiting" when it has not
|
|
141
154
|
* reviewed since `sinceIso`, "blocking" when the newest review requests changes or has a blocking inline comment,
|
|
@@ -148,10 +161,10 @@ export function isGreen(rollup = []) {
|
|
|
148
161
|
* `dispatched` holds the event keys already handled at exactly the cursor's instant, so a review the daemon is
|
|
149
162
|
* already fixing cannot also read as the reviewer clearing this head.
|
|
150
163
|
*/
|
|
151
|
-
export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [] }) {
|
|
164
|
+
export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [], includeIdentity = false }) {
|
|
152
165
|
if (!config.reviewer) return { verdict: "none", note: "no reviewer configured" };
|
|
153
166
|
const handled = new Set(dispatched);
|
|
154
|
-
const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.user)) && isAtOrAfter(r.submitted_at, sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: r
|
|
167
|
+
const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.actor ?? r.user)) && isAtOrAfter((r.at ?? r.submitted_at), sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: recordId(r) })));
|
|
155
168
|
|
|
156
169
|
// A verdict the reviewer posted as a plain comment rather than as a review, normalised into the shape above so the
|
|
157
170
|
// head guard and the blocking rules below judge it identically. Everything after this line is unchanged.
|
|
@@ -167,28 +180,58 @@ export function reviewerVerdict({ reviews, inline, comments = [], config, trust,
|
|
|
167
180
|
// a commit in its body, which `reviewedCommit` already parses and which the existing guard then checks against the
|
|
168
181
|
// head. Clean is never INFERRED from the absence of findings, because the reviewer says other things in comments and
|
|
169
182
|
// a chatty bot must not be able to clear a pull request by mentioning it. No commit named, no verdict.
|
|
170
|
-
const fromComments = (comments || [])
|
|
171
|
-
|
|
172
|
-
|
|
183
|
+
const fromComments = (comments || []).flatMap(c => {
|
|
184
|
+
if (c.deleted || c.headBinding === "exact" || !trust.isReviewer(actor(c.actor ?? c.user))) return [];
|
|
185
|
+
// The GitHub adapter recognizes authenticated, exact-head structured code
|
|
186
|
+
// review summaries. Required CI and independent review policies remain
|
|
187
|
+
// separate gates; an optional security scan is not a code-review verdict.
|
|
188
|
+
const summary = c.source?.host === "github.com" ? c.reviewSummary : null;
|
|
189
|
+
if (Object.hasOwn(c, "reviewSummary") && !summary) return [];
|
|
190
|
+
const at = summary?.at ?? c.at ?? c.created_at;
|
|
191
|
+
if (!isAtOrAfter(at, sinceIso) || !summary && !reviewedCommit(c.body, config.reviewer.reviewed_commit_pattern)) return [];
|
|
192
|
+
return [{ id: recordId(c), body: c.body, actor: c.actor ?? actor(c.user), submitted_at: at,
|
|
193
|
+
state: "COMMENTED", commit_id: null, fromComment: true, ...summary, fromStructuredSummary: Boolean(summary) }];
|
|
194
|
+
});
|
|
173
195
|
|
|
174
|
-
const mine = [...fromReviews, ...fromComments].sort(
|
|
196
|
+
const mine = [...fromReviews, ...fromComments].sort(compareReviewRecency);
|
|
175
197
|
if (!mine.length) return { verdict: "waiting", note: "waiting on reviewer" };
|
|
176
198
|
const newest = mine[0];
|
|
177
199
|
// Which commit was reviewed, from the reviewer's own body first and its `commit_id` second. A review that names no
|
|
178
200
|
// commit at all is no answer about this head: `commit_id` is null once the reviewed commit is gone from the
|
|
179
201
|
// repository, which is what a force-push plus garbage collection leaves, and a falsy value used to skip the guard
|
|
180
202
|
// entirely and clear whatever the head happened to be. So an unknown reviewed commit is `waiting`, not `clear`.
|
|
181
|
-
const reviewed = reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.commit_id || null;
|
|
203
|
+
const reviewed = newest.headBinding === "exact" ? newest.headSha : reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.headSha || newest.commit_id || null;
|
|
182
204
|
if (headSha && !reviewed) return { verdict: "waiting", note: "reviewer named no commit, so it cannot clear this head" };
|
|
183
|
-
if (headSha && !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha)) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
|
|
205
|
+
if (headSha && (newest.headBinding === "exact" ? headSha !== reviewed : !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha))) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
|
|
206
|
+
if (newest.headBinding === "exact" && !["clear", "blocking"].includes(newest.verdict)) return { verdict: "waiting", note: "waiting for an explicit current-head verdict" };
|
|
207
|
+
if (newest.verdict === "blocking") return { verdict: "blocking", note: "explicit blocking verdict" };
|
|
184
208
|
if (newest.state === "CHANGES_REQUESTED") return { verdict: "blocking", note: "changes requested" };
|
|
185
209
|
const re = new RegExp(config.reviewer.blocking_pattern);
|
|
186
|
-
|
|
210
|
+
// Authenticated structured summaries already carry the code-review verdict.
|
|
211
|
+
// Their body also contains optional security policy metadata (for example a
|
|
212
|
+
// P0 threshold), which is not a code finding. Plain reviews/receipts still
|
|
213
|
+
// use the configured severity pattern; required CI/security gates stay separate.
|
|
214
|
+
const structuredClear = newest.fromStructuredSummary === true && newest.headBinding === "exact" && newest.verdict === "clear";
|
|
215
|
+
if (!structuredClear && re.test(newest.body || "")) return { verdict: "blocking", note: "blocking finding in reviewer message" };
|
|
187
216
|
// Inline findings belong to a REVIEW. A comment-borne verdict has no review to own them, and `newest.id` is a
|
|
188
217
|
// comment id from a different id space, so matching on it could pair a verdict with another review's findings.
|
|
189
|
-
const hits = newest.fromComment ? [] : inline.filter(c => c.pull_request_review_id === newest
|
|
218
|
+
const hits = newest.fromComment ? [] : inline.filter(c => (c.reviewId ?? c.pull_request_review_id) === recordId(newest) && re.test(c.body || ""));
|
|
190
219
|
if (hits.length) return { verdict: "blocking", note: `${hits.length} blocking finding${hits.length > 1 ? "s" : ""}` };
|
|
191
|
-
|
|
220
|
+
const nativeHead = newest.headSha ?? newest.commit_id;
|
|
221
|
+
return { verdict: "clear", note: "no blocking findings", ...(includeIdentity ? {
|
|
222
|
+
reviewedHead: /^[a-f0-9]{40,64}$/.test(nativeHead || "") && nativeHead.startsWith(reviewed) ? nativeHead : reviewed,
|
|
223
|
+
} : {}) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** New policies resolve abbreviated bot identities through the host; a prefix
|
|
227
|
+
* collision or unavailable lookup cannot establish exact-head clearance. */
|
|
228
|
+
export async function exactReviewerVerdict(input, resolveHead) {
|
|
229
|
+
const result = reviewerVerdict({ ...input, includeIdentity: true });
|
|
230
|
+
if (result.verdict !== "clear" || result.reviewedHead === input.headSha) return result;
|
|
231
|
+
try {
|
|
232
|
+
if (typeof resolveHead === "function" && await resolveHead(result.reviewedHead) === input.headSha) return { ...result, reviewedHead: input.headSha };
|
|
233
|
+
} catch { /* preserve waiting: lookup failure is not clearance */ }
|
|
234
|
+
return { verdict: "waiting", note: "reviewer commit could not be resolved to the exact current head; restore host access or request a full commit identity" };
|
|
192
235
|
}
|
|
193
236
|
|
|
194
237
|
/**
|
|
@@ -236,8 +279,8 @@ export function reviewerClock({ config, pushedAt, previousAsk = null, floorAt })
|
|
|
236
279
|
}
|
|
237
280
|
|
|
238
281
|
const ownComments = (issueComments, ownLogin) => issueComments
|
|
239
|
-
.filter(c => actor(c.user ?? c.author).login === normalizeLogin(ownLogin))
|
|
240
|
-
.sort((a, b) => Date.parse(a.created_at ?? a.createdAt) - Date.parse(b.created_at ?? b.createdAt));
|
|
282
|
+
.filter(c => actor(c.actor ?? c.user ?? c.author).login === normalizeLogin(ownLogin))
|
|
283
|
+
.sort((a, b) => Date.parse(a.at ?? a.created_at ?? a.createdAt) - Date.parse(b.at ?? b.created_at ?? b.createdAt));
|
|
241
284
|
|
|
242
285
|
/**
|
|
243
286
|
* The iteration number: the daemon's own round comments since its last reset comment. Every round posts one
|
|
@@ -251,11 +294,11 @@ export function countRounds(issueComments, ownLogin) {
|
|
|
251
294
|
}
|
|
252
295
|
|
|
253
296
|
export const lastOwnCommentAt = (issueComments, ownLogin) => ownComments(issueComments, ownLogin)
|
|
254
|
-
.filter(c => isOwnComment(c.body)).map(c => c.created_at ?? c.createdAt).at(-1) ?? null;
|
|
297
|
+
.filter(c => isOwnComment(c.body)).map(c => (c.at ?? c.created_at) ?? c.createdAt).at(-1) ?? null;
|
|
255
298
|
|
|
256
299
|
/** Whether the daemon already posted a comment carrying `marker` after `sinceIso`. */
|
|
257
300
|
export const hasOwnComment = (issueComments, ownLogin, marker, sinceIso) => ownComments(issueComments, ownLogin)
|
|
258
|
-
.some(c => hasMarker(c.body, marker) && isAfter(c.created_at ?? c.createdAt, sinceIso));
|
|
301
|
+
.some(c => hasMarker(c.body, marker) && isAfter((c.at ?? c.created_at) ?? c.createdAt, sinceIso));
|
|
259
302
|
|
|
260
303
|
/** Only the lines a diff adds, so the completion gate never judges context or removed text. */
|
|
261
304
|
export const addedLines = diff => String(diff || "").split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++")).map(l => l.slice(1)).join("\n");
|