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
|
@@ -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
|
+
}
|
package/src/quilts/import-git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/quilts/import.js
CHANGED
|
@@ -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
|
}
|
package/src/quilts/index.js
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
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
|
}
|