@heyquilt/seam 0.22.0 → 0.27.0
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/bin/seam.js +80 -5
- package/lib/account.js +13 -13
- package/lib/quilts/cli.js +108 -9
- package/lib/quilts/client.js +11 -0
- package/lib/quilts/describe.js +31 -1
- package/lib/quilts/import-git.js +6 -3
- package/lib/quilts/runner.js +95 -0
- package/lib/quilts/tools.js +50 -11
- package/package.json +1 -1
package/bin/seam.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
-
import { readAccount, login, logout, resume } from '../lib/account.js'
|
|
8
|
+
import { readAccount, login, logout, resume, call } from '../lib/account.js'
|
|
9
9
|
|
|
10
10
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
11
11
|
// In the repo the client is read straight from the server's src/quilts (never a stale build);
|
|
@@ -17,10 +17,16 @@ const { version } = JSON.parse(fs.readFileSync(path.join(here, '../package.json'
|
|
|
17
17
|
const HELP = `seam ${version}: Seam, source control for people and their agents
|
|
18
18
|
Docs: https://seam.heyquilt.com/docs
|
|
19
19
|
|
|
20
|
-
seam login [--no-browser] Sign in with your heyquilt.com
|
|
20
|
+
seam login [--no-browser] Sign in with your Seam account (seam.heyquilt.com)
|
|
21
21
|
seam logout | seam whoami
|
|
22
|
+
seam agent new <name> | list | key <name> | revoke <name> | relink [agent:<id>] your agents and their keys
|
|
23
|
+
seam oauth client new <name> --redirect <uri> | list | revoke <client_id> apps that ask people for access (OAuth 2, PKCE)
|
|
22
24
|
seam quilts [new <slug> | show <q>] Your quilts (repositories)
|
|
23
25
|
seam import git <path> [--into <slug>]
|
|
26
|
+
seam github [import <owner/name> --installation <id> [--into <slug>]] from GitHub, followed
|
|
27
|
+
seam import git <remote-url> --into <slug> [--follow] [--interval <s>] [--credential <name>]
|
|
28
|
+
seam import status <id> | seam import poke <id> | seam import cutover <id> an import that follows git
|
|
29
|
+
seam export remote <url> --quilt <q> [--line main,release/*] [--releases] [--credential <name>] | seam export status
|
|
24
30
|
seam square open "<title>" --quilt <q> [--dir <folder>]
|
|
25
31
|
seam checkout <sq:id | revision> --quilt <q> [--dir <folder>] [-- paths]
|
|
26
32
|
seam status | diff | commit -m "<message>" in a block folder
|
|
@@ -35,8 +41,10 @@ Docs: https://seam.heyquilt.com/docs
|
|
|
35
41
|
seam commit --amend [-m "<message>"] change your square's last commit
|
|
36
42
|
seam square open "<title>" --on sq:<id> a square stacked on another
|
|
37
43
|
seam claim <path|glob> [--note why] | unclaim <id> files your square is working on
|
|
38
|
-
seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b]
|
|
39
|
-
seam check <name> green|red [--square sq | --patch n] report a check (a red one on a patch reverts it)
|
|
44
|
+
seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b] [--runners ci=<member>]
|
|
45
|
+
seam check <name> green|red [--square sq | --patch n] [--run-key k] report a check (a red one on a patch reverts it)
|
|
46
|
+
seam checks [--state queued] [--name ci] [--tier pre_merge|post_merge] check runs Seam asked runners for
|
|
47
|
+
seam runner --check ci --run "npm test" [--quilt <q>] [--once] run the checks Seam asks you for, on this machine
|
|
40
48
|
seam resolutions [forget <key>] conflicts settled before, settled the same way again
|
|
41
49
|
seam bisect <bad> <good> --run "<check>" seam grep <pattern> [rev] [--all] seam describe [rev] seam merge-base <a> <b>
|
|
42
50
|
seam log --follow <path> | log -S <text>
|
|
@@ -44,7 +52,7 @@ Docs: https://seam.heyquilt.com/docs
|
|
|
44
52
|
seam events [--webhook <url>] What happened to your squares
|
|
45
53
|
seam export git <dir> Back to git
|
|
46
54
|
seam mcp The same commands as MCP tools, for your AI tool
|
|
47
|
-
seam serve Run a Seam server (SEAM_IDENTITY_URL
|
|
55
|
+
seam serve Run a Seam server (SEAM_AUTH_URL: its own accounts; SEAM_IDENTITY_URL: a sign-in service)
|
|
48
56
|
|
|
49
57
|
Agents: set SEAM_TOKEN to the agent's key instead of signing in.`
|
|
50
58
|
|
|
@@ -64,6 +72,8 @@ async function main () {
|
|
|
64
72
|
}
|
|
65
73
|
if (cmd === 'logout') { const a = await logout(); return console.log(a ? `Signed out of ${a.account.email}.` : 'Not signed in.') }
|
|
66
74
|
if (cmd === 'whoami') { const a = readAccount(); if (!a) { process.exitCode = 1; return console.log('Not signed in.') } return console.log(`${a.account.name} (${a.account.email})`) }
|
|
75
|
+
if (cmd === 'agent') return agentCommand(argv[0], argv[1])
|
|
76
|
+
if (cmd === 'oauth') return oauthCommand(argv)
|
|
67
77
|
if (cmd === 'mcp') return mcp()
|
|
68
78
|
// The server is not in the package (it ships the client): it runs from Seam's source or its image.
|
|
69
79
|
if (cmd === 'serve') {
|
|
@@ -85,6 +95,71 @@ async function main () {
|
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
// `seam oauth client …`: OAuth apps the account registers (public clients: PKCE, no secret).
|
|
99
|
+
async function oauthCommand (argv) {
|
|
100
|
+
const a = readAccount()
|
|
101
|
+
if (!a) fail('Not signed in: run seam login.')
|
|
102
|
+
const go = async (method, route, body) => {
|
|
103
|
+
try { return await call(method, route, body, a.token) } catch (err) {
|
|
104
|
+
if (err.status !== 401) throw err
|
|
105
|
+
const back = await resume().catch(() => null)
|
|
106
|
+
if (!back) fail('Your sign-in has ended. Run seam login.')
|
|
107
|
+
return call(method, route, body, back.token)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const [what, sub, ...rest] = argv
|
|
111
|
+
if (what !== 'client') fail('seam oauth client new <name> --redirect <uri> | list | revoke <client_id>')
|
|
112
|
+
if (sub === 'new') {
|
|
113
|
+
const redirects = []; const words = []
|
|
114
|
+
for (let i = 0; i < rest.length; i++) { if (rest[i] === '--redirect') redirects.push(rest[++i]); else words.push(rest[i]) }
|
|
115
|
+
if (!words.length || !redirects.length) fail('seam oauth client new <name> --redirect <uri> [--redirect <uri>]')
|
|
116
|
+
const c = await go('POST', '/v1/oauth/clients', { name: words.join(' '), redirect_uris: redirects })
|
|
117
|
+
return console.log(`${c.name}\n\n client_id=${c.client_id}\n\nA public client: no secret, the app uses PKCE (S256). Send people to https://seam.heyquilt.com/oauth/authorize; trade the code at POST /oauth/token on Seam's API.`)
|
|
118
|
+
}
|
|
119
|
+
if (sub === 'revoke') { if (!rest[0]) fail('seam oauth client revoke <client_id>'); const c = await go('DELETE', `/v1/oauth/clients/${encodeURIComponent(rest[0])}`); return console.log(`Revoked ${c.name}: every token it was given has ended.`) }
|
|
120
|
+
if (!sub || sub === 'list') {
|
|
121
|
+
const { clients } = await go('GET', '/v1/oauth/clients')
|
|
122
|
+
if (!clients.length) return console.log('No apps yet: seam oauth client new <name> --redirect <uri>.')
|
|
123
|
+
for (const c of clients) console.log(`${c.client_id} ${c.name} ${c.redirect_uris.join(' ')}${c.revoked_at ? ' (revoked)' : ''}`)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
fail(`seam oauth client: '${sub}' is not a command (new, list, revoke).`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// `seam agent …`: an account's agents, each with its own key (SEAM_TOKEN for that agent).
|
|
130
|
+
async function agentCommand (sub, name) {
|
|
131
|
+
const a = readAccount()
|
|
132
|
+
if (!a) fail('Not signed in: run seam login.')
|
|
133
|
+
const go = async (method, route, body) => {
|
|
134
|
+
try { return await call(method, route, body, a.token) } catch (err) {
|
|
135
|
+
if (err.status !== 401) throw err
|
|
136
|
+
const back = await resume().catch(() => null)
|
|
137
|
+
if (!back) fail('Your sign-in has ended. Run seam login.')
|
|
138
|
+
return call(method, route, body, back.token)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const showKey = (r) => console.log(`${r.name} (${r.member_id})\n\n SEAM_TOKEN=${r.key}\n\nThis key is shown once. Give it to the agent as SEAM_TOKEN; seam agent key ${r.name} makes a new one and ends this one.`)
|
|
142
|
+
if (sub === 'new') { if (!name) fail('seam agent new <name>'); return showKey(await go('POST', '/v1/agents', { name })) }
|
|
143
|
+
if (sub === 'key') { if (!name) fail('seam agent key <name>'); return showKey(await go('POST', `/v1/agents/${encodeURIComponent(name)}/key`)) }
|
|
144
|
+
if (sub === 'revoke') { if (!name) fail('seam agent revoke <name>'); const r = await go('DELETE', `/v1/agents/${encodeURIComponent(name)}`); return console.log(`Revoked ${r.name}: its key no longer works. Its patches stay its own.`) }
|
|
145
|
+
if (sub === 'relink') {
|
|
146
|
+
if (!name) {
|
|
147
|
+
const { relink } = await go('GET', '/v1/agents')
|
|
148
|
+
if (!relink.length) return console.log('No old agents to re-link.')
|
|
149
|
+
for (const x of relink) console.log(`${x.member_id} ${x.name || ''} (seam agent relink ${x.member_id})`)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
return showKey(await go('POST', '/v1/agents', { claim: name }))
|
|
153
|
+
}
|
|
154
|
+
if (!sub || sub === 'list') {
|
|
155
|
+
const { agents } = await go('GET', '/v1/agents')
|
|
156
|
+
if (!agents.length) return console.log('No agents yet: seam agent new <name>.')
|
|
157
|
+
for (const x of agents) console.log(`${x.name} ${x.member_id}${x.revoked_at ? ' (revoked)' : ''}`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
fail(`seam agent: '${sub}' is not an agent command (new, list, key, revoke, relink).`)
|
|
161
|
+
}
|
|
162
|
+
|
|
88
163
|
// `seam mcp`: the source-control tools for an AI tool, over stdio, as the signed-in person (or SEAM_TOKEN).
|
|
89
164
|
async function mcp () {
|
|
90
165
|
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
|
package/lib/account.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
// seam's own sign-in
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// seam's own sign-in to a Seam account: an Ed25519 key for this computer in ~/.seam/identity.json
|
|
2
|
+
// and the computer's Seam key in ~/.seam/account.json (both private, 0600), through Seam's own
|
|
3
|
+
// device flow: `seam login` shows a page on seam.heyquilt.com to approve, and later a lapsed key
|
|
4
|
+
// is renewed with the computer's key, no browser.
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import os from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import crypto from 'node:crypto'
|
|
9
9
|
|
|
10
|
-
export const API_URL = 'https://api.heyquilt.com'
|
|
11
|
-
// Sign-in
|
|
12
|
-
export const apiUrl = () => String(process.env.
|
|
10
|
+
export const API_URL = 'https://api.seam.heyquilt.com'
|
|
11
|
+
// Sign-in is on Seam's own server, like everything else.
|
|
12
|
+
export const apiUrl = () => String(process.env.SEAM_API_URL || API_URL).replace(/\/+$/, '')
|
|
13
13
|
export const seamHome = () => process.env.SEAM_HOME || path.join(os.homedir(), '.seam')
|
|
14
14
|
const accountFile = () => path.join(seamHome(), 'account.json')
|
|
15
15
|
const identityFile = () => path.join(seamHome(), 'identity.json')
|
|
16
16
|
const signedOutFile = () => path.join(seamHome(), 'signed-out')
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
const DEVICE_LINK_CONTEXT = '
|
|
20
|
-
const DEVICE_RESUME_CONTEXT = '
|
|
18
|
+
// Seam's signing contexts for linking a computer and signing it back in (src/quilts/keys.js).
|
|
19
|
+
const DEVICE_LINK_CONTEXT = 'seam-device-link-v1'
|
|
20
|
+
const DEVICE_RESUME_CONTEXT = 'seam-device-resume-v1'
|
|
21
21
|
|
|
22
22
|
function writePrivate (file, data) {
|
|
23
23
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
@@ -58,15 +58,15 @@ function saveAccount (token, profile) {
|
|
|
58
58
|
return data
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async function call (method, route, body, token, extra = {}) {
|
|
61
|
+
export async function call (method, route, body, token, extra = {}) {
|
|
62
62
|
let res
|
|
63
63
|
try {
|
|
64
64
|
res = await fetch(apiUrl() + route, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined, ...extra })
|
|
65
65
|
} catch (err) {
|
|
66
|
-
throw new Error(`Couldn't reach
|
|
66
|
+
throw new Error(`Couldn't reach Seam (${err.cause?.code || err.message}).`)
|
|
67
67
|
}
|
|
68
68
|
const data = await res.json().catch(() => null)
|
|
69
|
-
if (!res.ok) throw Object.assign(new Error(data?.error || `
|
|
69
|
+
if (!res.ok) throw Object.assign(new Error(data?.error || `Seam answered ${res.status}.`), { status: res.status })
|
|
70
70
|
return data || {}
|
|
71
71
|
}
|
|
72
72
|
|
package/lib/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
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', '--installation', '--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>')
|
|
@@ -430,8 +491,36 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
|
|
|
430
491
|
const r = await client.tree(needQuilt(), pos[0] || 'main', pos.slice(1).concat(paths))
|
|
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
|
}
|
|
494
|
+
case 'github': {
|
|
495
|
+
// GitHub through the Seam app: what it reaches, and importing (and following) a repository.
|
|
496
|
+
if (pos[0] === 'import') {
|
|
497
|
+
if (!pos[1] || !flags['--installation']) throw new Error('seam github import <owner/name> --installation <id> [--into <slug>] [--no-follow]')
|
|
498
|
+
const r = await client.importGithub({ installation_id: flags['--installation'], repo: pos[1], slug: flags['--into'] || undefined, follow: !flags['--no-follow'] })
|
|
499
|
+
return print(r, `Importing ${pos[1]} (import ${r.id})${r.follow ? ', following it: GitHub tells Seam of every push' : ''}. seam import status ${r.id}`)
|
|
500
|
+
}
|
|
501
|
+
const g = await client.github()
|
|
502
|
+
if (!g.configured) return print(g, 'GitHub is not set up on this Seam server.')
|
|
503
|
+
if (!g.installations.length) return print(g, `No GitHub installations yet: install the Seam app from ${g.app.install_url}`)
|
|
504
|
+
return print(g, g.installations.map((i) => `${i.login} (installation ${i.id})\n${i.repos.map((r) => ` ${r.full_name}${r.private ? ' (private)' : ''}`).join('\n') || ' no repositories'}`).join('\n'))
|
|
505
|
+
}
|
|
433
506
|
case 'import': {
|
|
434
|
-
|
|
507
|
+
// From a git remote, on the server, and following it (14): status and poke name the import.
|
|
508
|
+
if (pos[0] === 'cutover') {
|
|
509
|
+
if (!pos[1]) throw new Error('seam import cutover <import-id> [--credential <name>]')
|
|
510
|
+
const r = await client.cutover(pos[1], { credential: flags['--credential'] || undefined })
|
|
511
|
+
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}`)
|
|
512
|
+
}
|
|
513
|
+
if (pos[0] === 'status' || pos[0] === 'poke') {
|
|
514
|
+
if (!pos[1]) throw new Error(`seam import ${pos[0]} <import-id>`)
|
|
515
|
+
const r = pos[0] === 'status' ? await client.importStatus(pos[1]) : await client.pokeImport(pos[1])
|
|
516
|
+
return print(r, pos[0] === 'poke' ? `Poked: import ${r.id} fetches the remote now.` : describeImportRecord(r))
|
|
517
|
+
}
|
|
518
|
+
if (pos[0] === 'git' && pos[1] && (flags['--follow'] || flags['--remote'] || /^(https?|ssh|git):\/\/|^[\w.-]+@[\w.-]+:/.test(pos[1]))) {
|
|
519
|
+
const slug = flags['--into'] || path.basename(String(pos[1]).replace(/\.git$/, '').replace(/\/+$/, '')).toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
|
520
|
+
const r = await client.startImport({ url: pos[1], slug, follow: !!flags['--follow'], interval: flags['--interval'] ? Number(flags['--interval']) : undefined, credential: flags['--credential'] || undefined })
|
|
521
|
+
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}`)
|
|
522
|
+
}
|
|
523
|
+
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
524
|
const { importGit } = await import('./import-git.js')
|
|
436
525
|
const repo = path.resolve(cwd, pos[1])
|
|
437
526
|
if (!fs.existsSync(path.join(repo, '.git')) && !fs.existsSync(path.join(repo, 'HEAD'))) throw new Error(`${repo} is not a git repository`)
|
|
@@ -439,6 +528,16 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
|
|
|
439
528
|
return print(r, describeImport(r))
|
|
440
529
|
}
|
|
441
530
|
case 'export': {
|
|
531
|
+
// To a git remote, on the server, after each merge (14): seam export remote <url> | status.
|
|
532
|
+
if (pos[0] === 'remote' && pos[1]) {
|
|
533
|
+
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'] })
|
|
534
|
+
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.`)
|
|
535
|
+
}
|
|
536
|
+
if (pos[0] === 'status') {
|
|
537
|
+
const r = await client.exportStatus(needQuilt())
|
|
538
|
+
const st = r.status || {}
|
|
539
|
+
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'))
|
|
540
|
+
}
|
|
442
541
|
if (pos[0] !== 'git' || !pos[1]) throw new Error('seam export git <dir> [--line main] [--branch <name>]')
|
|
443
542
|
const { exportGit } = await import('./export-git.js')
|
|
444
543
|
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/lib/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,14 @@ 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
|
+
github: () => call('GET', '/v1/github'),
|
|
116
|
+
importGithub: (b) => call('POST', '/v1/github/import', b),
|
|
117
|
+
importStatus: (id) => call('GET', `/v1/imports/${enc(id)}`),
|
|
118
|
+
cutover: (id, b = {}) => call('POST', `/v1/imports/${enc(id)}/cutover`, b),
|
|
119
|
+
pokeImport: (id) => call('POST', `/v1/imports/${enc(id)}/poke`),
|
|
109
120
|
|
|
110
121
|
/**
|
|
111
122
|
* Uploads objects the server does not have yet ([{ kind, id, bytes }]), in packs of at most
|
package/lib/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/lib/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
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// seam runner (plans/quilt-source-control/13 "Checks"): a ready-made check runner anyone starts on
|
|
2
|
+
// their own machine. It polls the runs Seam asked its member to run (GET /checks?state=queued),
|
|
3
|
+
// checks each run's commit out into a fresh block folder, runs the command there, and reports the
|
|
4
|
+
// verdict (exit 0 green, anything else red) on the run's run_key with a link to the log. A run
|
|
5
|
+
// Seam cancels (check.cancelled: a newer head superseded it) is stopped and not reported.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { checkout } from './block.js'
|
|
11
|
+
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
13
|
+
const safe = (key) => key.replace(/[^\w.-]+/g, '_').slice(0, 180)
|
|
14
|
+
|
|
15
|
+
/** The log link a verdict carries: --log-url with {run_key} and {file}, else the log file's own address. */
|
|
16
|
+
export function logLink (template, runKey, file) {
|
|
17
|
+
if (!template) return 'file://' + file
|
|
18
|
+
return template.replaceAll('{run_key}', encodeURIComponent(runKey)).replaceAll('{file}', encodeURIComponent(path.basename(file)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The environment a run's command gets: what to run and, when Seam knows, which tests the change reaches. */
|
|
22
|
+
export function runEnv (run, base = process.env) {
|
|
23
|
+
const a = run.affected || { confidence: 'all' }
|
|
24
|
+
return {
|
|
25
|
+
...base,
|
|
26
|
+
SEAM_CHECK: run.name, SEAM_TIER: run.tier, SEAM_COMMIT: run.commit, SEAM_RUN_KEY: run.run_key,
|
|
27
|
+
SEAM_LINE: run.line || '', SEAM_SQUARE: run.square || '',
|
|
28
|
+
SEAM_AFFECTED_CONFIDENCE: a.confidence, SEAM_AFFECTED_TESTS: a.confidence === 'all' ? '' : (a.tests || []).join('\n')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runs checks for one quilt. { client, quilt, checks: ['ci'], run: 'npm test', dir (where block
|
|
34
|
+
* folders and logs go), api, once (handle what is queued now, then return), intervalMs, logUrl,
|
|
35
|
+
* say(line) }. Returns the runs it finished: [{ run_key, status | 'cancelled', url }].
|
|
36
|
+
*/
|
|
37
|
+
export async function runChecks ({ client, quilt, checks, run: command, dir = path.join(os.tmpdir(), 'seam-runner'), api, once = false, intervalMs = 5000, logUrl, say = () => {}, keep = false }) {
|
|
38
|
+
if (!checks?.length) throw new Error('seam runner needs --check <name>[,<name>]')
|
|
39
|
+
if (!command) throw new Error('seam runner needs --run "<command>"')
|
|
40
|
+
const logs = path.join(dir, 'logs')
|
|
41
|
+
fs.mkdirSync(logs, { recursive: true })
|
|
42
|
+
const done = []
|
|
43
|
+
const seen = new Set()
|
|
44
|
+
for (;;) {
|
|
45
|
+
const queued = []
|
|
46
|
+
for (const name of checks) queued.push(...(await client.checkRuns(quilt, { state: 'queued', name })).runs)
|
|
47
|
+
const todo = queued.filter((r) => !seen.has(r.run_key)).sort((a, b) => a.at - b.at)
|
|
48
|
+
for (const r of todo) {
|
|
49
|
+
seen.add(r.run_key)
|
|
50
|
+
done.push(await runOne(r))
|
|
51
|
+
}
|
|
52
|
+
if (once) return done
|
|
53
|
+
await sleep(intervalMs)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runOne (r) {
|
|
57
|
+
const work = fs.mkdtempSync(path.join(dir, 'run-'))
|
|
58
|
+
const file = path.join(logs, safe(r.run_key) + '.log')
|
|
59
|
+
const url = logLink(logUrl, r.run_key, file)
|
|
60
|
+
say(`${r.name} (${r.tier}) on ${r.commit.slice(0, 12)}${r.square ? ` in ${r.square}` : ` at ${r.line}`}: running`)
|
|
61
|
+
const out = fs.openSync(file, 'w')
|
|
62
|
+
let status
|
|
63
|
+
try {
|
|
64
|
+
fs.writeSync(out, `seam runner: ${r.name} ${r.tier} ${r.commit} run_key ${r.run_key}\n$ ${command}\n`)
|
|
65
|
+
await checkout(client, { quilt, rev: r.commit, dir: work, api })
|
|
66
|
+
const child = spawn(command, { cwd: work, shell: true, env: runEnv(r), stdio: ['ignore', out, out], detached: process.platform !== 'win32' })
|
|
67
|
+
const exited = new Promise((resolve) => child.on('close', (code) => resolve(code)))
|
|
68
|
+
let cancelled = false
|
|
69
|
+
for (;;) {
|
|
70
|
+
const code = await Promise.race([exited, sleep(Math.min(intervalMs, 2000)).then(() => undefined)])
|
|
71
|
+
if (code !== undefined) { status = code === 0 ? 'green' : 'red'; break }
|
|
72
|
+
const gone = (await client.checkRuns(quilt, { state: 'cancelled', name: r.name, limit: 50 })).runs.some((x) => x.run_key === r.run_key)
|
|
73
|
+
if (gone && !cancelled) {
|
|
74
|
+
cancelled = true
|
|
75
|
+
try { process.kill(process.platform !== 'win32' ? -child.pid : child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') }
|
|
76
|
+
}
|
|
77
|
+
if (cancelled) { await exited; status = 'cancelled'; break }
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
fs.writeSync(out, `\nseam runner: ${err.message}\n`)
|
|
81
|
+
status = 'red'
|
|
82
|
+
} finally {
|
|
83
|
+
fs.writeSync(out, `\nseam runner: ${status}\n`)
|
|
84
|
+
fs.closeSync(out)
|
|
85
|
+
if (!keep) fs.rmSync(work, { recursive: true, force: true })
|
|
86
|
+
}
|
|
87
|
+
if (status !== 'cancelled') {
|
|
88
|
+
const body = { check: r.name, status, run_key: r.run_key, url }
|
|
89
|
+
if (r.tier === 'pre_merge' && r.square) await client.reportSquareCheck(quilt, r.square, { ...body, head: r.commit })
|
|
90
|
+
else await client.reportCheck(quilt, r.line, body)
|
|
91
|
+
}
|
|
92
|
+
say(`${r.name} on ${r.commit.slice(0, 12)}: ${status} (log ${url})`)
|
|
93
|
+
return { run_key: r.run_key, status, url }
|
|
94
|
+
}
|
|
95
|
+
}
|
package/lib/quilts/tools.js
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
// quilt and square, and seam_commit takes the files' new contents.
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { z } from 'zod'
|
|
11
|
-
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'
|
|
12
12
|
import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
|
|
13
13
|
import { status as blockStatus, commitFolder, amendFolder, syncTo, restore, reset, clean, localChanges } from './block.js'
|
|
14
14
|
|
|
15
|
-
export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke']
|
|
15
|
+
export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_github', 'seam_github_import', 'seam_import_status', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_checks', 'seam_check_rerun']
|
|
16
16
|
|
|
17
17
|
/** What every agent is told about source control, in each MCP's instructions. */
|
|
18
18
|
export const SEAM_GUIDE = 'Quilt source control (quilts): a quilt is a repository on the Quilt server; you work in a square (your own branch and working copy), commit save points in it, and merge it into main, where it becomes a patch. ' +
|
|
@@ -32,6 +32,8 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
32
32
|
seam_review: 'Reviews a square: kind approve, request_changes (say what in body), comment (body; path and line put it on a line of a file at the square\'s head) or dismiss (takes back your verdict). Nobody approves a square they or the person they work for opened or hold. An approval counts for the square\'s latest commit only.',
|
|
33
33
|
seam_claims: 'The claims that hold now: which square is working on which paths, who, and why (square: only that square\'s).',
|
|
34
34
|
seam_claim: 'Claims paths (patterns: files, folders or globs like src/api/**) for your square, so writes to them from other squares on its line are refused until your square merges or you let go. note says what for. Claim before a larger change across several files.',
|
|
35
|
+
seam_check_rerun: 'Asks a check\'s runners to run it again: with square, on that square\'s latest commit (pre_merge); with line, on the line\'s tip (post_merge); or on commit. A run of it still open there is cancelled (check.cancelled to its runners). Returns the new run_key.',
|
|
36
|
+
seam_checks: 'The quilt\'s check runs, newest first (filter by state, name, tier, line, square). A runner polls state "queued" for the checks its line\'s policy names it for (or gets check.requested events), runs each on its commit and reports with seam_check and the run\'s run_key.',
|
|
35
37
|
seam_check: 'Reports a post-merge check on a line (status red, green or flaky; a red check halves the line\'s merge window, green restores it). With patch (the patch number it failed at, found for example with seam_bisect) a red check reverts that patch on the line by itself and opens a square holding the change again for its holder to fix. With square instead, the result (green or red) is for that square\'s latest commit, which its line may require green before a merge.',
|
|
36
38
|
seam_unclaim: 'Lets go of claims on your square (patterns: the patterns or claim ids).',
|
|
37
39
|
seam_live_set: 'Shares your square\'s uncommitted work so others can read it as sq:<id>:live before you commit: in a block folder the folder\'s changed files; elsewhere files: [{ path, content }] and delete: [paths]. Each call replaces what was shared; none clears it. It is not a commit: seam_commit keeps work.',
|
|
@@ -44,6 +46,14 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
44
46
|
seam_line_freeze: 'Freezes a line (admins): merges into it are refused with line_frozen until seam_line_unfreeze.',
|
|
45
47
|
seam_line_unfreeze: 'Unfreezes a line (admins): it takes merges again.',
|
|
46
48
|
seam_tag_list: 'The quilt\'s tags: name, commit, the patch it is, and whether it is a release.',
|
|
49
|
+
seam_github: 'Your GitHub installations of the Seam app and the repositories each reaches (install it from seam.heyquilt.com/github). Import one with seam_github_import.',
|
|
50
|
+
seam_github_import: 'Imports a GitHub repository the Seam app reaches into a new quilt and follows it (GitHub tells Seam of every push), with the installation as its credential: no token to paste.',
|
|
51
|
+
seam_import_remote: 'Imports a git remote (https:// or ssh) into a new quilt on the server. With follow, it keeps taking new commits on main and release/* every interval (and on seam_import_poke) and freezes those lines until cut-over, because git stays the authority. Returns the import id and its poke secret (shown once).',
|
|
52
|
+
seam_export_remote: 'Exports lines of a quilt to a git remote (admins) after each merge on them, as fast-forwards that are never forced; releases: true pushes release tags too. While an import follows git, only to a scratch remote (scratch: true).',
|
|
53
|
+
seam_export_status: 'The export to a git remote: its target, lines, state (idle, ok, retrying, refused), each line\'s last exported patch and sha, and the last error.',
|
|
54
|
+
seam_import_cutover: 'Ends a following import for good (admins): a last pass, a check that the remote\'s tips equal the imported ones, then main and release/* take merges in Seam and main exports to the source after each merge. Refuses with remote_moved if git moved.',
|
|
55
|
+
seam_import_status: 'An import from a git remote: its state (reading, following, done, failed), its lines with their git tips, and its last error.',
|
|
56
|
+
seam_import_poke: 'Makes a following import fetch its git remote now instead of at its next interval.',
|
|
47
57
|
seam_import_git: 'Imports a git repository on this computer into a quilt: HEAD\'s branch as main, every other branch as a line of its name, every tag, submodules as gitlink entries. Run it again into the same quilt to add only the new commits.',
|
|
48
58
|
seam_sign: 'Signs a commit you made (rev, default your square\'s head) with your Ed25519 key, so seam_log with show_signature shows it as yours. With no signature, signs with this computer\'s key (seam_key_add makes one); a hosted agent signs the text "seam-commit <commit id>\\n" itself and passes key and signature (base64).',
|
|
49
59
|
seam_keys: 'Your signing keys (or member\'s): their ids, names, and when each was added or removed.',
|
|
@@ -59,9 +69,10 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
59
69
|
seam_squares: 'The open squares of a quilt, with holder, head, how far behind main each is and its conflicted paths. all: true for merged and abandoned ones too.',
|
|
60
70
|
seam_square_status: 'One square as the server sees it (head, behind_by, conflicted paths), the patches merged under it that changed its files, and which files would conflict if merged now; in a block folder also the files changed since the last commit.',
|
|
61
71
|
seam_square_handoff: 'Hands your square to another member (to), with a note on what is done and what is left. You stop being able to commit to it.',
|
|
72
|
+
seam_square_merged: 'Closes an open square whose work a person put into git by hand while an import follows git, when the follower could not match it (the diff was edited on the way). Admins; git_sha is the imported git commit that holds the work.',
|
|
62
73
|
seam_abandon: 'Abandons a square whose work is not wanted. Its commits stay readable and seam_reopen brings it back.',
|
|
63
74
|
seam_reopen: 'Reopens an abandoned square; you become its holder.',
|
|
64
|
-
seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff. Pass after (the cursor from the last answer) to get only new ones.',
|
|
75
|
+
seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff, check.requested (you run a check a line names you for: run it on the commit and report with seam_check and its run_key). Pass after (the cursor from the last answer) to get only new ones.',
|
|
65
76
|
seam_events_webhook: 'Has the quilt server POST your events to a URL (signed with HMAC-SHA256 over <timestamp>.<body>, retried 3 times), so you are woken instead of polling: url, events (default all), secret (made for you if left out). url: "off" removes it.',
|
|
66
77
|
seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Overlapping changes become conflict markers in the target.',
|
|
67
78
|
seam_split: 'Splits your square by paths: groups: [[paths], ...], each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square.',
|
|
@@ -88,12 +99,12 @@ export const SEAM_DESCRIPTIONS = {
|
|
|
88
99
|
|
|
89
100
|
export const SEAM_INPUTS = {
|
|
90
101
|
seam_live: { quilt: QUILT, square: SQUARE },
|
|
91
|
-
seam_line_require: { quilt: QUILT, line: z.string().max(120), approvals: z.number().int().min(0).max(20).optional(), checks: z.array(z.string().max(100)).max(20).optional().describe('Check names that must be green on a square\'s latest commit, e.g. ["ci"]; [] for none') },
|
|
102
|
+
seam_line_require: { quilt: QUILT, line: z.string().max(120), approvals: z.number().int().min(0).max(20).optional(), checks: z.array(z.string().max(100)).max(20).optional().describe('Check names that must be green on a square\'s latest commit, e.g. ["ci"]; [] for none'), runners: z.record(z.string().max(100), z.array(z.string().max(200)).max(20)).optional().describe('Who runs each check, e.g. {"ci": ["agent:ci"]}: they are asked to run it (check.requested) and only their verdicts count'), tiers: z.record(z.string().max(100), z.array(z.enum(['pre_merge', 'post_merge'])).max(2)).optional().describe('Where each check runs, e.g. {"e2e": ["post_merge"]}; default: a required check runs pre_merge on the square, a check with runners runs post_merge on the line'), tests: z.array(z.string().max(300)).max(50).optional().describe('Globs of the line\'s test files, e.g. ["test/**"]: a pre-merge check.requested names the tests a change reaches (affected)'), ci_config: z.array(z.string().max(300)).max(50).optional().describe('Globs of ci config: a change there makes affected confidence "all"') },
|
|
92
103
|
seam_reviews: { quilt: QUILT, square: SQUARE },
|
|
93
104
|
seam_review: { quilt: QUILT, square: SQUARE, kind: z.enum(['approve', 'request_changes', 'comment', 'dismiss']), body: z.string().max(20000).optional(), path: z.string().max(1000).optional().describe('A file, for a comment on it'), line: z.number().int().min(1).optional().describe('A line of that file at the square\'s head') },
|
|
94
105
|
seam_claims: { quilt: QUILT, square: SQUARE },
|
|
95
106
|
seam_claim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('Files, folders or globs, e.g. src/api/** or README.md'), note: z.string().max(300).optional().describe('What you are doing with them') },
|
|
96
|
-
seam_check: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), status: z.enum(['red', 'green', 'flaky']), square: z.string().max(100).optional().describe('A square (sq:…): the result is for its latest commit, which a line may require green before a merge'), line: z.string().max(110).optional().describe('Default main'), patch: z.number().int().min(1).optional().describe('The patch number the check failed at: reverts it') },
|
|
107
|
+
seam_check: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), status: z.enum(['red', 'green', 'flaky']), run_key: z.string().max(600).optional().describe('The run_key of the check.requested event or run being answered'), url: z.string().max(2000).optional().describe('A link to the run\'s log (http(s) or file://), kept on the run'), square: z.string().max(100).optional().describe('A square (sq:…): the result is for its latest commit, which a line may require green before a merge'), line: z.string().max(110).optional().describe('Default main'), patch: z.number().int().min(1).optional().describe('The patch number the check failed at: reverts it') },
|
|
97
108
|
seam_unclaim: { quilt: QUILT, square: SQUARE, patterns: z.array(z.string().max(500)).min(1).max(50).describe('The claimed patterns (or claim ids)') },
|
|
98
109
|
seam_live_set: { quilt: QUILT, square: SQUARE, files: z.array(z.object({ path: z.string().max(1000), content: z.string(), base64: z.boolean().optional(), mode: z.enum(['file', 'exec']).optional() })).max(2000).optional(), delete: z.array(z.string().max(1000)).max(2000).optional() },
|
|
99
110
|
seam_restore: { paths: z.array(z.string().max(1000)).min(1).max(200).describe('Files or folders, relative to the block folder ("." for all)') },
|
|
@@ -105,6 +116,14 @@ export const SEAM_INPUTS = {
|
|
|
105
116
|
seam_line_freeze: { quilt: QUILT, line: z.string().max(110) },
|
|
106
117
|
seam_line_unfreeze: { quilt: QUILT, line: z.string().max(110) },
|
|
107
118
|
seam_tag_list: { quilt: QUILT },
|
|
119
|
+
seam_github: {},
|
|
120
|
+
seam_github_import: { repo: z.string().max(201).describe('owner/name, as seam_github lists it'), installation: z.string().max(20).describe('The installation id seam_github shows for it'), into: z.string().max(63).optional().describe('The new quilt\'s slug (default the repo\'s name)'), follow: z.boolean().optional().describe('Keep following it (default true)') },
|
|
121
|
+
seam_import_remote: { url: z.string().max(2000).describe('The git remote: https://, ssh:// or user@host:path'), into: z.string().max(63).describe('The new quilt\'s slug'), follow: z.boolean().optional().describe('Keep following main and release/* until cut-over'), interval: z.number().int().min(60).optional().describe('Seconds between follow passes (default 600)'), credential: z.string().max(100).optional().describe('The name of the server secret that reads the remote (user:token or a token)') },
|
|
122
|
+
seam_export_remote: { quilt: z.string().max(100).optional().describe('The quilt'), target: z.string().max(2000).describe('The git remote'), lines: z.array(z.string().max(100)).max(20).optional().describe('Lines to export (default ["main"]); release/* for every release line'), releases: z.boolean().optional().describe('Push release tags too'), credential: z.string().max(100).optional().describe('The name of the server secret that may push'), scratch: z.boolean().optional().describe('A scratch remote nothing pins a sha from'), enabled: z.boolean().optional().describe('false turns the export off') },
|
|
123
|
+
seam_export_status: { quilt: z.string().max(100).optional().describe('The quilt') },
|
|
124
|
+
seam_import_cutover: { id: z.string().max(100).describe('The import id'), credential: z.string().max(100).optional().describe('The server secret that may push to the source (default the import\'s)') },
|
|
125
|
+
seam_import_status: { id: z.string().max(100).describe('The import id') },
|
|
126
|
+
seam_import_poke: { id: z.string().max(100).describe('The import id') },
|
|
108
127
|
seam_import_git: { path: z.string().max(1000).describe('The git repository\'s folder'), into: z.string().max(63).optional().describe('The quilt\'s slug (default the folder\'s name); a quilt an import made takes only the new commits'), validate: z.boolean().optional().describe('Compare every patch written with git\'s tree') },
|
|
109
128
|
seam_sign: { quilt: QUILT, rev: REV, key: z.string().max(40).optional().describe('Your key id (ed25519:…), with signature'), signature: z.string().max(200).optional().describe('Base64 Ed25519 signature of "seam-commit <commit id>\\n"') },
|
|
110
129
|
seam_keys: { member: z.string().max(200).optional().describe('Whose keys (default yours)') },
|
|
@@ -140,9 +159,12 @@ export const SEAM_INPUTS = {
|
|
|
140
159
|
},
|
|
141
160
|
seam_square_handoff: { quilt: QUILT, square: SQUARE, to: z.string().max(200).describe('The member to hand it to'), note: z.string().max(2000).optional() },
|
|
142
161
|
seam_abandon: { quilt: QUILT, square: SQUARE },
|
|
162
|
+
seam_square_merged: { quilt: QUILT, square: SQUARE, git_sha: z.string().max(64).describe('The git commit (full sha) that holds the square\'s work') },
|
|
143
163
|
seam_reopen: { quilt: QUILT, square: SQUARE },
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
seam_check_rerun: { quilt: QUILT, check: z.string().max(200).describe('The check\'s name, e.g. ci'), square: z.string().max(100).optional().describe('A square (sq:…): re-run on its latest commit'), line: z.string().max(110).optional().describe('A line: re-run on its tip (default main when no square)'), commit: z.string().max(80).optional().describe('A commit instead of the head or tip') },
|
|
165
|
+
seam_checks: { quilt: QUILT, state: z.enum(['queued', 'running', 'done', 'cancelled']).optional(), name: z.string().max(100).optional(), tier: z.enum(['pre_merge', 'post_merge']).optional(), line: z.string().max(120).optional(), square: z.string().max(100).optional() },
|
|
166
|
+
seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), kinds: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff', 'check.requested'])).optional() },
|
|
167
|
+
seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'conflict.added', 'square.handoff', 'check.requested'])).optional(), secret: z.string().min(16).max(200).optional() },
|
|
146
168
|
seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional() },
|
|
147
169
|
seam_split: { quilt: QUILT, square: SQUARE, groups: z.array(z.array(z.string().max(1000)).min(1).max(2000)).min(1).max(20).describe('Paths (files or directories) per new square'), titles: z.array(z.string().max(200)).max(20).optional() },
|
|
148
170
|
seam_merge: { quilt: QUILT, square: SQUARE, from: z.string().max(200).optional().describe('Merge this square (sq:…) or revision into your square instead of merging your square into a line'), into: z.string().max(100).optional().describe('The line, default main'), message: z.string().max(65536).optional(), dry_run: z.boolean().optional(), on_conflict: z.enum(['rebase', 'leave']).optional() },
|
|
@@ -169,8 +191,9 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
169
191
|
try {
|
|
170
192
|
switch (name) {
|
|
171
193
|
case 'seam_line_require': {
|
|
172
|
-
|
|
173
|
-
|
|
194
|
+
const extra = Object.fromEntries(['tiers', 'tests', 'ci_config'].filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
|
|
195
|
+
if (args.approvals === undefined && args.checks === undefined && args.runners === undefined && !Object.keys(extra).length) return describePolicy(await client.linePolicy(needQuilt(), args.line))
|
|
196
|
+
return describePolicy(await client.setLinePolicy(needQuilt(), args.line, { ...(args.approvals !== undefined ? { approvals: args.approvals } : {}), ...(args.checks ? { checks: args.checks } : {}), ...(args.runners ? { runners: args.runners } : {}), ...extra }))
|
|
174
197
|
}
|
|
175
198
|
case 'seam_reviews': return describeReviews(await client.reviews(needQuilt(), needSquare()))
|
|
176
199
|
case 'seam_review': return describeReviewed(await client.review(needQuilt(), needSquare(), { kind: args.kind, body: args.body || '', ...(args.path ? { path: args.path } : {}), ...(args.line ? { line: args.line } : {}) }))
|
|
@@ -180,8 +203,10 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
180
203
|
for (const p of args.patterns) made.push(await client.claim(needQuilt(), needSquare(), { pattern: p, note: args.note }))
|
|
181
204
|
return made.map((c) => `Claimed ${c.pattern} for ${c.square}: writes to it from other squares on ${c.line} are refused until the square merges or seam_unclaim.`).join('\n')
|
|
182
205
|
}
|
|
183
|
-
case 'seam_check': if (args.square) return describeSquareCheck(await client.reportSquareCheck(needQuilt(), args.square, { check: args.check, status: args.status }))
|
|
184
|
-
return describeCheck(await client.reportCheck(needQuilt(), args.line || 'main', { check: args.check, status: args.status, ...(args.patch ? { patch: args.patch } : {}) }))
|
|
206
|
+
case 'seam_check': if (args.square) return describeSquareCheck(await client.reportSquareCheck(needQuilt(), args.square, { check: args.check, status: args.status, ...(args.run_key ? { run_key: args.run_key } : {}), ...(args.url ? { url: args.url } : {}) }))
|
|
207
|
+
return describeCheck(await client.reportCheck(needQuilt(), args.line || 'main', { check: args.check, status: args.status, ...(args.patch ? { patch: args.patch } : {}), ...(args.run_key ? { run_key: args.run_key } : {}), ...(args.url ? { url: args.url } : {}) }))
|
|
208
|
+
case 'seam_check_rerun': return describeRerun(await client.rerunCheck(needQuilt(), { check: args.check, ...(args.square ? { square: args.square } : { line: args.line || 'main' }), ...(args.commit ? { commit: args.commit } : {}) }))
|
|
209
|
+
case 'seam_checks': return describeCheckRuns(await client.checkRuns(needQuilt(), { state: args.state, name: args.name, tier: args.tier, line: args.line, square: args.square }))
|
|
185
210
|
case 'seam_unclaim': {
|
|
186
211
|
const out = []
|
|
187
212
|
for (const p of args.patterns) out.push(await client.unclaim(needQuilt(), needSquare(), p))
|
|
@@ -260,6 +285,7 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
260
285
|
return signed(await client.commit(quilt, sq.id, { expect_head: sq.head, message: args.message, changes }), 'Committed')
|
|
261
286
|
}
|
|
262
287
|
case 'seam_square_handoff': { const sq = await client.handoff(needQuilt(), needSquare(), { to: args.to, note: args.note }); return `Handed ${sq.short} to ${sq.holder}.` }
|
|
288
|
+
case 'seam_square_merged': { const sq = await client.squareMerged(needQuilt(), needSquare(), { git_sha: args.git_sha }); return `${sq.short} is merged by hand: its work is patch ${sq.merged.patch_no} on ${sq.merged.line} (git ${sq.merged.git_sha.slice(0, 12)}).` }
|
|
263
289
|
case 'seam_abandon': { const sq = await client.abandon(needQuilt(), needSquare(), inFolder ? { expect_head: block.head } : {}); return `Abandoned ${sq.short}; seam_reopen brings it back.` }
|
|
264
290
|
case 'seam_reopen': { const sq = await client.reopen(needQuilt(), needSquare()); return `Reopened ${describeSquare(sq)}.` }
|
|
265
291
|
case 'seam_events': return describeEvents(await client.events(needQuilt(), { after: args.after, kinds: args.kinds }))
|
|
@@ -288,6 +314,19 @@ export async function runSeamTool (name, args, { client, found = null }) {
|
|
|
288
314
|
case 'seam_line_unfreeze': { const l = await client.unfreezeLine(needQuilt(), args.line); return `${l.name} takes merges again.` }
|
|
289
315
|
case 'seam_tag_list': { const ts = await client.tags(needQuilt()); return ts.length ? ts.map(describeTag).join('\n') : 'No tags yet.' }
|
|
290
316
|
case 'seam_release_list': { const rs = await client.releases(needQuilt()); return rs.length ? rs.map(describeRelease).join('\n') : 'No releases yet.' }
|
|
317
|
+
case 'seam_github': {
|
|
318
|
+
const g = await client.github()
|
|
319
|
+
if (!g.configured) return 'GitHub is not set up on this Seam server.'
|
|
320
|
+
if (!g.installations.length) return `No GitHub installations yet: install the Seam app from ${g.app.install_url} (or seam.heyquilt.com/github).`
|
|
321
|
+
return g.installations.map((i) => `${i.login} (installation ${i.id}): ${i.repos.map((r) => r.full_name).join(', ') || 'no repositories'}`).join('\n')
|
|
322
|
+
}
|
|
323
|
+
case 'seam_github_import': { const r = await client.importGithub({ installation_id: args.installation, repo: args.repo, slug: args.into, follow: args.follow !== false }); return `Importing ${args.repo} into ${r.id ? (args.into || args.repo.split('/')[1]) : '?'} (import ${r.id})${r.follow ? ', following it' : ''}.` }
|
|
324
|
+
case 'seam_import_remote': { const r = await client.startImport({ url: args.url, slug: args.into, follow: !!args.follow, interval: args.interval, credential: args.credential }); return `Importing ${args.url} into ${args.into} (import ${r.id})${r.follow ? `, following every ${r.interval_s} s; poke secret ${r.poke_secret} (shown once)` : ''}.` }
|
|
325
|
+
case 'seam_export_remote': { const r = await client.setExport(needQuilt(), { target: args.target, lines: args.lines, releases: args.releases, credential: args.credential, scratch: args.scratch, enabled: args.enabled !== false }); return r.enabled ? `Exporting ${r.lines.join(', ')} to ${r.target} after each merge.` : `The export to ${r.target} is off.` }
|
|
326
|
+
case 'seam_export_status': { const r = await client.exportStatus(needQuilt()); return JSON.stringify({ target: r.target, lines: r.lines, releases: r.releases, enabled: r.enabled, status: r.status }, null, 2) }
|
|
327
|
+
case 'seam_import_cutover': { const r = await client.cutover(args.id, { credential: args.credential }); return `Cut over: import ${r.import.id} is done; main exports to ${r.export.target} after each merge.` }
|
|
328
|
+
case 'seam_import_status': return describeImportRecord(await client.importStatus(args.id))
|
|
329
|
+
case 'seam_import_poke': { const r = await client.pokeImport(args.id); return `Poked: import ${r.id} fetches the remote now.` }
|
|
291
330
|
case 'seam_import_git': {
|
|
292
331
|
const { importGit } = await import('./import-git.js')
|
|
293
332
|
const { default: nodePath } = await import('node:path')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyquilt/seam",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "seam: Quilt Source's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|