heyquilt 0.3.121 → 0.3.122

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.
Files changed (48) hide show
  1. package/RELEASES.md +19 -0
  2. package/bin/quilt.js +23 -2
  3. package/package.json +2 -1
  4. package/src/api/memory-store.js +5 -1
  5. package/src/api/routes/seam-connect.js +103 -0
  6. package/src/api/routes/session-migrate.js +36 -0
  7. package/src/api/server.js +37 -9
  8. package/src/api/supabase-store.js +10 -0
  9. package/src/connection.js +4 -1
  10. package/src/control.js +12 -0
  11. package/src/history.js +15 -1
  12. package/src/mcp.js +12 -1
  13. package/src/quilts/accounts.js +136 -0
  14. package/src/quilts/affected.js +132 -0
  15. package/src/quilts/checks.js +308 -0
  16. package/src/quilts/cli.js +97 -10
  17. package/src/quilts/client.js +9 -0
  18. package/src/quilts/describe.js +31 -1
  19. package/src/quilts/events.js +13 -2
  20. package/src/quilts/export-remote.js +156 -0
  21. package/src/quilts/import-follow.js +236 -0
  22. package/src/quilts/import-git.js +6 -3
  23. package/src/quilts/import.js +67 -5
  24. package/src/quilts/index.js +28 -6
  25. package/src/quilts/keys.js +186 -0
  26. package/src/quilts/merge.js +16 -1
  27. package/src/quilts/oauth.js +202 -0
  28. package/src/quilts/reviews.js +18 -5
  29. package/src/quilts/routes.js +61 -1
  30. package/src/quilts/runner.js +95 -0
  31. package/src/quilts/server.js +144 -0
  32. package/src/quilts/service.js +41 -1
  33. package/src/quilts/setup.js +9 -2
  34. package/src/quilts/store-memory.js +164 -3
  35. package/src/quilts/store-rpc.js +12 -1
  36. package/src/quilts/tools.js +39 -11
  37. package/src/quilts/window.js +2 -0
  38. package/src/relay-mcp.js +33 -3
  39. package/src/relay-prune.js +4 -1
  40. package/src/server.js +317 -14
  41. package/src/session-follow.js +81 -0
  42. package/src/session-layout3.js +60 -0
  43. package/src/session-migrate.js +119 -0
  44. package/src/session-quilt.js +145 -0
  45. package/src/session-seam.js +71 -0
  46. package/src/session.js +74 -2
  47. package/src/source-live/live-block.js +4 -3
  48. package/src/source-live/live-square.js +1 -1
