heyquilt 0.3.122 → 0.3.138
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/RELEASES.md +273 -17
- package/bin/quilt.js +55 -9
- package/desktop/main.js +16 -2
- package/package.json +3 -2
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +16 -9
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/session-migrate.js +7 -4
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +13 -7
- package/src/api/supabase-store.js +43 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +36 -9
- package/src/control.js +129 -15
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +13 -3
- package/src/mcp.js +87 -12
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +151 -17
- package/src/relay-outbound.js +1 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1517 -86
- package/src/session-follow.js +461 -27
- package/src/session-layout3.js +410 -4
- package/src/session-migrate.js +15 -11
- package/src/session-quilt.js +1 -1
- package/src/session-seam.js +233 -20
- package/src/session.js +848 -67
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +504 -33
- package/src/source-live/live-square.js +16 -5
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/accounts.js +0 -136
- package/src/quilts/affected.js +0 -132
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/checks.js +0 -308
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -536
- package/src/quilts/client.js +0 -137
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -296
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -171
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export-remote.js +0 -156
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-follow.js +0 -236
- package/src/quilts/import-git.js +0 -294
- package/src/quilts/import.js +0 -196
- package/src/quilts/index.js +0 -94
- package/src/quilts/keys.js +0 -186
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -501
- package/src/quilts/oauth.js +0 -202
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -208
- package/src/quilts/routes.js +0 -186
- package/src/quilts/runner.js +0 -95
- package/src/quilts/server.js +0 -144
- package/src/quilts/service.js +0 -963
- package/src/quilts/setup.js +0 -41
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -844
- package/src/quilts/store-rpc.js +0 -90
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -403
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -121
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
package/src/quilts/records.js
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
// The immutable records of Quilt source control (plans/quilt-source-control/README §3.1):
|
|
2
|
-
// commits, their file changes and conflict terms, as JSON for the API and as canonical CBOR
|
|
3
|
-
// for their ids. A commit's id depends only on what its author made: no tree, no server time,
|
|
4
|
-
// no sequence number, no git hash.
|
|
5
|
-
import { encode, decode } from './cbor.js'
|
|
6
|
-
import { idOf, isId, idBytes } from './hash.js'
|
|
7
|
-
|
|
8
|
-
// gitlink: a git submodule, kept as git has it: its blob is the submodule commit's sha as text.
|
|
9
|
-
export const MODES = ['file', 'exec', 'symlink', 'quilt', 'gitlink']
|
|
10
|
-
export const TERM_KINDS = ['content', 'order', 'edit_vs_delete', 'name', 'mode', 'square_divergence']
|
|
11
|
-
export const MAX_FILES_PER_COMMIT = 20000
|
|
12
|
-
export const MAX_MESSAGE = 64 * 1024
|
|
13
|
-
|
|
14
|
-
const hex = (b) => Buffer.from(b).toString('hex')
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A path as the quilt keeps it: NFC, posix, relative, no empty, '.' or '..' segments, no
|
|
18
|
-
* trailing slash, never inside .git. Throws on anything else.
|
|
19
|
-
*/
|
|
20
|
-
export function cleanPath (p) {
|
|
21
|
-
if (typeof p !== 'string' || !p) throw new Error('a path is required')
|
|
22
|
-
const s = p.normalize('NFC')
|
|
23
|
-
if (s.includes('\\') || s.includes('\0')) throw new Error(`bad path ${JSON.stringify(p)}: use / and no NUL`)
|
|
24
|
-
const parts = s.split('/')
|
|
25
|
-
if (parts.some((x) => !x || x === '.' || x === '..')) throw new Error(`bad path ${JSON.stringify(p)}`)
|
|
26
|
-
if (parts[0] === '.git') throw new Error(`bad path ${JSON.stringify(p)}: .git is not part of a quilt`)
|
|
27
|
-
return s
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A path prefix (a block's directory): '' for the root, else a clean path. */
|
|
31
|
-
export function cleanPrefix (p) {
|
|
32
|
-
const s = String(p || '').replace(/^\/+|\/+$/g, '')
|
|
33
|
-
return s ? cleanPath(s) : ''
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Whether `path` lies under `prefix` (a directory, or the file itself). */
|
|
37
|
-
export const under = (path, prefix) => !prefix || path === prefix || path.startsWith(prefix + '/')
|
|
38
|
-
|
|
39
|
-
function side (s) {
|
|
40
|
-
return {
|
|
41
|
-
...(s.blob ? { b: idBytes(s.blob) } : {}),
|
|
42
|
-
...(s.mode ? { m: s.mode } : {}),
|
|
43
|
-
...(s.from_commit ? { fc: idBytes(s.from_commit) } : {}),
|
|
44
|
-
...(s.from_square ? { fs: s.from_square } : {}),
|
|
45
|
-
...(s.by ? { by: s.by } : {})
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
const unside = (s) => ({
|
|
49
|
-
...(s.b ? { blob: hex(s.b) } : {}),
|
|
50
|
-
...(s.m ? { mode: s.m } : {}),
|
|
51
|
-
...(s.fc ? { from_commit: hex(s.fc) } : {}),
|
|
52
|
-
...(s.fs ? { from_square: s.fs } : {}),
|
|
53
|
-
...(s.by ? { by: s.by } : {})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
/** A conflict term's canonical bytes (kind, N adds, N−1 removes, origin). */
|
|
57
|
-
export function termBytes (t) {
|
|
58
|
-
if (!TERM_KINDS.includes(t.kind)) throw new Error(`bad conflict kind ${t.kind}`)
|
|
59
|
-
if (!Array.isArray(t.adds) || !Array.isArray(t.removes) || t.adds.length !== t.removes.length + 1) throw new Error('a conflict term has N adds and N-1 removes')
|
|
60
|
-
return encode({
|
|
61
|
-
k: t.kind,
|
|
62
|
-
ad: t.adds.map(side),
|
|
63
|
-
rm: t.removes.map(side),
|
|
64
|
-
...(t.origin ? { or: { r: t.origin.reason, ...(t.origin.against ? { ag: idBytes(t.origin.against) } : {}) } } : {}),
|
|
65
|
-
// 07's suggestion: a ladder step that may not merge by itself (`words`) and the text it made.
|
|
66
|
-
...(t.suggestion ? { sg: { h: t.suggestion.how, b: idBytes(t.suggestion.blob) } } : {}),
|
|
67
|
-
// A name term's other paths (a file on a directory, or one case-folded name): kept side wins the name.
|
|
68
|
-
...(t.clash && t.clash.length ? { cl: [...t.clash].sort() } : {})
|
|
69
|
-
})
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function decodeTerm (bytes) {
|
|
73
|
-
const r = decode(bytes)
|
|
74
|
-
return {
|
|
75
|
-
kind: r.k,
|
|
76
|
-
adds: r.ad.map(unside),
|
|
77
|
-
removes: r.rm.map(unside),
|
|
78
|
-
...(r.or ? { origin: { reason: r.or.r, ...(r.or.ag ? { against: hex(r.or.ag) } : {}) } } : {}),
|
|
79
|
-
...(r.sg ? { suggestion: { how: r.sg.h, blob: hex(r.sg.b) } } : {}),
|
|
80
|
-
...(r.cl ? { clash: r.cl } : {})
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export const termId = (t) => idOf('term', termBytes(t))
|
|
85
|
-
|
|
86
|
-
/** Checks and normalises one FileChange (JSON form). */
|
|
87
|
-
export function cleanChange (c) {
|
|
88
|
-
const path = cleanPath(c.path)
|
|
89
|
-
if (c.op === 'delete') return { path, op: 'delete' }
|
|
90
|
-
if (c.op === 'put') {
|
|
91
|
-
if (!isId(c.blob)) throw new Error(`${path}: put needs a blob id`)
|
|
92
|
-
const mode = c.mode || 'file'
|
|
93
|
-
if (!MODES.includes(mode)) throw new Error(`${path}: bad mode ${mode}`)
|
|
94
|
-
if (!Number.isSafeInteger(c.size) || c.size < 0) throw new Error(`${path}: put needs a size`)
|
|
95
|
-
const out = { path, op: 'put', blob: c.blob, mode, size: c.size }
|
|
96
|
-
if (c.copy_from) out.copy_from = { path: cleanPath(c.copy_from.path), commit: c.copy_from.commit }
|
|
97
|
-
return out
|
|
98
|
-
}
|
|
99
|
-
if (c.op === 'conflict') {
|
|
100
|
-
if (!isId(c.term)) throw new Error(`${path}: conflict needs a term id`)
|
|
101
|
-
return { path, op: 'conflict', term: c.term }
|
|
102
|
-
}
|
|
103
|
-
throw new Error(`${path}: bad op ${c.op}`)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Sorts and checks a commit's changes: by path (bytewise, as the tree sorts), no duplicates. */
|
|
107
|
-
export function cleanChanges (changes) {
|
|
108
|
-
if (!Array.isArray(changes)) throw new Error('changes must be a list')
|
|
109
|
-
if (changes.length > MAX_FILES_PER_COMMIT) throw Object.assign(new Error(`too many files in one commit (${changes.length}; at most ${MAX_FILES_PER_COMMIT})`), { code: 'too_many_files' })
|
|
110
|
-
const out = changes.map(cleanChange).sort((a, b) => cmpPath(a.path, b.path))
|
|
111
|
-
for (let i = 1; i < out.length; i++) if (out[i].path === out[i - 1].path) throw new Error(`${out[i].path} is changed twice`)
|
|
112
|
-
return out
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Bytewise order of UTF-8 paths, the order of every tree and change list. */
|
|
116
|
-
export function cmpPath (a, b) {
|
|
117
|
-
return a === b ? 0 : Buffer.compare(Buffer.from(a), Buffer.from(b))
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function changeCbor (c) {
|
|
121
|
-
if (c.op === 'delete') return { p: c.path, o: 'delete' }
|
|
122
|
-
if (c.op === 'conflict') return { p: c.path, o: 'conflict', t: idBytes(c.term) }
|
|
123
|
-
return {
|
|
124
|
-
p: c.path, o: 'put', b: idBytes(c.blob), m: c.mode, z: c.size,
|
|
125
|
-
...(c.copy_from ? { cf: { p: c.copy_from.path, c: idBytes(c.copy_from.commit) } } : {})
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
function unchange (c) {
|
|
129
|
-
if (c.o === 'delete') return { path: c.p, op: 'delete' }
|
|
130
|
-
if (c.o === 'conflict') return { path: c.p, op: 'conflict', term: hex(c.t) }
|
|
131
|
-
return { path: c.p, op: 'put', blob: hex(c.b), mode: c.m, size: c.z, ...(c.cf ? { copy_from: { path: c.cf.p, commit: hex(c.cf.c) } } : {}) }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* A commit in its JSON form, checked and normalised: { quilt_id, square_id, parents,
|
|
136
|
-
* predecessor?, author: { member_id, kind, name, tool?, model? }, on_behalf_of?, task_id?,
|
|
137
|
-
* session_room?, message, authored_at, changes, stack, conflicted }.
|
|
138
|
-
*/
|
|
139
|
-
export function cleanCommit (c) {
|
|
140
|
-
if (!c.quilt_id || !c.square_id) throw new Error('a commit names its quilt and square')
|
|
141
|
-
const parents = c.parents || []
|
|
142
|
-
if (!parents.every(isId)) throw new Error('parents must be commit ids')
|
|
143
|
-
if (c.predecessor != null && !isId(c.predecessor)) throw new Error('predecessor must be a commit id')
|
|
144
|
-
const a = c.author || {}
|
|
145
|
-
if (!a.member_id || !['person', 'agent', 'system'].includes(a.kind)) throw new Error('author needs member_id and kind')
|
|
146
|
-
const message = String(c.message ?? '')
|
|
147
|
-
if (Buffer.byteLength(message) > MAX_MESSAGE) throw new Error('commit message over 64 KiB')
|
|
148
|
-
const changes = cleanChanges(c.changes || [])
|
|
149
|
-
const stack = {}
|
|
150
|
-
for (const [k, v] of Object.entries(c.stack || {})) if (v != null) stack[k] = String(v)
|
|
151
|
-
return {
|
|
152
|
-
quilt_id: String(c.quilt_id),
|
|
153
|
-
square_id: String(c.square_id),
|
|
154
|
-
parents: [...parents],
|
|
155
|
-
...(c.predecessor ? { predecessor: c.predecessor } : {}),
|
|
156
|
-
author: { member_id: String(a.member_id), kind: a.kind, name: String(a.name || ''), ...(a.tool ? { tool: String(a.tool) } : {}), ...(a.model ? { model: String(a.model) } : {}) },
|
|
157
|
-
...(c.on_behalf_of ? { on_behalf_of: String(c.on_behalf_of) } : {}),
|
|
158
|
-
...(c.task_id ? { task_id: String(c.task_id) } : {}),
|
|
159
|
-
...(c.session_room ? { session_room: String(c.session_room) } : {}),
|
|
160
|
-
message,
|
|
161
|
-
authored_at: Math.trunc(Number(c.authored_at) || 0),
|
|
162
|
-
changes,
|
|
163
|
-
stack,
|
|
164
|
-
conflicted: changes.filter((x) => x.op === 'conflict').length
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** A commit's canonical bytes. */
|
|
169
|
-
export function commitBytes (c) {
|
|
170
|
-
const a = c.author
|
|
171
|
-
return encode({
|
|
172
|
-
q: c.quilt_id,
|
|
173
|
-
s: c.square_id,
|
|
174
|
-
p: c.parents.map(idBytes),
|
|
175
|
-
pr: c.predecessor ? idBytes(c.predecessor) : undefined,
|
|
176
|
-
a: { m: a.member_id, k: a.kind, n: a.name, t: a.tool, md: a.model },
|
|
177
|
-
ob: c.on_behalf_of,
|
|
178
|
-
tk: c.task_id,
|
|
179
|
-
sr: c.session_room,
|
|
180
|
-
msg: c.message,
|
|
181
|
-
at: c.authored_at,
|
|
182
|
-
c: c.changes.map(changeCbor),
|
|
183
|
-
st: Object.keys(c.stack).length ? c.stack : undefined,
|
|
184
|
-
cn: c.conflicted
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function decodeCommit (bytes) {
|
|
189
|
-
const r = decode(bytes)
|
|
190
|
-
return {
|
|
191
|
-
quilt_id: r.q,
|
|
192
|
-
square_id: r.s,
|
|
193
|
-
parents: r.p.map(hex),
|
|
194
|
-
...(r.pr ? { predecessor: hex(r.pr) } : {}),
|
|
195
|
-
author: { member_id: r.a.m, kind: r.a.k, name: r.a.n, ...(r.a.t ? { tool: r.a.t } : {}), ...(r.a.md ? { model: r.a.md } : {}) },
|
|
196
|
-
...(r.ob ? { on_behalf_of: r.ob } : {}),
|
|
197
|
-
...(r.tk ? { task_id: r.tk } : {}),
|
|
198
|
-
...(r.sr ? { session_room: r.sr } : {}),
|
|
199
|
-
message: r.msg,
|
|
200
|
-
authored_at: r.at,
|
|
201
|
-
changes: r.c.map(unchange),
|
|
202
|
-
stack: r.st || {},
|
|
203
|
-
conflicted: r.cn
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** The commit's id, from its canonical bytes. */
|
|
208
|
-
export const commitId = (c) => idOf('commit', commitBytes(c))
|
|
209
|
-
|
|
210
|
-
/** A blob's id: BLAKE3 of the raw file bytes under the blob context. */
|
|
211
|
-
export const blobId = (bytes) => idOf('blob', bytes)
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// Remembered resolutions (plans/quilt-source-control/07, "Reuse", D12): when someone settles a
|
|
2
|
-
// conflicted file, seam keeps the answer keyed by the conflict's content: the path, the base and
|
|
3
|
-
// every side's bytes. When a later rebase, merge or fold meets the very same conflict (the usual
|
|
4
|
-
// case when a square is rebased again, or the same change is cherry-picked twice), the stored
|
|
5
|
-
// answer is applied and reported as `reuse`, naming who settled it first. Because the key is the
|
|
6
|
-
// content, a side that changed since cannot match, so a wrong reuse is impossible by construction.
|
|
7
|
-
// Kept per file, not per hunk: one key per file conflict is enough to never settle it twice, and
|
|
8
|
-
// it reads the same from the CLI, MCP and HTTP. A row stays until someone forgets it.
|
|
9
|
-
import { QuiltError } from './errors.js'
|
|
10
|
-
import { keyOf } from './hash.js'
|
|
11
|
-
|
|
12
|
-
const REUSABLE = new Set(['content', 'order', 'edit_vs_delete', 'mode'])
|
|
13
|
-
const sideKey = (s) => s && s.blob ? `${s.blob}:${s.mode || 'file'}` : '-'
|
|
14
|
-
|
|
15
|
-
/** The key of a conflict on `path` described by `term` ({ kind, adds, removes }), or null when it can't be remembered. */
|
|
16
|
-
export function resolutionKey (path, term) {
|
|
17
|
-
if (!term || !REUSABLE.has(term.kind) || term.clash) return null
|
|
18
|
-
const adds = (term.adds || []).map(sideKey).sort()
|
|
19
|
-
const base = sideKey((term.removes || [])[0])
|
|
20
|
-
return keyOf('resolution', Buffer.from(JSON.stringify([path, term.kind, base, adds]), 'utf8'))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The stored answer for this conflict, or null. */
|
|
24
|
-
export async function recall (store, q, path, term) {
|
|
25
|
-
const key = resolutionKey(path, term)
|
|
26
|
-
if (!key || !store.resolution) return null
|
|
27
|
-
return store.resolution(q, key)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Remembers settled conflicts: entries [{ path, term (id), pick: { blob, mode, size } | null, how }].
|
|
32
|
-
* Terms are read with `getTerm`; a term that can't be remembered (a name clash) is skipped.
|
|
33
|
-
*/
|
|
34
|
-
export async function remember (store, getTerm, q, entries, me, at) {
|
|
35
|
-
for (const e of entries) {
|
|
36
|
-
const t = await getTerm(q, e.term).catch(() => null)
|
|
37
|
-
const key = resolutionKey(e.path, t)
|
|
38
|
-
if (!key) continue
|
|
39
|
-
await store.setResolution({ quilt_id: q, key, path: e.path, blob: e.pick ? e.pick.blob : null, mode: e.pick ? e.pick.mode || 'file' : null, size: e.pick ? e.pick.size ?? null : null, how: e.how, by: me.member_id, at })
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const view = (r) => ({ key: r.key, short: r.key.slice(0, 12), path: r.path, answer: r.blob ? 'put' : 'delete', ...(r.blob ? { blob: r.blob, mode: r.mode, size: r.size } : {}), how: r.how, by: r.by, at: r.at })
|
|
44
|
-
|
|
45
|
-
export function resolutionOps (svc) {
|
|
46
|
-
const { store, quiltFor, fromStore } = svc.internals
|
|
47
|
-
|
|
48
|
-
/** GET /resolutions[?path=]: the remembered resolutions, newest first. */
|
|
49
|
-
async function resolutions (me, ref, { path } = {}) {
|
|
50
|
-
const q = await quiltFor(me, ref, 'read')
|
|
51
|
-
let rows = await store.resolutions(q.id)
|
|
52
|
-
if (path) rows = rows.filter((r) => r.path === path)
|
|
53
|
-
return { resolutions: rows.sort((a, b) => b.at - a.at).map(view) }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** DELETE /resolutions/:key (the key or its first 8+ characters): the conflict is asked about again next time. */
|
|
57
|
-
async function forgetResolution (me, ref, key) {
|
|
58
|
-
const q = await quiltFor(me, ref, 'write')
|
|
59
|
-
const k = String(key || '').toLowerCase()
|
|
60
|
-
if (!/^[0-9a-f]{8,64}$/.test(k)) throw new QuiltError(400, 'bad_request', 'name a resolution by its key (8 to 64 hex characters)')
|
|
61
|
-
const hits = (await store.resolutions(q.id)).filter((r) => r.key.startsWith(k))
|
|
62
|
-
if (!hits.length) throw new QuiltError(404, 'not_found', `no remembered resolution ${k}`)
|
|
63
|
-
if (hits.length > 1) throw new QuiltError(400, 'bad_request', `${k} names ${hits.length} resolutions; give more of the key`)
|
|
64
|
-
try { await store.deleteResolution({ quilt_id: q.id, key: hits[0].key }) } catch (err) { throw fromStore(err) }
|
|
65
|
-
return { ok: true, forgotten: view(hits[0]) }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return { resolutions, forgetResolution }
|
|
69
|
-
}
|
package/src/quilts/reviews.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
// Review on squares (plans/quilt-source-control/13): comments (on the square or on one line of a
|
|
2
|
-
// file at its head), approvals, requests for changes, and a line's rule that a square needs N
|
|
3
|
-
// approvals before it merges into that line. Every review row is kept; a member's latest verdict
|
|
4
|
-
// (approve, request_changes or dismiss) is the one that holds.
|
|
5
|
-
//
|
|
6
|
-
// An approval names the square's head when it was given and counts only while the head is the
|
|
7
|
-
// same: a new commit asks for a fresh look. Nobody approves their own square: the holder, whoever
|
|
8
|
-
// opened it, and the person either works for are the requester set, and an approval from that
|
|
9
|
-
// set is refused. A request for changes from someone whose approval would count holds the merge
|
|
10
|
-
// until they approve or dismiss it.
|
|
11
|
-
//
|
|
12
|
-
// A line can also require checks (the server-side hook: CI or any runner reports a named check
|
|
13
|
-
// green or red on a square's latest commit, and the merge is refused until each required check is
|
|
14
|
-
// green there). Seam does not run the checks; whoever runs them reports with a key.
|
|
15
|
-
import { QuiltError, squareShort } from './service.js'
|
|
16
|
-
import { uuidv7 } from './ids.js'
|
|
17
|
-
import { cleanRunners, cleanTiers, cleanGlobs } from './checks.js'
|
|
18
|
-
|
|
19
|
-
export const KINDS = ['comment', 'approve', 'request_changes', 'dismiss']
|
|
20
|
-
export const MAX_BODY = 20000
|
|
21
|
-
export const MAX_APPROVALS = 20
|
|
22
|
-
export const MAX_CHECKS = 20
|
|
23
|
-
const CHECK_NAME = /^[A-Za-z0-9._\/:-]{1,100}$/
|
|
24
|
-
|
|
25
|
-
/** The member ids that may not approve a square: its holder, its opener, and whom they act for. */
|
|
26
|
-
export function requesters (sq, actsFor = []) {
|
|
27
|
-
return new Set([sq.holder, sq.opened_by, ...actsFor].filter(Boolean))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Where a square stands against its line's rule, from its review rows (oldest first). */
|
|
31
|
-
export function standing (sq, rows, need = 0, not = requesters(sq)) {
|
|
32
|
-
const latest = new Map()
|
|
33
|
-
for (const r of rows) if (r.kind !== 'comment') latest.set(r.author, r)
|
|
34
|
-
const counts = (r) => !not.has(r.author) && !(r.on_behalf_of && not.has(r.on_behalf_of))
|
|
35
|
-
const approvedBy = []
|
|
36
|
-
const changesBy = []
|
|
37
|
-
for (const r of latest.values()) {
|
|
38
|
-
if (!counts(r)) continue
|
|
39
|
-
if (r.kind === 'approve' && r.head === (sq.head || null)) approvedBy.push(r.author)
|
|
40
|
-
if (r.kind === 'request_changes') changesBy.push(r.author)
|
|
41
|
-
}
|
|
42
|
-
return { need, have: approvedBy.length, approved_by: approvedBy, changes_requested_by: changesBy, ready: approvedBy.length >= need && !changesBy.length }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const view = (sq, r) => ({
|
|
46
|
-
id: r.id,
|
|
47
|
-
square: squareShort(r.square_id),
|
|
48
|
-
kind: r.kind,
|
|
49
|
-
author: r.author,
|
|
50
|
-
...(r.on_behalf_of ? { on_behalf_of: r.on_behalf_of } : {}),
|
|
51
|
-
body: r.body || '',
|
|
52
|
-
head: r.head || null,
|
|
53
|
-
...(r.path ? { path: r.path, line: r.line ?? null } : {}),
|
|
54
|
-
outdated: (r.head || null) !== (sq.head || null),
|
|
55
|
-
at: r.at
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
export function reviewOps (svc) {
|
|
59
|
-
const { store, now, quiltFor, squareByRef, getCommit, fromStore } = svc.internals
|
|
60
|
-
|
|
61
|
-
// The requester set: holder, opener, and the person the square's latest commit was made for.
|
|
62
|
-
async function notAllowed (q, sq) {
|
|
63
|
-
const c = sq.head ? await getCommit(q, sq.head).catch(() => null) : null
|
|
64
|
-
return requesters(sq, [c?.on_behalf_of])
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function ruleOf (q, line) {
|
|
68
|
-
const r = (await store.lineRules(q)).find((x) => x.line === line)
|
|
69
|
-
const tg = r?.targeting || {}
|
|
70
|
-
return { approvals: r ? r.approvals : 0, checks: r?.checks || [], runners: r?.runners || {}, ...(tg.tiers && Object.keys(tg.tiers).length ? { tiers: tg.tiers } : {}), ...(tg.tests?.length ? { tests: tg.tests } : {}), ...(tg.ci_config?.length ? { ci_config: tg.ci_config } : {}), since_seq: r?.since_seq ?? null }
|
|
71
|
-
}
|
|
72
|
-
const need = async (q, line) => (await ruleOf(q, line)).approvals
|
|
73
|
-
|
|
74
|
-
// The square's checks against what its line requires: each named check's latest report, and
|
|
75
|
-
// whether it is green for the square's latest commit.
|
|
76
|
-
async function checksOf (q, sq, required) {
|
|
77
|
-
const reports = await store.squareChecks(q, sq.id)
|
|
78
|
-
const names = [...new Set([...required, ...reports.map((r) => r.name)])]
|
|
79
|
-
return names.map((name) => {
|
|
80
|
-
const r = reports.find((x) => x.name === name)
|
|
81
|
-
const state = !r ? 'missing' : r.head !== (sq.head || null) ? 'stale' : r.status
|
|
82
|
-
return { name, required: required.includes(name), status: state, ...(r ? { by: r.by, at: r.at } : {}) }
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** GET /squares/:id/reviews: every review row, oldest first, and where the square stands. */
|
|
87
|
-
async function reviews (me, ref, sqRef) {
|
|
88
|
-
const q = await quiltFor(me, ref, 'read')
|
|
89
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
90
|
-
const rows = await store.reviews(q.id, sq.id) // in the order they were written
|
|
91
|
-
const rule = await ruleOf(q.id, sq.line)
|
|
92
|
-
const s = standing(sq, rows, rule.approvals, await notAllowed(q.id, sq))
|
|
93
|
-
const checks = await checksOf(q.id, sq, rule.checks)
|
|
94
|
-
const checksGreen = checks.every((c) => !c.required || c.status === 'green')
|
|
95
|
-
return { square: squareShort(sq.id), line: sq.line, head: sq.head || null, reviews: rows.map((r) => view(sq, r)), ...s, checks, ready: s.ready && checksGreen }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** POST /squares/:id/checks { check, status }: a check's result for the square's latest commit (a writer: CI with a key). */
|
|
99
|
-
async function reportSquareCheck (me, ref, sqRef, body = {}) {
|
|
100
|
-
const q = await quiltFor(me, ref, 'write')
|
|
101
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
102
|
-
const name = String(body.check || '').trim()
|
|
103
|
-
if (!CHECK_NAME.test(name)) throw new QuiltError(400, 'bad_request', 'check: a name of up to 100 letters, digits, dots, dashes, underscores, slashes or colons')
|
|
104
|
-
if (!['green', 'red'].includes(body.status)) throw new QuiltError(400, 'bad_request', 'status is green or red')
|
|
105
|
-
if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to check yet')
|
|
106
|
-
const head = body.head || sq.head
|
|
107
|
-
if (head !== sq.head) throw new QuiltError(409, 'head_moved', 'the square moved on since that commit: check its latest commit', { head: sq.head })
|
|
108
|
-
const rule = await ruleOf(q.id, sq.line)
|
|
109
|
-
const noted = svc.internals.noteVerdict ? await svc.internals.noteVerdict(me, q, { name, tier: 'pre_merge', commit: head, square: sq.id, line: sq.line, verdict: body.status, run_key: body.run_key, url: body.url }) : {}
|
|
110
|
-
if (noted.advisory) return { square: squareShort(sq.id), check: name, status: body.status, head, advisory: true, runners: rule.runners[name], checks: await checksOf(q.id, sq, rule.checks) }
|
|
111
|
-
try { await store.setSquareCheck({ quilt_id: q.id, square_id: sq.id, name, status: body.status, head, by: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
|
|
112
|
-
return { square: squareShort(sq.id), check: name, status: body.status, head, checks: await checksOf(q.id, sq, rule.checks) }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** POST /squares/:id/reviews { kind, body?, path?, line? }: a comment or a verdict on the square's head. */
|
|
116
|
-
async function review (me, ref, sqRef, body = {}) {
|
|
117
|
-
const q = await quiltFor(me, ref, 'read')
|
|
118
|
-
const sq = await squareByRef(q.id, sqRef)
|
|
119
|
-
const kind = body.kind || 'comment'
|
|
120
|
-
if (!KINDS.includes(kind)) throw new QuiltError(400, 'bad_request', `kind is one of ${KINDS.join(', ')}`)
|
|
121
|
-
const text = String(body.body || '')
|
|
122
|
-
if (text.length > MAX_BODY) throw new QuiltError(400, 'bad_request', `a review is up to ${MAX_BODY} characters`)
|
|
123
|
-
if ((kind === 'comment' || kind === 'request_changes') && !text.trim()) throw new QuiltError(400, 'bad_request', kind === 'comment' ? 'say something: body is empty' : 'say what to change: body is empty')
|
|
124
|
-
if (kind !== 'comment' && !['open', 'merging'].includes(sq.state)) throw new QuiltError(409, 'square_closed', `the square is ${sq.state}`)
|
|
125
|
-
if (kind === 'approve' || kind === 'request_changes') {
|
|
126
|
-
if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to review yet')
|
|
127
|
-
const not = await notAllowed(q.id, sq)
|
|
128
|
-
if (not.has(me.member_id) || (me.on_behalf_of && not.has(me.on_behalf_of))) {
|
|
129
|
-
throw new QuiltError(403, 'own_square', `you cannot ${kind === 'approve' ? 'approve' : 'request changes on'} a square you or the person you work for opened or hold: ask someone else to review it`)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
let path = null
|
|
133
|
-
let line = null
|
|
134
|
-
if (body.path != null && body.path !== '') {
|
|
135
|
-
if (kind !== 'comment') throw new QuiltError(400, 'bad_request', 'only a comment goes on a file')
|
|
136
|
-
path = String(body.path).replace(/^\.\//, '')
|
|
137
|
-
if (path.length > 1000 || path.split('/').includes('..') || path.startsWith('/')) throw new QuiltError(400, 'bad_request', 'path is a file path in the quilt')
|
|
138
|
-
if (body.line != null) {
|
|
139
|
-
line = Number(body.line)
|
|
140
|
-
if (!Number.isInteger(line) || line < 1) throw new QuiltError(400, 'bad_request', 'line is a line number from 1')
|
|
141
|
-
}
|
|
142
|
-
} else if (body.line != null) throw new QuiltError(400, 'bad_request', 'a line number goes with a path')
|
|
143
|
-
const row = { quilt_id: q.id, id: uuidv7(now()), square_id: sq.id, kind, author: me.member_id, on_behalf_of: me.on_behalf_of || null, body: text, head: sq.head || null, path, line, at: now() }
|
|
144
|
-
try { await store.addReview(row) } catch (err) { throw fromStore(err) }
|
|
145
|
-
const rows = await store.reviews(q.id, sq.id)
|
|
146
|
-
return { review: view(sq, row), ...standing(sq, rows, await need(q.id, sq.line), await notAllowed(q.id, sq)) }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** PUT /lines/:name/policy { approvals }: how many approvals a square needs to merge into the line. A person who is an admin only. */
|
|
150
|
-
async function setLinePolicy (me, ref, lineName, body = {}) {
|
|
151
|
-
const q = await quiltFor(me, ref, 'admin')
|
|
152
|
-
if (me.kind !== 'person') throw new QuiltError(403, 'forbidden', 'only a person sets what a line needs to merge')
|
|
153
|
-
const line = String(lineName)
|
|
154
|
-
if (!await store.getLine(q.id, line)) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
155
|
-
const was = await ruleOf(q.id, line)
|
|
156
|
-
const n = body.approvals === undefined ? was.approvals : Number(body.approvals)
|
|
157
|
-
if (!Number.isInteger(n) || n < 0 || n > MAX_APPROVALS) throw new QuiltError(400, 'bad_request', `approvals is a whole number from 0 to ${MAX_APPROVALS}`)
|
|
158
|
-
let checks = was.checks
|
|
159
|
-
if (body.checks !== undefined) {
|
|
160
|
-
if (!Array.isArray(body.checks) || body.checks.length > MAX_CHECKS) throw new QuiltError(400, 'bad_request', `checks is a list of up to ${MAX_CHECKS} check names`)
|
|
161
|
-
checks = [...new Set(body.checks.map((c) => String(c).trim()))]
|
|
162
|
-
const bad = checks.find((c) => !CHECK_NAME.test(c))
|
|
163
|
-
if (bad !== undefined) throw new QuiltError(400, 'bad_request', `${bad || '(empty)'} is not a check name: letters, digits, dots, dashes, underscores, slashes or colons`)
|
|
164
|
-
}
|
|
165
|
-
const runners = body.runners === undefined ? was.runners : cleanRunners(body.runners, (c) => CHECK_NAME.test(c))
|
|
166
|
-
const tiers = body.tiers === undefined ? (was.tiers || {}) : cleanTiers(body.tiers, (c) => CHECK_NAME.test(c))
|
|
167
|
-
const tests = body.tests === undefined ? (was.tests || []) : cleanGlobs(body.tests, 'tests')
|
|
168
|
-
const ciConfig = body.ci_config === undefined ? (was.ci_config || []) : cleanGlobs(body.ci_config, 'ci_config')
|
|
169
|
-
const targeting = { ...(Object.keys(tiers).length ? { tiers } : {}), ...(tests.length ? { tests } : {}), ...(ciConfig.length ? { ci_config: ciConfig } : {}) }
|
|
170
|
-
try { await store.setLineRule({ quilt_id: q.id, line, approvals: n, checks, runners, targeting, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
|
|
171
|
-
return { line, approvals: n, checks, runners, ...targeting }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** GET /lines/:name/policy */
|
|
175
|
-
async function linePolicy (me, ref, lineName) {
|
|
176
|
-
const q = await quiltFor(me, ref, 'read')
|
|
177
|
-
const line = String(lineName)
|
|
178
|
-
if (!await store.getLine(q.id, line)) throw new QuiltError(404, 'not_found', `no line ${line}`)
|
|
179
|
-
const { since_seq: seq, ...rule } = await ruleOf(q.id, line)
|
|
180
|
-
void seq
|
|
181
|
-
return { line, ...rule }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Refuses a merge of `sq` into `line` that the line's rule does not allow yet (428 policy). */
|
|
185
|
-
async function guardReview (q, sq, line) {
|
|
186
|
-
const rule = await ruleOf(q, line)
|
|
187
|
-
const n = rule.approvals
|
|
188
|
-
if (!n && !rule.checks.length) return
|
|
189
|
-
const s = n ? standing(sq, await store.reviews(q, sq.id), n, await notAllowed(q, sq)) : { ready: true, have: 0, changes_requested_by: [] }
|
|
190
|
-
const notGreen = rule.checks.length ? (await checksOf(q, sq, rule.checks)).filter((c) => c.required && c.status !== 'green') : []
|
|
191
|
-
if (notGreen.length) {
|
|
192
|
-
// Asking to merge asks the runners of a required check that has nothing on the head yet.
|
|
193
|
-
if (svc.internals.askForChecks) await svc.internals.askForChecks(q, sq, notGreen.filter((x) => x.status !== 'red').map((x) => x.name), sq.holder)
|
|
194
|
-
const c = notGreen[0]
|
|
195
|
-
const why = { missing: 'has not reported', stale: 'has not reported on the latest commit', red: 'is red' }[c.status]
|
|
196
|
-
throw new QuiltError(428, 'policy', `${line} needs check ${c.name} green on ${squareShort(sq.id)}'s latest commit and it ${why}: run it and report with seam check ${c.name} green --square ${squareShort(sq.id)}`,
|
|
197
|
-
{ needs: notGreen.map((x) => ({ rule: 'check', kind: 'check', check: x.name, status: x.status })) })
|
|
198
|
-
}
|
|
199
|
-
if (s.ready) return
|
|
200
|
-
const needs = []
|
|
201
|
-
if (s.have < n) needs.push({ rule: 'approvals', kind: 'approval', need: n, have: s.have })
|
|
202
|
-
if (s.changes_requested_by.length) needs.push({ rule: 'changes_requested', kind: 'approval', who: s.changes_requested_by })
|
|
203
|
-
const why = s.have < n ? `${line} needs ${n} approval${n === 1 ? '' : 's'} of the square's latest commit and it has ${s.have}` : `${s.changes_requested_by.join(', ')} asked for changes`
|
|
204
|
-
throw new QuiltError(428, 'policy', `${why}: ask someone to review ${squareShort(sq.id)} (seam review ${squareShort(sq.id)})`, { needs })
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return { reviews, review, reportSquareCheck, setLinePolicy, linePolicy, guardReview }
|
|
208
|
-
}
|