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
package/src/quilts/cli.js CHANGED
@@ -6,16 +6,43 @@ import path from 'node:path'
6
6
  import fs from 'node:fs'
7
7
  import os from 'node:os'
8
8
  import { quiltClient } from './client.js'
9
+ import { runChecks } from './runner.js'
9
10
  import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, restore, reset, clean, localChanges } from './block.js'
10
- import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken } from './describe.js'
11
+ import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeImportRecord, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeResolutions, describeLiveSet, describeMergeBase, describeGrep, describeBisect, describeCheck, describeReviews, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken, describeCheckRuns, describeRerun } from './describe.js'
11
12
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
12
13
 
13
- export const API_URL = 'https://api.heyquilt.com'
14
+ export const API_URL = 'https://api.seam.heyquilt.com'
14
15
 
15
16
  /** The verbs `seam` answers. */
16
- export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'claim', 'unclaim', 'check', 'resolutions', 'review', 'sign', 'key', 'token'])
17
+ export const SEAM_VERBS = new Set(['quilts', 'square', 'checkout', 'status', 'commit', 'log', 'show', 'diff', 'merge', 'rebase', 'revert', 'cherry-pick', 'blame', 'resolve', 'cat', 'ls', 'import', 'export', 'events', 'tag', 'release', 'line', 'restore', 'reset', 'clean', 'live', 'bisect', 'grep', 'describe', 'merge-base', 'claim', 'unclaim', 'check', 'checks', 'resolutions', 'review', 'sign', 'key', 'token', 'runner'])
17
18
 
