heyquilt 0.0.1 → 0.3.106
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/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/relay-ci.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// CI on GitHub becomes work on the board: checks failing on a branch the session follows open one task,
|
|
2
|
+
// "CI failing on <branch>", for the member whose commit it is (when they are in the session); a newer commit
|
|
3
|
+
// failing too is noted on it; checks passing again move it to Done. One task per branch (its id comes from the
|
|
4
|
+
// branch), so a run reported twice never adds a second.
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { readTasks, addTask, updateTask } from './tasks.js'
|
|
7
|
+
import { addComment } from './task-comments.js'
|
|
8
|
+
import { githubWriter } from './github-commit.js'
|
|
9
|
+
import { credentialsFor } from './relay-commit.js'
|
|
10
|
+
import { upstreamsOf, parseRepo } from './relay-upstream.js'
|
|
11
|
+
import { memberForAuthor, everyone } from './relay-clash.js'
|
|
12
|
+
import { runGit } from './relay-git.js'
|
|
13
|
+
import { RELAY_BY } from './branches.js'
|
|
14
|
+
|
|
15
|
+
const ORIGIN = 'relay-publish'
|
|
16
|
+
// What GitHub calls a check suite that failed (check_suite.conclusion).
|
|
17
|
+
const FAILED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure'])
|
|
18
|
+
|
|
19
|
+
/** The task id for CI on `branch` of this session: the same every time. */
|
|
20
|
+
export const ciTaskId = (room, branch) => crypto.createHash('sha256').update(`ci\0${room.name}\0${branch}`).digest('hex').slice(0, 16)
|
|
21
|
+
|
|
22
|
+
/** Who made commit `sha` (from the relay's mirror): the member it maps to, or ''. */
|
|
23
|
+
async function authorOf (room, rec, sha) {
|
|
24
|
+
if (!room.mirrors) return ''
|
|
25
|
+
try {
|
|
26
|
+
const repo = parseRepo(`https://${rec.repo}`)
|
|
27
|
+
const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
|
|
28
|
+
const r = await runGit(dir, ['log', '-1', '--format=%an%x1f%ae', sha])
|
|
29
|
+
if (!r.ok) return ''
|
|
30
|
+
const [name, email] = r.out.trim().split('\x1f')
|
|
31
|
+
// By their noreply address, else by name: a member on record, or one connected now.
|
|
32
|
+
const online = room.names ? [...room.names.values()] : []
|
|
33
|
+
return memberForAuthor(room, { name, email }) || everyone(room).find((m) => m.name === name)?.name || online.find((n) => n === name) || ''
|
|
34
|
+
} catch { return '' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A check suite on `repo` finished at commit `sha` of `branch` with `conclusion`. When the session follows that
|
|
39
|
+
* branch: failing opens (or reopens, or notes) its CI task; passing moves an open one to Done. Returns what it did
|
|
40
|
+
* ('opened', 'noted', 'closed') or null. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function noteCiResult (room, result, opts = {}) {
|
|
43
|
+
// One at a time per branch, in the order GitHub's results arrive: a failure takes longer to note (GitHub is asked
|
|
44
|
+
// which checks failed), and a pass right after it must not be noted first.
|
|
45
|
+
if (!room.ciQueue) room.ciQueue = new Map()
|
|
46
|
+
const k = String(result && result.branch)
|
|
47
|
+
const next = (room.ciQueue.get(k) || Promise.resolve()).then(() => noteCiResultNow(room, result, opts))
|
|
48
|
+
room.ciQueue.set(k, next.catch(() => null))
|
|
49
|
+
return next
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether commit `older` is an ancestor of `newer` in the relay's mirror of `rec`'s repository (false when it can't say). */
|
|
53
|
+
async function before (room, rec, older, newer) {
|
|
54
|
+
try {
|
|
55
|
+
const repo = parseRepo(`https://${rec.repo}`)
|
|
56
|
+
const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
|
|
57
|
+
return (await runGit(dir, ['merge-base', '--is-ancestor', older, newer])).ok
|
|
58
|
+
} catch { return false }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function noteCiResultNow (room, { repo, sha, branch, conclusion }, { fetch = globalThis.fetch, log = () => {} } = {}) {
|
|
62
|
+
try {
|
|
63
|
+
const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
|
|
64
|
+
const rec = Object.values(upstreamsOf(room)).find((r) => r && r.repo && r.ref === branch && String(r.repo).toLowerCase().replace(/^github\.com\//, '') === want)
|
|
65
|
+
if (!rec || !/^[0-9a-f]{40}$/.test(sha || '')) return null
|
|
66
|
+
const failed = FAILED.has(String(conclusion))
|
|
67
|
+
if (!failed && conclusion !== 'success') return null
|
|
68
|
+
// A failure on an older commit than one that passed since (GitHub's results can come out of order) is old news.
|
|
69
|
+
if (!room.ciPassed) room.ciPassed = new Map()
|
|
70
|
+
const passed = room.ciPassed.get(branch)
|
|
71
|
+
if (!failed) room.ciPassed.set(branch, sha)
|
|
72
|
+
else if (passed && passed !== sha && room.mirrors && await before(room, rec, sha, passed)) return null
|
|
73
|
+
const map = room.doc.getMap('tasks')
|
|
74
|
+
const id = ciTaskId(room, branch)
|
|
75
|
+
const task = readTasks(map).find((t) => t.id === id)
|
|
76
|
+
const open = task && !task.archived && task.column !== 'done'
|
|
77
|
+
const comment = (text) => addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: id, by: RELAY_BY, text }, ORIGIN)
|
|
78
|
+
if (!failed) {
|
|
79
|
+
if (!open) return null
|
|
80
|
+
comment(`CI passes again on ${branch} at ${sha.slice(0, 7)}.`)
|
|
81
|
+
updateTask(room.doc, map, { id, column: 'done' }, ORIGIN)
|
|
82
|
+
return 'closed'
|
|
83
|
+
}
|
|
84
|
+
// Which checks failed, when GitHub says (a failing suite is enough to act on).
|
|
85
|
+
let names = []
|
|
86
|
+
try {
|
|
87
|
+
const r = parseRepo(`https://${rec.repo}`)
|
|
88
|
+
const c = await githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name }).checks(sha)
|
|
89
|
+
names = c.failing || []
|
|
90
|
+
} catch (err) { log(`[${room.name}] ${branch}: could not read which checks failed on ${sha.slice(0, 7)}: ${err.message}`) }
|
|
91
|
+
const what = names.length ? names.slice(0, 5).join(', ') : 'checks'
|
|
92
|
+
// The relay shut down while GitHub was asked: the room notes it when it is back (server.js runCi).
|
|
93
|
+
if (room.destroyed) return null
|
|
94
|
+
const url = `https://${rec.repo}/commit/${sha}`
|
|
95
|
+
if (open) {
|
|
96
|
+
comment(`Still failing at ${sha.slice(0, 7)}: ${what}. ${url}`)
|
|
97
|
+
return 'noted'
|
|
98
|
+
}
|
|
99
|
+
const who = await authorOf(room, rec, sha)
|
|
100
|
+
if (room.destroyed) return null
|
|
101
|
+
const title = `CI failing on ${branch}: ${what}`
|
|
102
|
+
if (task) updateTask(room.doc, map, { id, title, column: 'todo', archived: false, ...(who ? { assignee: who } : {}) }, ORIGIN)
|
|
103
|
+
else addTask(room.doc, map, { id, title, by: RELAY_BY, assignee: who, column: 'todo' }, ORIGIN)
|
|
104
|
+
comment(`GitHub's checks failed on ${branch} at ${sha.slice(0, 7)}: ${what}. ${url}${who ? ` The commit is ${who}'s.` : ''}`)
|
|
105
|
+
log(`[${room.name}] ${branch}: CI failing at ${sha.slice(0, 7)} (${what}): task ${id}${who ? ` for ${who}` : ''}`)
|
|
106
|
+
return 'opened'
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log(`[${room.name}] ${branch}: could not note CI on ${String(sha).slice(0, 7)}: ${err.message}`)
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Automatic merges on the relay (docs/superpowers/specs/2026-10-18-automatic-merges.md). Where
|
|
2
|
+
// GitHub's commits and the session's work changed the same file, the relay combines them itself
|
|
3
|
+
// (upstream.js combine: lines only one side changed are kept, lines both changed go to the newer
|
|
4
|
+
// edit, an edit beats a deletion, a binary is the newer version) and the file lands at once: nobody
|
|
5
|
+
// is asked to merge, no task, nothing held. What lost is kept (the file's history; the session's
|
|
6
|
+
// version as a blob in the mirror, kept by its keep refs for RESOLVED_KEEP_MS) and whoever wrote it
|
|
7
|
+
// is told: a direct message from the relay to the member whose lines lost, or a line in the
|
|
8
|
+
// activity log for GitHub's. GitHub's checks failing on a commit that carried a merge tell both
|
|
9
|
+
// parties; the file is never held for it.
|
|
10
|
+
//
|
|
11
|
+
// quilt_clash stays registered for a release (older guides call it) and answers that there is
|
|
12
|
+
// nothing to do. Quilt never runs an AI itself.
|
|
13
|
+
import { syncStateOf, runGit } from './relay-git.js'
|
|
14
|
+
import { upstreamsOf, RELAY_ORIGIN } from './relay-upstream.js'
|
|
15
|
+
import { readTasks, updateTask } from './tasks.js'
|
|
16
|
+
import { RELAY_BY } from './branches.js'
|
|
17
|
+
import { slug } from './github-commit.js'
|
|
18
|
+
import { updateMerge } from './merges.js'
|
|
19
|
+
|
|
20
|
+
export const RESOLVED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
|
|
21
|
+
/** quilt_clash's actions, still accepted (each answers that Quilt merges on its own). */
|
|
22
|
+
export const CLASH_ACTIONS = ['propose', 'accept', 'keep_session', 'keep_github', 'keep_offline', 'revert', 'status']
|
|
23
|
+
/** The most replaced lines a notice lists. */
|
|
24
|
+
export const LOST_LINES_SHOWN = 12
|
|
25
|
+
export const NOTHING_TO_MERGE = 'Quilt merges on its own now: where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history (quilt_history), and whoever wrote them is told. Nothing to do.'
|
|
26
|
+
|
|
27
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
|
|
28
|
+
|
|
29
|
+
/** Everyone in the session: its members and its owner (who may not be in the members list): [{ id, name, kind, github? }]. */
|
|
30
|
+
export function everyone (room) {
|
|
31
|
+
const out = Object.entries(room.meta.members || {}).filter(([, m]) => m && m.name).map(([id, m]) => ({ id, ...m }))
|
|
32
|
+
if (room.meta.ownerName && !out.some((m) => m.name === room.meta.ownerName)) out.push({ id: room.meta.ownerSub || '', name: room.meta.ownerName, kind: 'human', ...(room.meta.ownerGithub ? { github: room.meta.ownerGithub } : {}) })
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A git author as a member: by the agents' or people's noreply address, or the GitHub account a person linked. */
|
|
37
|
+
export function memberForAuthor (room, { name = '', email = '' }) {
|
|
38
|
+
const e = String(email || '').toLowerCase()
|
|
39
|
+
const members = everyone(room)
|
|
40
|
+
let m
|
|
41
|
+
if ((m = /^([a-z0-9-]+)@agents\.noreply\.heyquilt\.com$/.exec(e))) return members.find((x) => x.kind === 'agent' && slug(x.name, 40) === m[1])?.name || null
|
|
42
|
+
if ((m = /^([a-z0-9-]+)@people\.noreply\.heyquilt\.com$/.exec(e))) return members.find((x) => x.kind !== 'agent' && slug(x.name, 40) === m[1])?.name || null
|
|
43
|
+
if ((m = /^(?:\d+\+)?([a-z0-9-]+)@users\.noreply\.github\.com$/.exec(e))) return members.find((x) => x.github && String(x.github.login).toLowerCase() === m[1])?.name || null
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }] (ts: commit time). */
|
|
48
|
+
export async function upstreamCommits (dir, base, head, rel) {
|
|
49
|
+
const r = await runGit(dir, ['log', '--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%s', `${base}..${head}`, '--', rel])
|
|
50
|
+
if (!r.ok) return []
|
|
51
|
+
return r.out.split('\n').filter(Boolean).map((l) => { const [sha, name, email, ct, subject] = l.split('\x1f'); return { sha, name, email, ts: Number(ct) * 1000, subject } })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stores `buf` (a Buffer or text) as a blob in the mirror: its sha, or null. */
|
|
55
|
+
export async function storeBlob (dir, buf) {
|
|
56
|
+
const r = await runGit(dir, ['hash-object', '-w', '--stdin'], { input: Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8') })
|
|
57
|
+
return r.ok ? r.out.trim() : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dm = (room, to, text, kind = 'nudge') => { if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind, text }) }
|
|
61
|
+
const activity = (room, entry) => {
|
|
62
|
+
if (!room.activity || !room.doc) return
|
|
63
|
+
room.doc.transact(() => {
|
|
64
|
+
room.activity.push([entry])
|
|
65
|
+
if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
|
|
66
|
+
}, RELAY_ORIGIN)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The replaced lines of `lost` (mergeAuto's), as a list for a notice: at most LOST_LINES_SHOWN. */
|
|
70
|
+
export function lostLines (lost) {
|
|
71
|
+
const all = lost.flatMap((l) => l.lines)
|
|
72
|
+
const shown = all.slice(0, LOST_LINES_SHOWN).map((l) => `- ${clip(l, 200)}`)
|
|
73
|
+
if (all.length > shown.length) shown.push(`- and ${all.length - shown.length} more`)
|
|
74
|
+
return shown.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The relay just combined `path` on branch `key` itself (upstream.js combine): whoever's lines lost
|
|
79
|
+
* is told, once. `lost`: combine's; `session`: { name, ts } the member who last changed it in the
|
|
80
|
+
* session; `github`: { name, member?, sha } the newest commit of GitHub's that changed it; `upstream`
|
|
81
|
+
* the upstream's name (origin/main). The session's lines lost: a direct message to that member, with
|
|
82
|
+
* the lines; GitHub's: a line in the activity log. `keptIn`: where the losing lines are kept. A notice, never a task.
|
|
83
|
+
*/
|
|
84
|
+
export function tellAutoMerge (room, key, { path: rel, lost = [], session = null, github = null, upstream = 'GitHub', head = '', deleted = null, keptIn = 'the file\'s history (quilt_history)', now = Date.now() }) {
|
|
85
|
+
const at = head ? ` ${head.slice(0, 7)}` : ''
|
|
86
|
+
const ghName = github ? (github.member || github.name || upstream) : upstream
|
|
87
|
+
if (deleted === 'theirs') activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: `${upstream}${at} deleted it, but the session changed it: it stays`, branch: key, ts: now })
|
|
88
|
+
if (deleted === 'ours' && session && session.name) dm(room, session.name, `${rel} on ${key}: it was deleted in the session, but ${ghName} changed it on GitHub (${upstream}${at}), so it is back with that change. Delete it again if it should go.`)
|
|
89
|
+
const ours = lost.filter((l) => l.side === 'ours')
|
|
90
|
+
const theirs = lost.filter((l) => l.side === 'theirs')
|
|
91
|
+
if (ours.length && session && session.name) {
|
|
92
|
+
const binary = ours.some((l) => l.binary)
|
|
93
|
+
dm(room, session.name, binary
|
|
94
|
+
? `Quilt merged ${upstream}${at} into ${key}: you and ${ghName} both changed ${rel}, a binary file, and ${ghName}'s newer version stays. Yours is kept in ${keptIn}.`
|
|
95
|
+
: `Quilt merged ${upstream}${at} into ${rel} on ${key}: where you and ${ghName} both changed the same lines, ${ghName}'s newer edit stays. The lines of yours it replaced are in ${keptIn}:\n${lostLines(ours)}`)
|
|
96
|
+
}
|
|
97
|
+
if (theirs.length) {
|
|
98
|
+
const n = theirs.reduce((k, l) => k + (l.binary ? 1 : l.lines.length), 0)
|
|
99
|
+
activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: theirs.some((l) => l.binary) ? `kept the session's newer version over ${upstream}${at}'s` : `kept the session's newer lines over ${n} line${n === 1 ? '' : 's'} of ${upstream}${at}`, branch: key, ts: now })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A held entry from an older relay (a clash, failing checks): its task Done and its merge record closed, as Quilt merged it itself. */
|
|
104
|
+
export function closeOldClash (room, key, h, { how = 'merged by Quilt', now = Date.now() } = {}) {
|
|
105
|
+
if (h.task) {
|
|
106
|
+
try {
|
|
107
|
+
const t = readTasks(room.tasks || room.doc.getMap('tasks')).find((x) => x.id === h.task)
|
|
108
|
+
if (t && t.column !== 'done') updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, column: 'done', verified: `${h.path}: ${how}. Quilt merges on its own now.` }, RELAY_ORIGIN)
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
if (h.mergeId) { try { const e = room.branchDoc(key); updateMerge(e.doc, e.doc.getMap('merges'), h.mergeId, { state: 'done', how: 'mine', resolvedBy: 'Quilt' }, RELAY_ORIGIN) } catch {} }
|
|
112
|
+
noteResolved(room, key, { path: h.path, kind: h.kind || 'upstream', base: h.base || null, theirs: h.theirs || null, oursBlob: h.oursBlob || null, otherBlob: h.otherBlob || null, baseBlob: h.baseBlob || null, how, at: now })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Keeps a merge's versions on record (state.resolved) for RESOLVED_KEEP_MS, so the mirror keeps their objects (relay-sync.js keptObjects). */
|
|
116
|
+
export function noteResolved (room, key, entry) {
|
|
117
|
+
const state = syncStateOf(room, key)
|
|
118
|
+
if (!Array.isArray(state.resolved)) state.resolved = []
|
|
119
|
+
const now = entry.at || Date.now()
|
|
120
|
+
state.resolved.push({ ...entry, at: now })
|
|
121
|
+
state.resolved = state.resolved.filter((r) => now - r.at < RESOLVED_KEEP_MS).slice(-100)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* quilt_clash, kept for older guides: every action answers that Quilt merges on its own and there
|
|
126
|
+
* is nothing to do. Resolves { ok: true, text }; never throws.
|
|
127
|
+
*/
|
|
128
|
+
export async function clashAction (room, { key = '', action = '' } = {}) {
|
|
129
|
+
if (action === 'status') return { ok: true, text: `No clashes${key ? ` on ${key}` : ''}. ${NOTHING_TO_MERGE}` }
|
|
130
|
+
return { ok: true, text: NOTHING_TO_MERGE }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const CLASH_DESCRIPTION = 'No longer needed: Quilt merges on its own. Where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history, and whoever wrote them is told. Every action answers that there is nothing to do.'
|
|
134
|
+
export const clashSchema = (z) => ({
|
|
135
|
+
action: z.enum(CLASH_ACTIONS).describe('Any: there is nothing to do'),
|
|
136
|
+
path: z.string().max(1024).optional().describe('Ignored'),
|
|
137
|
+
content: z.string().max(1024 * 1024).optional().describe('Ignored'),
|
|
138
|
+
checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('Ignored'),
|
|
139
|
+
force: z.boolean().optional().describe('Ignored')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A merged file (combined on the way in) is on record until it is pushed: then the commit it went
|
|
144
|
+
* in is, with both parties, so failing checks on that commit can tell them (noteMergeChecks).
|
|
145
|
+
*/
|
|
146
|
+
export function noteMerged (room, key, rel, parties, { now = Date.now() } = {}) {
|
|
147
|
+
const state = syncStateOf(room, key)
|
|
148
|
+
if (!state.merged || typeof state.merged !== 'object') state.merged = {}
|
|
149
|
+
state.merged[rel] = { parties, at: now }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Called by the outbound push: the merged files that went in `sha`. */
|
|
153
|
+
export function notePushedMerges (room, key, sha, changed) {
|
|
154
|
+
const state = syncStateOf(room, key)
|
|
155
|
+
if (!state.merged) return
|
|
156
|
+
const files = changed.filter((rel) => state.merged[rel])
|
|
157
|
+
if (!files.length) return
|
|
158
|
+
if (!state.mergeCommits || typeof state.mergeCommits !== 'object') state.mergeCommits = {}
|
|
159
|
+
state.mergeCommits[sha] = { files, parties: files.map((rel) => state.merged[rel].parties), at: Date.now() }
|
|
160
|
+
for (const rel of files) delete state.merged[rel]
|
|
161
|
+
const keys = Object.keys(state.mergeCommits)
|
|
162
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - 20))) delete state.mergeCommits[k]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The failing check runs' annotations on `sha` (REST metadata): [{ path, line }], or [] when GitHub doesn't say. */
|
|
166
|
+
async function failingLines (room, key, sha) {
|
|
167
|
+
const rec = upstreamsOf(room)[key]
|
|
168
|
+
try {
|
|
169
|
+
const [owner, name] = String(rec.repo).replace(/^github\.com\//, '').split('/')
|
|
170
|
+
const token = await room.gitToken({ owner, name, slug: rec.repo })
|
|
171
|
+
const f = room.githubFetch || globalThis.fetch
|
|
172
|
+
const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', ...(token ? { authorization: `Bearer ${token}` } : {}) }
|
|
173
|
+
const runs = await (await f(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/check-runs?per_page=50`, { headers })).json()
|
|
174
|
+
const out = []
|
|
175
|
+
for (const r of (runs && runs.check_runs) || []) {
|
|
176
|
+
if (r.conclusion !== 'failure' || !r.id) continue
|
|
177
|
+
const ann = await (await f(`https://api.github.com/repos/${owner}/${name}/check-runs/${r.id}/annotations?per_page=50`, { headers })).json()
|
|
178
|
+
for (const a of Array.isArray(ann) ? ann : []) if (a && a.path) out.push({ path: a.path, line: a.start_line || 0, message: String(a.message || '').slice(0, 200) })
|
|
179
|
+
}
|
|
180
|
+
return out
|
|
181
|
+
} catch { return [] }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* GitHub's checks on `sha` (a check_suite) finished. Failing on a commit that carried merged files:
|
|
186
|
+
* both parties of those merges are told, once, with the failing lines GitHub names. Nothing is held:
|
|
187
|
+
* the file keeps syncing, and a fix goes like any edit. Files an older relay held for failing checks
|
|
188
|
+
* are let go of. Returns what changed: [{ path, told: [names] }, { path, released: true }].
|
|
189
|
+
*/
|
|
190
|
+
export async function noteMergeChecks (room, key, { sha, conclusion }, { now = Date.now() } = {}) {
|
|
191
|
+
const state = syncStateOf(room, key)
|
|
192
|
+
const out = []
|
|
193
|
+
for (const h of state.held.filter((x) => x.why === 'checks')) {
|
|
194
|
+
state.held = state.held.filter((x) => x !== h)
|
|
195
|
+
closeOldClash(room, key, h, { how: 'let go of: a file is no longer held for failing checks', now })
|
|
196
|
+
if (room.outbound) room.outbound.note(key, h.path)
|
|
197
|
+
out.push({ path: h.path, released: true })
|
|
198
|
+
}
|
|
199
|
+
if (conclusion !== 'failure' && conclusion !== 'timed_out') return out
|
|
200
|
+
const mc = state.mergeCommits && state.mergeCommits[sha]
|
|
201
|
+
if (!mc || mc.told) return out
|
|
202
|
+
mc.told = now
|
|
203
|
+
const lines = await failingLines(room, key, sha)
|
|
204
|
+
const named = mc.files.filter((rel) => lines.some((l) => l.path === rel))
|
|
205
|
+
const files = named.length ? named : mc.files
|
|
206
|
+
for (const rel of files) {
|
|
207
|
+
const parties = mc.parties[mc.files.indexOf(rel)] || {}
|
|
208
|
+
const names = [...new Set([parties.session && parties.session.name, parties.github && (parties.github.member || null)].filter(Boolean))]
|
|
209
|
+
const where = lines.filter((l) => l.path === rel).slice(0, 5).map((l) => `\n- ${rel}:${l.line} ${l.message}`).join('')
|
|
210
|
+
for (const n of names) dm(room, n, `GitHub's checks failed on ${sha.slice(0, 7)}, which carried Quilt's merge of ${rel} on ${key} (your edit and ${names.find((x) => x !== n) || 'one from GitHub'}'s). It is not held: a fix goes in like any edit.${where}`)
|
|
211
|
+
out.push({ path: rel, told: names })
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|