agent-dealer 1.2.12 → 1.2.14
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/git-worktree.js +15 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +30 -0
- package/bundle/server/dist/adapters/linear-inbox.js +169 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +481 -1
- package/bundle/server/dist/adapters/linear-sync.js +33 -10
- package/bundle/server/dist/coordinator/auto-merge.js +10 -0
- package/bundle/server/dist/coordinator/branch-progress.js +5 -4
- package/bundle/server/dist/coordinator/commands.js +37 -3
- package/bundle/server/dist/coordinator/developer-effect.js +33 -6
- package/bundle/server/dist/coordinator/linear-merge-verify.js +382 -0
- package/bundle/server/dist/coordinator/linear-merge-verify.test.js +376 -0
- package/bundle/server/dist/coordinator/prompts.js +9 -1
- package/bundle/server/dist/coordinator/prompts.test.js +93 -0
- package/bundle/server/dist/coordinator/source-attachments.js +473 -0
- package/bundle/server/dist/coordinator/source-attachments.test.js +284 -0
- package/bundle/server/dist/coordinator/source-reload.js +178 -0
- package/bundle/server/dist/db/schema.sql +28 -0
- package/bundle/server/dist/docs-linear-attachments.test.js +36 -0
- package/bundle/server/dist/paths.js +12 -0
- package/bundle/server/dist/repository/intake-settings.js +50 -0
- package/bundle/server/dist/repository/intake-settings.test.js +72 -0
- package/bundle/server/dist/repository/source-attachments.js +56 -0
- package/bundle/server/dist/routes/human-actions.js +17 -0
- package/bundle/server/dist/routes/index.js +6 -3
- package/bundle/server/dist/routes/intake-linear-config.js +31 -0
- package/bundle/server/dist/routes/intake-linear.test.js +95 -5
- package/bundle/server/dist/routes/issues-execution-contract.test.js +296 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +411 -0
- package/bundle/server/dist/routes/issues.js +89 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/assets/index-DDbJ9ZU7.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +411 -2
- package/bundle/shared/dist/index.js +59 -1
- package/bundle/shared/dist/issues.d.ts +43 -2
- package/bundle/shared/dist/issues.js +10 -0
- package/bundle/shared/dist/linear-intake.d.ts +99 -0
- package/bundle/shared/dist/linear-intake.js +79 -0
- package/bundle/shared/dist/linear-intake.test.js +95 -2
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +7 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CBdkhTI_.js +0 -64
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +0 -1
|
@@ -1042,6 +1042,21 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
1042
1042
|
ref = `origin/${opts.branchName}`;
|
|
1043
1043
|
newBranch = opts.branchName;
|
|
1044
1044
|
}
|
|
1045
|
+
else if (reuseRemoteSha === null && !(await branchExists(opts.repo, opts.branchName))) {
|
|
1046
|
+
// NOT-362: the reuse path was taken for a branch that exists nowhere — a
|
|
1047
|
+
// freshly seeded Linear `branchName` (or `issue-<id>` fallback) has no
|
|
1048
|
+
// local ref and the remote never saw it (`git worktree add <path>
|
|
1049
|
+
// <branch>` would reject the unknown ref). Cut it fresh from the base
|
|
1050
|
+
// instead of checking out a branch that does not exist.
|
|
1051
|
+
const fresh = await fetchFreshBase(opts.repo, opts.baseBranch, opts.fetchTimeoutMs);
|
|
1052
|
+
if (!fresh.ok) {
|
|
1053
|
+
return { kind: "base_unavailable", reason: fresh.reason };
|
|
1054
|
+
}
|
|
1055
|
+
ref = fresh.ref;
|
|
1056
|
+
newBranch = opts.branchName;
|
|
1057
|
+
baseSha = fresh.sha;
|
|
1058
|
+
baseRef = fresh.ref;
|
|
1059
|
+
}
|
|
1045
1060
|
else {
|
|
1046
1061
|
// NOT-219: when the remote tip is known and the local ref is strictly behind
|
|
1047
1062
|
// it (an ancestor of it), advance the local ref to the pushed tip before
|
|
@@ -1183,6 +1183,36 @@ test("NOT-197: a fresh developer branch starts from the fetched origin/<base>, n
|
|
|
1183
1183
|
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
1184
1184
|
}
|
|
1185
1185
|
});
|
|
1186
|
+
test("NOT-362: a seeded Linear branch name that exists nowhere starts round 1 from a fresh base cut", async () => {
|
|
1187
|
+
// Round-1 shape after intake seeding: developer-effect computes
|
|
1188
|
+
// reuseBranch = true from the seeded issue.branch, but the branch has no
|
|
1189
|
+
// local ref and the remote never saw it. Pre-fix this reached
|
|
1190
|
+
// `git worktree add <path> <branch>` with no newBranch and git rejected the
|
|
1191
|
+
// unknown ref; now it cuts the seeded name fresh from the fetched base.
|
|
1192
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("362seed");
|
|
1193
|
+
const branch = "yusukemuraoka/ate-4-some-work";
|
|
1194
|
+
try {
|
|
1195
|
+
assert.equal(await branchExists(isoRepo, branch), false);
|
|
1196
|
+
const resolved = await resolveDeveloperWorktree({
|
|
1197
|
+
repo: isoRepo,
|
|
1198
|
+
sessionId: "s-362-seed",
|
|
1199
|
+
branchName: branch,
|
|
1200
|
+
baseBranch: "main",
|
|
1201
|
+
reuseBranch: true,
|
|
1202
|
+
});
|
|
1203
|
+
assert.equal(resolved.kind, "created");
|
|
1204
|
+
if (resolved.kind !== "created")
|
|
1205
|
+
return;
|
|
1206
|
+
const originTip = git(isoRepo, "rev-parse", "origin/main");
|
|
1207
|
+
assert.equal(git(resolved.path, "rev-parse", "HEAD"), originTip);
|
|
1208
|
+
assert.equal(git(isoRepo, "rev-parse", branch), originTip);
|
|
1209
|
+
await removeIsoWorktree(isoRepo, resolved.path, branch);
|
|
1210
|
+
}
|
|
1211
|
+
finally {
|
|
1212
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
1213
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1186
1216
|
test("NOT-197: a failed pre-branch fetch returns base_unavailable and creates no branch", async () => {
|
|
1187
1217
|
const { isoRepo, isoRemote } = makeIsoRepoPair("fail");
|
|
1188
1218
|
try {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { resolveLinearRepoWithMappings } from "@agent-dealer/shared";
|
|
2
|
+
import { getDb } from "../db/index.js";
|
|
3
|
+
import { getIssue, listIssuesByExternalId } from "../repository/issues.js";
|
|
2
4
|
import { DEFAULT_LINEAR_STATE_FILTER, getLinearIntakeConfig } from "../repository/intake-settings.js";
|
|
3
5
|
import { listRepositoryMappings } from "../repository/repository-mappings.js";
|
|
4
|
-
import { linearGraphqlRequest } from "./linear-graphql.js";
|
|
6
|
+
import { LinearApiKeyMissingError, linearGraphqlRequest } from "./linear-graphql.js";
|
|
5
7
|
export { DEFAULT_LINEAR_STATE_FILTER };
|
|
6
|
-
|
|
8
|
+
/** NOT-361: first-page bound for the From Linear picker — never walk further cursors. */
|
|
9
|
+
export const LINEAR_CANDIDATE_PAGE_SIZE = 50;
|
|
10
|
+
const PAGE_SIZE = LINEAR_CANDIDATE_PAGE_SIZE;
|
|
7
11
|
function hasApiKey() {
|
|
8
12
|
return Boolean(process.env.LINEAR_API_KEY);
|
|
9
13
|
}
|
|
@@ -21,8 +25,45 @@ async function linearQuery(operation, query, variables, opts) {
|
|
|
21
25
|
* deterministic (one valid label resolves, zero is unresolved, several
|
|
22
26
|
* conflict, a bad value is invalid) and never guesses from anything else.
|
|
23
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* NOT-364: the issue's own `attachments` connection — file uploads and
|
|
30
|
+
* external links attached to the ticket itself. Comment bodies and files
|
|
31
|
+
* uploaded only inside comments are never queried and never snapshotted.
|
|
32
|
+
* `source` is Linear's own metadata scalar; non-string values are coerced to
|
|
33
|
+
* a short string so the contract stays stable regardless of shape.
|
|
34
|
+
*/
|
|
35
|
+
export function nodeToAttachment(a) {
|
|
36
|
+
if (!a || typeof a.id !== "string" || !a.id)
|
|
37
|
+
return null;
|
|
38
|
+
if (typeof a.url !== "string" || !a.url)
|
|
39
|
+
return null;
|
|
40
|
+
const out = {
|
|
41
|
+
id: a.id,
|
|
42
|
+
title: typeof a.title === "string" && a.title ? a.title : a.id,
|
|
43
|
+
url: a.url,
|
|
44
|
+
};
|
|
45
|
+
if (typeof a.subtitle === "string" && a.subtitle)
|
|
46
|
+
out.subtitle = a.subtitle;
|
|
47
|
+
if (typeof a.source === "string" && a.source) {
|
|
48
|
+
out.source = a.source;
|
|
49
|
+
}
|
|
50
|
+
else if (a.source != null && typeof a.source === "object") {
|
|
51
|
+
try {
|
|
52
|
+
const rendered = JSON.stringify(a.source);
|
|
53
|
+
if (rendered && rendered !== "{}")
|
|
54
|
+
out.source = rendered;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore unserializable source metadata
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
24
62
|
export function nodeToCandidate(n, mappings) {
|
|
25
63
|
const labels = n.labels?.nodes.map((l) => l.name) ?? [];
|
|
64
|
+
const attachments = (n.attachments?.nodes ?? [])
|
|
65
|
+
.map(nodeToAttachment)
|
|
66
|
+
.filter((a) => a !== null);
|
|
26
67
|
return {
|
|
27
68
|
id: n.id,
|
|
28
69
|
identifier: n.identifier,
|
|
@@ -31,8 +72,15 @@ export function nodeToCandidate(n, mappings) {
|
|
|
31
72
|
url: n.url,
|
|
32
73
|
state: n.state?.name,
|
|
33
74
|
teamId: n.team?.id,
|
|
75
|
+
// NOT-362: Linear's suggested branch name, when it returns one.
|
|
76
|
+
...(typeof n.branchName === "string" && n.branchName.trim()
|
|
77
|
+
? { branchName: n.branchName }
|
|
78
|
+
: {}),
|
|
34
79
|
labels,
|
|
35
80
|
repoResolution: resolveLinearRepoWithMappings(labels, mappings ?? []),
|
|
81
|
+
// Always present (possibly empty) so the UI can distinguish "no
|
|
82
|
+
// attachments" from "queried before attachments existed".
|
|
83
|
+
attachments,
|
|
36
84
|
};
|
|
37
85
|
}
|
|
38
86
|
const ISSUE_FIELDS = `
|
|
@@ -41,9 +89,11 @@ const ISSUE_FIELDS = `
|
|
|
41
89
|
title
|
|
42
90
|
description
|
|
43
91
|
url
|
|
92
|
+
branchName
|
|
44
93
|
state { name }
|
|
45
94
|
team { id }
|
|
46
95
|
labels { nodes { name } }
|
|
96
|
+
attachments { nodes { id title url subtitle source } }
|
|
47
97
|
`;
|
|
48
98
|
export async function getLinearViewer() {
|
|
49
99
|
if (!hasApiKey())
|
|
@@ -81,35 +131,89 @@ export function parseLinearIssueRef(raw) {
|
|
|
81
131
|
}
|
|
82
132
|
return null;
|
|
83
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* NOT-361: at most one GraphQL page of candidates, most recently updated first.
|
|
136
|
+
* Does not walk `pageInfo` cursors — `hasMore` tells the UI when matches remain.
|
|
137
|
+
*/
|
|
84
138
|
export async function listLinearCandidates() {
|
|
85
139
|
if (!hasApiKey())
|
|
86
|
-
return [];
|
|
140
|
+
return { candidates: [], hasMore: false };
|
|
87
141
|
const settings = getLinearIntakeConfig();
|
|
88
142
|
let viewerId;
|
|
89
143
|
if (settings.assigneeMe) {
|
|
90
144
|
const viewer = await getLinearViewer();
|
|
91
145
|
viewerId = viewer?.id;
|
|
92
146
|
if (!viewerId)
|
|
93
|
-
return [];
|
|
147
|
+
return { candidates: [], hasMore: false };
|
|
94
148
|
}
|
|
95
149
|
const filter = buildIssueFilter(settings, viewerId);
|
|
96
|
-
const
|
|
97
|
-
|
|
150
|
+
const data = (await linearQuery("listLinearCandidates", `query PollIssues($filter: IssueFilter) {
|
|
151
|
+
issues(filter: $filter, first: ${PAGE_SIZE}, orderBy: updatedAt) {
|
|
152
|
+
nodes { ${ISSUE_FIELDS} }
|
|
153
|
+
pageInfo { hasNextPage }
|
|
154
|
+
}
|
|
155
|
+
}`, { filter }));
|
|
156
|
+
// NOT-260: mappings load once per list operation, not once per candidate.
|
|
157
|
+
const mappings = listRepositoryMappings();
|
|
158
|
+
return {
|
|
159
|
+
candidates: data.issues.nodes.map((n) => nodeToCandidate(n, mappings)),
|
|
160
|
+
hasMore: Boolean(data.issues.pageInfo.hasNextPage),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Page size when walking Linear teams for the filter editor metadata. */
|
|
164
|
+
const METADATA_TEAM_PAGE_SIZE = 50;
|
|
165
|
+
/**
|
|
166
|
+
* NOT-361: teams, workflow statuses, and the authenticated Linear viewer for
|
|
167
|
+
* the inline filter editor — never ask the operator for raw UUIDs or CSV text.
|
|
168
|
+
* Teams are walked page-by-page so large workspaces are not truncated at
|
|
169
|
+
* Linear's default first page.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchLinearIntakeMetadata() {
|
|
172
|
+
if (!hasApiKey())
|
|
173
|
+
throw new LinearApiKeyMissingError();
|
|
174
|
+
const viewerData = (await linearQuery("fetchLinearIntakeMetadataViewer", `query IntakeMetadataViewer { viewer { id name email } }`));
|
|
175
|
+
const teams = [];
|
|
176
|
+
const workflowStates = [];
|
|
177
|
+
let after = null;
|
|
98
178
|
for (;;) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
nodes {
|
|
179
|
+
const page = (await linearQuery("fetchLinearIntakeMetadataTeams", `query IntakeMetadataTeams($first: Int!, $after: String) {
|
|
180
|
+
teams(first: $first, after: $after) {
|
|
181
|
+
nodes {
|
|
182
|
+
id
|
|
183
|
+
name
|
|
184
|
+
key
|
|
185
|
+
states { nodes { name type } }
|
|
186
|
+
}
|
|
102
187
|
pageInfo { hasNextPage endCursor }
|
|
103
188
|
}
|
|
104
|
-
}`, {
|
|
105
|
-
|
|
106
|
-
|
|
189
|
+
}`, { first: METADATA_TEAM_PAGE_SIZE, after }));
|
|
190
|
+
for (const team of page.teams?.nodes ?? []) {
|
|
191
|
+
teams.push({ id: team.id, name: team.name, key: team.key });
|
|
192
|
+
for (const state of team.states?.nodes ?? []) {
|
|
193
|
+
const name = state.name?.trim();
|
|
194
|
+
if (!name)
|
|
195
|
+
continue;
|
|
196
|
+
workflowStates.push({ name, type: state.type, teamId: team.id });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!page.teams?.pageInfo?.hasNextPage || !page.teams.pageInfo.endCursor)
|
|
107
200
|
break;
|
|
108
|
-
after =
|
|
201
|
+
after = page.teams.pageInfo.endCursor;
|
|
109
202
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
203
|
+
// Stable team order by name; statuses keep Linear's per-team order, then
|
|
204
|
+
// unique-by-name order is left to the UI when "All teams" is selected.
|
|
205
|
+
teams.sort((a, b) => a.name.localeCompare(b.name));
|
|
206
|
+
return {
|
|
207
|
+
teams,
|
|
208
|
+
workflowStates,
|
|
209
|
+
viewer: viewerData.viewer
|
|
210
|
+
? {
|
|
211
|
+
id: viewerData.viewer.id,
|
|
212
|
+
name: viewerData.viewer.name,
|
|
213
|
+
email: viewerData.viewer.email,
|
|
214
|
+
}
|
|
215
|
+
: null,
|
|
216
|
+
};
|
|
113
217
|
}
|
|
114
218
|
export async function getLinearIssue(issueId) {
|
|
115
219
|
const data = (await linearQuery("getLinearIssue", `query Issue($id: String!) {
|
|
@@ -283,3 +387,52 @@ export async function lookupLinearIssue(raw) {
|
|
|
283
387
|
return null;
|
|
284
388
|
return getLinearIssue(id);
|
|
285
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* NOT-362: seed a freshly imported Linear issue's branch from Linear's own
|
|
392
|
+
* `branchName`, so published branches carry the Linear identifier on
|
|
393
|
+
* repositories Linear's GitHub integration covers.
|
|
394
|
+
*
|
|
395
|
+
* Runs once at intake (POST /api/issues, source `linear`): a branch the
|
|
396
|
+
* workflow already wrote (clean_handoff ground truth) is never overwritten,
|
|
397
|
+
* manual (`source != 'linear'`) issues are untouched, and a Linear issue with
|
|
398
|
+
* no branch name falls back to the conventional `issue-<id>` — the same value
|
|
399
|
+
* `developerBranchName` would derive, materialized so the stored row says it.
|
|
400
|
+
* First pass only: a second Dealer issue for the same ticket (NOT-141) keeps
|
|
401
|
+
* `issue-<id>` so it never reuses the first pass's branch.
|
|
402
|
+
* Best-effort: an unreadable Linear (no key, outage) leaves the branch null
|
|
403
|
+
* and never fails the import.
|
|
404
|
+
*/
|
|
405
|
+
export async function persistLinearBranchForIssue(issueId) {
|
|
406
|
+
try {
|
|
407
|
+
const issue = getIssue(issueId);
|
|
408
|
+
if (!issue || issue.source !== "linear" || !issue.externalId)
|
|
409
|
+
return issue?.branch ?? null;
|
|
410
|
+
if (issue.branch)
|
|
411
|
+
return issue.branch;
|
|
412
|
+
// NOT-362 round 3: Linear's branchName is per Linear ticket, but each Dealer
|
|
413
|
+
// issue needs a unique branch per pass. routes/issues.ts (NOT-141) creates a
|
|
414
|
+
// fresh Dealer issue when an earlier pass for the same ticket is done/closed,
|
|
415
|
+
// and auto-merge squashes without deleting the branch — so a second pass
|
|
416
|
+
// seeded with the same Linear name would check out the first pass's branch
|
|
417
|
+
// (still holding pre-squash commits) and `gh pr view <branch>` could return
|
|
418
|
+
// the old MERGED PR. Seed the Linear name only for the first pass; later
|
|
419
|
+
// passes keep the conventional `issue-<id>`.
|
|
420
|
+
const priorPass = listIssuesByExternalId(issue.source, issue.externalId).some((row) => row.id !== issue.id);
|
|
421
|
+
if (priorPass) {
|
|
422
|
+
const fallback = `issue-${issue.id}`;
|
|
423
|
+
getDb()
|
|
424
|
+
.prepare("UPDATE issues SET branch = ?, updated_at = ? WHERE id = ? AND branch IS NULL")
|
|
425
|
+
.run(fallback, new Date().toISOString(), issue.id);
|
|
426
|
+
return getIssue(issue.id)?.branch ?? fallback;
|
|
427
|
+
}
|
|
428
|
+
const candidate = await getLinearIssue(issue.externalId);
|
|
429
|
+
const branch = candidate?.branchName?.trim() || `issue-${issue.id}`;
|
|
430
|
+
getDb()
|
|
431
|
+
.prepare("UPDATE issues SET branch = ?, updated_at = ? WHERE id = ? AND branch IS NULL")
|
|
432
|
+
.run(branch, new Date().toISOString(), issue.id);
|
|
433
|
+
return getIssue(issue.id)?.branch ?? branch;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return getIssue(issueId)?.branch ?? null;
|
|
437
|
+
}
|
|
438
|
+
}
|