18
- const VALUE_FLAGS = new Set(['-m', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key'])
19
+ /** `ci=agent:ci,lint=agent:a+agent:b` → { ci: ['agent:ci'], lint: ['agent:a', 'agent:b'] }; `none` clears them. */
20
+ export function parseRunners (text) {
21
+ if (text === 'none') return null
22
+ const out = {}
23
+ for (const part of text.split(',').map((x) => x.trim()).filter(Boolean)) {
24
+ const i = part.indexOf('=')
25
+ if (i < 1) throw new Error(`--runners: <check>=<member>[+<member>], e.g. ci=agent:ci (got ${part})`)
26
+ out[part.slice(0, i)] = part.slice(i + 1).split('+').map((m) => m.trim()).filter(Boolean)
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** `e2e=post_merge,ci=pre_merge+post_merge` → { e2e: ['post_merge'], ci: ['pre_merge', 'post_merge'] }; `none` clears them. */
32
+ export function parseTiers (text) {
33
+ if (text === 'none') return null
34
+ const out = {}
35
+ for (const part of text.split(',').map((x) => x.trim()).filter(Boolean)) {
36
+ const i = part.indexOf('=')
37
+ if (i < 1) throw new Error(`--tiers: <check>=<tier>[+<tier>], e.g. e2e=post_merge (got ${part})`)
38
+ out[part.slice(0, i)] = part.slice(i + 1).split('+').map((m) => m.trim()).filter(Boolean)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /** `test/**,**\/*.test.js` → a list; `none` clears it. */
44
+ export const parseGlobs = (text) => text === 'none' ? null : text.split(',').map((x) => x.trim()).filter(Boolean)
45
+ const VALUE_FLAGS = new Set(['-m', '--quilts', '--message', '-n', '--max-count', '--into', '--onto', '--on-conflict', '--quilt', '--square', '--dir', '--line', '--task', '--level', '--title', '--slug', '--format', '--expect-tip', '--side', '--file', '--batch', '--to', '--note', '--after', '--kinds', '--webhook', '--secret', '--branch', '--from', '-S', '--run', '--marks', '--patch', '--on', '--path', '--checks', '--name', '--public-key', '--interval', '--credential', '--as', '--runners', '--run-key', '--state', '--tier', '--tiers', '--tests', '--ci-config', '--commit', '--check', '--log-url'])
19
46
 
20
47
  export function parse (argv) {
21
48
  const pos = []; const flags = {}; const paths = []
@@ -81,11 +108,18 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
81
108
  if (sub === 'require') {
82
109
  if (!pos[1]) throw new Error('Which line? seam line require <line> [<approvals>] [--checks ci,lint]')
83
110
  const checks = flags['--checks'] === undefined ? undefined : String(flags['--checks']).split(',').map((c) => c.trim()).filter(Boolean)
84
- if (pos[2] === undefined && checks === undefined) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
85
- const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}) })
111
+ // --runners ci=agent:ci,lint=agent:ci+agent:lint: who runs each check (their verdicts count; anyone else's is advisory)
112
+ const runners = flags['--runners'] === undefined ? undefined : parseRunners(String(flags['--runners']))
113
+ // --tiers e2e=post_merge: where each check runs; --tests / --ci-config: globs for the affected set
114
+ const tiers = flags['--tiers'] === undefined ? undefined : parseTiers(String(flags['--tiers']))
115
+ const tests = flags['--tests'] === undefined ? undefined : parseGlobs(String(flags['--tests']))
116
+ const ciConfig = flags['--ci-config'] === undefined ? undefined : parseGlobs(String(flags['--ci-config']))
117
+ const extra = { ...(tiers !== undefined ? { tiers } : {}), ...(tests !== undefined ? { tests } : {}), ...(ciConfig !== undefined ? { ci_config: ciConfig } : {}) }
118
+ if (pos[2] === undefined && checks === undefined && runners === undefined && !Object.keys(extra).length) { const p = await client.linePolicy(needQuilt(), pos[1]); return print(p, describePolicy(p)) }
119
+ const p = await client.setLinePolicy(needQuilt(), pos[1], { ...(pos[2] !== undefined ? { approvals: Number(pos[2]) } : {}), ...(checks ? { checks } : {}), ...(runners !== undefined ? { runners } : {}), ...extra })
86
120
  return print(p, describePolicy(p))
87
121
  }
88
- throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint]')
122
+ throw new Error('seam line [list] | new release/<name> [--from <rev>] | freeze <line> | unfreeze <line> | require <line> [<approvals>] [--checks ci,lint] [--runners ci=<member>] [--tiers e2e=post_merge] [--tests test/**] [--ci-config ops/**]')
89
123
  }
90
124
  case 'tag':
91
125
  case 'release': {
@@ -126,6 +160,12 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
126
160
  // seam square handoff [sq] --to <member> [--note text] | abandon [sq] | reopen [sq]
127
161
  if (sub === 'handoff') { const sq = await client.handoff(needQuilt(), pos[1] || needSquare(), { to: flags['--to'], note: flags['--note'] }); return print(sq, `Handed ${sq.short || sq.id} to ${sq.holder}.`) }
128
162
  if (sub === 'abandon') { const sq = await client.abandon(needQuilt(), pos[1] || needSquare()); return print(sq, `Abandoned ${sq.short || sq.id}; seam square reopen brings it back.`) }
163
+ // seam square merged [sq] --as <git sha>: closes a square whose work went into git by hand (admins, during a follow)
164
+ if (sub === 'merged') {
165
+ if (!flags['--as']) throw new Error('Which git commit holds the work? seam square merged [sq:id] --as <sha>')
166
+ const sq = await client.squareMerged(needQuilt(), pos[1] || needSquare(), { git_sha: flags['--as'] })
167
+ return print(sq, `${sq.short || sq.id} is merged by hand: patch ${sq.merged.patch_no} on ${sq.merged.line} (git ${sq.merged.git_sha.slice(0, 12)}).`)
168
+ }
129
169
  if (sub === 'reopen') { const sq = await client.reopen(needQuilt(), pos[1] || needSquare()); return print(sq, `Reopened ${sq.short || sq.id}; you hold it.`) }
130
170
  // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> [--title t]
131
171
  if (sub === 'fold') {
@@ -164,6 +204,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
164
204
  const r = await checkout(client, { quilt: needQuilt(), ...(isSquare ? { square: target } : { rev: target }), paths, dir, api })
165
205
  return print({ dir, files: r.files, block: { ...r.block, files: undefined } }, `Checked out ${r.files} file(s) of ${isSquare ? target : target} under ${r.block.paths.map((p) => p || '/').join(', ')} into ${dir}.`)
166
206
  }
207
+ case 'runner': {
208
+ // seam runner --check ci[,lint] --run "npm test" [--dir <folder>] [--interval <s>] [--log-url <template>] [--once] [--keep]
209
+ const checks = String(flags['--check'] || '').split(',').map((c) => c.trim()).filter(Boolean)
210
+ if (!checks.length || !flags['--run'] || flags['--run'] === true) throw new Error('seam runner --check <name>[,<name>] --run "<command>" [--dir <folder>] [--interval <seconds>] [--log-url <template>] [--once]')
211
+ const q = needQuilt()
212
+ const say = (l) => { if (!flags['--json']) console.log(l) }
213
+ if (!flags['--once']) say(`seam runner: running ${checks.join(', ')} for ${q} with: ${flags['--run']} (Ctrl-C to stop)`)
214
+ const done = await runChecks({ client, quilt: q, checks, run: String(flags['--run']), api, once: !!flags['--once'], keep: !!flags['--keep'], ...(flags['--dir'] ? { dir: path.resolve(cwd, flags['--dir']) } : {}), ...(flags['--interval'] ? { intervalMs: Math.max(1, Number(flags['--interval'])) * 1000 } : {}), ...(flags['--log-url'] ? { logUrl: String(flags['--log-url']) } : {}), say })
215
+ return print({ runs: done }, done.length ? `Ran ${done.length} check run(s).` : 'Nothing queued.')
216
+ }
167
217
  case 'status': {
168
218
  if (!found) throw new Error('Not in a block folder (no .seam/block.json here or above).')
169
219
  const st = await blockStatus(client, found.dir, block)
@@ -190,15 +240,26 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
190
240
  case 'check': {
191
241
  // seam check <name> red|green|flaky [--line main] [--patch n] | seam check <name> green|red --square <sq>
192
242
  const [name, status] = pos
243
+ if (name === 'rerun') {
244
+ // seam check rerun <check> --square <sq> | --line <line> [--commit <id>]: ask its runners again
245
+ if (!status) throw new Error('seam check rerun <check> --square <square> | --line <line> [--commit <id>]')
246
+ const r = await client.rerunCheck(needQuilt(), { check: status, ...(flags['--square'] ? { square: flags['--square'] } : { line: flags['--line'] || 'main' }), ...(flags['--commit'] ? { commit: flags['--commit'] } : {}) })
247
+ return print(r, describeRerun(r))
248
+ }
193
249
  if (!name || !['red', 'green', 'flaky'].includes(status)) throw new Error('seam check <name> red|green|flaky [--line <line>] [--patch <n>]')
194
250
  if (flags['--square']) {
195
251
  // seam check <name> green|red --square <sq>: a result for a square's latest commit (a line may require it before a merge)
196
- const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status })
252
+ const r = await client.reportSquareCheck(needQuilt(), flags['--square'], { check: name, status, ...(flags['--run-key'] ? { run_key: flags['--run-key'] } : {}) })
197
253
  return print(r, describeSquareCheck(r))
198
254
  }
199
- const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}) })
255
+ const r = await client.reportCheck(needQuilt(), flags['--line'] || 'main', { check: name, status, ...(flags['--patch'] != null ? { patch: Number(flags['--patch']) } : {}), ...(flags['--run-key'] ? { run_key: flags['--run-key'] } : {}) })
200
256
  return print(r, describeCheck(r))
