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.
- package/RELEASES.md +19 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +74 -2
- package/src/source-live/live-block.js +4 -3
- 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
|
-
|
|
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
|
-
|
|
85
|
-
const
|
|
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
|
-
|
|
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) } })
|
package/src/quilts/client.js
CHANGED
|
@@ -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
|
package/src/quilts/describe.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/quilts/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|