agent-dealer 1.0.0 → 1.0.2
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/bundle/server/dist/adapters/linear-graphql.js +165 -0
- package/bundle/server/dist/adapters/linear-graphql.test.js +129 -0
- package/bundle/server/dist/adapters/linear-inbox.js +12 -21
- package/bundle/server/dist/adapters/linear-sync.js +6 -19
- package/bundle/server/dist/adapters/managed-repo.js +11 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +55 -4
- package/bundle/server/dist/coordinator/auto-merge.js +72 -4
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +45 -3
- package/bundle/server/dist/coordinator/commands.js +17 -11
- package/bundle/server/dist/coordinator/commands.test.js +21 -4
- package/bundle/server/dist/coordinator/dependencies.js +5 -1
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +33 -1
- package/bundle/server/dist/coordinator/human-resolution.js +5 -2
- package/bundle/server/dist/coordinator/human-resolution.test.js +13 -1
- package/bundle/server/dist/coordinator/prompts.js +21 -13
- package/bundle/server/dist/coordinator/prompts.test.js +21 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +56 -11
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +67 -9
- package/bundle/server/dist/coordinator/reviewer-result.js +49 -1
- package/bundle/server/dist/coordinator/reviewer-result.test.js +38 -0
- package/bundle/server/dist/coordinator/routing.js +15 -4
- package/bundle/server/dist/coordinator/routing.test.js +17 -2
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/index.js +6 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +1 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +3 -2
- package/bundle/server/dist/direct-start-interrupt-probe.js +58 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +114 -24
- package/bundle/server/dist/direct-start-temp-home-cleanup.js +124 -0
- package/bundle/server/dist/direct-start-temp-home-cleanup.test.js +92 -0
- package/bundle/server/dist/repository/queue-entries.js +49 -11
- package/bundle/server/dist/routes/human-actions.test.js +2 -0
- package/bundle/server/dist/routes/queue-reorder.integration.test.js +144 -0
- package/bundle/server/dist/routes/queue.js +21 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-0kT1vk6L.js +60 -0
- package/bundle/server/static-ui/assets/{index-BxTx4b1d.css → index-hXICi1rX.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/queue-entries.d.ts +47 -0
- package/bundle/shared/dist/queue-entries.js +14 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.test.js +2 -2
- package/dist/index.js +1 -0
- package/dist/install.js +1 -1
- package/dist/lifecycle.contract.test.js +2 -2
- package/dist/queue.d.ts +5 -0
- package/dist/queue.js +50 -0
- package/dist/queue.test.js +39 -0
- package/dist/setup.js +5 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +0 -60
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// packages/server/src/adapters/linear-graphql.ts
|
|
2
|
+
//
|
|
3
|
+
// Shared Linear GraphQL POST helper (NOT-152): log HTTP failures with rate-limit
|
|
4
|
+
// headers, and surface a structured error so NOT-104 can back off until reset.
|
|
5
|
+
export const LINEAR_API = "https://api.linear.app/graphql";
|
|
6
|
+
const BODY_SNIPPET_MAX = 300;
|
|
7
|
+
/** Bounded default when Linear returns 429 without a usable reset / Retry-After. */
|
|
8
|
+
export const DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000;
|
|
9
|
+
export class LinearHttpError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
operation;
|
|
12
|
+
rateLimit;
|
|
13
|
+
bodySnippet;
|
|
14
|
+
/** ms until the rate-limit window reopens; null when not a rate-limit / unknown. */
|
|
15
|
+
retryAfterMs;
|
|
16
|
+
constructor(init) {
|
|
17
|
+
const remaining = init.rateLimit.requestsRemaining;
|
|
18
|
+
const reset = init.rateLimit.requestsReset;
|
|
19
|
+
const parts = [
|
|
20
|
+
`Linear HTTP ${init.status}`,
|
|
21
|
+
`operation=${init.operation}`,
|
|
22
|
+
remaining != null ? `requests-remaining=${remaining}` : null,
|
|
23
|
+
reset != null ? `requests-reset=${reset}` : null,
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
super(parts.join(" "));
|
|
26
|
+
this.name = "LinearHttpError";
|
|
27
|
+
this.status = init.status;
|
|
28
|
+
this.operation = init.operation;
|
|
29
|
+
this.rateLimit = init.rateLimit;
|
|
30
|
+
this.bodySnippet = init.bodySnippet;
|
|
31
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function parseLinearRateLimitHeaders(headers) {
|
|
35
|
+
const out = {};
|
|
36
|
+
const set = (key, name) => {
|
|
37
|
+
const v = headers.get(name);
|
|
38
|
+
if (v != null && v !== "")
|
|
39
|
+
out[key] = v;
|
|
40
|
+
};
|
|
41
|
+
set("requestsLimit", "x-ratelimit-requests-limit");
|
|
42
|
+
set("requestsRemaining", "x-ratelimit-requests-remaining");
|
|
43
|
+
set("requestsReset", "x-ratelimit-requests-reset");
|
|
44
|
+
set("complexityLimit", "x-ratelimit-complexity-limit");
|
|
45
|
+
set("complexityRemaining", "x-ratelimit-complexity-remaining");
|
|
46
|
+
set("complexityReset", "x-ratelimit-complexity-reset");
|
|
47
|
+
set("complexity", "x-complexity");
|
|
48
|
+
set("retryAfter", "retry-after");
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* How long to wait before retrying a rate-limited call.
|
|
53
|
+
* Linear's `X-RateLimit-Requests-Reset` is UTC epoch **milliseconds**.
|
|
54
|
+
* Prefer that reset header when present (NOT-152); Retry-After / default are fallbacks.
|
|
55
|
+
*/
|
|
56
|
+
export function computeRetryAfterMs(status, rateLimit, nowMs = Date.now()) {
|
|
57
|
+
if (status !== 429 && rateLimit.requestsRemaining !== "0")
|
|
58
|
+
return null;
|
|
59
|
+
if (rateLimit.requestsReset) {
|
|
60
|
+
const resetMs = Number(rateLimit.requestsReset);
|
|
61
|
+
if (Number.isFinite(resetMs) && resetMs > 0) {
|
|
62
|
+
// Accept seconds-epoch (10 digits) in case a proxy rewrites the header.
|
|
63
|
+
const absolute = resetMs < 1e12 ? resetMs * 1000 : resetMs;
|
|
64
|
+
return Math.max(0, absolute - nowMs);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (rateLimit.retryAfter) {
|
|
68
|
+
const seconds = Number(rateLimit.retryAfter);
|
|
69
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
70
|
+
return Math.ceil(seconds * 1000);
|
|
71
|
+
}
|
|
72
|
+
if (status === 429)
|
|
73
|
+
return DEFAULT_RATE_LIMIT_BACKOFF_MS;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export function formatLinearFailureLog(err) {
|
|
77
|
+
const rl = err.rateLimit;
|
|
78
|
+
const bits = [
|
|
79
|
+
`[linear] ${err.operation} failed`,
|
|
80
|
+
`status=${err.status}`,
|
|
81
|
+
rl.requestsRemaining != null ? `requests-remaining=${rl.requestsRemaining}` : null,
|
|
82
|
+
rl.requestsReset != null ? `requests-reset=${rl.requestsReset}` : null,
|
|
83
|
+
rl.requestsLimit != null ? `requests-limit=${rl.requestsLimit}` : null,
|
|
84
|
+
rl.complexityRemaining != null ? `complexity-remaining=${rl.complexityRemaining}` : null,
|
|
85
|
+
rl.complexityReset != null ? `complexity-reset=${rl.complexityReset}` : null,
|
|
86
|
+
rl.complexity != null ? `complexity=${rl.complexity}` : null,
|
|
87
|
+
err.bodySnippet ? `body=${err.bodySnippet}` : null,
|
|
88
|
+
].filter(Boolean);
|
|
89
|
+
return bits.join(" ");
|
|
90
|
+
}
|
|
91
|
+
function truncateBody(text) {
|
|
92
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
93
|
+
if (oneLine.length <= BODY_SNIPPET_MAX)
|
|
94
|
+
return oneLine;
|
|
95
|
+
return `${oneLine.slice(0, BODY_SNIPPET_MAX)}…`;
|
|
96
|
+
}
|
|
97
|
+
function isGraphqlRateLimited(errors) {
|
|
98
|
+
return errors.some((e) => {
|
|
99
|
+
if (!e || typeof e !== "object")
|
|
100
|
+
return false;
|
|
101
|
+
const ext = e.extensions;
|
|
102
|
+
return ext?.code === "RATELIMITED";
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* POST to Linear GraphQL. On non-OK HTTP (and GraphQL RATELIMITED), logs once and throws
|
|
107
|
+
* {@link LinearHttpError}. Callers keep their own catch for user-facing 502s.
|
|
108
|
+
*/
|
|
109
|
+
export async function linearGraphqlRequest(opts) {
|
|
110
|
+
const key = process.env.LINEAR_API_KEY;
|
|
111
|
+
if (!key)
|
|
112
|
+
throw new Error("LINEAR_API_KEY not set");
|
|
113
|
+
const res = await fetch(LINEAR_API, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", Authorization: key },
|
|
116
|
+
body: JSON.stringify({ query: opts.query, variables: opts.variables }),
|
|
117
|
+
...(opts.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
|
|
118
|
+
});
|
|
119
|
+
const rateLimit = parseLinearRateLimitHeaders(res.headers instanceof Headers ? res.headers : new Headers());
|
|
120
|
+
const rawBody = typeof res.text === "function"
|
|
121
|
+
? await res.text()
|
|
122
|
+
: JSON.stringify(await res.json());
|
|
123
|
+
const bodySnippet = truncateBody(rawBody);
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const err = new LinearHttpError({
|
|
126
|
+
status: res.status,
|
|
127
|
+
operation: opts.operation,
|
|
128
|
+
rateLimit,
|
|
129
|
+
bodySnippet,
|
|
130
|
+
retryAfterMs: computeRetryAfterMs(res.status, rateLimit),
|
|
131
|
+
});
|
|
132
|
+
console.error(formatLinearFailureLog(err));
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
let json;
|
|
136
|
+
try {
|
|
137
|
+
json = JSON.parse(rawBody);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const err = new LinearHttpError({
|
|
141
|
+
status: res.status,
|
|
142
|
+
operation: opts.operation,
|
|
143
|
+
rateLimit,
|
|
144
|
+
bodySnippet,
|
|
145
|
+
retryAfterMs: null,
|
|
146
|
+
});
|
|
147
|
+
console.error(formatLinearFailureLog(err));
|
|
148
|
+
throw new Error(`Linear GraphQL returned non-JSON for ${opts.operation}`);
|
|
149
|
+
}
|
|
150
|
+
if (json.errors?.length) {
|
|
151
|
+
if (isGraphqlRateLimited(json.errors) || rateLimit.requestsRemaining === "0") {
|
|
152
|
+
const err = new LinearHttpError({
|
|
153
|
+
status: 429,
|
|
154
|
+
operation: opts.operation,
|
|
155
|
+
rateLimit,
|
|
156
|
+
bodySnippet,
|
|
157
|
+
retryAfterMs: computeRetryAfterMs(429, rateLimit),
|
|
158
|
+
});
|
|
159
|
+
console.error(formatLinearFailureLog(err));
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
throw new Error(JSON.stringify(json.errors));
|
|
163
|
+
}
|
|
164
|
+
return json.data;
|
|
165
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// packages/server/src/adapters/linear-graphql.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-152: failed Linear GraphQL calls must log HTTP status + rate-limit headers,
|
|
4
|
+
// and expose a structured error so NOT-104 can back off until the reset window.
|
|
5
|
+
import { test, mock } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
const { LinearHttpError, computeRetryAfterMs, linearGraphqlRequest, parseLinearRateLimitHeaders, } = await import("./linear-graphql.js");
|
|
8
|
+
test("parseLinearRateLimitHeaders reads requests + complexity headers", () => {
|
|
9
|
+
const headers = new Headers({
|
|
10
|
+
"X-RateLimit-Requests-Limit": "2500",
|
|
11
|
+
"X-RateLimit-Requests-Remaining": "0",
|
|
12
|
+
"X-RateLimit-Requests-Reset": "1760000000000",
|
|
13
|
+
"X-RateLimit-Complexity-Limit": "1000000",
|
|
14
|
+
"X-RateLimit-Complexity-Remaining": "12",
|
|
15
|
+
"X-RateLimit-Complexity-Reset": "1760000000000",
|
|
16
|
+
"X-Complexity": "42",
|
|
17
|
+
});
|
|
18
|
+
assert.deepEqual(parseLinearRateLimitHeaders(headers), {
|
|
19
|
+
requestsLimit: "2500",
|
|
20
|
+
requestsRemaining: "0",
|
|
21
|
+
requestsReset: "1760000000000",
|
|
22
|
+
complexityLimit: "1000000",
|
|
23
|
+
complexityRemaining: "12",
|
|
24
|
+
complexityReset: "1760000000000",
|
|
25
|
+
complexity: "42",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
test("computeRetryAfterMs uses Linear reset epoch-ms on 429", () => {
|
|
29
|
+
const now = 1_760_000_000_000;
|
|
30
|
+
const ms = computeRetryAfterMs(429, { requestsRemaining: "0", requestsReset: String(now + 45_000) }, now);
|
|
31
|
+
assert.equal(ms, 45_000);
|
|
32
|
+
});
|
|
33
|
+
test("computeRetryAfterMs prefers requests-reset over Retry-After when both are set", () => {
|
|
34
|
+
const now = 1_760_000_000_000;
|
|
35
|
+
const ms = computeRetryAfterMs(429, {
|
|
36
|
+
requestsRemaining: "0",
|
|
37
|
+
requestsReset: String(now + 45_000),
|
|
38
|
+
retryAfter: "5",
|
|
39
|
+
}, now);
|
|
40
|
+
assert.equal(ms, 45_000);
|
|
41
|
+
});
|
|
42
|
+
test("computeRetryAfterMs falls back to Retry-After seconds when reset missing", () => {
|
|
43
|
+
const ms = computeRetryAfterMs(429, { retryAfter: "30" }, 1_000);
|
|
44
|
+
assert.equal(ms, 30_000);
|
|
45
|
+
});
|
|
46
|
+
test("linearGraphqlRequest logs HTTP status and rate-limit headers on non-OK", async () => {
|
|
47
|
+
process.env.LINEAR_API_KEY = "lin_test";
|
|
48
|
+
const errors = [];
|
|
49
|
+
const errorMock = mock.method(console, "error", (...args) => {
|
|
50
|
+
errors.push(args);
|
|
51
|
+
});
|
|
52
|
+
const fetchMock = mock.method(globalThis, "fetch", async () => {
|
|
53
|
+
return new Response("rate limited", {
|
|
54
|
+
status: 429,
|
|
55
|
+
headers: {
|
|
56
|
+
"X-RateLimit-Requests-Remaining": "0",
|
|
57
|
+
"X-RateLimit-Requests-Reset": "1760000045000",
|
|
58
|
+
"X-RateLimit-Requests-Limit": "2500",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await assert.rejects(() => linearGraphqlRequest({
|
|
64
|
+
operation: "listLinearCandidates",
|
|
65
|
+
query: "query { viewer { id } }",
|
|
66
|
+
}), (err) => {
|
|
67
|
+
assert.ok(err instanceof LinearHttpError);
|
|
68
|
+
assert.equal(err.status, 429);
|
|
69
|
+
assert.equal(err.operation, "listLinearCandidates");
|
|
70
|
+
assert.equal(err.rateLimit.requestsRemaining, "0");
|
|
71
|
+
assert.equal(err.rateLimit.requestsReset, "1760000045000");
|
|
72
|
+
assert.match(err.message, /429/);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
const joined = errors.map((a) => a.map(String).join(" ")).join("\n");
|
|
76
|
+
assert.match(joined, /\[linear\]/);
|
|
77
|
+
assert.match(joined, /listLinearCandidates/);
|
|
78
|
+
assert.match(joined, /429/);
|
|
79
|
+
assert.match(joined, /requests-remaining[=:]?\s*0/i);
|
|
80
|
+
assert.match(joined, /requests-reset[=:]?\s*1760000045000/i);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
errorMock.mock.restore();
|
|
84
|
+
fetchMock.mock.restore();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test("listLinearCandidates leaves a [linear] log line on HTTP 502 (intake path)", async () => {
|
|
88
|
+
// Mirrors GET /api/intake/linear: route returns String(e) to the client; dealer logs must
|
|
89
|
+
// still carry status + rate-limit headers from linearGraphqlRequest.
|
|
90
|
+
const fs = await import("node:fs");
|
|
91
|
+
const os = await import("node:os");
|
|
92
|
+
const path = await import("node:path");
|
|
93
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not152-"));
|
|
94
|
+
process.env.LINEAR_API_KEY = "lin_test";
|
|
95
|
+
const { migrate } = await import("../db/index.js");
|
|
96
|
+
migrate();
|
|
97
|
+
const { listLinearCandidates } = await import("./linear-inbox.js");
|
|
98
|
+
const errors = [];
|
|
99
|
+
const errorMock = mock.method(console, "error", (...args) => {
|
|
100
|
+
errors.push(args);
|
|
101
|
+
});
|
|
102
|
+
const fetchMock = mock.method(globalThis, "fetch", async () => {
|
|
103
|
+
return new Response("bad gateway", {
|
|
104
|
+
status: 502,
|
|
105
|
+
headers: {
|
|
106
|
+
"X-RateLimit-Requests-Remaining": "12",
|
|
107
|
+
"X-RateLimit-Requests-Reset": "1760000099000",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
await assert.rejects(() => listLinearCandidates(), (err) => {
|
|
113
|
+
assert.ok(err instanceof LinearHttpError);
|
|
114
|
+
assert.equal(err.status, 502);
|
|
115
|
+
assert.equal(err.operation, "listLinearCandidates");
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
const joined = errors.map((a) => a.map(String).join(" ")).join("\n");
|
|
119
|
+
assert.match(joined, /\[linear\]/);
|
|
120
|
+
assert.match(joined, /listLinearCandidates/);
|
|
121
|
+
assert.match(joined, /status=502/);
|
|
122
|
+
assert.match(joined, /requests-remaining=12/);
|
|
123
|
+
assert.match(joined, /requests-reset=1760000099000/);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
errorMock.mock.restore();
|
|
127
|
+
fetchMock.mock.restore();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
@@ -1,26 +1,17 @@
|
|
|
1
1
|
import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
2
|
+
import { linearGraphqlRequest } from "./linear-graphql.js";
|
|
2
3
|
export { DEFAULT_LINEAR_STATE_FILTER };
|
|
3
|
-
const LINEAR_API = "https://api.linear.app/graphql";
|
|
4
4
|
const PAGE_SIZE = 50;
|
|
5
5
|
function hasApiKey() {
|
|
6
6
|
return Boolean(process.env.LINEAR_API_KEY);
|
|
7
7
|
}
|
|
8
|
-
async function linearQuery(query, variables, opts) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
headers: { "Content-Type": "application/json", Authorization: key },
|
|
15
|
-
body: JSON.stringify({ query, variables }),
|
|
16
|
-
...(opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
|
|
8
|
+
async function linearQuery(operation, query, variables, opts) {
|
|
9
|
+
return linearGraphqlRequest({
|
|
10
|
+
operation,
|
|
11
|
+
query,
|
|
12
|
+
variables,
|
|
13
|
+
timeoutMs: opts?.timeoutMs,
|
|
17
14
|
});
|
|
18
|
-
if (!res.ok)
|
|
19
|
-
throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
|
|
20
|
-
const json = (await res.json());
|
|
21
|
-
if (json.errors?.length)
|
|
22
|
-
throw new Error(JSON.stringify(json.errors));
|
|
23
|
-
return json.data;
|
|
24
15
|
}
|
|
25
16
|
function nodeToCandidate(n) {
|
|
26
17
|
return {
|
|
@@ -47,7 +38,7 @@ const ISSUE_FIELDS = `
|
|
|
47
38
|
export async function getLinearViewer() {
|
|
48
39
|
if (!hasApiKey())
|
|
49
40
|
return null;
|
|
50
|
-
const data = (await linearQuery(`query { viewer { id name email } }`));
|
|
41
|
+
const data = (await linearQuery("getLinearViewer", `query { viewer { id name email } }`));
|
|
51
42
|
return data.viewer;
|
|
52
43
|
}
|
|
53
44
|
export function buildIssueFilter(settings, viewerId) {
|
|
@@ -95,7 +86,7 @@ export async function listLinearCandidates() {
|
|
|
95
86
|
const nodes = [];
|
|
96
87
|
let after;
|
|
97
88
|
for (;;) {
|
|
98
|
-
const data = (await linearQuery(`query PollIssues($filter: IssueFilter, $after: String) {
|
|
89
|
+
const data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter, $after: String) {
|
|
99
90
|
issues(filter: $filter, first: ${PAGE_SIZE}, after: $after) {
|
|
100
91
|
nodes { ${ISSUE_FIELDS} }
|
|
101
92
|
pageInfo { hasNextPage endCursor }
|
|
@@ -109,7 +100,7 @@ export async function listLinearCandidates() {
|
|
|
109
100
|
return nodes.map(nodeToCandidate);
|
|
110
101
|
}
|
|
111
102
|
export async function getLinearIssue(issueId) {
|
|
112
|
-
const data = (await linearQuery(`query Issue($id: String!) {
|
|
103
|
+
const data = (await linearQuery("getLinearIssue", `query Issue($id: String!) {
|
|
113
104
|
issue(id: $id) { ${ISSUE_FIELDS} }
|
|
114
105
|
}`, { id: issueId }));
|
|
115
106
|
if (!data.issue)
|
|
@@ -172,7 +163,7 @@ export async function fetchLinearBlockers(issueIds, opts = {}) {
|
|
|
172
163
|
let paging = [];
|
|
173
164
|
let after;
|
|
174
165
|
for (;;) {
|
|
175
|
-
const data = (await linearQuery(`query BlockingRelations($ids: [ID!], $after: String) {
|
|
166
|
+
const data = (await linearQuery("fetchLinearBlockers", `query BlockingRelations($ids: [ID!], $after: String) {
|
|
176
167
|
issues(filter: { id: { in: $ids } }, first: ${PAGE_SIZE}, after: $after, includeArchived: true) {
|
|
177
168
|
nodes {
|
|
178
169
|
id
|
|
@@ -263,7 +254,7 @@ async function fetchInverseRelationRound(pending, remaining) {
|
|
|
263
254
|
variables[`ids${i}`] = [entry.id];
|
|
264
255
|
variables[`after${i}`] = entry.after;
|
|
265
256
|
});
|
|
266
|
-
const data = (await linearQuery(`query BlockingRelationsPage(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`, variables, { timeoutMs: remaining() }));
|
|
257
|
+
const data = (await linearQuery("fetchLinearBlockersPage", `query BlockingRelationsPage(${varDefs.join(", ")}) {\n${selections.join("\n")}\n}`, variables, { timeoutMs: remaining() }));
|
|
267
258
|
const out = new Map();
|
|
268
259
|
pending.forEach((entry, i) => {
|
|
269
260
|
const page = data[`r${i}`]?.nodes?.find((n) => n.id === entry.id)?.inverseRelations;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { addArtifact } from "../repository/runs.js";
|
|
2
2
|
import { getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
3
3
|
import { getLinearIssue } from "./linear-inbox.js";
|
|
4
|
-
|
|
4
|
+
import { linearGraphqlRequest } from "./linear-graphql.js";
|
|
5
5
|
const STATE_BY_EVENT = {
|
|
6
6
|
// TODO(P2): configurable per team — see docs/LINEAR_INTEGRATION.md
|
|
7
7
|
done: "Done",
|
|
@@ -10,27 +10,14 @@ const workflowStateCache = new Map();
|
|
|
10
10
|
function webBaseUrl() {
|
|
11
11
|
return process.env.AGENT_DEALER_WEB_URL ?? "http://localhost:2222";
|
|
12
12
|
}
|
|
13
|
-
async function linearMutate(query, variables) {
|
|
14
|
-
|
|
15
|
-
if (!key)
|
|
16
|
-
throw new Error("LINEAR_API_KEY not set");
|
|
17
|
-
const res = await fetch(LINEAR_API, {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers: { "Content-Type": "application/json", Authorization: key },
|
|
20
|
-
body: JSON.stringify({ query, variables }),
|
|
21
|
-
});
|
|
22
|
-
if (!res.ok)
|
|
23
|
-
throw new Error(`Linear HTTP ${res.status}: ${await res.text()}`);
|
|
24
|
-
const json = (await res.json());
|
|
25
|
-
if (json.errors?.length)
|
|
26
|
-
throw new Error(JSON.stringify(json.errors));
|
|
27
|
-
return json.data;
|
|
13
|
+
async function linearMutate(operation, query, variables) {
|
|
14
|
+
return linearGraphqlRequest({ operation, query, variables });
|
|
28
15
|
}
|
|
29
16
|
async function getWorkflowStates(teamId) {
|
|
30
17
|
const cached = workflowStateCache.get(teamId);
|
|
31
18
|
if (cached)
|
|
32
19
|
return cached;
|
|
33
|
-
const data = (await linearMutate(`query TeamStates($teamId: String!) {
|
|
20
|
+
const data = (await linearMutate("getWorkflowStates", `query TeamStates($teamId: String!) {
|
|
34
21
|
team(id: $teamId) {
|
|
35
22
|
states { nodes { id name } }
|
|
36
23
|
}
|
|
@@ -43,12 +30,12 @@ async function getWorkflowStates(teamId) {
|
|
|
43
30
|
return map;
|
|
44
31
|
}
|
|
45
32
|
async function commentCreate(issueId, body) {
|
|
46
|
-
await linearMutate(`mutation Comment($issueId: String!, $body: String!) {
|
|
33
|
+
await linearMutate("commentCreate", `mutation Comment($issueId: String!, $body: String!) {
|
|
47
34
|
commentCreate(input: { issueId: $issueId, body: $body }) { success }
|
|
48
35
|
}`, { issueId, body });
|
|
49
36
|
}
|
|
50
37
|
async function issueUpdateState(issueId, stateId) {
|
|
51
|
-
await linearMutate(`mutation UpdateIssue($issueId: String!, $stateId: String!) {
|
|
38
|
+
await linearMutate("issueUpdateState", `mutation UpdateIssue($issueId: String!, $stateId: String!) {
|
|
52
39
|
issueUpdate(id: $issueId, input: { stateId: $stateId }) { success }
|
|
53
40
|
}`, { issueId, stateId });
|
|
54
41
|
}
|
|
@@ -26,6 +26,17 @@ async function git(cwd, args) {
|
|
|
26
26
|
export function managedRepoPath(identity) {
|
|
27
27
|
return path.join(getExecutionRoot(), "repos", identity);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Test helper: mark a managed identity as having a local clone so merge cwd resolution
|
|
31
|
+
* (and similar) does not fail closed. Does not create a real git repo.
|
|
32
|
+
*/
|
|
33
|
+
export function stubManagedCloneForTests(repoInput) {
|
|
34
|
+
const classified = classifyIssueRepo(repoInput);
|
|
35
|
+
if (classified.kind === "managed") {
|
|
36
|
+
fs.mkdirSync(path.join(classified.repoPath, ".git"), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
return classified.repoPath;
|
|
39
|
+
}
|
|
29
40
|
export function managedWorktreePath(identity, sessionId, role) {
|
|
30
41
|
return path.join(getExecutionRoot(), "worktrees", identity, `${sessionId}-${role}`);
|
|
31
42
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// NOT-102 acceptance: auto-merge on/off, merge failure escalation, recent repos.
|
|
2
|
+
// NOT-151: portable github.com/… issue.repo must resolve to managed clone cwd, not the identity.
|
|
2
3
|
import { test, before, beforeEach, afterEach } from "node:test";
|
|
3
4
|
import assert from "node:assert/strict";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
7
9
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-"));
|
|
8
10
|
const { migrate, getDb } = await import("../db/index.js");
|
|
9
11
|
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
@@ -14,7 +16,23 @@ const { claimWorkItem, listWorkItemsForIssue } = await import("../repository/wor
|
|
|
14
16
|
const { startWorkflow, applyCompletion } = await import("./commands.js");
|
|
15
17
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
16
18
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
17
|
-
|
|
19
|
+
const { managedRepoPath } = await import("../adapters/managed-repo.js");
|
|
20
|
+
/** Real local checkout so resolveAutoMergeCwd accepts the default legacy repo. */
|
|
21
|
+
let fixtureRepo = "";
|
|
22
|
+
function initFixtureRepo() {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-repo-"));
|
|
24
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
|
|
25
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
26
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
|
|
27
|
+
fs.writeFileSync(path.join(dir, "README.md"), "hi\n");
|
|
28
|
+
execFileSync("git", ["add", "."], { cwd: dir });
|
|
29
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: dir });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
before(() => {
|
|
33
|
+
migrate();
|
|
34
|
+
fixtureRepo = initFixtureRepo();
|
|
35
|
+
});
|
|
18
36
|
beforeEach(() => {
|
|
19
37
|
getDb().exec(`
|
|
20
38
|
DELETE FROM work_items;
|
|
@@ -40,7 +58,7 @@ function newIssue(opts = {}) {
|
|
|
40
58
|
title: "Coordinate me",
|
|
41
59
|
description: "d",
|
|
42
60
|
acceptanceCriteria: "It works",
|
|
43
|
-
repo: opts.repo ??
|
|
61
|
+
repo: opts.repo ?? fixtureRepo,
|
|
44
62
|
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
45
63
|
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
46
64
|
baseBranch: "main",
|
|
@@ -110,7 +128,7 @@ test("autoMerge off: final_review complete undrafts+merges then marks done", asy
|
|
|
110
128
|
assert.equal(resolved.instanceCompleted, true);
|
|
111
129
|
assert.equal(resolved.triggerReflect, true);
|
|
112
130
|
}
|
|
113
|
-
assert.deepEqual(calls, [{ cwd:
|
|
131
|
+
assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
|
|
114
132
|
assert.equal(getIssue(issueId).status, "done");
|
|
115
133
|
assert.equal(getActiveWorkflowInstance(issueId), null);
|
|
116
134
|
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
@@ -153,9 +171,42 @@ test("autoMerge on: reviewer approve merges PR, marks done, skips final_review h
|
|
|
153
171
|
assert.equal(issue.status, "done");
|
|
154
172
|
assert.equal(getActiveWorkflowInstance(issueId), null);
|
|
155
173
|
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.actionType === "final_review").length, 0);
|
|
156
|
-
assert.deepEqual(calls, [{ cwd:
|
|
174
|
+
assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
|
|
157
175
|
assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "issue.completed"));
|
|
158
176
|
});
|
|
177
|
+
test("NOT-151: portable github.com repo merges via managed clone path, not identity string", async () => {
|
|
178
|
+
const identity = "github.com/not-so-fat/agent-dealer";
|
|
179
|
+
const managed = managedRepoPath(identity);
|
|
180
|
+
fs.mkdirSync(path.join(managed, ".git"), { recursive: true });
|
|
181
|
+
const calls = [];
|
|
182
|
+
setMergePrForTests(async (opts) => {
|
|
183
|
+
calls.push(opts);
|
|
184
|
+
return { ok: true };
|
|
185
|
+
});
|
|
186
|
+
const issueId = newIssue({ autoMerge: true, repo: identity });
|
|
187
|
+
startWorkflow(issueId);
|
|
188
|
+
await complete(issueId, cleanHandoff);
|
|
189
|
+
await complete(issueId, { kind: "verdict", result: okReview("approved") });
|
|
190
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
191
|
+
assert.deepEqual(calls, [{ cwd: managed, number: 42 }]);
|
|
192
|
+
assert.notEqual(calls[0]?.cwd, identity);
|
|
193
|
+
});
|
|
194
|
+
test("NOT-151: missing managed clone escalates clearly without spawn gh ENOENT", async () => {
|
|
195
|
+
let mergeCalled = false;
|
|
196
|
+
setMergePrForTests(async () => {
|
|
197
|
+
mergeCalled = true;
|
|
198
|
+
return { ok: true };
|
|
199
|
+
});
|
|
200
|
+
const issueId = newIssue({ autoMerge: true, repo: "github.com/missing/no-clone" });
|
|
201
|
+
startWorkflow(issueId);
|
|
202
|
+
await complete(issueId, cleanHandoff);
|
|
203
|
+
await complete(issueId, { kind: "verdict", result: okReview("approved") });
|
|
204
|
+
assert.equal(mergeCalled, false);
|
|
205
|
+
const issue = getIssue(issueId);
|
|
206
|
+
assert.equal(issue.status, "needs_human");
|
|
207
|
+
assert.match(issue.currentIntent ?? "", /Managed clone missing/);
|
|
208
|
+
assert.doesNotMatch(issue.currentIntent ?? "", /ENOENT/);
|
|
209
|
+
});
|
|
159
210
|
test("autoMerge on: merge failure escalates to policy_escalation; issue not left half-done as final_review", async () => {
|
|
160
211
|
setMergePrForTests(async () => ({ ok: false, reason: "required status checks failed" }));
|
|
161
212
|
const issueId = newIssue({ autoMerge: true });
|
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Concurrency: finalizeAutoMerge is single-flight per issueId (in-process). Success and
|
|
17
17
|
// escalate txns are also defensive if a racer already wrote done / needs_human.
|
|
18
|
+
//
|
|
19
|
+
// NOT-151: issue.repo is a portable GitHub identity after NOT-149 — never pass it to
|
|
20
|
+
// execFile as cwd (Node reports that as misleading `spawn gh ENOENT`). Resolve via
|
|
21
|
+
// classifyIssueRepo → managed/legacy local path first.
|
|
18
22
|
import { execFile } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
19
25
|
import { promisify } from "node:util";
|
|
26
|
+
import { classifyIssueRepo } from "../adapters/managed-repo.js";
|
|
20
27
|
import { getDb } from "../db/index.js";
|
|
21
28
|
import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
|
|
22
29
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
@@ -27,17 +34,69 @@ export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
|
27
34
|
/** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
|
|
28
35
|
export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
|
|
29
36
|
const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
|
|
37
|
+
/**
|
|
38
|
+
* Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
|
|
39
|
+
* `gh pr merge`. Missing managed clones fail closed with a clear reason — never hand the
|
|
40
|
+
* identity string to execFile.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAutoMergeCwd(repoField) {
|
|
43
|
+
try {
|
|
44
|
+
const classified = classifyIssueRepo(repoField);
|
|
45
|
+
const cwd = classified.repoPath;
|
|
46
|
+
if (classified.kind === "managed") {
|
|
47
|
+
const hasGit = fs.existsSync(path.join(cwd, ".git")) || fs.existsSync(path.join(cwd, "HEAD"));
|
|
48
|
+
if (!hasGit) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: `Managed clone missing for ${classified.identity} (${cwd}). Re-run the developer step so Dealer can clone it, or restore the checkout under execution/repos.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, cwd };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
30
64
|
/** True when Node killed the child for exceeding `timeout` (promisify(execFile)). */
|
|
31
65
|
export function isGhTimeoutError(err) {
|
|
32
66
|
const e = err;
|
|
33
67
|
return Boolean(e.killed || e.signal === "SIGTERM");
|
|
34
68
|
}
|
|
69
|
+
/** True when Node failed to spawn (missing binary *or* missing cwd — both surface ENOENT). */
|
|
70
|
+
export function isGhSpawnEnoent(err) {
|
|
71
|
+
const e = err;
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
return true;
|
|
74
|
+
const msg = (e.message ?? "").toLowerCase();
|
|
75
|
+
return msg.includes("spawn") && msg.includes("enoent");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Distinguish "cwd is not a real directory" from "gh missing on PATH" — both look like
|
|
79
|
+
* `spawn gh ENOENT` from Node. Prefer checking the cwd on disk over trusting the message.
|
|
80
|
+
*/
|
|
81
|
+
export function ghSpawnEnoentReason(err, cwd) {
|
|
82
|
+
if (!isGhSpawnEnoent(err))
|
|
83
|
+
return null;
|
|
84
|
+
if (!fs.existsSync(cwd)) {
|
|
85
|
+
return `invalid merge cwd (${cwd}) — path does not exist (portable issue.repo must not be used as cwd)`;
|
|
86
|
+
}
|
|
87
|
+
return "gh not on PATH — install GitHub CLI (`gh`) and ensure the daemon can see it";
|
|
88
|
+
}
|
|
35
89
|
/** Map an execFile failure to a stable reason string (timeout vs stderr/stdout). */
|
|
36
|
-
export function ghErrorReason(err, fallback) {
|
|
90
|
+
export function ghErrorReason(err, fallback, cwd) {
|
|
37
91
|
const e = err;
|
|
38
92
|
if (isGhTimeoutError(err)) {
|
|
39
93
|
return `gh timed out after ${GH_MERGE_TIMEOUT_MS}ms`;
|
|
40
94
|
}
|
|
95
|
+
if (cwd) {
|
|
96
|
+
const spawnReason = ghSpawnEnoentReason(err, cwd);
|
|
97
|
+
if (spawnReason)
|
|
98
|
+
return spawnReason;
|
|
99
|
+
}
|
|
41
100
|
return (e.stderr || e.stdout || e.message || fallback).trim() || fallback;
|
|
42
101
|
}
|
|
43
102
|
/** Production: mark draft ready (ignore if already), then squash-merge — async + timed. */
|
|
@@ -52,7 +111,12 @@ export const realMergePr = async ({ cwd, number }) => {
|
|
|
52
111
|
catch (err) {
|
|
53
112
|
// Timeout is a hang, not "already ready" — fail closed so we do not burn another 20s on merge.
|
|
54
113
|
if (isGhTimeoutError(err)) {
|
|
55
|
-
return { ok: false, reason: ghErrorReason(err, "gh pr ready failed") };
|
|
114
|
+
return { ok: false, reason: ghErrorReason(err, "gh pr ready failed", cwd) };
|
|
115
|
+
}
|
|
116
|
+
// Bad cwd / missing gh on the ready step would also fail merge — surface now.
|
|
117
|
+
const spawnReason = ghSpawnEnoentReason(err, cwd);
|
|
118
|
+
if (spawnReason) {
|
|
119
|
+
return { ok: false, reason: spawnReason };
|
|
56
120
|
}
|
|
57
121
|
// Already ready / not a draft — ignore; merge is the authority.
|
|
58
122
|
}
|
|
@@ -65,7 +129,7 @@ export const realMergePr = async ({ cwd, number }) => {
|
|
|
65
129
|
return { ok: true };
|
|
66
130
|
}
|
|
67
131
|
catch (err) {
|
|
68
|
-
const reason = ghErrorReason(err, "gh pr merge failed");
|
|
132
|
+
const reason = ghErrorReason(err, "gh pr merge failed", cwd);
|
|
69
133
|
// Crash between a successful merge and the done-transition: retry must not escalate.
|
|
70
134
|
if (ALREADY_MERGED.test(reason))
|
|
71
135
|
return { ok: true };
|
|
@@ -118,7 +182,11 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
118
182
|
if (issue.prNumber == null) {
|
|
119
183
|
return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
|
|
120
184
|
}
|
|
121
|
-
const
|
|
185
|
+
const resolvedCwd = resolveAutoMergeCwd(issue.repo);
|
|
186
|
+
if (!resolvedCwd.ok) {
|
|
187
|
+
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
|
|
188
|
+
}
|
|
189
|
+
const merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
|
|
122
190
|
if (!merge.ok) {
|
|
123
191
|
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
|
|
124
192
|
}
|