201
257
  }
258
+ case 'checks': {
259
+ // seam checks [--state queued] [--name ci] [--tier pre_merge|post_merge] [--line main] [--square sq]: the quilt's check runs
260
+ const r = await client.checkRuns(needQuilt(), { state: flags['--state'], name: flags['--name'], tier: flags['--tier'], line: flags['--line'], square: flags['--square'] })
261
+ return print(r, describeCheckRuns(r))
262
+ }
202
263
  case 'unclaim': {
203
264
  // seam unclaim <pattern|claim id>...
204
265
  if (!pos.length) throw new Error('Which claim? seam unclaim <pattern>')
@@ -431,7 +492,23 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
431
492
  return print(r, r.entries.map((e) => `${e.mode}\t${e.blob ? e.blob.slice(0, 12) : e.term.slice(0, 12)}\t${e.path}`).join('\n'))
432
493
  }
433
494
  case 'import': {
434
- if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate]')
495
+ // From a git remote, on the server, and following it (14): status and poke name the import.
496
+ if (pos[0] === 'cutover') {
497
+ if (!pos[1]) throw new Error('seam import cutover <import-id> [--credential <name>]')
498
+ const r = await client.cutover(pos[1], { credential: flags['--credential'] || undefined })
499
+ return print(r, `Cut over: import ${r.import.id} is done for good, its lines take merges in Seam, and main exports to ${r.export.target} after each merge.\nseam export status --quilt ${r.import.id}`)
500
+ }
501
+ if (pos[0] === 'status' || pos[0] === 'poke') {
502
+ if (!pos[1]) throw new Error(`seam import ${pos[0]} <import-id>`)
503
+ const r = pos[0] === 'status' ? await client.importStatus(pos[1]) : await client.pokeImport(pos[1])
504
+ return print(r, pos[0] === 'poke' ? `Poked: import ${r.id} fetches the remote now.` : describeImportRecord(r))
505
+ }
506
+ if (pos[0] === 'git' && pos[1] && (flags['--follow'] || flags['--remote'] || /^(https?|ssh|git):\/\/|^[\w.-]+@[\w.-]+:/.test(pos[1]))) {
507
+ const slug = flags['--into'] || path.basename(String(pos[1]).replace(/\.git$/, '').replace(/\/+$/, '')).toLowerCase().replace(/[^a-z0-9-]/g, '-')
508
+ const r = await client.startImport({ url: pos[1], slug, follow: !!flags['--follow'], interval: flags['--interval'] ? Number(flags['--interval']) : undefined, credential: flags['--credential'] || undefined })
509
+ return print(r, `Importing ${pos[1]} into ${slug} on the server (import ${r.id}).${r.follow ? ` It follows main and release/* every ${r.interval_s} s; they are frozen until cut-over.\nPoke it from a git hook or cron job with: curl -X POST -H 'Authorization: Bearer ${r.poke_secret}' <server>/v1/imports/${r.id}/poke (the secret is shown once)` : ''}\nseam import status ${r.id}`)
510
+ }
511
+ if (pos[0] !== 'git' || !pos[1]) throw new Error('seam import git <path> [--into <quilt-slug>] [--validate] | seam import git <remote-url> [--into <slug>] [--follow] [--interval <s>] [--credential <name>] | seam import status|poke <import-id>')
435
512
  const { importGit } = await import('./import-git.js')
436
513
  const repo = path.resolve(cwd, pos[1])
437
514
  if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
@@ -439,6 +516,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
439
516
  return print(r, describeImport(r))
440
517
  }
441
518
  case 'export': {
519
+ // To a git remote, on the server, after each merge (14): seam export remote <url> | status.
520
+ if (pos[0] === 'remote' && pos[1]) {
521
+ const r = await client.setExport(needQuilt(), { target: pos[1], lines: flags['--line'] ? String(flags['--line']).split(',') : undefined, releases: !!flags['--releases'], credential: flags['--credential'] || undefined, scratch: !!flags['--scratch'], enabled: !flags['--off'] })
522
+ return print(r, r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge${r.releases ? ', with release tags' : ''}. seam export status` : `The export to ${r.target} is off.`)
523
+ }
524
+ if (pos[0] === 'status') {
525
+ const r = await client.exportStatus(needQuilt())
526
+ const st = r.status || {}
527
+ return print(r, [`Export to ${r.target} (${r.lines.join(', ')}${r.releases ? ', releases' : ''}${r.enabled ? '' : ', off'}): ${st.state || 'idle'}${st.error ? ` - ${st.error}` : ''}`, ...Object.entries(st.lines || {}).map(([l, x]) => ` ${l}: patch ${x.patch_no} -> ${String(x.sha || '').slice(0, 12)}`)].join('\n'))
528
+ }
442
529
  if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
443
530
  const { exportGit } = await import('./export-git.js')
444
531
  const r = await exportGit(client, { q: needQuilt(), dir: path.resolve(cwd, pos[1]), line: flags['--line'] || 'main', branch: flags['--branch'] || undefined, log: (s) => { if (!json) out(s) } })
@@ -48,6 +48,7 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
48
48
  amend: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/amend`, b),
49
49
  handoff: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/handoff`, b),
50
50
  abandon: (q, s, b = {}) => call('POST', `${Q(q)}/squares/${enc(s)}/abandon`, b),
51
+ squareMerged: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merged`, b),
51
52
  reopen: (q, s) => call('POST', `${Q(q)}/squares/${enc(s)}/reopen`, {}),
52
53
  mergeSquare: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/merge`, b),
53
54
  fold: (q, s, b) => call('POST', `${Q(q)}/squares/${enc(s)}/fold`, b),
@@ -91,6 +92,8 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
91
92
  reviews: (q, sq) => call('GET', `${Q(q)}/squares/${enc(sq)}/reviews`),
92
93
  review: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/reviews`, b),
93
94
  reportSquareCheck: (q, sq, b) => call('POST', `${Q(q)}/squares/${enc(sq)}/checks`, b),
95
+ rerunCheck: (q, { check, square, line, commit }) => call('POST', square ? `${Q(q)}/squares/${enc(square)}/checks/${enc(check)}/rerun` : `${Q(q)}/lines/${enc(line || 'main')}/checks/${enc(check)}/rerun`, commit ? { commit } : {}),
96
+ checkRuns: (q, o = {}) => call('GET', `${Q(q)}/checks${qs(o)}`),
94
97
  linePolicy: (q, line) => call('GET', `${Q(q)}/lines/${enc(line)}/policy`),
95
98
  setLinePolicy: (q, line, b) => call('PUT', `${Q(q)}/lines/${enc(line)}/policy`, b),
96
99
  claims: async (q, square) => (await call('GET', `${Q(q)}/claims${square ? `?square=${enc(square)}` : ''}`)).claims,
@@ -106,6 +109,12 @@ export function quiltClient ({ api, token, fetch: fetchImpl = globalThis.fetch,
106
109
  uploadPack: (q, bytes) => call('POST', `${Q(q)}/packs`, bytes, { raw: true }),
107
110
  importCommits: (q, items, lines = []) => call('POST', `${Q(q)}/import/commits`, { items, ...(lines.length ? { lines } : {}) }),
108
111
  importKnown: (q, shas) => call('POST', `${Q(q)}/import/known`, { shas }),
112
+ setExport: (q, b) => call('PUT', `${Q(q)}/export`, b),
113
+ exportStatus: (q) => call('GET', `${Q(q)}/export`),
114
+ startImport: (b) => call('POST', '/v1/imports', b),
115
+ importStatus: (id) => call('GET', `/v1/imports/${enc(id)}`),
116
+ cutover: (id, b = {}) => call('POST', `/v1/imports/${enc(id)}/cutover`, b),
117
+ pokeImport: (id) => call('POST', `/v1/imports/${enc(id)}/poke`),
109
118
 
110
119
  /**
111
120
  * Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most
@@ -117,6 +117,8 @@ export function describeEvents (r) {
117
117
  const p = e.payload || {}
118
118
  if (e.kind === 'patch.merged') return `#${e.event_seq} patch.merged: patch ${e.patch_no} by ${e.by.member_id} "${first(p.message || '')}" changed ${e.paths.join(', ')} in your square ${e.square_short}${p.would_conflict ? `; would conflict in ${p.would_conflict_paths.join(', ')}` : '; merges cleanly'}`
119
119
  if (e.kind === 'conflict.added') return `#${e.event_seq} conflict.added: ${p.path} (${p.kind}) in ${e.square_short}${p.caused_by && p.caused_by.patch_no ? `, against patch ${p.caused_by.patch_no}` : ''}; settle it and merge again`
120
+ if (e.kind === 'check.cancelled') return `#${e.event_seq} check.cancelled: stop ${p.check} (${p.tier}) on ${p.commit.slice(0, 12)}, run_key ${p.run_key}: ${p.superseded_by} replaces it`
121
+ if (e.kind === 'check.requested') return `#${e.event_seq} check.requested: run ${p.check} (${p.tier}) on ${p.commit.slice(0, 12)}${e.square_short ? ` in ${e.square_short}` : ` at ${e.line}:${p.patch_no}`}; report with run_key ${p.run_key}`
120
122
  if (e.kind === 'square.handoff') return `#${e.event_seq} square.handoff: ${e.square_short} from ${p.from} to ${p.to}${p.note ? `: ${p.note}` : ''}`
121
123
  return `#${e.event_seq} ${e.kind}`
122
124
  }
@@ -195,6 +197,7 @@ export function describeClaims (claims) {
195
197
 
196
198
  export function describeCheck (r) {
197
199
  const lines = [`${r.line}: merge window ${r.window} an hour${r.red.length ? ` (red: ${r.red.join(', ')})` : ''}.`]
200
+ if (r.advisory) lines.push('Your verdict is advisory: you are not one of the check\'s runners, so it changes nothing.')
198
201
  const v = r.revert
199
202
  if (v && v.reverted) lines.push(`Reverted patch ${r.line}:${v.patch_no} as patch ${r.line}:${v.revert_patch_no}.${v.fix_square ? ` The change is again in square ${v.fix_square.short}, held by ${v.fix_square.holder}: fix it there, commit, merge.` : ''}`)
200
203
  else if (v) lines.push(`Did not revert patch ${r.line}:${v.patch_no}: ${(v.refused && (v.refused.detail || v.refused.code)) || 'refused'}.${v.square ? ` The revert waits in square ${v.square.short}.` : ''}`)
@@ -238,11 +241,32 @@ export function describePolicy (p) {
238
241
  const parts = []
239
242
  if (p.approvals) parts.push(`${p.approvals} approval${p.approvals === 1 ? '' : 's'} of a square's latest commit from someone other than its holder`)
240
243
  if (p.checks?.length) parts.push(`check${p.checks.length === 1 ? '' : 's'} ${p.checks.join(', ')} green on it`)
241
- return parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`
244
+ const run = Object.entries(p.runners || {}).map(([c, ms]) => `${c} by ${ms.join(', ')}`)
245
+ const runs = run.length ? ` Runners: ${run.join('; ')} (asked to run their checks; anyone else's verdict is advisory).` : ''
246
+ const tiers = Object.entries(p.tiers || {}).map(([c, ts]) => `${c} ${ts.length ? ts.join(' and ') : 'never'}`)
247
+ const tg = (tiers.length ? ` Tiers: ${tiers.join('; ')}.` : '') +
248
+ (p.tests?.length ? ` Tests: ${p.tests.join(', ')} (a pre-merge run names the ones a change reaches).` : '') +
249
+ (p.ci_config?.length ? ` CI config: ${p.ci_config.join(', ')} (a change there runs everything).` : '')
250
+ return (parts.length ? `${p.line} needs ${parts.join(' and ')} to take a merge.` : `${p.line} takes merges without approvals or checks.`) + runs + tg
251
+ }
252
+
253
+ /** A run's affected set in a few words. */
254
+ const affectedWords = (a) => !a ? '' : a.confidence === 'all' ? ' runs all' : ` runs ${a.tests.length} test${a.tests.length === 1 ? '' : 's'}`
255
+
256
+ /** Check runs, newest first: one per line. */
257
+ export function describeCheckRuns (r) {
258
+ if (!r.runs.length) return 'No check runs.'
259
+ return r.runs.map((x) => `${x.name} ${x.tier} ${x.state}${x.verdict ? ` ${x.verdict}` : ''}${x.advisory ? ' (advisory)' : ''}${affectedWords(x.affected)} ${x.commit.slice(0, 12)}${x.square ? ` ${x.square}` : ` ${x.line}${x.patch_no != null ? `:${x.patch_no}` : ''}`} run_key ${x.run_key}${x.by ? ` by ${x.by}` : ''}`).join('\n')
260
+ }
261
+
262
+ /** A check asked to run again. */
263
+ export function describeRerun (r) {
264
+ return `Asked ${r.check}'s runners to run it again (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at the tip of ${r.line}`}: run_key ${r.run_key}. An open run of it there is cancelled (check.cancelled).`
242
265
  }
243
266
 
244
267
  /** A check reported on a square. */
245
268
  export function describeSquareCheck (r) {
269
+ if (r.advisory) return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}), advisory: you are not one of its runners (${(r.runners || []).join(', ')}), so it gates nothing.`
246
270
  const req = r.checks.filter((c) => c.required)
247
271
  const left = req.filter((c) => c.status !== 'green').map((c) => `${c.name} ${c.status}`)
248
272
  return `${r.check} is ${r.status} on ${r.square} (${r.head.slice(0, 12)}).${req.length ? (left.length ? ` Its line still needs: ${left.join(', ')}.` : ' Every check its line requires is green.') : ''}`
@@ -264,3 +288,9 @@ export function describeTokens (r) {
264
288
  }
265
289
 
266
290
  export const describeNewToken = (t) => `Made token ${t.name} (${t.level} on ${t.quilt}, id ${t.id}). Its secret, shown only this once:\n\n ${t.token}\n\nSet it as SEAM_TOKEN (or send it as Authorization: Bearer <token>).`
291
+
292
+ /** An import record (14) in a few lines. */
293
+ export function describeImportRecord (r) {
294
+ const lines = (r.lines || []).map((l) => ` ${l.line}: ${l.patches} patches, git ${l.tip_sha ? l.tip_sha.slice(0, 12) : '(none)'}`)
295
+ return [`Import ${r.id} of ${r.source?.url}: ${r.state}${r.follow ? `, following every ${r.interval_s} s` : ''}${r.error ? ` (${r.error})` : ''}`, ...lines].join('\n')
296
+ }
@@ -6,7 +6,7 @@
6
6
  import { QuiltError, squareShort } from './service.js'
7
7
  import { makeSubscription, deliverWebhook, RETRY_DELAYS_MS } from './webhook.js'
8
8
 
9
- export const QUILT_EVENT_KINDS = ['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff']
9
+ export const QUILT_EVENT_KINDS = ['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'check.requested', 'check.cancelled']
10
10
  const MAX_DIFF_PATHS = 200
11
11
 
12
12
  export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays = RETRY_DELAYS_MS, log = () => {} } = {}) {
@@ -24,6 +24,8 @@ export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays =
24
24
  /** The events one log row causes. */
25
25
  async function derive (qid, row) {
26
26
  const out = []
27
+ // check.requested (13, checks.js): to the runners a line's policy names.
28
+ if (svc.internals.checkRequests) out.push(...await svc.internals.checkRequests(qid, row))
27
29
  if (row.kind === 'merge' && row.payload?.reverts) {
28
30
  // patch.reverted: the reverted patch's holder (and whom they acted for) learns it is gone (07 step 4).
29
31
  const c = await getCommit(qid, row.commit)
@@ -93,10 +95,19 @@ export function eventOps (svc, sq, { allowLocalWebhooks = false, fetch, delays =
93
95
  const cursor = await store.eventCursor(qid)
94
96
  const rows = await store.logRows(qid, { after: cursor, limit: 200 })
95
97
  if (!rows.length) return made
96
- const events = []
98
+ let events = []
97
99
  for (const r of rows) events.push(...await derive(qid, r))
100
+ // A new check request cancels what it supersedes (checks.js settleRuns).
101
+ let cancels = []
102
+ if (svc.internals.settleRuns) ({ events, cancels } = await svc.internals.settleRuns(qid, events))
103
+ // A check.requested event's run record is stored first (idempotent on run_key), so a
104
+ // runner polling GET /checks finds every run it is woken for.
105
+ const runs = events.filter((e) => e.run).map((e) => e.run)
106
+ if (runs.length) await store.addCheckRuns({ quilt_id: qid, runs })
107
+ for (const e of events) delete e.run
98
108
  try {
99
109
  made.push(...await store.addEvents({ quilt_id: qid, events, expect_cursor: cursor, cursor: rows.at(-1).seq }))
110
+ if (cancels.length) await svc.internals.applyCancels(qid, cancels)
100
111
  } catch (err) { if (err.code !== 'cursor_moved') throw err }
101
112
  }
102
113
  })
@@ -0,0 +1,156 @@
1
+ // Export to a git remote (plans/quilt-source-control/14, "Export"): `settings.export` names a
2
+ // remote (any: GitHub, GitLab, a bare repository), a credential the owner gives by name, the
3
+ // lines to export (names, or `release/*`) and whether releases go out as tags. After each merge
4
+ // on those lines the server writes the new patches as 14's commits (export.js and export-git.js:
5
+ // deterministic, so the same patch gives the same sha on any machine) in a bare repository in
6
+ // scratch and pushes them, fast-forward only: a remote that moved is refused, never forced, and
7
+ // the status says which line and sha. Failures to reach the remote retry with backoff. A
8
+ // quilt that an import made continues git's own history when the target is the import's source:
9
+ // the first exported patch's parent is the last imported sha (14, cut-over). While its import
10
+ // follows git, the export may go only to a scratch remote that is not the source.
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 { QuiltError } from './service.js'
16
+ import { exportGit } from './export-git.js'
17
+ import { remoteUrl, run, authArgs, localClient } from './import-follow.js'
18
+
19
+ const LEASE = ' export'
20
+ const matches = (patterns, line) => patterns.some((p) => p === line || (p.endsWith('/*') && line.startsWith(p.slice(0, -1))))
21
+ const BACKOFF = [30_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000]
22
+
23
+ export function exportRemoteOps (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 = `exporter:${process.pid}:${crypto.randomBytes(4).toString('hex')}` } = {}) {
24
+ const { store, now, quiltFor, fromStore } = svc.internals
25
+ const running = new Map()
26
+ const again = new Set() // a write came in while a pass ran: run once more after it
27
+ const retryAt = new Map() // quilt id → when to try again (this server)
28
+
29
+ const save = async (qid, rec, actor, logged = false) => { try { return await store.setExport({ quilt_id: qid, export: rec, logged, actor, at: now() }) } catch (err) { throw fromStore(err) } }
30
+
31
+ /** PUT /v1/quilts/:q/export { target, credential?, lines?, releases?, scratch?, enabled? } (admin). */
32
+ async function setExport (me, ref, { target, credential = null, lines = ['main'], releases = false, scratch: isScratch = false, enabled = true } = {}) {
33
+ const q = await quiltFor(me, ref, 'admin')
34
+ const url = remoteUrl(target, { allowLocal })
35
+ const list = (Array.isArray(lines) ? lines : [lines]).map(String)
36
+ if (!list.length || list.some((l) => !/^[A-Za-z0-9._-][A-Za-z0-9._/-]{0,99}(\/\*)?$/.test(l))) throw new QuiltError(400, 'bad_request', 'lines: line names, or a prefix such as release/*')
37
+ 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')
38
+ const imp = q.settings?.import
39
+ if (imp && imp.follow && ['reading', 'following'].includes(imp.state)) {
40
+ // 14: never push to the source while git is the authority, and only to a remote nothing pins.
41
+ if (url === imp.source?.url) throw new QuiltError(409, 'export_is_source', `${url} is the source this quilt's import follows; export to a scratch remote until cut-over`)
42
+ if (!isScratch) throw new QuiltError(409, 'scratch_required', 'while the import follows git, the export goes only to a scratch remote: say scratch: true (--scratch) to state that nothing pins a sha from it')
43
+ }
44
+ const prev = q.settings?.export || null
45
+ const rec = { target: url, ...(credential ? { credential: String(credential) } : {}), lines: list, releases: !!releases, scratch: !!isScratch, enabled: !!enabled, by: { member_id: me.member_id, kind: me.kind || 'person', name: me.name || null }, status: prev && prev.target === url ? prev.status : { state: 'idle', lines: {} } }
46
+ await save(q.id, rec, me.member_id, true)
47
+ if (rec.enabled) kick(q.id)
48
+ return view(rec)
49
+ }
50
+
51
+ const view = (rec) => rec
52
+ async function exportStatus (me, ref) {
53
+ const q = await quiltFor(me, ref, 'read')
54
+ if (!q.settings?.export) throw new QuiltError(404, 'not_found', 'this quilt exports nowhere')
55
+ return view(q.settings.export)
56
+ }
57
+
58
+ /** One pass: every exported line's new patches, pushed; then the release tags. */
59
+ async function pass (qid) {
60
+ const q = await store.getQuilt(qid)
61
+ const rec = q?.settings?.export
62
+ if (!rec || !rec.enabled) return
63
+ const lease = await store.takeLease({ kind: 'line', quilt_id: qid, line: LEASE, holder, ttl_ms: 30 * 60_000, at: now() })
64
+ if (!lease.held) { retryAt.set(qid, now() + 30_000); return }
65
+ const status = { ...(rec.status || {}), lines: { ...(rec.status?.lines || {}) } }
66
+ try {
67
+ const dir = path.join(scratch, `export-${qid}.git`)
68
+ if (!fs.existsSync(path.join(dir, 'HEAD'))) { fs.mkdirSync(dir, { recursive: true }); await run(['init', '-q', '--bare', dir]) }
69
+ const auth = authArgs(rec.credential ? await secret(rec.credential, { quilt_id: qid }) : null)
70
+ const env = { GIT_ALLOW_PROTOCOL: allowLocal ? 'https:http:ssh:git:file' : 'https:http:ssh:git' }
71
+ const client = localClient(api, rec.by)
72
+ const imp = q.settings?.import
73
+ const stateFile = path.join(dir, 'quilt-export.json')
74
+ for (const ln of await store.lines(qid)) {
75
+ if (!matches(rec.lines, ln.name) || !ln.tip) continue
76
+ // What the remote has now; the export only ever adds to it.
77
+ const remoteRef = `refs/seam-remote/${ln.name}`
78
+ await run([...auth, '-C', dir, 'fetch', '--quiet', '--no-tags', rec.target, `+refs/heads/${ln.name}:${remoteRef}`], { env }).catch(() => run(['-C', dir, 'update-ref', '-d', remoteRef]).catch(() => {}))
79
+ const remoteSha = (await run(['-C', dir, 'rev-parse', '--verify', '-q', remoteRef]).catch(() => '')).trim() || null
80
+ const all = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}
81
+ const key = `${ln.name}→${ln.name}`
82
+ // An imported quilt exporting to its source continues git's history from the import's tip.
83
+ const base = imp && imp.source?.url === rec.target ? (imp.lines || []).find((l) => l.line === ln.name) : null
84
+ if (!all[key] && base?.tip_sha && remoteSha === base.tip_sha) {
85
+ await run(['-C', dir, 'update-ref', `refs/heads/${ln.name}`, base.tip_sha])
86
+ all[key] = { quilt: qid, line: ln.name, branch: ln.name, patch_no: base.patches, sha: base.tip_sha, version: null }
87
+ fs.writeFileSync(stateFile, JSON.stringify(all, null, 2) + '\n')
88
+ }
89
+ const r = await exportGit(client, { q: qid, dir, line: ln.name })
90
+ if (remoteSha && remoteSha !== r.sha) {
91
+ // Is the remote behind us (fine: fast-forward) or somewhere else (refused)?
92
+ const behind = await run(['-C', dir, 'merge-base', '--is-ancestor', remoteSha, `refs/heads/${ln.name}`]).then(() => true, () => false)
93
+ if (!behind) throw Object.assign(new Error(`the remote's ${ln.name} moved to ${remoteSha.slice(0, 12)}, which the export did not write: it never forces (bring the commit in with a square, or reset the remote's branch)`), { refused: true, line: ln.name, sha: remoteSha })
94
+ }
95
+ if (r.sha && r.sha !== remoteSha) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, `refs/heads/${ln.name}:refs/heads/${ln.name}`], { env })
96
+ status.lines[ln.name] = { patch_no: r.patch_no, sha: r.sha }
97
+ }
98
+ if (rec.releases) await pushTags(qid, dir, rec, auth, env)
99
+ Object.assign(status, { state: 'ok', at: now(), attempts: 0 })
100
+ delete status.error; delete status.next_at
101
+ retryAt.delete(qid)
102
+ } catch (err) {
103
+ const attempts = (status.attempts || 0) + 1
104
+ if (err.refused) Object.assign(status, { state: 'refused', error: err.message, line: err.line, remote_sha: err.sha, at: now() })
105
+ else {
106
+ const wait = BACKOFF[Math.min(attempts - 1, BACKOFF.length - 1)]
107
+ Object.assign(status, { state: 'retrying', error: String(err.message || err), attempts, next_at: now() + wait, at: now() })
108
+ retryAt.set(qid, now() + wait)
109
+ }
110
+ } finally {
111
+ const cur = (await store.getQuilt(qid))?.settings?.export
112
+ if (cur && cur.target === rec.target) await save(qid, { ...cur, status }, rec.by.member_id, cur.status?.state !== status.state).catch(() => {})
113
+ await store.giveLease({ kind: 'line', quilt_id: qid, line: LEASE, holder }).catch(() => {})
114
+ }
115
+ }
116
+
117
+ /** Tags on exported commits: a release as an annotated tag with its notes, a tag as a plain one. Never moved. */
118
+ async function pushTags (qid, dir, rec, auth, env) {
119
+ const shaFile = path.join(dir, 'quilt-export-shas')
120
+ const shas = new Map()
121
+ if (fs.existsSync(shaFile)) for (const l of fs.readFileSync(shaFile, 'utf8').split('\n')) { const [line, n, sha] = l.split(' '); if (sha) shas.set(`${line}/${n}`, sha) }
122
+ const tags = await store.tags(qid)
123
+ for (const t of tags) {
124
+ const c = t.commit ? await store.getCommit(qid, t.commit) : null
125
+ const sha = c?.patch ? shas.get(`${c.patch.line}/${c.patch.patch_no}`) : null
126
+ if (!sha) continue
127
+ const have = (await run(['-C', dir, 'rev-parse', '--verify', '-q', `refs/tags/${t.name}`]).catch(() => '')).trim()
128
+ if (!have) {
129
+ if (t.release) {
130
+ // Deterministic: the tagger and date are the release's, not the clock's.
131
+ const at = Math.floor(Number(t.at || 0) / 1000)
132
+ await run(['-C', dir, '-c', 'user.name=Quilt', '-c', 'user.email=export@heyquilt.com', 'tag', '-a', t.name, sha, '-m', t.notes || t.name], { env: { GIT_COMMITTER_DATE: `${at} +0000` } })
133
+ } else await run(['-C', dir, 'tag', t.name, sha])
134
+ }
135
+ }
136
+ if (tags.length) await run([...auth, '-C', dir, 'push', '--quiet', rec.target, 'refs/tags/*:refs/tags/*'], { env })
137
+ }
138
+
139
+ function kick (qid) {
140
+ if (running.has(qid)) { again.add(qid); return running.get(qid) }
141
+ const p = pass(qid).catch(() => {}).finally(() => { running.delete(qid); if (again.delete(qid)) kick(qid) })
142
+ running.set(qid, p)
143
+ return p
144
+ }
145
+
146
+ /** After a write: export when the quilt exports. */
147
+ async function afterWrite (qid) {
148
+ const q = await store.getQuilt(qid)
149
+ if (q?.settings?.export?.enabled) kick(qid)
150
+ }
151
+
152
+ async function exportIdle (qid) { while (running.has(qid)) await running.get(qid) }
153
+ async function exportTick () { for (const [qid, at] of retryAt) if (now() >= at) kick(qid) }
154
+
155
+ return { setExport, exportStatus, exportIdle, exportTick, exportAfterWrite: afterWrite, exportPass: kick }
156
+ }