heyquilt 0.3.138 → 0.3.140
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 +6 -0
- package/bin/quilt.js +7 -143
- package/package.json +1 -1
- package/src/account.js +0 -29
- package/src/api/memory-store.js +0 -29
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +5 -19
- package/src/api/supabase-store.js +0 -52
- package/src/branch-view.js +6 -131
- package/src/control.js +2 -33
- package/src/duties.js +1 -1
- package/src/github-commit.js +3 -315
- package/src/mcp.js +8 -157
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-mcp.js +6 -418
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/server.js +33 -1331
- package/src/session.js +52 -3526
- package/src/status.js +2 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +0 -1
- package/src/ui/common.js +0 -2
- package/src/ui/git-rule.js +2 -30
- package/src/ui/home.js +3 -76
- package/src/ui/session.js +34 -528
- package/src/ui/tree.js +2 -8
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +1 -38
- package/src/api/routes/github.js +0 -380
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1069
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -923
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- package/src/upstream.js +0 -192
package/src/control.js
CHANGED
|
@@ -137,8 +137,6 @@ export async function startControl (session, extras = {}) {
|
|
|
137
137
|
'POST /chat-link': (b) => session.createChatLink({ name: b.name, minutes: b.minutes }),
|
|
138
138
|
// Owner only: how long a chat link still works. { who: name or key, minutes } -> { name, expiresAt }
|
|
139
139
|
'POST /chat-link/extend': (b) => session.extendChatLink(String(b.who || ''), b.minutes),
|
|
140
|
-
// Owner only: the relay's read-only GitHub token, for commits while everyone's offline. { token } ('' clears) -> { githubToken }
|
|
141
|
-
'POST /github-token': (b) => session.setGithubToken(b.token),
|
|
142
140
|
'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
|
|
143
141
|
'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
|
|
144
142
|
// The chronology: { path, by, since, task, limit } (see Session.historyQuery).
|
|
@@ -146,11 +144,6 @@ export async function startControl (session, extras = {}) {
|
|
|
146
144
|
'POST /history': (b) => session.historyLinked(b),
|
|
147
145
|
'GET /tree': () => session.tree(),
|
|
148
146
|
'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
|
|
149
|
-
'GET /commits': () => session.commitStatus({ includeMe: false }),
|
|
150
|
-
'GET /branches': () => ({ branches: session.status().branches, git: session.status().git }),
|
|
151
|
-
// The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
|
|
152
|
-
'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
|
|
153
|
-
'POST /branches/sync': () => session.syncBranchNow(),
|
|
154
147
|
// `quilt session migrate --dry-run`: what making the session a quilt would do (the relay's dry run).
|
|
155
148
|
'POST /quilt-plan': () => session.quiltPlan(),
|
|
156
149
|
// The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
|
|
@@ -170,23 +163,7 @@ export async function startControl (session, extras = {}) {
|
|
|
170
163
|
'POST /quilt-follow': (b) => session.quiltFollow(null, b.dir),
|
|
171
164
|
// `quilt commit <message>`: at layout 3, the square this folder follows is committed, as this person.
|
|
172
165
|
'POST /square-commit': (b) => session.squareCommit(String(b.message || '')),
|
|
173
|
-
'POST /commit-request': (b) => session.requestCommit(b.message, { files: b.files == null ? null : b.files, description: b.description || '', task: b.task || null, via: b.via ? String(b.via) : null }),
|
|
174
|
-
// quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
|
|
175
|
-
'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
|
|
176
|
-
// GitHub as a node: the owner's switch, and a person adopting an agent's branch (relay-outbound.js).
|
|
177
|
-
'POST /github-sync': async (b) => (await session.setGithubSync(b.on !== false)).githubSync === false ? { githubSync: false } : { githubSync: true },
|
|
178
|
-
'POST /adopt': (b) => session.adoptBranch(String(b.from || '')),
|
|
179
|
-
// The branch's pull requests on GitHub, and merging one, through the relay (relay-commit.js).
|
|
180
|
-
'POST /github/recheck': () => session.githubRecheck(),
|
|
181
|
-
'POST /unpublished': (b) => session.unpublished({ files: Array.isArray(b.files) ? b.files : null, task: b.task || null, column: b.column || '' }),
|
|
182
|
-
// git in an agent's folder: what it signs in to GitHub with (`quilt git-credential`), and whether a push may go (`quilt git-pre-push`).
|
|
183
|
-
'POST /git/credential': (b) => session.gitCredential({ host: b.host || '' }),
|
|
184
|
-
'POST /git/push-check': (b) => session.pushCheck({ remote: b.remote || 'origin', refs: Array.isArray(b.refs) ? b.refs : [] }),
|
|
185
166
|
|
|
186
|
-
// A commit request committed in this folder, which has git (commit.js).
|
|
187
|
-
'POST /commit-request/commit': (b) => session.commitRequest(String(b.id || '')),
|
|
188
|
-
'GET /uncommitted': () => session.uncommittedWork(),
|
|
189
|
-
'POST /commit-request/done': (b) => ({ done: session.resolveCommitRequests({ ids: b.id ? [String(b.id)] : null }) }),
|
|
190
167
|
'POST /work': (b) => {
|
|
191
168
|
if (b.state === 'working') session.personaSays(b.via, b.note)
|
|
192
169
|
const work = session.setWork(b.state, b.note)
|
|
@@ -212,17 +189,13 @@ export async function startControl (session, extras = {}) {
|
|
|
212
189
|
session.noteTaskChange(null, task, b.via)
|
|
213
190
|
return { task, tasks: session.taskList() }
|
|
214
191
|
},
|
|
215
|
-
'POST /tasks/update':
|
|
192
|
+
'POST /tasks/update': (b) => {
|
|
216
193
|
const before = session.taskList().find((t) => t.id === b.id) || null
|
|
217
|
-
// Done merges the task's pull request first (relay-commit.js): refused with the reason when it can't be.
|
|
218
|
-
const finished = b.column === 'done' && before && before.column !== 'done' ? await session.finishTask(b.id) : null
|
|
219
194
|
const task = session.updateTask(b)
|
|
220
195
|
session.noteTaskChange(before, task, b.via)
|
|
221
196
|
if (b.column === 'doing' && task) session.personaSays(b.via, task.title) // taking a task can name an AI session
|
|
222
197
|
if (task && (b.column === 'doing' || b.column === 'done')) session.stitchClock(b.via, b.column === 'doing')
|
|
223
|
-
|
|
224
|
-
const branch = b.column === 'doing' && task && before && before.column !== 'doing' ? await session.branchForTask(task).catch(() => null) : null
|
|
225
|
-
return { task, tasks: session.taskList(), ...(finished && finished.merged ? { merged: finished.merged } : {}), ...(branch ? { branch } : {}) }
|
|
198
|
+
return { task, tasks: session.taskList() }
|
|
226
199
|
},
|
|
227
200
|
// What an agent gets when it picks a task up: history for its files, claims, the project's checks.
|
|
228
201
|
'POST /tasks/brief': (b) => session.taskBrief(b.id),
|
|
@@ -233,10 +206,6 @@ export async function startControl (session, extras = {}) {
|
|
|
233
206
|
return r
|
|
234
207
|
},
|
|
235
208
|
'POST /tasks/delete': (b) => { session.deleteTask(b.id); return { tasks: session.taskList() } },
|
|
236
|
-
// Merges Quilt made on its own (session.js autoSettle), and files waiting for their holder to let go.
|
|
237
|
-
'GET /merges': () => ({ merges: session.mergeList() }),
|
|
238
|
-
// quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js).
|
|
239
|
-
'POST /clash': (b) => session.clash({ action: String(b.action || ''), path: String(b.path || ''), content: typeof b.content === 'string' ? b.content : null, checks: b.checks && typeof b.checks === 'object' ? b.checks : null, force: b.force === true }),
|
|
240
209
|
'GET /rules': () => ({ text: session.projectRulesText() }),
|
|
241
210
|
// Secrets (`quilt run`, quilt_secret_names): asked of the API as this session's own identity
|
|
242
211
|
// (its person, or the agent it runs as). Run tokens and names only: never a value.
|
package/src/duties.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Messages are chat entries ({ id, by, to, text, ts }) the reader can see.
|
|
17
17
|
import { mentionsMe, mentioned, ALL_AGENTS } from './inbox.js'
|
|
18
|
-
import { RELAY_BY } from './
|
|
18
|
+
import { RELAY_NAME as RELAY_BY } from './ui/chat.js'
|
|
19
19
|
import { RELAY_OLD_NAMES } from './ui/chat.js'
|
|
20
20
|
|
|
21
21
|
|
package/src/github-commit.js
CHANGED
|
@@ -1,320 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// its machine, and with nobody else online. The session's copy of the files is the content; the
|
|
4
|
-
// branch's commit on GitHub is the parent; only the files named change. No folder, index or
|
|
5
|
-
// working tree is involved, so nobody's uncommitted work is swept in.
|
|
6
|
-
//
|
|
7
|
-
// It never rewrites history: a ref is only moved forward (GitHub refuses anything else without
|
|
8
|
-
// force, which is never sent), and a file that changed on GitHub since the session's copy was
|
|
9
|
-
// taken is refused rather than overwritten. Public and private repositories work alike: writing
|
|
10
|
-
// needs the session's GitHub token with write access to the repository's contents (and to pull
|
|
11
|
-
// requests, to open one).
|
|
12
|
-
//
|
|
13
|
-
// Git for this session (AGENT_COMMITS; docs/superpowers/specs/2026-10-17-git-for-everyone.md): off for
|
|
14
|
-
// everyone, or on with what agents may push: their own branches only (quilt/<agent>/…, the default),
|
|
15
|
-
// or any branch, the session's own included. People may push any branch while it is on.
|
|
16
|
-
import crypto from 'node:crypto'
|
|
1
|
+
// R3 removed GitHub commits; only this pure helper stays, because test/unit/history.test.js (claimed in
|
|
2
|
+
// another square, sq:09a100d0) still imports it from here. Delete this file with that test's import.
|
|
17
3
|
import { blameChange } from './history.js'
|
|
18
4
|
|
|
19
|
-
|
|
20
|
-
export const AGENT_COMMITS = ['off', 'branches', 'any']
|
|
21
|
-
export const DEFAULT_AGENT_COMMITS = 'branches'
|
|
22
|
-
export const MAX_COMMIT_FILES = 300
|
|
23
|
-
export const MAX_COMMIT_BYTES = 20 * 1024 * 1024
|
|
24
|
-
const BRANCH = /^(?!\/)(?!.*\/\/)(?!.*\.\.)(?!.*\.lock(\/|$))(?!.*[\s~^:?*[\\\x00-\x1f\x7f])(?!.*@\{)[^]+(?<![/.])$/
|
|
25
|
-
|
|
26
|
-
export class CommitRefused extends Error {}
|
|
27
|
-
|
|
28
|
-
/** Who set the rule over a session, in words: "the workspace Platform", "the team Night shift", "the org Acme". */
|
|
29
|
-
export const ruleSetter = (set) => `the ${set.by === 'org' ? 'org' : set.by === 'team' ? 'team' : 'workspace'}${set.name ? ` ${set.name}` : ''}`
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Git for everyone, in one line, for whoever is in the session: `policy` ('off', 'branches', 'any'),
|
|
33
|
-
* `by` (the rule over the session, { by, name }, when one set it) and `login` (the GitHub account
|
|
34
|
-
* whose connection covers the repository, when one does).
|
|
35
|
-
*/
|
|
36
|
-
export function gitForEveryoneWords ({ policy = DEFAULT_AGENT_COMMITS, by = null, login = '' } = {}) {
|
|
37
|
-
if (policy === 'off') return `Git is off for everyone in this session: ${by ? ruleSetter(by) : 'the session owner'} turned it off.`
|
|
38
|
-
const agents = policy === 'any' ? 'people and agents push to any branch' : 'people push to any branch, agents to branches of their own'
|
|
39
|
-
if (login) return `Git is on for everyone here, through @${login}'s connection: ${agents}, with nothing to set up.`
|
|
40
|
-
return `Git is on for everyone here once someone connects GitHub (one click in the commit panel, by anyone whose GitHub account can write to the repository): ${agents}.`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** git's id for a file's bytes (what GitHub's trees list), so content is compared without fetching it. */
|
|
44
|
-
export function gitBlobSha (buf) {
|
|
45
|
-
return crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** "duncan", "daniel-carmichael": a name as a branch path segment. */
|
|
49
|
-
export function slug (s, max = 40) {
|
|
50
|
-
return String(s || '').toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, max).replace(/-$/, '') || 'work'
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The branches an agent owns: quilt/<its name>/… */
|
|
54
|
-
export const agentPrefix = (name) => `quilt/${slug(name, 30)}/`
|
|
55
|
-
|
|
56
|
-
/** Whether `name` is a branch name git and GitHub accept. */
|
|
57
|
-
export const validBranchName = (name) => typeof name === 'string' && name.length > 0 && name.length <= 200 && BRANCH.test(name)
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Where a member's commit goes, or why it may not: { branch, own } or throws CommitRefused.
|
|
61
|
-
* `sessionBranch`: the GitHub branch the session's branch follows (main). `target`: what they
|
|
62
|
-
* asked for ('' = the default). `kind`: 'agent' for agents, anything else for people.
|
|
63
|
-
*/
|
|
64
|
-
export function commitTarget ({ policy = DEFAULT_AGENT_COMMITS, kind = 'human', name, sessionBranch, target = '', message = '' }) {
|
|
65
|
-
const agent = kind === 'agent'
|
|
66
|
-
const mode = AGENT_COMMITS.includes(policy) ? policy : DEFAULT_AGENT_COMMITS
|
|
67
|
-
if (agent && mode === 'off') throw new CommitRefused('Git is off for everyone in this session. Ask a person for the commit with quilt_request_commit (your files and a message).')
|
|
68
|
-
const prefix = agentPrefix(name)
|
|
69
|
-
let branch = String(target || '').trim().replace(/^refs\/heads\//, '')
|
|
70
|
-
if (!branch) branch = agent && mode === 'branches' ? `${prefix}${slug(message, 40)}` : sessionBranch
|
|
71
|
-
if (!validBranchName(branch)) throw new CommitRefused(`"${branch}" is not a branch name git accepts.`)
|
|
72
|
-
if (agent && mode === 'branches' && !branch.startsWith(prefix)) {
|
|
73
|
-
throw new CommitRefused(`The session owner lets agents commit only to branches of their own: ${prefix}<topic>${branch === sessionBranch ? ` (not ${sessionBranch} itself)` : ''}. Leave branch out to get one, and open a pull request with pull_request: true.`)
|
|
74
|
-
}
|
|
75
|
-
return { branch, own: branch !== sessionBranch }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* What a commit of `files` would change: { changes: [{ path, sha?, deleted, bytes }], same: [paths], moved: [paths] }.
|
|
80
|
-
* `files`: Map path -> Buffer (the session's content) or null (deleted in the session).
|
|
81
|
-
* `parent`: Map path -> blob sha at the commit the new one goes on; `base`: at the commit the
|
|
82
|
-
* session's copy is measured from. A file whose blob differs between the two changed on GitHub
|
|
83
|
-
* since the session took its copy: it is `moved`, and committing the session's copy would undo
|
|
84
|
-
* that change. Without `base` (a branch only its agent writes to) nothing counts as moved.
|
|
85
|
-
*/
|
|
86
|
-
export function planCommit (files, { parent, base = null }) {
|
|
87
|
-
const changes = []
|
|
88
|
-
const same = []
|
|
89
|
-
const moved = []
|
|
90
|
-
for (const [rel, buf] of files) {
|
|
91
|
-
const now = buf ? gitBlobSha(buf) : null
|
|
92
|
-
const at = parent.get(rel) || null
|
|
93
|
-
if (now === at) { same.push(rel); continue }
|
|
94
|
-
if (base && (base.get(rel) || null) !== at) { moved.push(rel); continue }
|
|
95
|
-
changes.push({ path: rel, sha: now, deleted: !buf, bytes: buf || null })
|
|
96
|
-
}
|
|
97
|
-
return { changes, same, moved }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Who changed each file in the session since `since` (ms) besides `me`, from the branch's
|
|
102
|
-
* history (entries oldest first): Map path -> [names]. Pulls from git are nobody's.
|
|
103
|
-
*/
|
|
104
|
-
export function othersEditing (paths, history, { me, since = 0 } = {}) {
|
|
105
|
-
const want = new Set(paths)
|
|
106
|
-
const out = new Map()
|
|
107
|
-
for (const e of history || []) {
|
|
108
|
-
if (!e || !want.has(e.path) || e.pulled || e.seed || !e.by || e.by === me || (e.ts || 0) <= since) continue
|
|
109
|
-
const list = out.get(e.path) || []
|
|
110
|
-
if (!list.includes(e.by)) list.push(e.by)
|
|
111
|
-
out.set(e.path, list)
|
|
112
|
-
}
|
|
113
|
-
return out
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Who besides `me` made the changes a commit of one text file would carry (history.js blameChange). */
|
|
5
|
+
/** Who besides `me` made the changes in one text file's edit (history.js blameChange). */
|
|
117
6
|
export function othersInChange ({ before = '', after = '', entries = [], me }) {
|
|
118
7
|
return blameChange({ before, after, entries }).filter((n) => n !== me)
|
|
119
8
|
}
|
|
120
|
-
|
|
121
|
-
/** The commit message, and who made it: an agent is named as the author, the token's account commits it. */
|
|
122
|
-
export function messageFor ({ message, by, task = null, coAuthors = [] }) {
|
|
123
|
-
const lines = [String(message || '').trim() || 'Changes from the Quilt session']
|
|
124
|
-
const trailer = [`Made in Quilt by ${by}${task ? ` for task ${task.id}: ${task.title}` : ''}.`]
|
|
125
|
-
for (const n of coAuthors) trailer.push(`Co-authored-by: ${n} <${noreply(n)}>`)
|
|
126
|
-
return `${lines.join('\n')}\n\n${trailer.join('\n')}`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export const noreply = (name) => `${slug(name, 40)}@agents.noreply.heyquilt.com`
|
|
130
|
-
|
|
131
|
-
class GitHubWriteError extends Error {
|
|
132
|
-
constructor (status, what, body) {
|
|
133
|
-
super(`GitHub answered ${status} to ${what}${body && body.message ? `: ${body.message}` : ''}`)
|
|
134
|
-
this.status = status
|
|
135
|
-
this.body = body
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** The GitHub calls a commit needs. The token goes in a header, never a URL. */
|
|
140
|
-
export function githubWriter ({ fetch = globalThis.fetch, token, owner, name }) {
|
|
141
|
-
const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}` }
|
|
142
|
-
const repo = `${API}/repos/${owner}/${name}`
|
|
143
|
-
const req = async (method, path, body, what) => {
|
|
144
|
-
const res = await fetch(`${repo}${path}`, { method, headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'follow' })
|
|
145
|
-
let json = null
|
|
146
|
-
try { json = await res.json() } catch {}
|
|
147
|
-
if (!res.ok) throw new GitHubWriteError(res.status, what, json)
|
|
148
|
-
return json
|
|
149
|
-
}
|
|
150
|
-
const enc = (ref) => ref.split('/').map(encodeURIComponent).join('/')
|
|
151
|
-
return {
|
|
152
|
-
/** The branch's head commit, or null when there is no such branch. */
|
|
153
|
-
async ref (branch) {
|
|
154
|
-
try { return (await req('GET', `/git/ref/heads/${enc(branch)}`, null, `branch ${branch}`)).object.sha } catch (err) { if (err.status === 404) return null; throw err }
|
|
155
|
-
},
|
|
156
|
-
/** A commit's tree sha and date. */
|
|
157
|
-
async commit (sha) {
|
|
158
|
-
const c = await req('GET', `/git/commits/${sha}`, null, `commit ${sha.slice(0, 7)}`)
|
|
159
|
-
return { tree: c.tree.sha, ts: Date.parse(c.committer && c.committer.date) || 0 }
|
|
160
|
-
},
|
|
161
|
-
/** Every file's blob sha in a tree: Map path -> sha (null when GitHub cut the listing short). */
|
|
162
|
-
async blobs (treeSha) {
|
|
163
|
-
const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
|
|
164
|
-
if (t.truncated) return null
|
|
165
|
-
return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.sha]))
|
|
166
|
-
},
|
|
167
|
-
async modes (treeSha) {
|
|
168
|
-
const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
|
|
169
|
-
return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.mode]))
|
|
170
|
-
},
|
|
171
|
-
/** A file's bytes at a commit, or null when it isn't there. */
|
|
172
|
-
async content (sha, rel) {
|
|
173
|
-
const res = await fetch(`${repo}/contents/${enc(rel)}?ref=${sha}`, { headers: { ...headers, accept: 'application/vnd.github.raw' }, redirect: 'follow' })
|
|
174
|
-
if (res.status === 404) return null
|
|
175
|
-
if (!res.ok) throw new GitHubWriteError(res.status, rel, null)
|
|
176
|
-
return Buffer.from(await res.arrayBuffer())
|
|
177
|
-
},
|
|
178
|
-
async blob (buf) { return (await req('POST', '/git/blobs', { content: buf.toString('base64'), encoding: 'base64' }, 'a file')).sha },
|
|
179
|
-
async tree (baseTree, entries) { return (await req('POST', '/git/trees', { base_tree: baseTree, tree: entries }, 'the new file list')).sha },
|
|
180
|
-
async newCommit ({ message, tree, parent, author }) {
|
|
181
|
-
return (await req('POST', '/git/commits', { message, tree, parents: [parent], ...(author ? { author } : {}) }, 'the commit')).sha
|
|
182
|
-
},
|
|
183
|
-
/** Moves the branch forward to `sha` (never forced: GitHub refuses a move that would lose commits). */
|
|
184
|
-
async move (branch, sha) { await req('PATCH', `/git/refs/heads/${enc(branch)}`, { sha, force: false }, `branch ${branch}`) },
|
|
185
|
-
async create (branch, sha) { await req('POST', '/git/refs', { ref: `refs/heads/${branch}`, sha }, `new branch ${branch}`) },
|
|
186
|
-
async pull ({ title, head, base, body }) {
|
|
187
|
-
try {
|
|
188
|
-
const p = await req('POST', '/pulls', { title, head, base, body }, 'the pull request')
|
|
189
|
-
return { number: p.number, url: p.html_url }
|
|
190
|
-
} catch (err) {
|
|
191
|
-
// One is already open for this branch: say which.
|
|
192
|
-
if (err.status === 422) {
|
|
193
|
-
const open = await req('GET', `/pulls?head=${encodeURIComponent(`${owner}:${head}`)}&state=open`, null, 'pull requests')
|
|
194
|
-
if (Array.isArray(open) && open[0]) return { number: open[0].number, url: open[0].html_url, existed: true }
|
|
195
|
-
}
|
|
196
|
-
throw err
|
|
197
|
-
}
|
|
198
|
-
},
|
|
199
|
-
/** The branch's newest commits: [{ sha, message, author, date, url }]. */
|
|
200
|
-
async log (branch, n = 10) {
|
|
201
|
-
const list = await req('GET', `/commits?sha=${encodeURIComponent(branch)}&per_page=${Math.max(1, Math.min(50, n | 0))}`, null, `commits on ${branch}`)
|
|
202
|
-
return (Array.isArray(list) ? list : []).map((c) => ({ sha: c.sha, message: (c.commit && c.commit.message) || '', author: (c.commit && c.commit.author && c.commit.author.name) || (c.author && c.author.login) || '', date: (c.commit && c.commit.author && c.commit.author.date) || '', url: c.html_url }))
|
|
203
|
-
},
|
|
204
|
-
/** Open pull requests, newest first: [{ number, title, head, base, url, draft, by, sha }]. */
|
|
205
|
-
async pulls () {
|
|
206
|
-
const list = await req('GET', '/pulls?state=open&per_page=50&sort=updated&direction=desc', null, 'pull requests')
|
|
207
|
-
return (Array.isArray(list) ? list : []).map((p) => ({ number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, draft: !!p.draft, by: p.user && p.user.login, sha: p.head && p.head.sha }))
|
|
208
|
-
},
|
|
209
|
-
/** One pull request: { number, title, head, base, url, sha, state, merged, mergeable, mergeableState }. */
|
|
210
|
-
async pullRequest (number) {
|
|
211
|
-
const p = await req('GET', `/pulls/${Number(number)}`, null, `pull request #${number}`)
|
|
212
|
-
return { number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, sha: p.head && p.head.sha, state: p.state, merged: !!p.merged, mergeable: p.mergeable, mergeableState: p.mergeable_state || '' }
|
|
213
|
-
},
|
|
214
|
-
/** How `head` stands to `base`: 'identical', 'ahead' (base is in head), 'behind' or 'diverged' (it isn't). */
|
|
215
|
-
async compare (base, head) {
|
|
216
|
-
const c = await req('GET', `/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, null, 'a comparison')
|
|
217
|
-
return (c && c.status) || 'diverged'
|
|
218
|
-
},
|
|
219
|
-
/** How a commit's checks stand: 'passing', 'failing', 'pending' or 'none', with the failing ones named. */
|
|
220
|
-
async checks (sha) {
|
|
221
|
-
const runs = await req('GET', `/commits/${sha}/check-runs?per_page=100`, null, 'checks')
|
|
222
|
-
const st = await req('GET', `/commits/${sha}/status`, null, 'statuses')
|
|
223
|
-
const items = (runs && Array.isArray(runs.check_runs) ? runs.check_runs : []).map((c) => ({ name: c.name, done: c.status === 'completed', ok: ['success', 'neutral', 'skipped'].includes(c.conclusion) }))
|
|
224
|
-
for (const s of (st && Array.isArray(st.statuses) ? st.statuses : [])) items.push({ name: s.context, done: s.state !== 'pending', ok: s.state === 'success' })
|
|
225
|
-
if (!items.length) return { state: 'none', failing: [] }
|
|
226
|
-
const failing = items.filter((c) => c.done && !c.ok).map((c) => c.name)
|
|
227
|
-
if (failing.length) return { state: 'failing', failing }
|
|
228
|
-
return { state: items.every((c) => c.done) ? 'passing' : 'pending', failing: [] }
|
|
229
|
-
},
|
|
230
|
-
/** Merges a pull request: { sha }. GitHub refuses when it can't be merged as it is (conflicts, rules). */
|
|
231
|
-
async merge (number, { method = 'squash', title = '' } = {}) {
|
|
232
|
-
const r = await req('PUT', `/pulls/${Number(number)}/merge`, { merge_method: method, ...(title ? { commit_title: title } : {}) }, `merging #${number}`)
|
|
233
|
-
return { sha: r.sha, merged: !!r.merged }
|
|
234
|
-
},
|
|
235
|
-
async deleteBranch (branch) {
|
|
236
|
-
try { await req('DELETE', `/git/refs/heads/${enc(branch)}`, null, `deleting ${branch}`) } catch (err) { if (err.status !== 404 && err.status !== 422) throw err }
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Why GitHub refused, in words for whoever asked (never the token). */
|
|
242
|
-
export function writeProblem (err, repo) {
|
|
243
|
-
const s = err && err.status
|
|
244
|
-
if (s === 401) return `the session's GitHub token was refused by GitHub (expired or revoked): ask the session owner for a new one (quilt_github_token)`
|
|
245
|
-
if (s === 403 || s === 404) return `the session's GitHub token can't write to ${repo}: the owner needs a token with "Contents: Read and write" on it (and "Pull requests: Read and write" to open pull requests), set with quilt_github_token`
|
|
246
|
-
if (s === 409) return `${repo} is empty or being changed: try again`
|
|
247
|
-
if (s === 422) return `GitHub refused: ${err.body && err.body.message ? err.body.message : 'the branch moved meanwhile'}`
|
|
248
|
-
return err && err.message ? err.message : String(err)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Makes the commit on GitHub: `files` (Map path -> Buffer|null) onto `branch`, which starts at
|
|
253
|
-
* `from` when it doesn't exist yet (the session's commit), checked against `base` (the commit
|
|
254
|
-
* the session's copy is measured from; null for a branch of the asker's own, which only they write to). Resolves { sha, branch, created, changed, same } or
|
|
255
|
-
* throws CommitRefused (nothing to commit, files changed on GitHub) or a GitHub error.
|
|
256
|
-
*/
|
|
257
|
-
export async function commitOnGitHub (gh, { files, branch, from, base, message, author, tries = 2 }) {
|
|
258
|
-
for (let attempt = 1; ; attempt++) {
|
|
259
|
-
const head = await gh.ref(branch)
|
|
260
|
-
const parent = head || from
|
|
261
|
-
const pc = await gh.commit(parent)
|
|
262
|
-
const parentBlobs = await gh.blobs(pc.tree)
|
|
263
|
-
if (!parentBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
|
|
264
|
-
const baseBlobs = !base ? null : base === parent ? parentBlobs : await gh.blobs((await gh.commit(base)).tree)
|
|
265
|
-
if (base && !baseBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
|
|
266
|
-
const plan = planCommit(files, { parent: parentBlobs, base: baseBlobs })
|
|
267
|
-
if (plan.moved.length) {
|
|
268
|
-
throw new CommitRefused(`${plan.moved.join(', ')} changed on ${head ? branch : 'GitHub'} since the session's copy was taken, so committing the session's copy would undo that. ` +
|
|
269
|
-
'Bring those commits into the session first (quilt_sync_branch; the session then has both), then commit again.')
|
|
270
|
-
}
|
|
271
|
-
if (!plan.changes.length) return { sha: parent, branch, created: false, changed: [], same: plan.same, nothing: true, exists: !!head }
|
|
272
|
-
const modes = await gh.modes(pc.tree)
|
|
273
|
-
const entries = []
|
|
274
|
-
for (const c of plan.changes) {
|
|
275
|
-
if (c.deleted) entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: null })
|
|
276
|
-
else entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: await gh.blob(c.bytes) })
|
|
277
|
-
}
|
|
278
|
-
const tree = await gh.tree(pc.tree, entries)
|
|
279
|
-
const sha = await gh.newCommit({ message, tree, parent, author })
|
|
280
|
-
try {
|
|
281
|
-
if (head) await gh.move(branch, sha)
|
|
282
|
-
else await gh.create(branch, sha)
|
|
283
|
-
return { sha, parent, branch, created: !head, changed: plan.changes.map((c) => c.path), same: plan.same }
|
|
284
|
-
} catch (err) {
|
|
285
|
-
// Someone pushed to the branch meanwhile: start again from its new head.
|
|
286
|
-
if ((err.status === 422 || err.status === 409) && attempt < tries) continue
|
|
287
|
-
throw err
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** quilt_commit, the same for every agent (local MCP, hosted MCP, CLI). */
|
|
293
|
-
export const GIT_COMMIT_DESCRIPTION = 'Commit your work to the branch you are on, on GitHub: the session\'s copy of exactly these files (by default every change of yours on record on this branch), with your message. ' +
|
|
294
|
-
'On a branch of your own (quilt/<you>/…, where taking a task puts you) the pull request to the session\'s branch opens by itself and is tied to your task. ' +
|
|
295
|
-
'The session\'s branch itself is open to agents only when the owner lets agents push to any branch; otherwise take a task or quilt_git_branch create first. ' +
|
|
296
|
-
'Refused for a file that changed on GitHub since the session\'s copy (quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
|
|
297
|
-
'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
|
|
298
|
-
export const gitCommitSchema = (z) => ({
|
|
299
|
-
message: z.string().min(1).max(2000).describe('What the change does (the first line is the subject)'),
|
|
300
|
-
files: z.array(z.string().min(1).max(500)).max(200).optional().describe('The files to commit, deleted ones too; default: every change of yours on record on this branch'),
|
|
301
|
-
task: z.string().max(40).optional().describe('The task this is for: its title names the pull request, and the task follows it'),
|
|
302
|
-
with_others: z.boolean().optional().describe('Also commit files that hold other people\'s uncommitted changes (they are named as co-authors)')
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
export const COMMIT_DESCRIPTION = 'For agents with no folder (hosted, over HTTP): commit your finished work to the repository on GitHub through the relay, since you have no git of your own; nobody else needs to be online. Agents with a folder push with git instead. ' +
|
|
306
|
-
'Quilt commits the session\'s copy of exactly these files (by default every change of yours on record on this branch) with your message, and everyone sees it in chat. ' +
|
|
307
|
-
'Where it goes is the session owner\'s call: by default a branch of your own (quilt/<you>/<topic>; pull_request: true opens a pull request to the session\'s branch); ' +
|
|
308
|
-
'when the owner lets agents commit to any branch, leaving branch out commits to the session\'s branch itself. ' +
|
|
309
|
-
'Refused for a file that changed on GitHub since the session\'s copy (bring it in with quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
|
|
310
|
-
'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
|
|
311
|
-
|
|
312
|
-
/** quilt_commit's arguments, the same for the local and the hosted tool (`z`: zod). */
|
|
313
|
-
export const commitSchema = (z) => ({
|
|
314
|
-
message: z.string().max(2000).optional().describe('The commit message: what the change does (a task\'s title by default)'),
|
|
315
|
-
files: z.array(z.string().max(1024)).max(300).optional().describe('The files to commit, relative to the project\'s top folder (deleted ones too); default: every change of yours on record on this branch'),
|
|
316
|
-
branch: z.string().max(200).optional().describe('The GitHub branch to commit to; default: a branch of your own (quilt/<you>/<topic>), or the session\'s branch when the owner lets agents commit there'),
|
|
317
|
-
pull_request: z.boolean().optional().describe('Open a pull request from your branch to the session\'s branch'),
|
|
318
|
-
with_others: z.boolean().optional().describe('Also commit files that hold others\' uncommitted changes (they are named as co-authors)'),
|
|
319
|
-
task: z.string().max(40).optional().describe('The task this work was for: its title is the message and its changes the files, unless you give them')
|
|
320
|
-
})
|