@@ -0,0 +1,236 @@
1
+ // The remote driver of a git import, and the follow (plans/quilt-source-control/14, "Who runs
2
+ // it and where" and "Importing a live repository"; 16 step 1). POST /v1/imports makes a new
3
+ // quilt and imports a git remote into it from a mirror in scratch, with the same code as the
4
+ // local driver (import-git.js). With follow: true the import then keeps following the remote's
5
+ // main and release/* every `interval` seconds (default 600, at least 60), or at once on a poke
6
+ // (POST /v1/imports/:id/poke, with the import's secret, so a git hook or a cron job can send it
7
+ // without a member's key). While it follows, those lines are frozen with reason import_follow:
8
+ // merges into them are refused with the import's id, because git is the authority. A rewritten
9
+ // ref stops the follow (state failed, error 'ref rewritten'). One import per quilt, never
10
+ // reopened (D19): the import's id is its quilt's id.
11
+ import fs from 'node:fs'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+ import crypto from 'node:crypto'
15
+ import { execFile } from 'node:child_process'
16
+ import { QuiltError } from './service.js'
17
+ import { buildPack } from './pack.js'
18
+ import { importGit } from './import-git.js'
19
+
20
+ export const POKE_PREFIX = 'sip_'
21
+ const hash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
22
+ const followed = (name) => name === 'main' || name.startsWith('release/')
23
+ const LEASE = ' import' // a line lease no line can hold: one pass at a time across servers
24
+
25
+ /** Who may read a remote: a URL a server may fetch (never a path on the server unless allowLocal). */
26
+ export function remoteUrl (raw, { allowLocal = false } = {}) {
27
+ const s = String(raw || '').trim()
28
+ if (/^(https?|ssh|git):\/\/[^\s]+$/i.test(s) || /^[\w.-]+@[\w.-]+:[^\s]+$/.test(s)) return s
29
+ if (allowLocal && (path.isAbsolute(s) || /^file:\/\//.test(s))) return s
30
+ throw new QuiltError(400, 'bad_request', 'url: a git remote (https://, ssh:// or user@host:path)')
31
+ }
32
+
33
+ export function run (args, { env = {}, cwd } = {}) {
34
+ return new Promise((resolve, reject) => {
35
+ execFile('git', args, { cwd, env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0', ...env }, maxBuffer: 64 << 20 }, (err, stdout, stderr) => {
36
+ if (err) return reject(new Error(String(stderr || err.message).trim().split('\n').pop()))
37
+ resolve(stdout)
38
+ })
39
+ })
40
+ }
41
+
42
+ /** The import's credential as git config: `user:token`, or a bare token as x-access-token's. */
43
+ export function authArgs (secret) {
44
+ if (!secret) return []
45
+ const pair = secret.includes(':') ? secret : `x-access-token:${secret}`
46
+ return ['-c', `http.extraHeader=Authorization: Basic ${Buffer.from(pair).toString('base64')}`]
47
+ }
48
+
49
+ /** importGit's client, in this process: the same operations the HTTP routes call. */
50
+ export function localClient (api, me) {
51
+ return {
52
+ quilt: (q) => api.quiltInfo(me, q),
53
+ createQuilt: (b) => api.createQuilt(me, b),
54
+ importKnown: (q, shas) => api.importKnown(me, q, { shas }),
55
+ lines: async (q) => (await api.lines(me, q)).lines,
56
+ tags: async (q) => (await api.tags(me, q)).tags,
57
+ createTag: (q, b) => api.createTag(me, q, b),
58
+ tree: (q, rev, paths) => api.tree(me, q, rev, { paths: paths || [''] }),
59
+ exportPatches: (q, o) => api.exportPatches(me, q, o),
60
+ blobs: (q, ids) => api.blobs(me, q, ids),
61
+ importCommits: (q, items, lines = []) => api.importBatch(me, q, JSON.parse(JSON.stringify({ items, lines }))),
62
+ async upload (q, objs) {
63
+ const missing = new Set()
64
+ for (let i = 0; i < objs.length; i += 10000) for (const id of (await api.have(me, q, objs.slice(i, i + 10000).map((o) => o.id))).missing) missing.add(id)
65
+ let batch = []; let size = 0
66
+ const flush = async () => { if (batch.length) await api.uploadPack(me, q, buildPack(batch).bytes); batch = []; size = 0 }
67
+ for (const o of objs.filter((x) => missing.has(x.id))) {
68
+ if (size + o.bytes.length > 48 * 1024 * 1024) await flush()
69
+ batch.push(o); size += o.bytes.length
70
+ }
71
+ await flush()
72
+ }
73
+ }
74
+ }
75
+
76
+ export function importFollowOps (svc, api, { scratch = path.join(os.tmpdir(), 'seam-imports'), allowLocal = false, secret = (name) => process.env[`SEAM_SECRET_${String(name).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`] || null, holder = `follower:${process.pid}:${crypto.randomBytes(4).toString('hex')}`, tickMs = 30_000 } = {}) {
77
+ const { store, now, quiltFor, fromStore } = svc.internals
78
+ const running = new Map() // quilt id → the pass in flight
79
+ const lastPass = new Map() // quilt id → when its last pass started, on this server
80
+ let timer = null
81
+
82
+ const view = (rec) => { const { poke_hash: _, ...rest } = rec; return { ...rest, quilt_id: rec.id } }
83
+ const save = async (qid, rec, opts = {}) => { try { return await store.setImport({ quilt_id: qid, import: rec, actor: rec.by.member_id, at: now(), ...opts }) } catch (err) { throw fromStore(err) } }
84
+
85
+ async function record (qid) {
86
+ const q = await store.getQuilt(qid)
87
+ return q?.settings?.import || null
88
+ }
89
+
90
+ /** One follow pass: fetch the remote into the mirror, import what is new, freeze what is followed. */
91
+ async function pass (qid) {
92
+ const rec = await record(qid)
93
+ if (!rec || !['reading', 'following'].includes(rec.state)) return
94
+ const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
95
+ if (!lease.held) return
96
+ lastPass.set(qid, now())
97
+ const was = rec.state
98
+ try {
99
+ const mirror = path.join(scratch, qid)
100
+ const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
101
+ const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
102
+ if (!fs.existsSync(path.join(mirror, 'HEAD'))) {
103
+ fs.mkdirSync(scratch, { recursive: true })
104
+ fs.rmSync(mirror, { recursive: true, force: true })
105
+ await run([...auth, 'clone', '--quiet', '--mirror', rec.source.url, mirror], { env })
106
+ } else {
107
+ await run([...auth, '-C', mirror, 'fetch', '--quiet', '--prune', 'origin'], { env })
108
+ }
109
+ const r = await importGit(localClient(api, rec.by), { repo: mirror, slug: qid, follow: true })
110
+ const lines = []
111
+ for (const l of await store.lines(qid)) {
112
+ if (!followed(l.name)) continue
113
+ lines.push({ line: l.name, patches: l.patch_count, tip_sha: l.tip ? await store.gitShaOf(qid, l.tip) : null })
114
+ }
115
+ const next = { ...rec, lines, passes: (rec.passes || 0) + 1, last_pass_at: now(), commits: (rec.commits || 0) + r.commits }
116
+ delete next.error
117
+ if (r.rewritten.length) Object.assign(next, { state: 'failed', error: 'ref rewritten', rewritten: r.rewritten, finished_at: now() })
118
+ else if (!rec.follow) Object.assign(next, { state: 'done', finished_at: now() })
119
+ else next.state = 'following'
120
+ // While git leads, every followed line stays frozen (a stopped follow too: nobody merges
121
+ // over git's history until the owner decides).
122
+ await save(qid, next, { freeze: rec.follow ? true : null, lines: lines.map((l) => l.line), expect_state: was })
123
+ } catch (err) {
124
+ // A first pass that fails ends the import; a later one keeps following and says why.
125
+ const cur = await record(qid)
126
+ if (cur && cur.state === was) await save(qid, was === 'reading' ? { ...cur, state: 'failed', error: err.message, finished_at: now() } : { ...cur, error: err.message }, { expect_state: was }).catch(() => {})
127
+ } finally {
128
+ await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
129
+ }
130
+ }
131
+
132
+ /** Runs a pass now, or joins the one in flight. */
133
+ function kick (qid) {
134
+ if (!running.has(qid)) running.set(qid, pass(qid).catch(() => {}).finally(() => running.delete(qid)))
135
+ return running.get(qid)
136
+ }
137
+
138
+ /** POST /v1/imports { url, slug, follow?, interval?, credential? } → the import, and its poke secret (shown once). */
139
+ async function startImport (me, { url, slug, follow = false, interval = 600, credential = null } = {}) {
140
+ const source = remoteUrl(url, { allowLocal })
141
+ const s = String(slug || '').toLowerCase()
142
+ let existing = null
143
+ try { existing = await quiltFor(me, s) } catch (err) { if (err.status !== 404) throw err }
144
+ if (existing) throw new QuiltError(409, existing.settings?.import ? 'import_closed' : 'slug_taken', existing.settings?.import ? `${s} already has its import (one import per quilt, never reopened)` : `you already have a quilt called ${s}`)
145
+ if (credential != null && !/^[A-Za-z0-9_.-]{1,100}$/.test(String(credential))) throw new QuiltError(400, 'bad_request', 'credential: the name of a secret, not the secret')
146
+ const q = await api.createQuilt(me, { slug: s, name: s, import: true })
147
+ const poke = POKE_PREFIX + crypto.randomBytes(24).toString('base64url')
148
+ const rec = { id: q.id, source: { url: source }, ...(credential ? { credential: String(credential) } : {}), follow: !!follow, interval_s: Math.max(60, Math.floor(Number(interval) || 600)), state: 'reading', lines: [], passes: 0, commits: 0, poke_hash: hash(poke), by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, started_at: now() }
149
+ // main is frozen before anything is imported, so nothing merges ahead of git.
150
+ await save(q.id, rec, { freeze: rec.follow ? true : null, lines: ['main'], expect_state: null })
151
+ kick(q.id)
152
+ return { ...view(rec), poke_secret: poke }
153
+ }
154
+
155
+ async function importStatus (me, id) {
156
+ const q = await quiltFor(me, id, 'read')
157
+ const rec = q.settings?.import
158
+ if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
159
+ return view(rec)
160
+ }
161
+
162
+ /** POST /v1/imports/:id/poke: a member who can write, or anyone with the import's secret. */
163
+ async function pokeImport (me, id, { secret: given } = {}) {
164
+ let rec
165
+ if (given) {
166
+ const q = /^[0-9a-f-]{36}$/i.test(String(id)) ? await store.getQuilt(String(id)) : null
167
+ rec = q?.settings?.import
168
+ if (!rec || !rec.poke_hash || !crypto.timingSafeEqual(Buffer.from(rec.poke_hash), Buffer.from(hash(given)))) throw new QuiltError(401, 'bad_token', 'this poke secret is not the import\'s')
169
+ } else {
170
+ const q = await quiltFor(me, id, 'write')
171
+ rec = q.settings?.import
172
+ if (!rec) throw new QuiltError(404, 'not_found', 'this quilt has no import')
173
+ }
174
+ if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${rec.state}, not following`)
175
+ kick(rec.id)
176
+ return { ok: true, id: rec.id, queued: true }
177
+ }
178
+
179
+ /**
180
+ * POST /v1/imports/:id/cutover (admin; 14 step 3, 16 step 3): a last follow pass, the remote's
181
+ * tips checked against the imported tips, then in one go: the follow ended for good (state
182
+ * done, closed), the followed lines unfrozen, and the export of main to the source turned on.
183
+ * Refuses (remote_moved, or conflict when a line moved in Seam) and leaves the follow as it was.
184
+ */
185
+ async function cutover (me, id, { credential } = {}) {
186
+ const q = await quiltFor(me, id, 'admin')
187
+ if (!q.settings?.import) throw new QuiltError(404, 'not_found', 'this quilt has no import')
188
+ if (q.settings.import.state !== 'following') throw new QuiltError(409, 'not_following', `the import is ${q.settings.import.state}, not following`)
189
+ await (running.get(q.id) || Promise.resolve())
190
+ await kick(q.id)
191
+ const rec = await record(q.id)
192
+ if (rec.state !== 'following') throw new QuiltError(409, 'not_following', `the last follow pass ended the import: ${rec.error || rec.state}`)
193
+ const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: q.id }) : null)
194
+ const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
195
+ const remote = new Map()
196
+ for (const row of (await run([...auth, 'ls-remote', rec.source.url, 'HEAD', 'refs/heads/release/*'], { env })).split('\n').filter(Boolean)) {
197
+ const [sha, ref] = row.split('\t')
198
+ remote.set(ref === 'HEAD' ? 'main' : ref.replace(/^refs\/heads\//, ''), sha)
199
+ }
200
+ const have = new Map(rec.lines.map((l) => [l.line, l.tip_sha]))
201
+ for (const [line, sha] of remote) if (have.get(line) !== sha) throw new QuiltError(409, 'remote_moved', `the remote's ${line} is ${sha.slice(0, 12)}, the import has ${have.has(line) ? String(have.get(line)).slice(0, 12) : 'no such line'}: stop pushes to the remote (branch protection), then cut over again`, { line, sha })
202
+ const tips = {}
203
+ for (const l of await store.lines(q.id)) if (followed(l.name)) tips[l.name] = l.tip
204
+ const done = { ...rec, state: 'done', closed: true, finished_at: now() }
205
+ delete done.error
206
+ await save(q.id, done, { freeze: false, lines: Object.keys(tips), expect_state: 'following', expect_tips: tips })
207
+ let exp
208
+ try {
209
+ exp = await api.setExport(me, q.id, { target: rec.source.url, credential: credential || rec.credential || null, lines: ['main'], releases: false })
210
+ } catch (err) {
211
+ // The export must be on before anything merges in Seam: put the follow back as it was.
212
+ await save(q.id, rec, { freeze: true, lines: Object.keys(tips), expect_state: 'done' }).catch(() => {})
213
+ throw err
214
+ }
215
+ return { ok: true, import: view(done), export: exp }
216
+ }
217
+
218
+ const follower = {
219
+ /** Every tick, a pass for each following import whose interval is up (on this server). */
220
+ start () {
221
+ if (timer) return
222
+ const tick = async () => {
223
+ let list = []
224
+ try { list = await store.importsFollowing() } catch { return }
225
+ for (const { quilt_id: qid, import: rec } of list) if (now() - (lastPass.get(qid) || 0) >= rec.interval_s * 1000) kick(qid)
226
+ api.exportTick?.().catch(() => {})
227
+ }
228
+ timer = setInterval(tick, tickMs)
229
+ timer.unref?.()
230
+ tick()
231
+ },
232
+ stop () { if (timer) clearInterval(timer); timer = null }
233
+ }
234
+
235
+ return { startImport, importStatus, pokeImport, cutover, importIdle: (id) => running.get(id) || Promise.resolve(), follower, importPass: kick }
236
+ }
@@ -115,9 +115,10 @@ function gitTags (repo) {
115
115
  * where only the commits it does not have yet are added. Returns { quilt, slug, commits,
116
116
  * patches, lines, tags, report, validated? }.
117
117
  */
118
- export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0, onlyHead = false, fresh = false }) {
118
+ export async function importGit (client, { repo, slug, batch = 100, log = () => {}, validate = false, limit = 0, onlyHead = false, fresh = false, follow = false }) {
119
119
  const head = git(repo, ['rev-parse', 'HEAD']).toString().trim()
120
- const refs = onlyHead ? [{ name: 'main', sha: head, git: 'HEAD' }] : branches(repo, head)
120
+ // follow: only main and release/* (14: the lines an import follows).
121
+ const refs = onlyHead ? [{ name: 'main', sha: head, git: 'HEAD' }] : branches(repo, head).filter((r) => !follow || r.name === 'main' || r.name.startsWith('release/'))
121
122
  const all = git(repo, ['rev-list', ...new Set(refs.map((r) => r.sha))]).toString().split('\n').filter(Boolean)
122
123
  const cat = catFile(repo)
123
124
  const report = []
@@ -150,6 +151,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
150
151
  let pending = []
151
152
  let commits = 0; let patches = 0
152
153
  const lines = []
154
+ const rewritten = [] // git branches whose line no longer follows them (a rewritten ref)
153
155
 
154
156
  const flush = async () => {
155
157
  if (!items.length && !newLines.length) return
@@ -211,6 +213,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
211
213
  lineTips.set(ref.name, base)
212
214
  } else if ((lineTips.get(ref.name) || null) !== base) {
213
215
  // The line moved on in the quilt (merges since the import), or git rewrote the branch.
216
+ rewritten.push(ref.name)
214
217
  if (chain.length) report.push({ kind: 'branch', ref: ref.git, note: `line ${ref.name} no longer follows git's branch; its ${chain.length} new commits were left out` })
215
218
  return
216
219
  }
@@ -282,7 +285,7 @@ export async function importGit (client, { repo, slug, batch = 100, log = () =>
282
285
  validated++
283
286
  }
284
287
  }
285
- return { quilt: quilt.id, slug: quilt.slug, again, commits, patches, lines, tags: tagged, report, ...(validate ? { validated } : {}), tip: lineTips.get('main') }
288
+ return { quilt: quilt.id, slug: quilt.slug, again, commits, patches, lines, tags: tagged, report, rewritten, ...(validate ? { validated } : {}), tip: lineTips.get('main') }
286
289
  } finally {
287
290
  cat.close()
288
291
  }
@@ -19,14 +19,44 @@ export function importLineName (raw) {
19
19
  }
20
20
 
21
21
  export function importOps (svc) {
22
- const { store, now, quiltFor, getCommit, treeGrower, seal, snapshot, fromStore } = svc.internals
22
+ const { store, now, quiltFor, getCommit, treeGrower, seal, snapshot, fromStore, squareByRef, squareView } = svc.internals
23
23
 
24
24
  /** The quilt, if an import made it: only those take imported commits. */
25
25
  async function importQuilt (me, ref) {
26
26
  const q = await quiltFor(me, ref, 'admin')
27
27
  const quilt = await store.getQuilt(q.id)
28
+ if (quilt.settings?.import?.closed) throw new QuiltError(409, 'import_closed', 'this quilt\'s import is over (cut-over): git content comes in only as a square now')
28
29
  if (!quilt.settings?.importing) throw new QuiltError(409, 'bad_request', 'this quilt was not made by an import (seam import git makes a new quilt, or adds to one it made)')
29
- return q
30
+ return { ...q, settings: quilt.settings }
31
+ }
32
+
33
+ /** Whether git still leads this quilt's line: an import follows it and has not cut over (14). */
34
+ const following = (settings, line) => {
35
+ const imp = settings?.import
36
+ return !!(imp && imp.follow && !imp.closed && imp.state !== 'done' && (line === 'main' || line.startsWith('release/')))
37
+ }
38
+
39
+ /**
40
+ * Squares during a follow (14, step 2): the open square on `line` whose base has the same tree
41
+ * as the imported commit's parent and whose head has the same tree as the commit. The oldest
42
+ * such square wins; one commit closes one square.
43
+ */
44
+ async function matchSquare (qid, line, parentRoot, root, taken) {
45
+ const open = (await store.squares(qid, { state: 'open' })).filter((s) => s.line === line && !s.import && s.head && !taken.has(s.id))
46
+ open.sort((x, y) => (x.opened_at || 0) - (y.opened_at || 0))
47
+ for (const s of open) {
48
+ if ((await snapshot(qid, s.base)) !== parentRoot) continue
49
+ if ((await snapshot(qid, s.head)) === root) return s
50
+ }
51
+ return null
52
+ }
53
+
54
+ /** Closes a square whose work reached git: merged, with how it was found and the imported commit. */
55
+ async function closeMerged (qid, sq, { line, patch_no: patchNo, commit, git_sha: gitSha, how }, actor) {
56
+ const merged = { line, patch_no: patchNo, commit, how, ...(gitSha ? { git_sha: gitSha } : {}) }
57
+ try {
58
+ return await store.setSquare({ square_id: sq.id, op: 'merged', expect: { state: 'open', head: sq.head || null }, actor, at: now(), patch: { state: 'merged', merged } })
59
+ } catch (err) { throw fromStore(err) }
30
60
  }
31
61
 
32
62
  /**
@@ -70,6 +100,7 @@ export function importOps (svc) {
70
100
  }
71
101
  const h = heads.get(ln)
72
102
  if ((p.c.parents[0] || null) !== (h.tip || null)) throw new QuiltError(422, 'bad_request', `patch ${p.id.slice(0, 12)} does not follow ${ln}'s tip`)
103
+ p.parentRoot = h.root
73
104
  const t = await grow(h.root, p.c.changes)
74
105
  h.root = t.root
75
106
  nodes.push(...t.objects)
@@ -89,6 +120,8 @@ export function importOps (svc) {
89
120
  }
90
121
  if (stubs.size) await store.addStubs({ quilt_id: q.id, stubs: [...stubs.values()], at: now() })
91
122
  let patches = 0; let commits = 0
123
+ const matched = []
124
+ const taken = new Set()
92
125
  for (const p of prepared) {
93
126
  if (p.done) continue
94
127
  const at = now()
@@ -102,9 +135,18 @@ export function importOps (svc) {
102
135
  if (p.it.kind === 'patch') {
103
136
  const ln = p.it.line || 'main'
104
137
  const cur = await store.getLine(q.id, ln)
105
- await store.merge({ commit: record, line: ln, expected_tip: cur.tip, expect_head: null, snapshot_root: p.root, actor: me.member_id, at, payload, import: true })
138
+ const m = await store.merge({ commit: record, line: ln, expected_tip: cur.tip, expect_head: null, snapshot_root: p.root, actor: me.member_id, at, payload, import: true })
106
139
  await store.fillLinePaths({ quilt_id: q.id })
107
140
  patches++
141
+ // While git leads, a square whose work came in through git is closed as matched.
142
+ if (following(q.settings, ln)) {
143
+ const sq = await matchSquare(q.id, ln, p.parentRoot, p.root, taken)
144
+ if (sq) {
145
+ taken.add(sq.id)
146
+ await closeMerged(q.id, sq, { line: ln, patch_no: m.patch_no, commit: p.id, git_sha: p.it.git_sha, how: 'matched' }, me.member_id)
147
+ matched.push({ square: sq.id, commit: p.id, ...(p.it.git_sha ? { git_sha: p.it.git_sha } : {}) })
148
+ }
149
+ }
108
150
  } else {
109
151
  await store.importCommit({ commit: record, actor: me.member_id, at, payload })
110
152
  }
@@ -112,7 +154,27 @@ export function importOps (svc) {
112
154
  if (p.it.git_sha) await store.setGitSha({ quilt_id: q.id, sha: p.it.git_sha, commit_id: p.id })
113
155
  }
114
156
  const after = await store.getLine(q.id, 'main')
115
- return { ok: true, commits, patches, tip: after.tip, patch_count: after.patch_count }
157
+ return { ok: true, commits, patches, tip: after.tip, patch_count: after.patch_count, ...(matched.length ? { matched } : {}) }
158
+ }
159
+
160
+ /**
161
+ * POST /squares/:id/merged { git_sha } (admin): closes an open square whose work a person put
162
+ * into git by hand and the follower could not match (14, step 2). merged.how = 'manual' and
163
+ * merged.commit is the imported commit of that sha, which keeps its own import square.
164
+ */
165
+ async function squareMerged (me, ref, sqRef, { git_sha: raw } = {}) {
166
+ const q = await quiltFor(me, ref, 'admin')
167
+ const quilt = await store.getQuilt(q.id)
168
+ if (!quilt.settings?.import) throw new QuiltError(409, 'bad_request', 'this quilt has no git import: a square merges with seam merge')
169
+ const sha = String(raw || '').toLowerCase()
170
+ if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new QuiltError(400, 'bad_request', 'give git_sha: the git commit that holds the square\'s work')
171
+ const sq = await squareByRef(q.id, sqRef)
172
+ if (sq.state !== 'open') throw new QuiltError(409, 'square_state', `the square is ${sq.state}`)
173
+ const id = await store.gitSha(q.id, sha)
174
+ if (!id) throw new QuiltError(404, 'not_found', `git commit ${sha.slice(0, 12)} is not imported yet: poke the import (seam import poke) and try again`)
175
+ const c = await getCommit(q.id, id)
176
+ if (!c.patch) throw new QuiltError(422, 'bad_request', `git commit ${sha.slice(0, 12)} is not on a line's first-parent history`)
177
+ return squareView(await closeMerged(q.id, sq, { line: c.patch.line, patch_no: c.patch.patch_no, commit: id, git_sha: sha, how: 'manual' }, me.member_id))
116
178
  }
117
179
 
118
180
  /** POST /import/known { shas }: which git commits this quilt already has → { known: { sha: commit id } }. */
@@ -130,5 +192,5 @@ export function importOps (svc) {
130
192
  return { ok: true, tip: line.tip, patch_count: line.patch_count, tip_commit: line.tip ? (await getCommit(q.id, line.tip)).id : null }
131
193
  }
132
194
 
133
- return { importBatch, importDone, importKnown }
195
+ return { importBatch, importDone, importKnown, squareMerged }
134
196
  }
@@ -14,33 +14,46 @@ import { lineOps } from './lines.js'
14
14
  import { tagOps } from './tags.js'
15
15
  import { claimOps } from './claims.js'
16
16
  import { reviewOps } from './reviews.js'
17
+ import { checkOps } from './checks.js'
17
18
  import { resolutionOps } from './resolutions.js'
18
19
  import { liveOps } from './live.js'
19
20
  import { historyOps } from './history.js'
20
21
  import { signingOps } from './signing.js'
21
22
  import { tokenOps } from './tokens.js'
23
+ import { accountOps } from './accounts.js'
24
+ import { keyOps } from './keys.js'
25
+ import { oauthOps } from './oauth.js'
22
26
  import { createLimiter } from './limits.js'
27
+ import { importFollowOps } from './import-follow.js'
28
+ import { exportRemoteOps } from './export-remote.js'
23
29
 
24
30
  // Writes after which the events job runs (in the background; GET /events runs it too).
25
- const WRITES = ['commit', 'amend', 'mergeIntoLine', 'rebase', 'cherryPick', 'revert', 'resolve', 'handoff', 'abandon', 'reopen', 'mergeSquare', 'fold', 'split', 'newLine', 'freezeLine', 'unfreezeLine', 'createTag', 'createRelease', 'reportCheck', 'review', 'setLinePolicy', 'reportSquareCheck']
31
+ const WRITES = ['commit', 'amend', 'mergeIntoLine', 'rebase', 'cherryPick', 'revert', 'resolve', 'handoff', 'abandon', 'squareMerged', 'reopen', 'mergeSquare', 'fold', 'split', 'newLine', 'freezeLine', 'unfreezeLine', 'createTag', 'createRelease', 'reportCheck', 'review', 'setLinePolicy', 'reportSquareCheck', 'rerunCheck']
26
32
 
27
- export function createQuilts ({ store, objects, now = Date.now, events: eventOpts = {}, archive: archiveOpts = {}, mergeWindow: windowOpts = {}, rateLimits = {} }) {
33
+ export function createQuilts ({ store, objects, now = Date.now, events: eventOpts = {}, archive: archiveOpts = {}, mergeWindow: windowOpts = {}, rateLimits = {}, importFollow = {}, siteUrl }) {
28
34
  const svc = createQuiltService({ store, objects, now })
29
35
  // The merge window (07): `mergeWindow: false` turns the check off, `{ max }` sets the default.
30
36
  const win = windowOps(svc, windowOpts || {})
31
37
  if (windowOpts !== false) svc.internals.mergeWindow = win.checkWindow
38
+ const checks = checkOps(svc)
39
+ svc.internals.checkRequests = checks.requestedBy
40
+ svc.internals.askForChecks = checks.askForMissing
41
+ svc.internals.noteVerdict = checks.noteVerdict
42
+ svc.internals.settleRuns = checks.settleRuns
43
+ svc.internals.applyCancels = checks.applyCancels
32
44
  const { guardReview, ...reviewApi } = reviewOps(svc)
33
45
  svc.internals.guardReview = guardReview
34
46
  const merges = mergeOps(svc)
35
47
  const { mergeInternals, ...mergeApi } = merges
36
48
  const squares = squareOps(svc, mergeInternals)
37
49
  const ev = eventOps(svc, squares, eventOpts)
50
+ svc.internals.pumpEvents = ev.pumpEvents
38
51
  // The log archiver (03): after each write, in the background, writes the segments that are due.
39
52
  const archiver = archiveOpts === false ? null : createArchiver({ store, objects, now, ...archiveOpts })
40
53
  const { checkWindow, ...windowApi } = win
41
54
  // Per-token rate limits (04), applied by the HTTP routes: `rateLimits: false` turns them off.
42
55
  const rateLimiter = rateLimits === false ? null : createLimiter({ now, ...rateLimits })
43
- const api = { archiver, rateLimiter, ...windowApi, ...svc, ...mergeApi, ...blameOps(svc), ...importOps(svc), ...exportOps(svc), ...lineOps(svc), ...tagOps(svc), ...claimOps(svc), ...reviewApi, ...resolutionOps(svc), ...tokenOps(svc), ...liveOps(svc), ...historyOps(svc, { mergeBase: squares.mergeBase }), ...squares, ...ev }
56
+ const api = { archiver, rateLimiter, ...windowApi, ...svc, ...mergeApi, ...blameOps(svc), ...importOps(svc), ...exportOps(svc), ...lineOps(svc), ...tagOps(svc), ...claimOps(svc), ...reviewApi, checkRuns: checks.checkRuns, rerunCheck: checks.rerunCheck, ...resolutionOps(svc), ...tokenOps(svc), ...accountOps(svc), ...keyOps(svc, siteUrl ? { siteUrl } : {}), ...oauthOps(svc), ...liveOps(svc), ...historyOps(svc, { mergeBase: squares.mergeBase }), ...squares, ...ev }
44
57
  // Signed commits (signing.js): log { show_signature } checks each commit's signature.
45
58
  const { withSignatures, ...signApi } = signingOps(svc)
46
59
  Object.assign(api, signApi)
@@ -57,14 +70,23 @@ export function createQuilts ({ store, objects, now = Date.now, events: eventOpt
57
70
  const n = body.patch == null ? null : Number(body.patch)
58
71
  if (n != null && (!Number.isInteger(n) || n < 1)) throw new QuiltError(400, 'bad_request', 'patch: the number of the patch on the line the check failed at')
59
72
  const w = await report(me, ref, line, body)
60
- if (n == null || body.status !== 'red') return w
73
+ if (n == null || body.status !== 'red' || w.advisory) return w
61
74
  return { ...w, revert: await auto.revertCulprit(me, ref, line, String(body.check).trim(), n) }
62
75
  }
76
+ // Imports from a git remote, and the follow (import-follow.js): follower.start() runs the passes.
77
+ Object.assign(api, importFollowOps(svc, api, importFollow || {}))
78
+ // Export to a git remote after each merge on its lines (export-remote.js), same scratch and secrets.
79
+ Object.assign(api, exportRemoteOps(svc, api, importFollow || {}))
63
80
  for (const name of WRITES) {
64
81
  const fn = api[name]
82
+ const after = (me, ref) => svc.internals.quiltFor(me, ref).then((q) => { archiver?.maybe(q.id); if (q.settings?.export?.enabled) api.exportPass(q.id); mergeApi.sweepMerges(q.id).catch(() => {}); return ev.pumpEvents(q.id) }).catch(() => {})
65
83
  api[name] = async (me, ref, ...rest) => {
66
- const r = await fn(me, ref, ...rest)
67
- svc.internals.quiltFor(me, ref).then((q) => { archiver?.maybe(q.id); mergeApi.sweepMerges(q.id).catch(() => {}); return ev.pumpEvents(q.id) }).catch(() => {})
84
+ let r
85
+ try { r = await fn(me, ref, ...rest) } catch (err) {
86
+ if (err?.status === 428) after(me, ref) // a refused merge may have asked runners for checks (checks.js)
87
+ throw err
88
+ }
89
+ after(me, ref)
68
90
  return r
69
91
  }
70
92
  }