heyquilt 0.3.138 → 0.3.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/RELEASES.md +10 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session-follow.js +18 -5
  21. package/src/session.js +58 -3526
  22. package/src/status.js +2 -58
  23. package/src/turn-sync.js +15 -2
  24. package/src/ui/changes.js +5 -14
  25. package/src/ui/chat.js +0 -1
  26. package/src/ui/common.js +0 -2
  27. package/src/ui/git-rule.js +2 -30
  28. package/src/ui/home.js +3 -76
  29. package/src/ui/session.js +34 -528
  30. package/src/ui/tree.js +2 -8
  31. package/src/ui/workspaces.js +2 -122
  32. package/src/ui-server.js +1 -38
  33. package/src/api/routes/github.js +0 -380
  34. package/src/branches.js +0 -194
  35. package/src/clash.js +0 -128
  36. package/src/commit-request.js +0 -59
  37. package/src/commit.js +0 -213
  38. package/src/git-hook.js +0 -48
  39. package/src/gitignore.js +0 -49
  40. package/src/gitstate.js +0 -1069
  41. package/src/relay-ci.js +0 -111
  42. package/src/relay-clash.js +0 -214
  43. package/src/relay-commit.js +0 -923
  44. package/src/relay-filekey.js +0 -46
  45. package/src/relay-git.js +0 -274
  46. package/src/relay-githttp.js +0 -166
  47. package/src/relay-github.js +0 -109
  48. package/src/relay-outbound.js +0 -1295
  49. package/src/relay-repo-moves.js +0 -43
  50. package/src/relay-sync.js +0 -795
  51. package/src/relay-upstream.js +0 -889
  52. package/src/sync-words.js +0 -42
  53. package/src/upstream.js +0 -192
