@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.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/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +87 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import search from '@inquirer/search';
|
|
2
|
+
import { DIRTY_EXPLANATION, DIRTY_ROW_LABEL, } from '../domain/source.js';
|
|
3
|
+
import { escapeTerminalText } from './picker.js';
|
|
4
|
+
const confirmationChoices = [
|
|
5
|
+
{ name: 'Launch pinned comparison', value: 'launch' },
|
|
6
|
+
{ name: 'Back', value: 'back' },
|
|
7
|
+
];
|
|
8
|
+
async function defaultPrompt(config) {
|
|
9
|
+
return await search({
|
|
10
|
+
message: config.message,
|
|
11
|
+
source: async () => [...config.choices],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function confirmPinnedComparison(comparison, dependencies = {}) {
|
|
15
|
+
const output = dependencies.output ?? console.log;
|
|
16
|
+
const prompt = dependencies.prompt ?? defaultPrompt;
|
|
17
|
+
output('Confirm pinned comparison');
|
|
18
|
+
output(`Base: ${escapeTerminalText(comparison.base.label)}\n${comparison.base.oid}`);
|
|
19
|
+
output(`Head: ${escapeTerminalText(comparison.head.label)}\n${comparison.head.oid}`);
|
|
20
|
+
output(`Merge base:\n${comparison.mergeBaseOid}`);
|
|
21
|
+
if (comparison.base.source?.kind === 'worktree') {
|
|
22
|
+
output(`Base worktree: ${escapeTerminalText(comparison.base.source.path)}`);
|
|
23
|
+
}
|
|
24
|
+
if (comparison.head.source?.kind === 'worktree') {
|
|
25
|
+
output(`Head worktree: ${escapeTerminalText(comparison.head.source.path)}`);
|
|
26
|
+
}
|
|
27
|
+
if ((comparison.base.source?.kind === 'worktree' &&
|
|
28
|
+
comparison.base.source.dirty) ||
|
|
29
|
+
(comparison.head.source?.kind === 'worktree' &&
|
|
30
|
+
comparison.head.source.dirty)) {
|
|
31
|
+
output(`${DIRTY_ROW_LABEL}\n${DIRTY_EXPLANATION}`);
|
|
32
|
+
}
|
|
33
|
+
output('This session is pinned to the commits shown below and does not follow moving refs.');
|
|
34
|
+
return await prompt({
|
|
35
|
+
message: 'Launch this pinned comparison?',
|
|
36
|
+
choices: confirmationChoices,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import search, { Separator } from '@inquirer/search';
|
|
2
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
3
|
+
import { DIRTY_ROW_LABEL, PENDING_ROW_LABEL, UNAVAILABLE_WORKTREE_REASON, } from '../domain/source.js';
|
|
4
|
+
const BASE_PROMPT = 'Choose base — changes will be compared from its merge base with head';
|
|
5
|
+
const HEAD_PROMPT = 'Choose head — this committed state will be reviewed';
|
|
6
|
+
const BACK_TO_BASE = `${RENAME_MAP.slug}:back-to-base`;
|
|
7
|
+
const UNCOMMITTED_ROW_IDS = {
|
|
8
|
+
staged: `${RENAME_MAP.slug}:uncommitted:staged`,
|
|
9
|
+
unstaged: `${RENAME_MAP.slug}:uncommitted:unstaged`,
|
|
10
|
+
worktree: `${RENAME_MAP.slug}:uncommitted:worktree`,
|
|
11
|
+
};
|
|
12
|
+
const UNCOMMITTED_ROW_KIND_BY_ID = new Map([
|
|
13
|
+
[UNCOMMITTED_ROW_IDS.staged, 'staged'],
|
|
14
|
+
[UNCOMMITTED_ROW_IDS.unstaged, 'unstaged'],
|
|
15
|
+
[UNCOMMITTED_ROW_IDS.worktree, 'worktree'],
|
|
16
|
+
]);
|
|
17
|
+
const UNCOMMITTED_BASE_PROMPT = 'Choose base — changes will be compared from its merge base with HEAD';
|
|
18
|
+
export function escapeTerminalText(value) {
|
|
19
|
+
let result = '';
|
|
20
|
+
for (const character of value) {
|
|
21
|
+
const codePoint = character.codePointAt(0);
|
|
22
|
+
switch (codePoint) {
|
|
23
|
+
case 0x09:
|
|
24
|
+
result += '\\t';
|
|
25
|
+
break;
|
|
26
|
+
case 0x0a:
|
|
27
|
+
result += '\\n';
|
|
28
|
+
break;
|
|
29
|
+
case 0x0d:
|
|
30
|
+
result += '\\r';
|
|
31
|
+
break;
|
|
32
|
+
case 0x5c:
|
|
33
|
+
result += '\\\\';
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
result +=
|
|
37
|
+
codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
38
|
+
? `\\x${codePoint.toString(16).toUpperCase().padStart(2, '0')}`
|
|
39
|
+
: character;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function worktreeState(candidate) {
|
|
45
|
+
if (candidate.availability === 'dirty') {
|
|
46
|
+
return DIRTY_ROW_LABEL;
|
|
47
|
+
}
|
|
48
|
+
if (candidate.availability === 'pending') {
|
|
49
|
+
return PENDING_ROW_LABEL;
|
|
50
|
+
}
|
|
51
|
+
if (candidate.availability === 'unavailable') {
|
|
52
|
+
return 'Unavailable';
|
|
53
|
+
}
|
|
54
|
+
return 'Clean';
|
|
55
|
+
}
|
|
56
|
+
function candidateMatches(candidate, query) {
|
|
57
|
+
if (query.length === 0) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const identifyingText = candidate.kind === 'branch'
|
|
61
|
+
? [
|
|
62
|
+
'branch',
|
|
63
|
+
'local branch',
|
|
64
|
+
candidate.label,
|
|
65
|
+
candidate.refName,
|
|
66
|
+
candidate.commitOid,
|
|
67
|
+
candidate.shortOid,
|
|
68
|
+
].join(' ')
|
|
69
|
+
: [
|
|
70
|
+
'worktree',
|
|
71
|
+
candidate.label,
|
|
72
|
+
candidate.path,
|
|
73
|
+
candidate.commitOid ?? '',
|
|
74
|
+
candidate.shortOid ?? '',
|
|
75
|
+
candidate.detached ? 'detached' : 'attached',
|
|
76
|
+
worktreeState(candidate),
|
|
77
|
+
candidate.unavailableReason ?? '',
|
|
78
|
+
].join(' ');
|
|
79
|
+
return identifyingText.toLowerCase().includes(query);
|
|
80
|
+
}
|
|
81
|
+
function candidateItem(candidate, options) {
|
|
82
|
+
const suggested = options.role === 'head' && candidate.id === options.suggestedHeadId
|
|
83
|
+
? ' · Suggested: current checkout'
|
|
84
|
+
: '';
|
|
85
|
+
if (candidate.kind === 'branch') {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'candidate',
|
|
88
|
+
value: candidate.id,
|
|
89
|
+
name: `[Branch] ${escapeTerminalText(candidate.label)} · ${candidate.shortOid}${suggested}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const detached = candidate.detached ? ' · Detached' : '';
|
|
93
|
+
const shortOid = candidate.shortOid ?? 'no committed HEAD';
|
|
94
|
+
return {
|
|
95
|
+
kind: 'candidate',
|
|
96
|
+
value: candidate.id,
|
|
97
|
+
name: `[Worktree] ${escapeTerminalText(candidate.label)} · ${shortOid}` +
|
|
98
|
+
`${detached} · ${worktreeState(candidate)}${suggested}\n` +
|
|
99
|
+
` ${escapeTerminalText(candidate.path)}`,
|
|
100
|
+
disabled: candidate.availability === 'unavailable'
|
|
101
|
+
? candidate.unavailableReason ?? UNAVAILABLE_WORKTREE_REASON
|
|
102
|
+
: undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function buildSourceSearchItems(candidates, term, options) {
|
|
106
|
+
const query = (term ?? '').toLowerCase();
|
|
107
|
+
const branchItems = candidates
|
|
108
|
+
.filter((candidate) => candidate.kind === 'branch' && candidateMatches(candidate, query))
|
|
109
|
+
.map((candidate) => candidateItem(candidate, options));
|
|
110
|
+
const worktreeItems = candidates
|
|
111
|
+
.filter((candidate) => candidate.kind === 'worktree' && candidateMatches(candidate, query))
|
|
112
|
+
.map((candidate) => candidateItem(candidate, options));
|
|
113
|
+
const items = [];
|
|
114
|
+
if (options.role === 'base' && options.includeUncommittedGroup === true) {
|
|
115
|
+
items.push({ kind: 'separator', label: 'Uncommitted' }, { kind: 'candidate', value: UNCOMMITTED_ROW_IDS.staged, name: 'Staged' }, {
|
|
116
|
+
kind: 'candidate',
|
|
117
|
+
value: UNCOMMITTED_ROW_IDS.unstaged,
|
|
118
|
+
name: 'Unstaged',
|
|
119
|
+
}, {
|
|
120
|
+
kind: 'candidate',
|
|
121
|
+
value: UNCOMMITTED_ROW_IDS.worktree,
|
|
122
|
+
name: 'Working tree',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
items.push({ kind: 'separator', label: 'Local branches' }, ...branchItems, { kind: 'separator', label: 'Worktrees' }, ...worktreeItems);
|
|
126
|
+
if (branchItems.length === 0 && worktreeItems.length === 0) {
|
|
127
|
+
items.push({
|
|
128
|
+
kind: 'separator',
|
|
129
|
+
label: 'No branches or worktrees match this search.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (options.role === 'head') {
|
|
133
|
+
items.push({ kind: 'action', value: BACK_TO_BASE, name: 'Back' });
|
|
134
|
+
}
|
|
135
|
+
return items;
|
|
136
|
+
}
|
|
137
|
+
function promptItems(items) {
|
|
138
|
+
return items.map((item) => item.kind === 'separator'
|
|
139
|
+
? new Separator(item.label)
|
|
140
|
+
: {
|
|
141
|
+
value: item.value,
|
|
142
|
+
name: item.name,
|
|
143
|
+
...(item.kind === 'candidate' && item.disabled !== undefined
|
|
144
|
+
? { disabled: item.disabled }
|
|
145
|
+
: {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function mergedCandidates(candidates, branches) {
|
|
149
|
+
const branchIds = new Set();
|
|
150
|
+
const mergedBranches = branches.filter((candidate) => {
|
|
151
|
+
if (branchIds.has(candidate.id)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
branchIds.add(candidate.id);
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
return [
|
|
158
|
+
...mergedBranches,
|
|
159
|
+
...candidates.filter((candidate) => candidate.kind === 'worktree'),
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
function sourceForPrompt(candidates, searchBranches, candidateById, startCandidateEnrichment, options, initialSearchTerm = '') {
|
|
163
|
+
let firstRequest = true;
|
|
164
|
+
return async (term, { signal }) => {
|
|
165
|
+
signal.throwIfAborted();
|
|
166
|
+
const startEnrichment = firstRequest;
|
|
167
|
+
const effectiveTerm = firstRequest && term === undefined ? initialSearchTerm : term;
|
|
168
|
+
firstRequest = false;
|
|
169
|
+
if ((effectiveTerm ?? '').length === 0) {
|
|
170
|
+
const items = promptItems(buildSourceSearchItems(candidates(), effectiveTerm, options));
|
|
171
|
+
if (startEnrichment) {
|
|
172
|
+
startCandidateEnrichment?.();
|
|
173
|
+
}
|
|
174
|
+
return items;
|
|
175
|
+
}
|
|
176
|
+
const branches = await searchBranches(effectiveTerm, signal);
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
for (const candidate of branches) {
|
|
179
|
+
candidateById.set(candidate.id, candidate);
|
|
180
|
+
}
|
|
181
|
+
const items = promptItems(buildSourceSearchItems(mergedCandidates(candidates(), branches), undefined, options));
|
|
182
|
+
if (startEnrichment) {
|
|
183
|
+
startCandidateEnrichment?.();
|
|
184
|
+
}
|
|
185
|
+
return items;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export async function pickOrderedSources(options, dependencies = {}) {
|
|
189
|
+
let currentCandidates = options.candidates;
|
|
190
|
+
const candidateById = new Map(currentCandidates.map((candidate) => [candidate.id, candidate]));
|
|
191
|
+
const candidateEnrichment = options.candidateEnrichment?.then((enrichedCandidates) => {
|
|
192
|
+
currentCandidates = enrichedCandidates;
|
|
193
|
+
for (const candidate of enrichedCandidates) {
|
|
194
|
+
candidateById.set(candidate.id, candidate);
|
|
195
|
+
}
|
|
196
|
+
return enrichedCandidates;
|
|
197
|
+
});
|
|
198
|
+
void candidateEnrichment?.catch(() => undefined);
|
|
199
|
+
const prompt = dependencies.prompt ??
|
|
200
|
+
(async (config) => await search({
|
|
201
|
+
message: config.message,
|
|
202
|
+
source: config.source,
|
|
203
|
+
...(config.default === undefined ? {} : { default: config.default }),
|
|
204
|
+
}));
|
|
205
|
+
let base = options.initialBase;
|
|
206
|
+
let head = options.initialHead;
|
|
207
|
+
while (true) {
|
|
208
|
+
if (base === undefined) {
|
|
209
|
+
const selectedBaseId = await prompt({
|
|
210
|
+
message: head === undefined
|
|
211
|
+
? BASE_PROMPT
|
|
212
|
+
: `${BASE_PROMPT}\nHead retained: ${escapeTerminalText(head.label)} · ${head.shortOid ?? 'unavailable'}`,
|
|
213
|
+
source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base' }, options.recovery?.role === 'base'
|
|
214
|
+
? options.recovery.searchTerm
|
|
215
|
+
: ''),
|
|
216
|
+
...(options.recovery?.role === 'base' &&
|
|
217
|
+
options.recovery.focusedCandidateId !== undefined
|
|
218
|
+
? { default: options.recovery.focusedCandidateId }
|
|
219
|
+
: {}),
|
|
220
|
+
});
|
|
221
|
+
base =
|
|
222
|
+
selectedBaseId === undefined
|
|
223
|
+
? undefined
|
|
224
|
+
: candidateById.get(selectedBaseId);
|
|
225
|
+
if (base === undefined) {
|
|
226
|
+
throw new Error('Base selection did not identify an available source');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (head === undefined) {
|
|
230
|
+
const selectedHeadId = await prompt({
|
|
231
|
+
message: options.recovery?.role === 'head'
|
|
232
|
+
? `${HEAD_PROMPT}\nBase retained: ${escapeTerminalText(base.label)} · ${base.shortOid ?? 'unavailable'}`
|
|
233
|
+
: HEAD_PROMPT,
|
|
234
|
+
source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, {
|
|
235
|
+
role: 'head',
|
|
236
|
+
suggestedHeadId: options.suggestedHeadId,
|
|
237
|
+
}, options.recovery?.role === 'head' ? options.recovery.searchTerm : ''),
|
|
238
|
+
default: options.recovery?.role === 'head'
|
|
239
|
+
? options.recovery.focusedCandidateId
|
|
240
|
+
: options.suggestedHeadId,
|
|
241
|
+
backValue: BACK_TO_BASE,
|
|
242
|
+
});
|
|
243
|
+
if (selectedHeadId === BACK_TO_BASE) {
|
|
244
|
+
base = undefined;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
head =
|
|
248
|
+
selectedHeadId === undefined
|
|
249
|
+
? undefined
|
|
250
|
+
: candidateById.get(selectedHeadId);
|
|
251
|
+
if (head === undefined) {
|
|
252
|
+
throw new Error('Head selection did not identify an available source');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (candidateEnrichment !== undefined &&
|
|
256
|
+
(base.kind === 'worktree' || head.kind === 'worktree')) {
|
|
257
|
+
options.startCandidateEnrichment?.();
|
|
258
|
+
await candidateEnrichment;
|
|
259
|
+
base = candidateById.get(base.id);
|
|
260
|
+
head = candidateById.get(head.id);
|
|
261
|
+
if (base === undefined) {
|
|
262
|
+
throw new Error('Base selection did not identify an available source');
|
|
263
|
+
}
|
|
264
|
+
if (head === undefined) {
|
|
265
|
+
throw new Error('Head selection did not identify an available source');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (base.kind === 'worktree' && base.availability === 'unavailable') {
|
|
269
|
+
base = undefined;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (head.kind === 'worktree' && head.availability === 'unavailable') {
|
|
273
|
+
head = undefined;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return Object.freeze({ base, head });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
export async function pickSource(options, dependencies = {}) {
|
|
280
|
+
if (options.initialBase !== undefined || options.initialHead !== undefined) {
|
|
281
|
+
const result = await pickOrderedSources(options, dependencies);
|
|
282
|
+
return Object.freeze({ kind: 'committed', ...result });
|
|
283
|
+
}
|
|
284
|
+
let currentCandidates = options.candidates;
|
|
285
|
+
const candidateById = new Map(currentCandidates.map((candidate) => [candidate.id, candidate]));
|
|
286
|
+
const candidateEnrichment = options.candidateEnrichment?.then((enrichedCandidates) => {
|
|
287
|
+
currentCandidates = enrichedCandidates;
|
|
288
|
+
for (const candidate of enrichedCandidates) {
|
|
289
|
+
candidateById.set(candidate.id, candidate);
|
|
290
|
+
}
|
|
291
|
+
return enrichedCandidates;
|
|
292
|
+
});
|
|
293
|
+
void candidateEnrichment?.catch(() => undefined);
|
|
294
|
+
const prompt = dependencies.prompt ??
|
|
295
|
+
(async (config) => await search({
|
|
296
|
+
message: config.message,
|
|
297
|
+
source: config.source,
|
|
298
|
+
...(config.default === undefined ? {} : { default: config.default }),
|
|
299
|
+
}));
|
|
300
|
+
const selectedBaseId = await prompt({
|
|
301
|
+
message: BASE_PROMPT,
|
|
302
|
+
source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base', includeUncommittedGroup: true }, options.recovery?.role === 'base' ? options.recovery.searchTerm : ''),
|
|
303
|
+
...(options.recovery?.role === 'base' &&
|
|
304
|
+
options.recovery.focusedCandidateId !== undefined
|
|
305
|
+
? { default: options.recovery.focusedCandidateId }
|
|
306
|
+
: {}),
|
|
307
|
+
});
|
|
308
|
+
const uncommittedKind = selectedBaseId === undefined
|
|
309
|
+
? undefined
|
|
310
|
+
: UNCOMMITTED_ROW_KIND_BY_ID.get(selectedBaseId);
|
|
311
|
+
if (uncommittedKind !== undefined) {
|
|
312
|
+
if (uncommittedKind === 'unstaged') {
|
|
313
|
+
return Object.freeze({ kind: 'uncommitted', sourceKind: 'unstaged' });
|
|
314
|
+
}
|
|
315
|
+
const selectedSecondBaseId = await prompt({
|
|
316
|
+
message: UNCOMMITTED_BASE_PROMPT,
|
|
317
|
+
source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base' }),
|
|
318
|
+
...(options.preselectedBaseId === undefined
|
|
319
|
+
? {}
|
|
320
|
+
: { default: options.preselectedBaseId }),
|
|
321
|
+
});
|
|
322
|
+
const resolvedBase = selectedSecondBaseId === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: candidateById.get(selectedSecondBaseId);
|
|
325
|
+
if (resolvedBase === undefined) {
|
|
326
|
+
throw new Error('Base selection did not identify an available source');
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
kind: 'uncommitted',
|
|
330
|
+
sourceKind: uncommittedKind,
|
|
331
|
+
base: resolvedBase,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const initialBase = selectedBaseId === undefined
|
|
335
|
+
? undefined
|
|
336
|
+
: candidateById.get(selectedBaseId);
|
|
337
|
+
if (initialBase === undefined) {
|
|
338
|
+
throw new Error('Base selection did not identify an available source');
|
|
339
|
+
}
|
|
340
|
+
const result = await pickOrderedSources({ ...options, initialBase }, dependencies);
|
|
341
|
+
return Object.freeze({ kind: 'committed', ...result });
|
|
342
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { AgentReviewRequestSchema, MAX_AGENT_REQUEST_BYTES, } from '../contracts/request.js';
|
|
3
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
4
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
5
|
+
const requestErrorMessages = {
|
|
6
|
+
'empty-request': `Request input is empty. Pipe one ${RENAME_MAP.kindPrefix}.review-request JSON document.`,
|
|
7
|
+
'request-too-large': 'Request input exceeds 1048576 bytes. Reduce the request and try again.',
|
|
8
|
+
'invalid-utf8': 'Request input must be valid UTF-8 JSON.',
|
|
9
|
+
'malformed-json': 'Request input must contain one JSON document.',
|
|
10
|
+
'unsupported-version': 'Request schema version is unsupported. Use schemaVersion 1.',
|
|
11
|
+
'invalid-request': `Request is invalid. Use kind "${RENAME_MAP.kindPrefix}.review-request", schemaVersion 1, and one supported source mode.`,
|
|
12
|
+
'legacy-kind': `Request kind is outdated. Use kind "${RENAME_MAP.kindPrefix}.review-request" instead.`,
|
|
13
|
+
'unknown-mode': 'Request mode is not supported. Use revisions, patch, uncommitted, or files.',
|
|
14
|
+
'base-with-unstaged': '"base" cannot be combined with source "unstaged".',
|
|
15
|
+
'path-outside-repository': 'Request references a path outside the repository.',
|
|
16
|
+
'pathspec-no-match': 'Request pathspec does not match any file.',
|
|
17
|
+
'pathspec-ignored': 'Request pathspec matches only ignored files.',
|
|
18
|
+
};
|
|
19
|
+
export class AgentRequestError extends Error {
|
|
20
|
+
kind;
|
|
21
|
+
constructor(kind) {
|
|
22
|
+
super(requestErrorMessages[kind]);
|
|
23
|
+
this.name = 'AgentRequestError';
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// The literal old-kind string is an intentional, documented grep-audit exception
|
|
28
|
+
// (17.1-PATTERNS.md Pattern 1): it must match the exact pre-rename kind so requests
|
|
29
|
+
// still carrying it are recognized and rejected with a specific, named error.
|
|
30
|
+
function isLegacyKind(value) {
|
|
31
|
+
return (typeof value === 'object' &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
!Array.isArray(value) &&
|
|
34
|
+
'kind' in value &&
|
|
35
|
+
value.kind === 'cumpa.review-request');
|
|
36
|
+
}
|
|
37
|
+
function isUnsupportedVersion(value) {
|
|
38
|
+
return (typeof value === 'object' &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
!Array.isArray(value) &&
|
|
41
|
+
'schemaVersion' in value &&
|
|
42
|
+
value.schemaVersion !== 1);
|
|
43
|
+
}
|
|
44
|
+
function isUnknownMode(value) {
|
|
45
|
+
return (typeof value === 'object' &&
|
|
46
|
+
value !== null &&
|
|
47
|
+
!Array.isArray(value) &&
|
|
48
|
+
'mode' in value &&
|
|
49
|
+
typeof value.mode === 'string' &&
|
|
50
|
+
!['revisions', 'patch', 'uncommitted', 'files'].includes(value.mode));
|
|
51
|
+
}
|
|
52
|
+
function isBaseWithUnstaged(value) {
|
|
53
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || !('uncommitted' in value)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const uncommitted = value.uncommitted;
|
|
57
|
+
return (typeof uncommitted === 'object' &&
|
|
58
|
+
uncommitted !== null &&
|
|
59
|
+
!Array.isArray(uncommitted) &&
|
|
60
|
+
'source' in uncommitted &&
|
|
61
|
+
uncommitted.source === 'unstaged' &&
|
|
62
|
+
'base' in uncommitted &&
|
|
63
|
+
uncommitted.base !== undefined);
|
|
64
|
+
}
|
|
65
|
+
function freezeRequest(request) {
|
|
66
|
+
if (request.mode === 'revisions') {
|
|
67
|
+
Object.freeze(request.revisions.pathspecs);
|
|
68
|
+
Object.freeze(request.revisions);
|
|
69
|
+
}
|
|
70
|
+
else if (request.mode === 'patch') {
|
|
71
|
+
Object.freeze(request.patch.target);
|
|
72
|
+
Object.freeze(request.patch);
|
|
73
|
+
}
|
|
74
|
+
else if (request.mode === 'uncommitted') {
|
|
75
|
+
Object.freeze(request.uncommitted.pathspecs);
|
|
76
|
+
Object.freeze(request.uncommitted);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
Object.freeze(request.files.paths);
|
|
80
|
+
Object.freeze(request.files);
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze(request);
|
|
83
|
+
}
|
|
84
|
+
export async function readAgentReviewRequest(input) {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let byteLength = 0;
|
|
87
|
+
for await (const chunk of input) {
|
|
88
|
+
byteLength += chunk.byteLength;
|
|
89
|
+
if (byteLength > MAX_AGENT_REQUEST_BYTES) {
|
|
90
|
+
throw new AgentRequestError('request-too-large');
|
|
91
|
+
}
|
|
92
|
+
chunks.push(chunk);
|
|
93
|
+
}
|
|
94
|
+
if (byteLength === 0) {
|
|
95
|
+
throw new AgentRequestError('empty-request');
|
|
96
|
+
}
|
|
97
|
+
let value;
|
|
98
|
+
try {
|
|
99
|
+
value = JSON.parse(strictUtf8.decode(Buffer.concat(chunks, byteLength)));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof TypeError) {
|
|
103
|
+
throw new AgentRequestError('invalid-utf8');
|
|
104
|
+
}
|
|
105
|
+
throw new AgentRequestError('malformed-json');
|
|
106
|
+
}
|
|
107
|
+
const parsed = AgentReviewRequestSchema.safeParse(value);
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
throw new AgentRequestError(isLegacyKind(value)
|
|
110
|
+
? 'legacy-kind'
|
|
111
|
+
: isUnsupportedVersion(value)
|
|
112
|
+
? 'unsupported-version'
|
|
113
|
+
: isUnknownMode(value)
|
|
114
|
+
? 'unknown-mode'
|
|
115
|
+
: isBaseWithUnstaged(value)
|
|
116
|
+
? 'base-with-unstaged'
|
|
117
|
+
: 'invalid-request');
|
|
118
|
+
}
|
|
119
|
+
return freezeRequest(parsed.data);
|
|
120
|
+
}
|