@@ -1,923 +0,0 @@
1
- // quilt_commit, on the relay: a member's finished work committed to GitHub from the session's
2
- // copy of the files (github-commit.js), for an agent on any tool or a person, from a folder
3
- // (through its connection) or over HTTP (hosted agents). The relay is always there, so an
4
- // agent's work is never held up waiting for a person to be online.
5
- import { parseRepo, upstreamsOf, validToken } from './relay-upstream.js'
6
- import { RELAY_BY, isRelay } from './branches.js'
7
- import { DEFAULT_KEY } from './branchdocs.js'
8
- import { isDiff, docKey, FOLLOWS_GIT, ruleFiles } from './branch-view.js'
9
- import { appCredentials, noCredentialsText } from './relay-github.js'
10
- import crypto from 'node:crypto'
11
- import { notePublished, notePublishedIds, publishedOf } from './upstream.js'
12
- import { mayChange } from './session-access.js'
13
- import { isSafeRelPath, makeIgnore, scopeIgnore, isIgnored } from './pathrules.js'
14
- import {
15
- CommitRefused, commitTarget, commitOnGitHub, githubWriter, gitBlobSha, messageFor, noreply, othersEditing, othersInChange, writeProblem,
16
- AGENT_COMMITS, DEFAULT_AGENT_COMMITS, MAX_COMMIT_FILES, MAX_COMMIT_BYTES, agentPrefix, slug, ruleSetter, gitForEveryoneWords
17
- } from './github-commit.js'
18
- import { readTasks, addTask, updateTask } from './tasks.js'
19
- import { addComment } from './task-comments.js'
20
- import { quiltOf } from './session-seam.js'
21
-
22
- export { CommitRefused }
23
-
24
- // The origin of the relay's own writes to the room's record of what was published.
25
- const RELAY_PUBLISH = 'relay-publish'
26
-
27
- /** How strict each mode is: a rule over a session is a ceiling, and the strictest holds. */
28
- export const POLICY_RANK = { off: 0, branches: 1, any: 2 }
29
-
30
- /**
31
- * Git for this session (docs/superpowers/specs/2026-10-17-git-for-everyone.md): 'off' (for everyone, people
32
- * included), or on with what agents may push: 'branches' (their own) or 'any'. A rule over the session (its
33
- * org's, a team's, its workspace's: server.js refreshPolicy) is a ceiling: the owner's own choice holds only
34
- * when it is stricter. Neither set: the default (branches).
35
- */
36
- export const agentCommitsOf = (room) => {
37
- const set = room.meta.agentCommitsSet
38
- const rule = set && AGENT_COMMITS.includes(set.mode) ? set.mode : null
39
- const own = AGENT_COMMITS.includes(room.meta.agentCommits) ? room.meta.agentCommits : null
40
- if (rule && own) return POLICY_RANK[own] < POLICY_RANK[rule] ? own : rule
41
- return rule || own || DEFAULT_AGENT_COMMITS
42
- }
43
-
44
- export { ruleSetter }
45
-
46
- export const POLICY_WORDS = {
47
- off: 'git is off for everyone here: nobody pushes through Quilt, and agents ask a person to commit (quilt_request_commit)',
48
- branches: 'git is on for everyone here: people push any branch, agents push to branches of their own (quilt/<agent>/…) and open pull requests; people merge them',
49
- any: 'git is on for everyone here: people and agents push to any branch, the session\'s own included'
50
- }
51
-
52
- const cleanList = (files) => {
53
- const out = []
54
- for (const f of Array.isArray(files) ? files : []) {
55
- const p = String(f || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
56
- if (isSafeRelPath(p) && !out.includes(p)) out.push(p)
57
- }
58
- return out
59
- }
60
-
61
- /**
62
- * What the relay commits to owner/name with: the session owner's GitHub connection (relay-github.js),
63
- * or a token the owner set by hand (quilt_github_token). Throws CommitRefused saying what the owner
64
- * can do when there is neither.
65
- */
66
- export async function credentialsFor (room, repo, { actingFor = '' } = {}) {
67
- const full = `${repo.owner}/${repo.name}`
68
- const r = await appCredentials(room, full, { access: 'write', actingFor })
69
- if (r.token) return r.token
70
- if (validToken(room.meta.githubToken)) return room.meta.githubToken
71
- tellOwner(room, full, r)
72
- throw new CommitRefused(noCredentialsText(r, full))
73
- }
74
-
75
- // An agent was held up for want of a GitHub connection: the session owner hears it once (a direct
76
- // message in chat that needs no reply), at most every six hours per repository, with the one click that fixes it.
77
- const TOLD_MS = 6 * 60 * 60 * 1000
78
- function tellOwner (room, full, r) {
79
- const owner = room.meta && room.meta.ownerName
80
- if (!owner || !room.postChat || !['not-connected', 'not-installed', 'no-access'].includes(r.state)) return
81
- if (!room.toldConnect) room.toldConnect = new Map()
82
- const at = room.toldConnect.get(full) || 0
83
- if (Date.now() - at < TOLD_MS) return
84
- room.toldConnect.set(full, Date.now())
85
- const what = r.state === 'not-installed' ? `Quilt isn't installed on ${full} on GitHub` : r.state === 'no-access' ? `no connected GitHub account (@${r.login}) may write to ${full}` : `nobody here has connected GitHub for ${full}`
86
- room.postChat({ by: RELAY_BY, to: owner, text: `An agent tried to commit, but ${what}. Click Connect GitHub in the commit panel (one click, once): agents then commit and merge on their own, and pushes come in within seconds.`, kind: 'commit' })
87
- }
88
-
89
- /** The session branch's repository on GitHub, or throws CommitRefused saying why the relay can't act on it. */
90
- /**
91
- * The branch record that says where work on `key` lands on GitHub: the branch's own when the relay
92
- * follows it there (a member's folder has it as its upstream), otherwise the room's default
93
- * branch's (a branch an agent made in its folder for a feature has no upstream yet; its work is
94
- * measured against, and merges into, the default branch). Null when neither is known.
95
- */
96
- export function repoRecordFor (room, key) {
97
- const recs = upstreamsOf(room)
98
- const k = room.resolveKey ? room.resolveKey(key) : key
99
- const own = recs[k]
100
- // An agent's branch (quilt/<agent>/…) always measures against, and merges into, the default
101
- // branch, even once the relay has a record of the branch itself (its folder pushed it).
102
- const agents = /^quilt\//.test(k)
103
- if (own && own.repo && own.ref && !agents) return { key: k, rec: own }
104
- const d = room.resolveKey ? room.resolveKey(DEFAULT_KEY) : DEFAULT_KEY
105
- const main = recs[d]
106
- if (main && main.repo && main.ref) return { key: d, rec: main }
107
- // No default named yet: main or master, else the room's one followed branch that isn't an agent's.
108
- for (const name of ['main', 'master']) if (recs[name] && recs[name].repo && recs[name].ref) return { key: name, rec: recs[name] }
109
- const followed = Object.entries(recs).filter(([n, r]) => r && r.repo && r.ref && !/^quilt\//.test(n))
110
- if (followed.length === 1) return { key: followed[0][0], rec: followed[0][1] }
111
- if (own && own.repo && own.ref) return { key: k, rec: own }
112
- return null
113
- }
114
-
115
- export function githubRepoOf (room, key) {
116
- const found = repoRecordFor(room, key)
117
- if (!found) throw new CommitRefused(`Quilt doesn't know which GitHub repository \`${room.resolveKey ? room.resolveKey(key) : key}\` follows yet: a member's folder on it that is a git clone tells it once it is online.`)
118
- const { rec } = found
119
- const repo = parseRepo(`https://${rec.repo}`)
120
- if (!repo || !repo.github) throw new CommitRefused(`\`${found.key}\` follows ${rec.repo}, which isn't on GitHub: commit from a folder with git, or ask a person (quilt_request_commit).`)
121
- return { key: found.key, rec, repo, full: `${repo.owner}/${repo.name}` }
122
- }
123
-
124
- /** git's ignore rules for a branch: every .gitignore in the session's copy, scoped to its folder, plus Quilt's own. */
125
- function gitIgnoreOf (e, fromGit = new Map()) {
126
- const texts = []
127
- const add = (t, rel) => {
128
- if (rel !== '.gitignore' && !rel.endsWith('/.gitignore')) return
129
- const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
130
- texts.push(scopeIgnore(t.toString(), dir))
131
- }
132
- e.files.forEach(add)
133
- fromGit.forEach((t, rel) => { if (!e.files.has(rel)) add(t, rel) })
134
- return makeIgnore(texts)
135
- }
136
-
137
- /** A branch's ignore rules: a diff branch's unchanged .gitignore files are git's (branch-view.js). */
138
- async function ignoreRulesOf (room, key, e) {
139
- return gitIgnoreOf(e, isDiff(e) ? await ruleFiles(room, key, e, ['.gitignore']).catch(() => new Map()) : new Map())
140
- }
141
-
142
- /** Without the files a diff branch leaves to git (the session never changed them, so they are git's already). */
143
- const ownChanges = (e, list) => isDiff(e) ? list.filter((rel) => docKey(e, rel) !== FOLLOWS_GIT) : list
144
-
145
- /** The session's copy of a file on a branch: its bytes, null when it isn't there, or throws for one the relay can't read. */
146
- function sessionBytes (e, rel) {
147
- const t = e.files.get(rel)
148
- if (t) return Buffer.from(t.toString(), 'utf8')
149
- const b = e.blobs.get(rel)
150
- if (b && b.stored) throw new CommitRefused(`${rel} is a large file kept encrypted in storage, which the relay can't read: commit it from a folder with git.`)
151
- if (b) return Buffer.from(b.data || '', 'base64')
152
- return null
153
- }
154
-
155
- /**
156
- * The committer's boxes on files now in git open (docs/superpowers/specs/2026-10-16-git-boxes-design.md):
157
- * committed, they're no longer unfinished work, so they reach everyone's folder. Returns how many opened.
158
- */
159
- export function openBoxesCommitted (room, who, key, rels) {
160
- if (!room.claimFor || !room.dropClaims) return 0
161
- const boxes = [...new Set(rels || [])].map((rel) => room.claimFor(rel, key)).filter((c) => c && c.box && c.pattern && c.by === who.name && !/[*?[]/.test(c.pattern))
162
- if (!boxes.length) return 0
163
- const n = room.dropClaims((c) => boxes.includes(c), (c) => `${c.by} committed it.`)
164
- if (room.saveMeta) room.saveMeta()
165
- if (room.broadcastClaims) room.broadcastClaims()
166
- return n
167
- }
168
-
169
- /**
170
- * Commits `files` (or, without them, the files `who` changed on record, or a task's) from
171
- * branch `key` of the session to GitHub. `who`: { name, kind ('agent' | 'human'), access }.
172
- * Resolves { sha, branch, created, changed, same, url, pr, nothing } or throws (CommitRefused, or
173
- * an Error saying what GitHub refused).
174
- */
175
- export async function commitForMember (room, { who, key, files = null, message = '', branch: target = '', pullRequest = false, withOthers = false, task = null }, { fetch = globalThis.fetch, log = () => {} } = {}) {
176
- const a = who.access || {}
177
- if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
178
- if (a.role === 'viewer') throw new CommitRefused('You can only view this session, so you can\'t commit.')
179
- key = room.resolveKey ? room.resolveKey(key) : key
180
- // The files come from `key` (the member's branch document); where they land on GitHub is that
181
- // branch's record, or the default branch's for a branch the relay has no record of yet (an agent's own).
182
- const found = repoRecordFor(room, key)
183
- const rec = found ? found.rec : null
184
- if (!rec) throw new CommitRefused(`Quilt doesn't know which GitHub repository \`${key}\` follows yet: a member's folder on it that is a git clone tells it once it is online.`)
185
- if (!rec.sha) throw new CommitRefused(`Quilt doesn't know which commit the session's files on \`${key}\` are at yet: a member's folder on it, in step with the session, tells it.`)
186
- const repo = parseRepo(`https://${rec.repo}`)
187
- if (!repo || !repo.github) throw new CommitRefused(`\`${key}\` follows ${rec.repo}, which isn't on GitHub: commit from a folder with git, or ask a person (quilt_request_commit).`)
188
- // The commit is for `who`: when the org requires GitHub, their (or their agent's person's) GitHub permissions count (plan §2).
189
- const actor = Object.entries((room.meta && room.meta.members) || {}).find(([k, m]) => m && m.name === who.name && /^(person|agent):/.test(k))
190
- const token = await credentialsFor(room, repo, { actingFor: actor ? actor[0] : '' })
191
- const t = task ? room.doc.getMap('tasks').get(String(task)) : null
192
- if (task && !t) throw new CommitRefused(`No task ${task} on the board.`)
193
- message = String(message || (t && t.title) || '').trim().slice(0, 2000)
194
- if (!message) throw new CommitRefused('Say what the commit does (message).')
195
- const where = commitTarget({ policy: agentCommitsOf(room), kind: who.kind, name: who.name, sessionBranch: rec.ref, target, message })
196
-
197
- const e = room.branchDoc(key)
198
- const history = e.doc.getArray('history').toArray()
199
- let list = cleanList(files)
200
- if (Array.isArray(files) && files.length && !list.length) throw new CommitRefused('None of those are files in the project (give paths relative to its top folder).')
201
- if (!list.length) {
202
- // What was changed for the task, or by this member, on record (pulls from git are nobody's).
203
- for (const h of history) {
204
- if (!h || h.pulled || h.seed || typeof h.path !== 'string') continue
205
- if (t ? h.task && h.task.id === t.id : h.by === who.name) { if (!list.includes(h.path)) list.push(h.path) }
206
- }
207
- if (t) for (const f of Array.isArray(t.files) ? t.files : []) if (!list.includes(f)) list.push(f)
208
- list = cleanList(list)
209
- }
210
- if (!list.length) throw new CommitRefused(t ? `Task ${t.id} has no changed files on record: give them in files.` : 'Quilt has no changes of yours on record on this branch: give the files in files.')
211
- if (list.length > MAX_COMMIT_FILES) throw new CommitRefused(`That is ${list.length} files; commit at most ${MAX_COMMIT_FILES} at a time.`)
212
- list = ownChanges(e, list)
213
- if (!list.length) throw new CommitRefused('Those files are as git has them on this branch: nothing to commit.')
214
- // What git ignores never goes in: the repository's .gitignore files (as the session has them, each
215
- // scoped to its folder) and Quilt's own list (.env and the like), whatever a member wrote.
216
- const ig = await ignoreRulesOf(room, key, e)
217
- const ignored = list.filter((rel) => isIgnored(ig, rel))
218
- if (ignored.length && ignored.length === list.length) throw new CommitRefused(`${ignored.slice(0, 5).join(', ')}${ignored.length > 5 ? ', …' : ''} ${ignored.length === 1 ? 'is' : 'are'} ignored by the repository's .gitignore (or is a secrets file such as .env), so ${ignored.length === 1 ? 'it isn\'t' : 'they aren\'t'} committed.`)
219
- list = list.filter((rel) => !ignored.includes(rel))
220
- const outside = list.filter((rel) => !mayChange(a, rel))
221
- if (outside.length) throw new CommitRefused(`You may not change ${outside.slice(0, 5).join(', ')}${outside.length > 5 ? ', …' : ''} in this session, so you can't commit ${outside.length === 1 ? 'it' : 'them'}.`)
222
- const content = new Map()
223
- let bytes = 0
224
- for (const rel of list) {
225
- const b = sessionBytes(e, rel)
226
- bytes += b ? b.length : 0
227
- content.set(rel, b)
228
- }
229
- if (bytes > MAX_COMMIT_BYTES) throw new CommitRefused(`Those files come to ${Math.round(bytes / 1048576)} MB; commit at most ${MAX_COMMIT_BYTES / 1048576} MB at a time.`)
230
-
231
- const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
232
- const full = `${repo.owner}/${repo.name}`
233
- try {
234
- // Other people's unfinished work in these files would go in with them: said, not swept in.
235
- const base = await gh.commit(rec.sha)
236
- const baseBlobs = await gh.blobs(base.tree)
237
- const differ = baseBlobs ? list.filter((rel) => (content.get(rel) ? gitBlobSha(content.get(rel)) : null) !== (baseBlobs.get(rel) || null)) : list
238
- // Each changed line is put down to whoever added or removed it last (the session's history);
239
- // a file the relay can't read as text falls back to who edited it since the session's commit.
240
- const others = new Map()
241
- for (const rel of differ) {
242
- const now = content.get(rel)
243
- const text = !e.blobs.get(rel) && (now === null || e.files.get(rel))
244
- let names
245
- if (text) {
246
- const was = baseBlobs && baseBlobs.get(rel) ? await gh.content(rec.sha, rel) : null
247
- names = othersInChange({ before: was ? was.toString('utf8') : '', after: now ? now.toString('utf8') : '', entries: history.filter((h) => h && h.path === rel), me: who.name })
248
- } else names = othersEditing([rel], history, { me: who.name, since: base.ts }).get(rel) || []
249
- names = names.filter((n) => !isRelay(n))
250
- if (names.length) others.set(rel, names)
251
- }
252
- if (others.size && !withOthers) {
253
- const lines = [...others].map(([rel, names]) => `${rel} (${names.join(', ')})`)
254
- throw new CommitRefused(`These files also hold changes by others that are not in git yet: ${lines.join('; ')}. ` +
255
- 'Leave them out (files), ask those people to commit first, or commit them together with with_others: true (they are named as co-authors).')
256
- }
257
- const coAuthors = [...new Set([...others.values()].flat())]
258
- const r = await commitOnGitHub(gh, {
259
- files: content,
260
- branch: where.branch,
261
- from: rec.sha,
262
- base: where.own ? null : rec.sha,
263
- message: messageFor({ message, by: who.name, task: t ? { id: t.id, title: t.title } : null, coAuthors: withOthers ? coAuthors : [] }),
264
- author: who.kind === 'agent' ? { name: who.name, email: noreply(who.name) } : null
265
- })
266
- const out = { ...r, repo: full, url: `https://github.com/${full}/commit/${r.sha}`, ...(ignored.length ? { ignored } : {}) }
267
- const pr = () => gh.pull({ title: message.split('\n')[0].slice(0, 200), head: where.branch, base: rec.ref, body: `Made in Quilt by ${who.name}${t ? ` for task ${t.id}: ${t.title}` : ''}.\n\n${message}` })
268
- // Already committed: a pull request can still be asked for, for a branch that is there.
269
- // What git has now as the session does (committed, or already so) is no longer the committer's unfinished work.
270
- openBoxesCommitted(room, who, key, [...(r.changed || []), ...(r.same || [])])
271
- if (r.nothing) {
272
- if (pullRequest && where.own && r.exists) { out.pr = await pr(); if (t) linkTaskPull(room, t.id, { number: out.pr.number, url: out.pr.url, branch: where.branch, base: rec.ref }, { log }) }
273
- return out
274
- }
275
- if (pullRequest && where.own) {
276
- out.pr = await pr()
277
- if (t) linkTaskPull(room, t.id, { number: out.pr.number, url: out.pr.url, branch: where.branch, base: rec.ref }, { log })
278
- }
279
- if (where.own && r.sha) for (const x of readTasks(room.doc.getMap('tasks'))) if (x.pr && x.pr.branch === where.branch) noteTaskRevision(room, x.pr.number, r.sha)
280
- // What went to GitHub, by content: when that branch is merged (or squashed, or rebased) into the
281
- // session's branch, pulling it down keeps the session's copy, which is that or newer (upstream.js).
282
- notePublished(room.doc, new Map(r.changed.map((rel) => [rel, content.get(rel)])), RELAY_PUBLISH)
283
- log(`[${room.name}] ${key}: ${who.name} committed ${r.sha.slice(0, 7)} to ${full} ${where.branch} (${r.changed.length} files)`)
284
- // Everyone sees what went into git, and by whom; it wakes nobody.
285
- if (room.postChat) {
286
- room.postChat({ by: RELAY_BY, text: `${who.name} committed ${r.sha.slice(0, 7)} to ${where.branch}${r.created ? ' (a new branch)' : ''}: "${message.split('\n')[0].slice(0, 200)}" (${r.changed.length} file${r.changed.length === 1 ? '' : 's'})${out.pr ? `, pull request ${out.pr.url}` : ''}.`, kind: 'commit' })
287
- }
288
- // The session's own branch moved. Made on the session's commit, the new one is where the session
289
- // now stands (its files are the session's own): measured from it, so the next commit isn't taken
290
- // for a change on GitHub. On a later commit, folders and the relay bring the rest in as usual.
291
- if (!where.own) {
292
- if (r.parent === rec.sha) { rec.sha = r.sha; rec.head = r.sha; room.saveMeta(); if (room.broadcastBranches) room.broadcastBranches() }
293
- if (room.upstreamSoon) room.upstreamSoon(key)
294
- }
295
- closeRequests(room, who.name, new Set([...r.changed, ...r.same]), r.sha)
296
- return out
297
- } catch (err) {
298
- if (err instanceof CommitRefused) throw err
299
- throw new Error(writeProblem(err, full))
300
- }
301
- }
302
-
303
- /** Open commit requests by `name` whose files are all in this commit: done, with its hash. */
304
- function closeRequests (room, name, files, sha) {
305
- const map = room.doc.getMap('commitRequests')
306
- room.doc.transact(() => {
307
- for (const r of map.values()) {
308
- if (!r || r.state !== 'open' || r.by !== name || !Array.isArray(r.files) || !r.files.length) continue
309
- if (r.files.every((f) => files.has(f))) map.set(r.id, { ...r, state: 'done', doneBy: name, hash: sha, doneAt: Date.now(), pushed: true })
310
- }
311
- })
312
- }
313
-
314
- /** quilt_commit's answer. */
315
- export function describeCommit (r, { policy } = {}) {
316
- if (r.nothing) return `Nothing to commit: ${r.same.length ? r.same.join(', ') : 'those files'} ${r.same.length === 1 ? 'is' : 'are'} already on ${r.branch} as they are in the session.${r.pr ? ` Pull request${r.pr.existed ? ' (already open)' : ''}: ${r.pr.url}` : ''}`
317
- const lines = [`Committed ${r.sha.slice(0, 7)} to ${r.repo} ${r.branch}${r.created ? ' (new branch)' : ''}: ${r.changed.length} file${r.changed.length === 1 ? '' : 's'} (${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''}). ${r.url}`]
318
- if (r.same.length) lines.push(`Already as in the session, so not in the commit: ${r.same.slice(0, 10).join(', ')}${r.same.length > 10 ? ', …' : ''}.`)
319
- if (r.ignored && r.ignored.length) lines.push(`Left out, as git ignores ${r.ignored.length === 1 ? 'it' : 'them'}: ${r.ignored.slice(0, 10).join(', ')}.`)
320
- if (r.pr) lines.push(`Pull request${r.pr.existed ? ' (already open)' : ''}: ${r.pr.url}`)
321
- else if (r.created && policy === 'branches') lines.push('Open a pull request for it with quilt_commit again and pull_request: true, or ask a person to merge the branch.')
322
- return lines.join('\n')
323
- }
324
-
325
- /**
326
- * The open pull requests of the session branch's repository, with how each one's checks stand:
327
- * [{ number, title, head, base, url, by, draft, checks }]. Throws CommitRefused when the relay can't read it.
328
- */
329
- export async function pullRequestsFor (room, { key }, { fetch = globalThis.fetch } = {}) {
330
- const { repo, full } = githubRepoOf(room, key)
331
- const token = await credentialsFor(room, repo)
332
- const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
333
- try {
334
- const list = await gh.pulls()
335
- const out = []
336
- for (const p of list.slice(0, 20)) out.push({ ...p, checks: p.sha ? await gh.checks(p.sha).catch(() => ({ state: 'none', failing: [] })) : { state: 'none', failing: [] } })
337
- return { repo: full, pulls: out }
338
- } catch (err) { throw new Error(writeProblem(err, full)) }
339
- }
340
-
341
- /**
342
- * Merges a pull request (by number, or the branch it comes from) into its base, for a member:
343
- * people always (as far as GitHub lets the session's connection), agents only when the owner lets
344
- * them commit to any branch. Refused while checks fail or are still running, unless `force`
345
- * (people only). The head branch is deleted afterwards when it is a Quilt agent's (quilt/…) or
346
- * `deleteBranch`. Resolves { sha, number, title, head, base, deleted }.
347
- */
348
- /**
349
- * Branch `head`'s pull request merged into `into`: its upstream record says so (as notePull does
350
- * for GitHub's webhook), so the relay can let the branch go once nobody is on it (Room.trimMergedBranches).
351
- */
352
- export function noteMerged (room, head, { into = '', number = null, sha = '' } = {}) {
353
- if (!head || !into || head === into) return false
354
- const rec = upstreamsOf(room)[room.resolveKey ? room.resolveKey(head) : head]
355
- if (!rec || rec.ref !== head || (rec.merged && rec.merged.number === number)) return false
356
- rec.merged = { at: Date.now(), into, number, ...(sha ? { sha: String(sha) } : {}) }
357
- if (room.saveMeta) room.saveMeta()
358
- if (room.trimMergedBranches) room.trimMergedBranches()
359
- else if (room.broadcastBranches) room.broadcastBranches()
360
- return true
361
- }
362
-
363
- export async function mergeForMember (room, { who, key, number = null, branch = '', method = 'squash', deleteBranch = null, force = false }, { fetch = globalThis.fetch, log = () => {} } = {}) {
364
- const a = who.access || {}
365
- if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
366
- if (a.role === 'viewer') throw new CommitRefused('You can only view this session, so you can\'t merge.')
367
- if (who.kind === 'agent' && agentCommitsOf(room) !== 'any') throw new CommitRefused('The session owner lets agents commit only to branches of their own (or not at all), so merging is for people: ask one in chat, with the pull request\'s link.')
368
- if (!['merge', 'squash', 'rebase'].includes(method)) throw new CommitRefused('method is merge, squash or rebase.')
369
- const { repo, full, rec } = githubRepoOf(room, key)
370
- const token = await credentialsFor(room, repo)
371
- const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
372
- try {
373
- let n = Number(number) || 0
374
- if (!n) {
375
- const head = String(branch || '').trim().replace(/^refs\/heads\//, '')
376
- if (!head) throw new CommitRefused('Say which pull request (number) or branch to merge.')
377
- const open = (await gh.pulls()).find((p) => p.head === head)
378
- if (!open) throw new CommitRefused(`No open pull request from ${head}. Open one first (push the branch from a folder, or quilt_git_commit on it without one: Quilt opens it).`)
379
- n = open.number
380
- }
381
- const pr = await gh.pullRequest(n)
382
- if (pr.merged) return { sha: '', number: n, title: pr.title, head: pr.head, base: pr.base, deleted: false, already: true }
383
- if (pr.state !== 'open') throw new CommitRefused(`Pull request #${n} is closed.`)
384
- const checks = await gh.checks(pr.sha)
385
- if (checks.state === 'failing' && !(force && who.kind !== 'agent')) throw new CommitRefused(`Pull request #${n} has failing checks (${checks.failing.join(', ')}): fix them first${who.kind !== 'agent' ? ', or merge with force: true' : ''}.`)
386
- if (checks.state === 'pending' && !(force && who.kind !== 'agent')) throw new CommitRefused(`Pull request #${n}'s checks are still running: try again when they have finished${who.kind !== 'agent' ? ', or merge with force: true' : ''}.`)
387
- if (pr.mergeable === false) throw new CommitRefused(`Pull request #${n} can't be merged as it is (${pr.mergeableState || 'conflicts'}): bring ${pr.base} into ${pr.head} and resolve, then try again.`)
388
- const r = await gh.merge(n, { method })
389
- const del = deleteBranch === null ? /^quilt\//.test(pr.head) : !!deleteBranch
390
- if (del && pr.head !== rec.ref) {
391
- await gh.deleteBranch(pr.head)
392
- const was = upstreamsOf(room)[room.resolveKey ? room.resolveKey(pr.head) : pr.head]
393
- if (was) { was.gone = Date.now(); delete was.problem } // nothing more to bring into that branch; it stays in the session as it is
394
- }
395
- noteMerged(room, pr.head, { into: pr.base, number: n, sha: r.sha })
396
- log(`[${room.name}] ${key}: ${who.name} merged #${n} (${pr.head} → ${pr.base}) as ${String(r.sha).slice(0, 7)}`)
397
- if (room.postChat) room.postChat({ by: RELAY_BY, text: `${who.name} merged pull request #${n} (${pr.head} → ${pr.base}) as ${String(r.sha).slice(0, 7)}: "${pr.title}"${del ? `, and deleted ${pr.head}` : ''}.`, kind: 'commit' })
398
- // The base moved: look now (GitHub's webhook says so too, when the app is on the repository).
399
- if (pr.base === rec.ref && room.upstreamSoon) room.upstreamSoon(key)
400
- return { sha: r.sha, number: n, title: pr.title, head: pr.head, base: pr.base, deleted: del }
401
- } catch (err) {
402
- if (err instanceof CommitRefused) throw err
403
- throw new Error(writeProblem(err, full))
404
- }
405
- }
406
-
407
- /**
408
- * quilt_git_commit, for an agent without a folder: commits to the branch the agent is on (`key`,
409
- * its branch document). A branch of its own gets its pull request opened and tied to its task; the
410
- * session's branch itself is open to agents only under policy "any". Resolves commitForMember's
411
- * result plus { pr } when one was opened or found.
412
- */
413
- export async function gitCommitFor (room, { who, key, message, files = null, task = null, withOthers = false }, { fetch = globalThis.fetch, log = () => {} } = {}) {
414
- const { rec } = githubRepoOf(room, key)
415
- const here = room.resolveKey ? room.resolveKey(key) : key
416
- const own = who.kind === 'agent' && here.startsWith(agentPrefix(who.name))
417
- const policy = agentCommitsOf(room)
418
- if (who.kind === 'agent' && policy === 'off') throw new CommitRefused(`${gitForEveryoneWords({ policy: 'off', by: room.meta.agentCommitsSet && room.meta.agentCommitsSet.mode === 'off' ? room.meta.agentCommitsSet : null })} Ask a person for the commit with quilt_request_commit.`)
419
- if (who.kind === 'agent' && !own && policy !== 'any') {
420
- throw new CommitRefused(`You are on ${here}, and the owner lets agents commit only to branches of their own (${agentPrefix(who.name)}<topic>). Take the task (quilt_move_task to doing puts you on a branch for it) or quilt_git_branch with create: true, then commit there; the pull request opens by itself.`)
421
- }
422
- const target = here === rec.ref ? '' : here
423
- const r = await commitForMember(room, { who, key: here, message, files, branch: target, pullRequest: false, withOthers, task }, { fetch, log })
424
- if (own && (r.changed.length || r.exists)) {
425
- try {
426
- const pr = await openPullOnPush(room, { who, key: here, branch: here, files: r.changed, subject: String(message || '').split('\n')[0], taskId: task || null }, { fetch, log })
427
- if (pr) r.pr = { number: pr.number, url: pr.url, existed: !r.changed.length || undefined }
428
- } catch (err) { log(`[${room.name}] ${here}: committed, but could not open the pull request: ${err.message}`) }
429
- }
430
- return r
431
- }
432
-
433
- /** quilt_git_commit's answer. */
434
- export function describeGitCommit (r, { policy } = {}) {
435
- if (r.nothing) return `Nothing to commit: ${r.same.length ? r.same.join(', ') : 'those files'} ${r.same.length === 1 ? 'is' : 'are'} already on ${r.branch} as they are in the session.${r.pr ? ` Pull request #${r.pr.number}: ${r.pr.url}` : ''}`
436
- const lines = [`Committed ${r.sha.slice(0, 7)} to ${r.repo} ${r.branch}${r.created ? ' (new branch)' : ''}: ${r.changed.length} file${r.changed.length === 1 ? '' : 's'} (${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''}). ${r.url}`]
437
- if (r.same.length) lines.push(`Already as in the session, so not in the commit: ${r.same.slice(0, 10).join(', ')}${r.same.length > 10 ? ', …' : ''}.`)
438
- if (r.ignored && r.ignored.length) lines.push(`Left out, as git ignores ${r.ignored.length === 1 ? 'it' : 'them'}: ${r.ignored.slice(0, 10).join(', ')}.`)
439
- if (r.pr) lines.push(`Pull request #${r.pr.number} (${r.pr.existed ? 'already open' : 'opened'}, tied to your task): ${r.pr.url}. Move the task to QA when the work is done and tested; Done merges it${policy === 'any' ? '' : ' (a person moves it)'}.`)
440
- return lines.join('\n')
441
- }
442
-
443
- /** quilt_git_status: where an agent without a folder stands in git. */
444
- export async function gitStatusFor (room, { who, key }, { fetch = globalThis.fetch } = {}) {
445
- const here = room.resolveKey ? room.resolveKey(key) : key
446
- const policy = agentCommitsOf(room)
447
- const able = agentsCanCommit(room, here)
448
- let repo = null; let base = null
449
- try { const g = githubRepoOf(room, here); repo = g.full; base = g.rec.ref } catch {}
450
- const own = who.kind === 'agent' && here.startsWith(agentPrefix(who.name))
451
- const u = able ? await unpublishedFor(room, { key: here, who: who.name }, { fetch }) : { unpublished: [], awaitingMerge: [] }
452
- const tasks = readTasks(room.doc.getMap('tasks')).filter((t) => !t.archived && t.assignee === who.name && t.column !== 'done')
453
- const task = tasks.find((t) => t.pr && t.pr.branch === here) || tasks.find((t) => t.column === 'doing') || null
454
- let onGitHub = null
455
- if (repo && able) {
456
- try {
457
- const { repo: r } = githubRepoOf(room, here)
458
- const gh = githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name })
459
- onGitHub = await gh.ref(here)
460
- } catch {}
461
- }
462
- return { branch: here, base, repo, policy, able, own, onGitHub, unpublished: u.unpublished || [], awaitingMerge: u.awaitingMerge || [], task: task ? { id: task.id, title: task.title, column: task.column, pr: task.pr || null } : null }
463
- }
464
-
465
- export function describeGitStatus (s) {
466
- const lines = [`On ${s.branch}${s.repo ? ` of ${s.repo}` : ''}${s.base && s.base !== s.branch ? ` (merges into ${s.base})` : ''}${s.onGitHub ? `, on GitHub at ${s.onGitHub.slice(0, 7)}` : s.onGitHub === null && s.repo && s.able ? ', not on GitHub yet' : ''}.`]
467
- if (!s.repo) lines.push('The session doesn\'t follow a GitHub repository yet (a member\'s folder that is a clone tells it).')
468
- else if (!s.able) lines.push(s.policy === 'off' ? 'Agents may not commit here (the owner\'s setting): ask a person with quilt_request_commit.' : 'Nobody has connected GitHub for this session yet, so the relay can\'t commit for you; ask the owner to connect it.')
469
- else lines.push(`Agents may push ${s.policy === 'any' ? 'to any branch' : 'to branches of their own only'}${s.own ? '; this one is yours' : s.policy === 'any' ? '' : '; this one is not (take a task, or quilt_git_branch with create: true)'}.`)
470
- if (s.unpublished.length) lines.push(`Your changes not in git yet: ${s.unpublished.slice(0, 15).join(', ')}${s.unpublished.length > 15 ? `, and ${s.unpublished.length - 15} more` : ''}. quilt_git_commit puts them on this branch.`)
471
- else if (s.able) lines.push('Nothing of yours is uncommitted on this branch.')
472
- if (s.awaitingMerge.length) lines.push(`On this branch but not merged into ${s.base} yet: ${s.awaitingMerge.slice(0, 15).join(', ')}.`)
473
- if (s.task) lines.push(`Your task: "${s.task.title}" [${s.task.id}] in ${s.task.column}${s.task.pr ? `, pull request #${s.task.pr.number} ${s.task.pr.url}` : ', no pull request yet'}.`)
474
- return lines.join('\n')
475
- }
476
-
477
- /** quilt_git_log: the branch's newest commits on GitHub (the branch it merges into when the branch itself isn't there yet). */
478
- export async function gitLogFor (room, { key, n = 10 }, { fetch = globalThis.fetch } = {}) {
479
- const here = room.resolveKey ? room.resolveKey(key) : key
480
- const { repo, full, rec } = githubRepoOf(room, here)
481
- const gh = githubWriter({ fetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
482
- try {
483
- const exists = here === rec.ref || !!(await gh.ref(here))
484
- const branch = exists ? here : rec.ref
485
- return { repo: full, branch, asked: here, commits: await gh.log(branch, n) }
486
- } catch (err) { throw new Error(writeProblem(err, full)) }
487
- }
488
-
489
- export function describeGitLog (r) {
490
- const head = r.branch === r.asked ? `Newest commits on ${r.repo} ${r.branch}:` : `${r.asked} isn't on GitHub yet; newest commits on ${r.branch}, which it starts from:`
491
- if (!r.commits.length) return `${head} none.`
492
- return `${head}\n${r.commits.map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split('\n')[0].slice(0, 100)} (${c.author}${c.date ? `, ${c.date.slice(0, 10)}` : ''})`).join('\n')}`
493
- }
494
-
495
- /**
496
- * quilt_git_diff: how the session's copy of files differs from GitHub (the branch's head, or the
497
- * branch it merges into when it isn't on GitHub yet). `files`: those, or the agent's own changes.
498
- */
499
- export async function gitDiffFor (room, { who, key, files = null }, { fetch = globalThis.fetch, lineDiff } = {}) {
500
- const here = room.resolveKey ? room.resolveKey(key) : key
501
- const { repo, full, rec } = githubRepoOf(room, here)
502
- const e = room.branchDoc(here)
503
- let list = cleanList(files)
504
- if (!list.length) list = (await unpublishedFor(room, { key: here, who: who.name }, { fetch })).unpublished
505
- if (!list.length) return { repo: full, branch: here, against: null, files: [] }
506
- const gh = githubWriter({ fetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
507
- try {
508
- const head = (here === rec.ref ? null : await gh.ref(here)) || await gh.ref(rec.ref)
509
- const against = head === (await gh.ref(rec.ref)) && here !== rec.ref ? rec.ref : here
510
- const out = []
511
- for (const rel of list.slice(0, 20)) {
512
- let now = null
513
- if (isDiff(e) && docKey(e, rel) === FOLLOWS_GIT) { out.push({ path: rel, note: 'same' }); continue } // the session never changed it: git's
514
- try { now = sessionBytes(e, rel) } catch { out.push({ path: rel, note: 'a large stored file: not compared' }); continue }
515
- const was = head ? await gh.content(head, rel) : null
516
- const text = (b) => b === null || b === undefined ? '' : b.toString('utf8')
517
- const binary = (b) => !!b && b.includes(0)
518
- if (binary(now) || binary(was)) { out.push({ path: rel, note: now && was && Buffer.compare(now, was) === 0 ? 'same' : 'binary, differs' }); continue }
519
- if (!now && !was) { out.push({ path: rel, note: 'not in the session, not on GitHub' }); continue }
520
- if (now && was && Buffer.compare(now, was) === 0) { out.push({ path: rel, note: 'same' }); continue }
521
- out.push({ path: rel, diff: lineDiff ? lineDiff(text(was), text(now)) : `${was ? '-' : ''}${now ? '+' : ''}`, created: !was, deleted: !now })
522
- }
523
- return { repo: full, branch: here, against, head, files: out }
524
- } catch (err) { throw new Error(writeProblem(err, full)) }
525
- }
526
-
527
- export function describeGitDiff (r) {
528
- if (!r.files.length) return `Nothing of yours differs from GitHub on ${r.branch}.`
529
- const head = `The session's copy on ${r.branch} against GitHub's ${r.against}${r.head ? ` (${r.head.slice(0, 7)})` : ''}:`
530
- return `${head}\n${r.files.map((f) => f.note ? `--- ${f.path}: ${f.note}` : `--- ${f.path}${f.created ? ' (new)' : f.deleted ? ' (deleted)' : ''}\n${f.diff}`).join('\n')}`
531
- }
532
-
533
- export const GIT_STATUS_DESCRIPTION = 'Where you stand in git: the branch you are on and what it merges into, the repository, what agents may push here, your changes not in git yet, what is on your branch but not merged, and your task\'s pull request. Read it before committing or when unsure what shipped.'
534
- export const GIT_LOG_DESCRIPTION = 'The newest commits on GitHub for the branch you are on (or the branch it starts from while yours isn\'t pushed yet): who committed what, when.'
535
- export const GIT_DIFF_DESCRIPTION = 'How the session\'s copy of files differs from GitHub: your uncommitted changes by default, or the files you name (unified diff per text file). Read it before committing, or to review what someone changed.'
536
- export const GIT_REVERT_DESCRIPTION = 'Put GitHub\'s version of files back into the session (the branch\'s head, or the branch it starts from): undoes uncommitted changes to those files in the session, for everyone. Say which files; nothing else is touched.'
537
-
538
- /** quilt_pull_requests' answer. */
539
- export function describePulls ({ repo, pulls }) {
540
- if (!pulls.length) return `No open pull requests in ${repo}.`
541
- const checks = (c) => c.state === 'passing' ? 'checks passing' : c.state === 'failing' ? `checks failing: ${c.failing.join(', ')}` : c.state === 'pending' ? 'checks running' : 'no checks'
542
- return `Open pull requests in ${repo}:\n${pulls.map((p) => `- #${p.number} ${p.title} (${p.head} → ${p.base}, by ${p.by || '?'}${p.draft ? ', draft' : ''}; ${checks(p.checks)}) ${p.url}`).join('\n')}`
543
- }
544
-
545
- /** quilt_merge's answer. */
546
- export function describeMerge (r) {
547
- if (r.already) return `Pull request #${r.number} (${r.head} → ${r.base}) was already merged.`
548
- return `Merged pull request #${r.number} (${r.head} → ${r.base}) as ${String(r.sha).slice(0, 7)}: ${r.title}.${r.deleted ? ` ${r.head} is deleted.` : ''} The session on ${r.base} brings it in now.`
549
- }
550
-
551
- export const PULLS_DESCRIPTION = 'The open pull requests of this branch\'s repository on GitHub, with how each one\'s checks stand (passing, failing and which, still running, or none). Read it before merging, and to find the pull request for a branch.'
552
- export const MERGE_DESCRIPTION = 'Merge a pull request on GitHub (by number, or by the branch it comes from) into its base. ' +
553
- 'Refused while its checks fail or are still running. A Quilt agent\'s branch (quilt/…) is deleted after the merge. ' +
554
- 'People may always merge (as far as the session\'s GitHub connection may); agents only when the session owner lets agents commit to any branch, otherwise ask a person in chat with the pull request\'s link. ' +
555
- 'The session on the base branch brings the merge in within seconds.'
556
- /** quilt_merge's arguments, the same for the local and the hosted tool (`z`: zod). */
557
- export const mergeSchema = (z) => ({
558
- number: z.number().int().min(1).optional().describe('The pull request number'),
559
- branch: z.string().max(200).optional().describe('Or the branch the pull request comes from'),
560
- method: z.enum(['squash', 'merge', 'rebase']).optional().describe('How to merge (default squash)'),
561
- delete_branch: z.boolean().optional().describe('Delete the merged branch (default: yes for quilt/… branches, no otherwise)'),
562
- force: z.boolean().optional().describe('People only: merge even while checks fail or are still running')
563
- })
564
-
565
- /**
566
- * Whether agents can commit on branch `key` themselves: the owner lets them, and a member's GitHub
567
- * connection (or the owner's token) covers the repository. Cheap: from the relay's record.
568
- */
569
- export function agentsCanCommit (room, key) {
570
- // A session that is a quilt has no GitHub path for anyone's work: it lands with Seam (session-seam.js).
571
- if (quiltOf(room.meta)) return false
572
- if (agentCommitsOf(room) === 'off') return false
573
- const found = repoRecordFor(room, key)
574
- if (!found) return false
575
- const rec = found.rec
576
- return !!(validToken(room.meta.githubToken) || (rec.github && rec.github.state === 'ok'))
577
- }
578
-
579
- /**
580
- * A folder's git pushed (its pre-push hook told its session, which tells the relay): the pushed
581
- * content goes on the room's published record, so the branch comes back down clean once merged
582
- * (upstream.js), and the chat hears it. `files`: [{ path, id }] (id: sha1 of the bytes); only
583
- * paths the member may change are recorded. Written by the relay, so a member whose own writes
584
- * are held to a folder (session-access.js) is on record all the same.
585
- */
586
- export function notePushedFor (room, { who, key, branch = '', sha = '', count = 1, files = [], subject = '' }, { fetch = globalThis.fetch, log = () => {} } = {}) {
587
- const a = who.access || {}
588
- if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
589
- const list = (Array.isArray(files) ? files : []).filter((f) => f && typeof f.path === 'string' && isSafeRelPath(f.path) && mayChange(a, f.path)).slice(0, 2000)
590
- notePublishedIds(room.doc, list.map((f) => [f.path, f.id]), RELAY_PUBLISH)
591
- let full = null
592
- try { full = githubRepoOf(room, key).full } catch {}
593
- const where = full ? `${full} ${branch}` : branch
594
- const n = Math.max(1, Number(count) || 1)
595
- const names = list.map((f) => f.path)
596
- log(`[${room.name}] ${key}: ${who.name} pushed ${n} commit${n === 1 ? '' : 's'} to ${where} (${names.length} files)`)
597
- if (room.postChat) room.postChat({ by: RELAY_BY, text: `${who.name} pushed ${n} commit${n === 1 ? '' : 's'} to ${where}${names.length ? `: ${names.slice(0, 8).join(', ')}${names.length > 8 ? ` and ${names.length - 8} more` : ''}` : ''}${full && /^[0-9a-f]{40}$/.test(sha) ? ` https://github.com/${full}/commit/${sha}` : ''}`, kind: 'commit' })
598
- // An agent's branch of its own: the pull request opens by itself (after this answer), so no push is left hanging.
599
- if (who.kind === 'agent' && full && branch.startsWith(agentPrefix(who.name)) && agentCommitsOf(room) !== 'off') {
600
- openPullOnPush(room, { who, key, branch, files: names, subject: String(subject || '') }, { fetch, log }).catch((err) => log(`[${room.name}] ${key}: could not open a pull request for ${who.name}'s ${branch}: ${err.message}`))
601
- }
602
- return { ok: true, recorded: list.length, branch, repo: full }
603
- }
604
-
605
- /** Sets the pull request a task's work is in ({ number, url, branch, base }) and notes it on the task. */
606
- /**
607
- * The commit a task's pull request is meant to carry (addendum 16.5): every push to the pull request's
608
- * branch for the task moves it. reviewPulls moves the task to Done only when the merge has this commit.
609
- */
610
- export function noteTaskRevision (room, number, sha) {
611
- const map = room.doc.getMap('tasks')
612
- for (const t of readTasks(map)) {
613
- if (!t.pr || t.pr.number !== number || t.archived || t.column === 'done' || t.pr.revision === sha) continue
614
- updateTask(room.doc, map, { id: t.id, pr: { ...t.pr, revision: sha } }, RELAY_PUBLISH)
615
- }
616
- }
617
-
618
- export function linkTaskPull (room, taskId, pr, { log = () => {} } = {}) {
619
- const map = room.doc.getMap('tasks')
620
- const cur = readTasks(map).find((t) => t.id === taskId)
621
- if (!cur) return null
622
- if (cur.pr && cur.pr.number === pr.number) return cur
623
- const task = updateTask(room.doc, map, { id: taskId, pr }, RELAY_PUBLISH)
624
- try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId, by: RELAY_BY, text: `Pull request #${pr.number} (${pr.branch} → ${pr.base}): ${pr.url}. Moving this task to Done merges it, once its checks pass.` }, RELAY_PUBLISH) } catch (err) { log(`[${room.name}] could not note the pull request on task ${taskId}: ${err.message}`) }
625
- return task
626
- }
627
-
628
- /**
629
- * The task an agent's push is for: the agent's task In progress whose title slug the branch ends
630
- * with, else its only task In progress, else a task made for the push (so every push has a task,
631
- * and the board's gates cover it). `by` names the agent.
632
- */
633
- function taskForPush (room, { who, branch, files, subject, taskId = null }) {
634
- const map = room.doc.getMap('tasks')
635
- const tasks = readTasks(map).filter((t) => !t.archived && t.column !== 'done')
636
- const named = taskId ? tasks.find((t) => t.id === taskId) : null
637
- if (named) return named
638
- const mine = tasks.filter((t) => t.assignee === who.name)
639
- const tail = branch.slice(agentPrefix(who.name).length)
640
- const linked = mine.find((t) => t.pr && t.pr.branch === branch)
641
- if (linked) return linked
642
- const bySlug = mine.find((t) => t.column === 'doing' && slug(t.title) === tail) || mine.find((t) => slug(t.title) === tail)
643
- if (bySlug) return bySlug
644
- const doing = mine.filter((t) => t.column === 'doing' && !t.pr)
645
- if (doing.length === 1) return doing[0]
646
- const title = (subject || '').split('\n')[0].trim().slice(0, 120) || tail.replace(/-/g, ' ')
647
- return addTask(room.doc, map, { title, by: RELAY_BY, assignee: who.name, column: 'doing', files: (files || []).slice(0, 20), conv: '' }, RELAY_PUBLISH)
648
- }
649
-
650
- /**
651
- * Opens the pull request for an agent's pushed branch into the branch its work merges into (or
652
- * finds the one already open), ties it to the agent's task (made if none), and tells the chat.
653
- */
654
- export async function openPullOnPush (room, { who, key, branch, files = [], subject = '', taskId = null, revision = null }, { fetch = globalThis.fetch, log = () => {} } = {}) {
655
- const { repo, full, rec } = githubRepoOf(room, key)
656
- if (branch === rec.ref) return null
657
- const token = await credentialsFor(room, repo)
658
- const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
659
- const task = taskForPush(room, { who, branch, files, subject, taskId })
660
- if (task.pr && task.pr.branch === branch) { if (revision) noteTaskRevision(room, task.pr.number, revision); return task.pr }
661
- const title = task.title
662
- const pr = await gh.pull({ title, head: branch, base: rec.ref, body: `${who.name}'s work on task ${task.id}: ${task.title}.\n\nOpened by Quilt when the branch was pushed. Moving the task to Done in Quilt merges this once its checks pass.` })
663
- const linked = { number: pr.number, url: pr.url, branch, base: rec.ref, ...(revision ? { revision } : {}) }
664
- linkTaskPull(room, task.id, linked, { log })
665
- if (revision) noteTaskRevision(room, pr.number, revision)
666
- log(`[${room.name}] ${key}: ${pr.existed ? 'found' : 'opened'} pull request #${pr.number} for ${who.name}'s ${branch} (task ${task.id})`)
667
- if (!pr.existed && room.postChat) room.postChat({ by: RELAY_BY, text: `Opened pull request #${pr.number} for ${who.name}'s ${branch} → ${rec.ref}: "${title}" ${pr.url}. Moving task ${task.id} to Done merges it once its checks pass.`, kind: 'commit' })
668
- return linked
669
- }
670
-
671
- /**
672
- * Moving a task to Done: when its work is on a branch (a pull request) and not yet on the branch
673
- * it merges into, Done merges that pull request, once its checks pass. People may always; agents
674
- * only when the owner lets agents push to any branch (otherwise the task waits in QA for a person).
675
- * Resolves { merged: { sha, number, head, base } } or { merged: null } (nothing to merge); throws
676
- * CommitRefused with what to do when it can't.
677
- */
678
- export async function finishTaskFor (room, { who, key, task: taskId }, { fetch = globalThis.fetch, log = () => {} } = {}) {
679
- const map = room.doc.getMap('tasks')
680
- const task = readTasks(map).find((t) => t.id === taskId)
681
- if (!task) return { merged: null }
682
- // In a quilt a task's work lands with seam_merge: a pull request from before the switch is git's, and stays as it is.
683
- if (quiltOf(room.meta)) return { merged: null }
684
- // A clash task (clash.js) is the merge itself.
685
- if (Object.values((room.meta && room.meta.upstreams) || {}).some((rec) => rec && rec.clash && rec.clash.task === taskId)) return { merged: null }
686
- if (!agentsCanCommit(room, key) && !task.pr) return { merged: null }
687
- const u = await unpublishedFor(room, { key, who: task.assignee || who.name, task: taskId, column: 'done' }, { fetch })
688
- if (u.unpublished.length) throw new CommitRefused(unpublishedRefusal(task, u.unpublished, 'done', []))
689
- if (!u.awaitingMerge.length && !task.pr) return { merged: null }
690
- if (!task.pr) throw new CommitRefused(`Not moved to Done: "${task.title}"'s work is on a branch with no pull request yet. Push the branch (from a folder: git push, and Quilt opens it; without a folder: quilt_git_commit on your branch with task: "${task.id}"), then move the task again.`)
691
- if (who.kind === 'agent' && agentCommitsOf(room) !== 'any') {
692
- throw new CommitRefused(`Not moved to Done: Done merges pull request #${task.pr.number} (${task.pr.branch} → ${task.pr.base}), and here that is a person's call (the owner lets agents push only to branches of their own). Leave the task in QA: a person moves it to Done when it is good, and Quilt merges. ${task.pr.url}`)
693
- }
694
- const r = await mergeForMember(room, { who, key, number: task.pr.number, method: 'squash' }, { fetch, log })
695
- if (r.already) return { merged: null, already: true }
696
- try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId, by: RELAY_BY, text: `Merged pull request #${r.number} (${r.head} → ${r.base}) as ${String(r.sha).slice(0, 7)}, as ${who.name} moved the task to Done.` }, RELAY_PUBLISH) } catch {}
697
- return { merged: { sha: r.sha, number: r.number, head: r.head, base: r.base } }
698
- }
699
-
700
- // Pull requests tied to tasks are looked at with the relay's looks (about every ten minutes) and
701
- // when GitHub says one changed: merged elsewhere moves the task to Done; closed is told; green and
702
- // waiting in QA nudges whoever may merge; failing checks nudge the agent. Each event (a pull
703
- // request at a commit, closed, failing at a commit) is told once: the room's saved record
704
- // (meta.pullTold) says which were, across restarts. One look's news goes as one message per person.
705
- export const PULL_TOLD_KEEP_MS = 30 * 24 * 60 * 60 * 1000
706
-
707
- /** Whether event `k` was told already; records it as told now when not. Saved with the room. */
708
- function firstTelling (room, k, now) {
709
- const told = room.meta.pullTold && typeof room.meta.pullTold === 'object' ? room.meta.pullTold : (room.meta.pullTold = {})
710
- if (told[k]) return false
711
- told[k] = now
712
- for (const [key, at] of Object.entries(told)) if (now - at > PULL_TOLD_KEEP_MS) delete told[key]
713
- return true
714
- }
715
-
716
- const prLine = (pr, t) => `PR #${pr.number} "${t.title}" ${pr.url}`
717
-
718
- export async function reviewPulls (room, { fetch = globalThis.fetch, log = () => {}, now = Date.now(), only = null } = {}) {
719
- const map = room.doc.getMap('tasks')
720
- const tasks = readTasks(map).filter((t) => t.pr && !t.archived && t.column !== 'done' && (only == null || t.pr.number === only))
721
- if (!tasks.length) return []
722
- let gh
723
- try {
724
- const { repo } = githubRepoOf(room, DEFAULT_KEY)
725
- const token = await credentialsFor(room, repo)
726
- gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
727
- } catch { return [] }
728
- const once = (k) => firstTelling(room, k, now)
729
- // What this look has to say, per person: { ready: [lines], failing: [lines], closed: [lines] }.
730
- const news = new Map()
731
- const tell = (to, kind, line) => {
732
- if (!to) return
733
- const n = news.get(to) || { ready: [], failing: [], closed: [] }
734
- if (!n[kind].includes(line)) n[kind].push(line)
735
- news.set(to, n)
736
- }
737
- const out = []
738
- for (const t of tasks) {
739
- let pr
740
- try { pr = await gh.pullRequest(t.pr.number) } catch (err) { log(`[${room.name}] could not read pull request #${t.pr.number}: ${err.message}`); continue }
741
- if (pr.merged) {
742
- // Addendum 16.5: Done only when the merge has the task's last change. A push that reached the branch after
743
- // the merge (or a merge of an older head) leaves the task where it is, and its assignee is told.
744
- const rev = t.pr.revision
745
- if (rev && pr.sha && rev !== pr.sha) {
746
- let has = null
747
- try { has = ['identical', 'ahead'].includes(await gh.compare(rev, pr.sha)) } catch (err) { log(`[${room.name}] could not compare ${rev.slice(0, 7)} with pull request #${pr.number}: ${err.message}`) }
748
- if (has !== true) {
749
- if (has === false && once(`missed\0${t.pr.number}\0${rev}`)) {
750
- const text = `Pull request #${pr.number} was merged on GitHub without the task's last change (${rev.slice(0, 7)}, pushed to ${pr.head} after the merged ${String(pr.sha).slice(0, 7)}): "${t.title}" stays in ${t.column} until that change is merged too.`
751
- try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: t.id, by: RELAY_BY, text }, RELAY_PUBLISH) } catch {}
752
- if (room.postChat) room.postChat({ by: RELAY_BY, ...(t.assignee ? { to: t.assignee } : {}), kind: 'nudge', text })
753
- }
754
- out.push({ task: t.id, merged: true, missing: rev })
755
- continue
756
- }
757
- }
758
- noteMerged(room, pr.head, { into: pr.base, number: pr.number, sha: pr.sha })
759
- if (!once(`merged\0${pr.number}`)) continue
760
- updateTask(room.doc, map, { id: t.id, column: 'done', verified: `Pull request #${pr.number} (${pr.head} → ${pr.base}) merged on GitHub as ${String(pr.sha || '').slice(0, 7) || 'a commit'}.` }, RELAY_PUBLISH)
761
- try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: t.id, by: RELAY_BY, text: `Pull request #${pr.number} was merged on GitHub: the task is Done.` }, RELAY_PUBLISH) } catch {}
762
- if (room.postChat) room.postChat({ by: RELAY_BY, text: `PR #${pr.number} "${t.title}" was merged on GitHub; its task is Done.`, kind: 'commit' })
763
- if (room.upstreamSoon) room.upstreamSoon(DEFAULT_KEY)
764
- out.push({ task: t.id, merged: true })
765
- continue
766
- }
767
- if (pr.state !== 'open') {
768
- if (t.assignee && once(`closed\0${t.pr.number}`)) tell(t.assignee, 'closed', prLine(pr, t))
769
- out.push({ task: t.id, closed: true })
770
- continue
771
- }
772
- const checks = pr.sha ? await gh.checks(pr.sha).catch(() => ({ state: 'none', failing: [] })) : { state: 'none', failing: [] }
773
- if (checks.state === 'failing') {
774
- if (t.assignee && once(`failing\0${t.pr.number}\0${pr.sha}`)) {
775
- tell(t.assignee, 'failing', `${prLine(pr, t)} (${checks.failing.join(', ')})`)
776
- out.push({ task: t.id, failing: true })
777
- }
778
- continue
779
- }
780
- if (t.column === 'qa' && (checks.state === 'passing' || checks.state === 'none') && once(`ready\0${t.pr.number}\0${pr.sha}`)) {
781
- const owner = room.meta && room.meta.ownerName
782
- tell(owner, 'ready', prLine(pr, t))
783
- if (agentCommitsOf(room) === 'any' && t.assignee !== owner) tell(t.assignee, 'ready', prLine(pr, t))
784
- out.push({ task: t.id, ready: true })
785
- }
786
- }
787
- if (news.size && room.saveMeta) room.saveMeta()
788
- if (room.postChat) {
789
- for (const [to, n] of news) {
790
- const parts = []
791
- if (n.ready.length) parts.push(n.ready.length === 1 ? `Ready to merge: ${n.ready[0]}. Move its task to Done to merge it.` : `Ready to merge (move each task to Done):\n${n.ready.map((l) => `- ${l}`).join('\n')}`)
792
- if (n.failing.length) parts.push(n.failing.length === 1 ? `Checks failing: ${n.failing[0]}. Fix them on its branch and push again.` : `Checks failing (fix each on its branch and push again):\n${n.failing.map((l) => `- ${l}`).join('\n')}`)
793
- if (n.closed.length) parts.push(`Closed on GitHub without merging: ${n.closed.join('; ')}. If the work still stands, push its branch again; if not, move the task back to To do or archive it.`)
794
- room.postChat({ by: RELAY_BY, to, kind: 'nudge', text: parts.join('\n') })
795
- }
796
- }
797
- return out
798
- }
799
-
800
- /**
801
- * Which of `files` (or of every file `who` changed on record on the branch) are not in git as the
802
- * session has them: { able, unpublished: [paths], awaitingMerge: [paths] }. `unpublished` is nowhere
803
- * on GitHub; `awaitingMerge` is on a branch (pushed from a folder, or quilt_commit: the room's
804
- * published record) but not on the session branch's head yet. For `column` 'qa' only `unpublished`
805
- * counts (a pull request under review is in git); for 'done' and by default both. `able` is
806
- * whether agents can commit here themselves (agentsCanCommit); without it nothing is checked.
807
- * GitHub's file list for the head is kept until the head moves. Never throws: an error reads as nothing unpublished.
808
- */
809
- export async function unpublishedFor (room, { key, who, files = null, task = null, column = '' }, { fetch = globalThis.fetch } = {}) {
810
- const able = agentsCanCommit(room, key)
811
- if (!able) return { able, unpublished: [], awaitingMerge: [] }
812
- try {
813
- const { repo, rec } = githubRepoOf(room, key)
814
- const e = room.branchDoc(room.resolveKey ? room.resolveKey(key) : key)
815
- const history = e.doc.getArray('history').toArray()
816
- let list = cleanList(files)
817
- if (!list.length) {
818
- const t = task ? room.doc.getMap('tasks').get(String(task)) : null
819
- for (const h of history) {
820
- if (!h || h.pulled || h.seed || typeof h.path !== 'string') continue
821
- if (t ? h.task && h.task.id === t.id : h.by === who) { if (!list.includes(h.path)) list.push(h.path) }
822
- }
823
- if (t) for (const f of Array.isArray(t.files) ? t.files : []) if (!list.includes(f)) list.push(f)
824
- list = cleanList(list)
825
- }
826
- const ig = await ignoreRulesOf(room, room.resolveKey ? room.resolveKey(key) : key, e)
827
- list = ownChanges(e, list.filter((rel) => !isIgnored(ig, rel)))
828
- if (!list.length) return { able, unpublished: [], awaitingMerge: [] }
829
- const token = await credentialsFor(room, repo)
830
- const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
831
- const head = await gh.ref(rec.ref)
832
- if (!head) return { able, unpublished: [], awaitingMerge: [] }
833
- if (!room.headBlobs) room.headBlobs = new Map()
834
- let cached = room.headBlobs.get(rec.ref)
835
- if (!cached || cached.head !== head) {
836
- const blobs = await gh.blobs((await gh.commit(head)).tree)
837
- if (!blobs) return { able, unpublished: [], awaitingMerge: [] }
838
- cached = { head, blobs }
839
- room.headBlobs.set(rec.ref, cached)
840
- }
841
- const published = publishedOf(room.doc)
842
- const unpublished = []
843
- const awaitingMerge = []
844
- for (const rel of list) {
845
- let now = null
846
- try { now = sessionBytes(e, rel) } catch { continue } // a large stored file: committed from a folder
847
- const id = now ? gitBlobSha(now) : null
848
- if (id === (cached.blobs.get(rel) || null)) continue
849
- if (now && (published.get(rel) || []).includes(crypto.createHash('sha1').update(now).digest('hex'))) awaitingMerge.push(rel) // the published record keeps content ids: sha1 of the bytes (upstream.js)
850
- else unpublished.push(rel)
851
- }
852
- return { able, unpublished, awaitingMerge: column === 'qa' ? [] : awaitingMerge, head }
853
- } catch { return { able, unpublished: [], awaitingMerge: [] } }
854
- }
855
-
856
- /**
857
- * Why a task may not move on: its files are not in git yet (and the agent can get them there itself),
858
- * or, for Done, they are on a branch whose pull request isn't merged into the session's branch.
859
- */
860
- export function unpublishedRefusal (task, unpublished, column, awaitingMerge = []) {
861
- const title = task && task.title ? `"${task.title}"` : 'this task'
862
- const some = (l) => `${l.slice(0, 8).join(', ')}${l.length > 8 ? `, and ${l.length - 8} more` : ''}`
863
- if (!unpublished.length && awaitingMerge.length) {
864
- return `Not moved to Done: ${title}'s work is on a branch but not merged into the session's branch yet (${some(awaitingMerge)}). ` +
865
- 'Open its pull request if you haven\'t and ask a person to merge it (an agent may merge when the owner lets agents push to any branch); move the task to QA meanwhile.'
866
- }
867
- return `Not moved to ${column === 'qa' ? 'QA' : 'Done'}: ${title} has work that isn't in git yet (${some(unpublished)}). ` +
868
- `Get it into git first: commit those files and push from your folder (a branch of your own, then a pull request, unless the owner lets agents push to the session's branch); without a folder, quilt_git_commit on your branch (files: those, and a message${task && task.id ? `, or task: "${task.id}"` : ''}). Then move the task again. Work only in the session has not shipped.`
869
- }
870
-
871
- // Agents that leave work uncommitted are reminded (a message that needs no reply, which wakes
872
- // them): once every REMIND_MS per agent and branch, for files they last changed REMIND_QUIET_MS ago or more.
873
- export const REMIND_MS = 30 * 60 * 1000
874
- export const REMIND_QUIET_MS = 5 * 60 * 1000
875
- /**
876
- * Every agent with unpublished work on branch `key` is reminded to commit it (relay-commit.js).
877
- * Run by the relay with its looks, about every ten minutes; cheap when nothing changed.
878
- */
879
- export async function remindUnpublished (room, key, { fetch = globalThis.fetch, now = Date.now() } = {}) {
880
- if (!agentsCanCommit(room, key) || !room.postChat) return []
881
- const agents = new Set(Object.values((room.meta && room.meta.members) || {}).filter((m) => m && m.kind === 'agent' && m.name).map((m) => m.name))
882
- // Only a branch that is open (loaded): loading every branch of a room at once, each tick, ran the relay out of
883
- // memory. Its agents are reminded when it is next open.
884
- const e = room.store && room.store.get ? room.store.get(room.resolveKey ? room.resolveKey(key) : key) : room.branchDoc(room.resolveKey ? room.resolveKey(key) : key)
885
- if (!e) { handBackRequests(room, key, agents); return [] }
886
- const history = e.doc.getArray('history').toArray()
887
- const last = new Map() // agent -> ts of their newest edit
888
- for (const h of history) if (h && !h.pulled && !h.seed && h.by && agents.has(h.by) && now - (h.ts || 0) < 24 * 60 * 60 * 1000) last.set(h.by, Math.max(last.get(h.by) || 0, h.ts || 0))
889
- if (!room.reminded) room.reminded = new Map()
890
- const told = []
891
- for (const [name, ts] of last) {
892
- if (now - ts < REMIND_QUIET_MS) continue // still at it
893
- const k = `${name}\0${key}`
894
- if (now - (room.reminded.get(k) || 0) < REMIND_MS) continue
895
- const r = await unpublishedFor(room, { key, who: name }, { fetch })
896
- if (!r.unpublished.length) continue
897
- room.reminded.set(k, now)
898
- room.postChat({ by: RELAY_BY, to: name, kind: 'nudge', text: `You changed ${r.unpublished.slice(0, 8).join(', ')}${r.unpublished.length > 8 ? ` and ${r.unpublished.length - 8} more` : ''} on ${key}, and ${r.unpublished.length === 1 ? 'it isn\'t' : 'they aren\'t'} in git. When that work is finished and tested, commit those files and push from your folder (a branch of your own with a pull request, unless the owner lets agents push to ${key}); without a folder, quilt_git_commit with the files and a message. If it is still in progress, carry on.` })
899
- told.push(name)
900
- }
901
- handBackRequests(room, key, agents)
902
- return told
903
- }
904
-
905
- /**
906
- * Open commit requests by agents that can get their work into git themselves (asked before they
907
- * could, or before GitHub was connected) are handed back: closed for people, and the agent is told
908
- * to push it. So nothing of an agent's sits on a person's plate once the agent can do it.
909
- */
910
- export function handBackRequests (room, key, agents) {
911
- if (!room.postChat || !agentsCanCommit(room, key)) return []
912
- const map = room.doc.getMap('commitRequests')
913
- const back = [...map.values()].filter((r) => r && r.state === 'open' && agents.has(r.by) && (!r.branch || r.branch === key))
914
- if (!back.length) return []
915
- room.doc.transact(() => {
916
- for (const r of back) map.set(r.id, { ...r, state: 'done', doneBy: RELAY_BY, doneAt: Date.now(), hash: '', committed: [], note: `handed back to ${r.by}: agents get their own work into git here` })
917
- }, RELAY_PUBLISH)
918
- for (const r of back) {
919
- const files = Array.isArray(r.files) && r.files.length ? ` (${r.files.slice(0, 8).join(', ')}${r.files.length > 8 ? `, and ${r.files.length - 8} more` : ''})` : ''
920
- room.postChat({ by: RELAY_BY, to: r.by, kind: 'nudge', text: `Your commit request "${r.message}"${files} was handed back to you: agents get their own work into git here now. Commit those files and push from your folder (a branch of your own with a pull request, unless the owner lets agents push to ${key}); without a folder, quilt_git_commit.` })
921
- }
922
- return back.map((r) => r.id)
923
- }