@arjunkhera/atlas 0.4.37 → 0.4.38
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/.claude-plugin/plugin.json +1 -1
- package/door/kinds-of-change.yaml +188 -0
- package/door/lib/brake.mjs +42 -7
- package/door/lib/gate-check.mjs +33 -11
- package/door/lib/gate.mjs +15 -1
- package/door/lib/kinds.mjs +128 -0
- package/door/lib/owner-only.mjs +5 -3
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# The starting list of the kinds of change, for each kind of repo (roadmap R51,
|
|
2
|
+
# design docs/design-docs/kinds-of-change/ in the Atlas repo). Onboarding
|
|
3
|
+
# (roadmap R63) copies one list into the `kinds_of_change` key of a new repo's
|
|
4
|
+
# atlas.yaml, and the repo changes it to fit its paths.
|
|
5
|
+
#
|
|
6
|
+
# No kind here has a `tried:` date. Locked rule 2: nothing runs alone until its
|
|
7
|
+
# undo was tried once. So the gate sends each kind to the owner until the repo
|
|
8
|
+
# tries its undo and writes the date and the proof.
|
|
9
|
+
#
|
|
10
|
+
# The first kind whose paths hold a path is its kind, so the specific kinds
|
|
11
|
+
# come first. Paths match as in owner-only.yaml: a trailing `/` is a folder,
|
|
12
|
+
# `*` is one name, `**/` is any depth.
|
|
13
|
+
starting:
|
|
14
|
+
service:
|
|
15
|
+
- kind: Agent instructions and repo facts
|
|
16
|
+
paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
|
|
17
|
+
impact: how every agent session works in this repo
|
|
18
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
19
|
+
needs: git and gh only
|
|
20
|
+
- kind: CI
|
|
21
|
+
paths: [.github/]
|
|
22
|
+
impact: what runs on each pull request and on the main branch
|
|
23
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
24
|
+
needs: git and gh only
|
|
25
|
+
- kind: Tests
|
|
26
|
+
paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
|
|
27
|
+
impact: what the checks prove
|
|
28
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
29
|
+
needs: git and gh only
|
|
30
|
+
- kind: Schema migrations
|
|
31
|
+
paths: [migrations/, db/migrations/, "**/*.sql"]
|
|
32
|
+
impact: the data of every tenant, after the deploy runs it
|
|
33
|
+
undo: a new migration that takes the change back, merged and deployed; a dropped column or table cannot be undone
|
|
34
|
+
needs: git, gh and the deploy procedure
|
|
35
|
+
- kind: Deploy
|
|
36
|
+
paths: [deploy/, infra/, Dockerfile, "docker-compose*.yml", "*.tf"]
|
|
37
|
+
impact: where and how the service runs
|
|
38
|
+
undo: git revert -m 1 <merge commit>, merge on green, then run the deploy procedure
|
|
39
|
+
needs: git, gh and the deploy procedure
|
|
40
|
+
- kind: Service code
|
|
41
|
+
paths: [src/, app/, server/, lib/, scripts/]
|
|
42
|
+
impact: every request that the deployed service answers
|
|
43
|
+
undo: git revert -m 1 <merge commit>, merge on green, then run the deploy procedure
|
|
44
|
+
needs: git, gh and the deploy procedure
|
|
45
|
+
- kind: Dependencies
|
|
46
|
+
paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
|
|
47
|
+
impact: the code that builds and runs with this repo
|
|
48
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
49
|
+
needs: git and gh only
|
|
50
|
+
- kind: Docs
|
|
51
|
+
paths: [docs/, "*.md", "**/README.md"]
|
|
52
|
+
impact: what readers and agents read; no code runs it
|
|
53
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
54
|
+
needs: git and gh only
|
|
55
|
+
library:
|
|
56
|
+
- kind: Agent instructions and repo facts
|
|
57
|
+
paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
|
|
58
|
+
impact: how every agent session works in this repo
|
|
59
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
60
|
+
needs: git and gh only
|
|
61
|
+
- kind: CI
|
|
62
|
+
paths: [.github/]
|
|
63
|
+
impact: what runs on each pull request and on the main branch
|
|
64
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
65
|
+
needs: git and gh only
|
|
66
|
+
- kind: Tests
|
|
67
|
+
paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
|
|
68
|
+
impact: what the checks prove
|
|
69
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
70
|
+
needs: git and gh only
|
|
71
|
+
- kind: Library code
|
|
72
|
+
paths: [src/, lib/, packages/, bin/]
|
|
73
|
+
impact: every repo that takes the next release
|
|
74
|
+
undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
|
|
75
|
+
needs: git and gh only
|
|
76
|
+
- kind: Dependencies
|
|
77
|
+
paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
|
|
78
|
+
impact: the code that builds and runs with this repo
|
|
79
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
80
|
+
needs: git and gh only
|
|
81
|
+
- kind: Docs
|
|
82
|
+
paths: [docs/, "*.md", "**/README.md"]
|
|
83
|
+
impact: what readers and agents read; no code runs it
|
|
84
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
85
|
+
needs: git and gh only
|
|
86
|
+
schema:
|
|
87
|
+
- kind: Agent instructions and repo facts
|
|
88
|
+
paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
|
|
89
|
+
impact: how every agent session works in this repo
|
|
90
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
91
|
+
needs: git and gh only
|
|
92
|
+
- kind: CI
|
|
93
|
+
paths: [.github/]
|
|
94
|
+
impact: what runs on each pull request and on the main branch
|
|
95
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
96
|
+
needs: git and gh only
|
|
97
|
+
- kind: Tests
|
|
98
|
+
paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
|
|
99
|
+
impact: what the checks prove
|
|
100
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
101
|
+
needs: git and gh only
|
|
102
|
+
- kind: Schemas
|
|
103
|
+
paths: [schemas/, schema/, "**/*.schema.json", "**/*.proto", "**/*.graphql"]
|
|
104
|
+
impact: every reader and writer of the data these schemas name
|
|
105
|
+
undo: git revert -m 1 <merge commit>, merge on green, release; a removed field cannot be undone for data written meanwhile
|
|
106
|
+
needs: git and gh only
|
|
107
|
+
- kind: Schema code
|
|
108
|
+
paths: [src/, lib/, scripts/]
|
|
109
|
+
impact: how the schemas are checked and built
|
|
110
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
111
|
+
needs: git and gh only
|
|
112
|
+
- kind: Dependencies
|
|
113
|
+
paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
|
|
114
|
+
impact: the code that builds and runs with this repo
|
|
115
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
116
|
+
needs: git and gh only
|
|
117
|
+
- kind: Docs
|
|
118
|
+
paths: [docs/, "*.md", "**/README.md"]
|
|
119
|
+
impact: what readers and agents read; no code runs it
|
|
120
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
121
|
+
needs: git and gh only
|
|
122
|
+
cli:
|
|
123
|
+
- kind: Agent instructions and repo facts
|
|
124
|
+
paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
|
|
125
|
+
impact: how every agent session works in this repo
|
|
126
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
127
|
+
needs: git and gh only
|
|
128
|
+
- kind: CI
|
|
129
|
+
paths: [.github/]
|
|
130
|
+
impact: what runs on each pull request and on the main branch
|
|
131
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
132
|
+
needs: git and gh only
|
|
133
|
+
- kind: Tests
|
|
134
|
+
paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
|
|
135
|
+
impact: what the checks prove
|
|
136
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
137
|
+
needs: git and gh only
|
|
138
|
+
- kind: Command code
|
|
139
|
+
paths: [src/, cmd/, bin/, lib/, internal/]
|
|
140
|
+
impact: every person and job that installs the next release
|
|
141
|
+
undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
|
|
142
|
+
needs: git and gh only
|
|
143
|
+
- kind: Dependencies
|
|
144
|
+
paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
|
|
145
|
+
impact: the code that builds and runs with this repo
|
|
146
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
147
|
+
needs: git and gh only
|
|
148
|
+
- kind: Docs
|
|
149
|
+
paths: [docs/, "*.md", "**/README.md"]
|
|
150
|
+
impact: what readers and agents read; no code runs it
|
|
151
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
152
|
+
needs: git and gh only
|
|
153
|
+
plugin:
|
|
154
|
+
- kind: Agent instructions and repo facts
|
|
155
|
+
paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
|
|
156
|
+
impact: how every agent session works in this repo
|
|
157
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
158
|
+
needs: git and gh only
|
|
159
|
+
- kind: CI
|
|
160
|
+
paths: [.github/]
|
|
161
|
+
impact: what runs on each pull request and on the main branch
|
|
162
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
163
|
+
needs: git and gh only
|
|
164
|
+
- kind: Tests
|
|
165
|
+
paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
|
|
166
|
+
impact: what the checks prove
|
|
167
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
168
|
+
needs: git and gh only
|
|
169
|
+
- kind: Plugin
|
|
170
|
+
paths: [.claude-plugin/, skills/, agents/, hooks/, commands/, .mcp.json, "plugins/"]
|
|
171
|
+
impact: every session that loads the next release of the plugin
|
|
172
|
+
undo: git revert -m 1 <merge commit>, merge on green; the release ships the old plugin under the next number
|
|
173
|
+
needs: git and gh only
|
|
174
|
+
- kind: Plugin code
|
|
175
|
+
paths: [src/, lib/, bin/, scripts/]
|
|
176
|
+
impact: the tools and scripts the plugin runs
|
|
177
|
+
undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
|
|
178
|
+
needs: git and gh only
|
|
179
|
+
- kind: Dependencies
|
|
180
|
+
paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
|
|
181
|
+
impact: the code that builds and runs with this repo
|
|
182
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
183
|
+
needs: git and gh only
|
|
184
|
+
- kind: Docs
|
|
185
|
+
paths: [docs/, "*.md", "**/README.md"]
|
|
186
|
+
impact: what readers and agents read; no code runs it
|
|
187
|
+
undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
|
|
188
|
+
needs: git and gh only
|
package/door/lib/brake.mjs
CHANGED
|
@@ -7,14 +7,21 @@
|
|
|
7
7
|
// windowCommits(root, base, since) the first-parent commits of main in the window
|
|
8
8
|
// revertTrips(root, commits) line 1: a revert of a change that main held
|
|
9
9
|
// ciTrips({ commits, runs, homes, ... }, jobsOf) lines 2 and 3: a red main that a rerun confirmed, a failed live check
|
|
10
|
-
// brakeState({ root, base, now, ci }) the trips, and the date the brake ends
|
|
10
|
+
// brakeState({ root, base, now, ci }) the trips with their kinds, and the date the brake ends
|
|
11
11
|
// undoOf(root, mergeBase, head, trips) line 4: the trip that this head undoes exactly, or null
|
|
12
|
+
// clearOf(root, base, trip, clears) line 6: the owner's signed clear of one trip, or null
|
|
12
13
|
//
|
|
13
14
|
// The brake works per repo (line 5): it reads only the history and the runs
|
|
14
|
-
// of the repo that the gate judges.
|
|
15
|
-
//
|
|
15
|
+
// of the repo that the gate judges. Where the repo lists its kinds of change,
|
|
16
|
+
// each trip holds only the kinds of its culprit (roadmap R51, kinds.mjs).
|
|
17
|
+
// Line 6: one signed act of the owner ends a trip early (roadmap R72). The act
|
|
18
|
+
// is a `brake-clear` for the repo and the commit that set the trip. It sits in
|
|
19
|
+
// a comment on that commit, and the key comes from atlas/owner-signers on the
|
|
20
|
+
// base. So the clear is no change to atlas.yaml, and never needs the gate.
|
|
16
21
|
import { spawnSync } from 'node:child_process';
|
|
17
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
23
|
+
import { kindsList, kindsOfCommit } from './kinds.mjs';
|
|
24
|
+
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE } from './signed-act.mjs';
|
|
18
25
|
|
|
19
26
|
export const BRAKE_DAYS = 7;
|
|
20
27
|
// A job that ends like this failed. A cancelled or skipped job did not.
|
|
@@ -142,19 +149,47 @@ export function ciTrips({ commits, runs, homes, checks, live = '', liveChecks =
|
|
|
142
149
|
return trips;
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
// Line 6: the owner's signed clear of one trip. `clears` is { repo,
|
|
153
|
+
// commentsOf(sha) }: commentsOf gives the comment texts of one commit, or
|
|
154
|
+
// null when GitHub cannot say. It fails closed: no key on the base, a comment
|
|
155
|
+
// that does not read, or an act for another repo or commit clears nothing.
|
|
156
|
+
export function clearOf(root, base, trip, clears) {
|
|
157
|
+
if (!clears?.repo || typeof clears.commentsOf !== 'function') return null;
|
|
158
|
+
const { keys } = parseSigners(git(root, ['show', `${base}:${SIGNERS_FILE}`], { allowFail: true }) ?? '');
|
|
159
|
+
if (!keys.length) return null;
|
|
160
|
+
let texts;
|
|
161
|
+
try { texts = clears.commentsOf(trip.sha); } catch { return null; }
|
|
162
|
+
if (!Array.isArray(texts) || !texts.length) return null;
|
|
163
|
+
const found = findAct(verifyTexts(texts, keys), { kind: 'brake-clear', repo: clears.repo, commit: trip.sha });
|
|
164
|
+
return found ? { sha: trip.sha, key: found.key, signed_at: found.signed_at } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
// The brake of the repo at the base commit. `ci` holds the trips of lines 2
|
|
146
168
|
// and 3, which the gate reads from GitHub; with no GitHub read it is empty.
|
|
147
|
-
|
|
169
|
+
// `clears` reads the owner's signed clears (line 6); a cleared trip holds
|
|
170
|
+
// nothing, and the answer lists it under `cleared`.
|
|
171
|
+
export function brakeState({ root, base, now = new Date(), ci = [], clears = null }) {
|
|
148
172
|
const sha = fullSha(root, base);
|
|
149
173
|
if (!sha) throw new BrakeError(`the base "${base}" is not a commit in this repo.`);
|
|
150
|
-
const
|
|
174
|
+
const text = git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true });
|
|
175
|
+
const facts = brakeFacts(text);
|
|
176
|
+
const list = kindsList(text);
|
|
151
177
|
const since = new Date(now.getTime() - facts.days * 864e5);
|
|
152
178
|
const commits = windowCommits(root, sha, since);
|
|
153
179
|
// A time after now, from an odd commit date, counts as now.
|
|
154
180
|
const stamp = now.toISOString();
|
|
155
|
-
|
|
181
|
+
// Each trip holds the kinds of its culprit (roadmap R51); null holds them all.
|
|
182
|
+
const all = [...revertTrips(root, commits), ...ci]
|
|
183
|
+
.map((trip) => ({ ...trip, time: trip.time > stamp ? stamp : trip.time, kinds: kindsOfCommit(root, list, trip.culprit) }))
|
|
184
|
+
.sort((a, b) => b.time.localeCompare(a.time));
|
|
185
|
+
// One clear for each commit that set a trip; a trip with no clear holds.
|
|
186
|
+
const cleared = [];
|
|
187
|
+
const bySha = new Map();
|
|
188
|
+
for (const trip of all) if (!bySha.has(trip.sha)) bySha.set(trip.sha, clearOf(root, sha, trip, clears));
|
|
189
|
+
for (const found of bySha.values()) if (found) cleared.push(found);
|
|
190
|
+
const trips = all.filter((trip) => !bySha.get(trip.sha));
|
|
156
191
|
const until = trips.length ? new Date(Date.parse(trips[0].time) + facts.days * 864e5).toISOString() : null;
|
|
157
|
-
return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips };
|
|
192
|
+
return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared };
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
// The patch id of a diff, or null for an empty diff. --verbatim keeps every
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// changed; a head that git cannot read
|
|
17
17
|
// owner a path or a sign of the owner-only list; a head job named like a
|
|
18
18
|
// check; a refactor claim (until the review job, roadmap R28); a
|
|
19
|
-
// file too large to scan; the brake (roadmap R13, brake.mjs)
|
|
19
|
+
// file too large to scan; the brake (roadmap R13, brake.mjs); a
|
|
20
|
+
// kind of change with no tried undo (roadmap R51, kinds.mjs)
|
|
20
21
|
// success no rule holds the pull request, or the owner signed this head
|
|
21
22
|
//
|
|
22
23
|
// An owner result turns into success when a comment of the pull request holds
|
|
@@ -41,13 +42,14 @@ import { listChanged } from './changed-tests.mjs';
|
|
|
41
42
|
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
42
43
|
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
43
44
|
import { brakeState, undoOf, tripText } from './brake.mjs';
|
|
45
|
+
import { kindsAt, nameKinds, kindRows } from './kinds.mjs';
|
|
44
46
|
|
|
45
47
|
export const VERSION = 1;
|
|
46
48
|
export const GATE_CHECK = 'gate';
|
|
47
49
|
export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
|
|
48
50
|
export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
|
|
49
51
|
export const RESULTS = Object.freeze(['success', 'owner', 'red']);
|
|
50
|
-
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed']);
|
|
52
|
+
export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds']);
|
|
51
53
|
export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
|
|
52
54
|
// A file over this size is not scanned for secrets, and comes to the owner.
|
|
53
55
|
export const SCAN_LIMIT = 1024 * 1024;
|
|
@@ -275,7 +277,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
275
277
|
const baseSha = commitOf(top, base);
|
|
276
278
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
277
279
|
const headSha = commitOf(top, head);
|
|
278
|
-
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null };
|
|
280
|
+
const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null };
|
|
279
281
|
if (!headSha) {
|
|
280
282
|
answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
|
|
281
283
|
return finish(answer);
|
|
@@ -287,12 +289,17 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
287
289
|
}
|
|
288
290
|
answer.merge_base = mergeBase;
|
|
289
291
|
const changes = changedPaths(top, mergeBase, headSha);
|
|
292
|
+
// The kind of each changed path, by the list on the base (roadmap R51).
|
|
293
|
+
const list = kindsAt(top, baseSha);
|
|
294
|
+
const named = nameKinds(list, changes.map((change) => change.path));
|
|
295
|
+
answer.kinds = { listed: list.listed, names: named.names, unlisted: list.listed ? named.unlisted : [] };
|
|
290
296
|
let undo = null;
|
|
291
297
|
if (brake !== false) {
|
|
292
|
-
const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [] });
|
|
298
|
+
const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null });
|
|
293
299
|
undo = undoOf(top, mergeBase, headSha, state.trips);
|
|
294
|
-
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null };
|
|
295
|
-
answer.rows.push(
|
|
300
|
+
answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared };
|
|
301
|
+
for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
|
|
302
|
+
answer.rows.push(...brakeRows(state, undo, list.listed ? named : null));
|
|
296
303
|
}
|
|
297
304
|
// The rules come from the base commit, never from the merge base. An agent
|
|
298
305
|
// picks the merge base when it picks where its branch starts, also a middle
|
|
@@ -300,6 +307,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
300
307
|
// change back (item d56a9c03). The diff stays merge base to head.
|
|
301
308
|
const ruleRef = rules ?? baseSha;
|
|
302
309
|
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
310
|
+
answer.rows.push(...kindRows(list, named));
|
|
303
311
|
answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
|
|
304
312
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
305
313
|
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
@@ -428,13 +436,27 @@ function carryOne(root, baseSha, headSha, mergeBase, act) {
|
|
|
428
436
|
}
|
|
429
437
|
|
|
430
438
|
// Line 1: while the brake is on, every pull request of the repo comes to the
|
|
431
|
-
// owner, except the exact undo of a trip (line 4).
|
|
432
|
-
|
|
439
|
+
// owner, except the exact undo of a trip (line 4). Where the repo lists its
|
|
440
|
+
// kinds (roadmap R51), a trip holds only a pull request that shares a kind
|
|
441
|
+
// with it. A trip with no kinds, or a pull request with a path of no kind,
|
|
442
|
+
// holds as before.
|
|
443
|
+
const holds = (trip, named) => !named || !trip.kinds || named.unlisted.length > 0 || trip.kinds.some((kind) => named.names.includes(kind));
|
|
444
|
+
|
|
445
|
+
function brakeRows(state, undo, named = null) {
|
|
433
446
|
if (!state.on) return [];
|
|
434
|
-
const
|
|
435
|
-
|
|
447
|
+
const holding = state.trips.filter((trip) => holds(trip, named));
|
|
448
|
+
if (!undo && !holding.length) {
|
|
449
|
+
const kinds = [...new Set(state.trips.flatMap((trip) => trip.kinds))].join(', ');
|
|
450
|
+
return [{ kind: 'note', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC for ${kinds}; this pull request is ${named.names.join(', ') || 'no kind'}` }];
|
|
451
|
+
}
|
|
452
|
+
const newest = holding[0] ?? state.trips[0];
|
|
453
|
+
const more = holding.length > 1 ? `; ${holding.length} trips in ${state.days} days` : '';
|
|
436
454
|
if (undo) return [{ kind: 'note', class: 'brake', cause: `the exact undo of ${undo.culprit.slice(0, 7)}, which tripped the brake: ${tripText(undo)}` }];
|
|
437
|
-
|
|
455
|
+
const until = new Date(Date.parse(newest.time) + state.days * 864e5).toISOString();
|
|
456
|
+
const owner = { kind: 'owner', class: 'brake', cause: `the brake is on until ${until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` };
|
|
457
|
+
// Done line 6: one signed clear for each trip, as a comment on its commit.
|
|
458
|
+
const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)}` }));
|
|
459
|
+
return [owner, ...clear];
|
|
438
460
|
}
|
|
439
461
|
|
|
440
462
|
function finish(answer) {
|
package/door/lib/gate.mjs
CHANGED
|
@@ -705,6 +705,20 @@ export function prComments(gh, repo, pr) {
|
|
|
705
705
|
return kept;
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
+
// The comment texts of one commit on main that carry the signed-act marker:
|
|
709
|
+
// where the owner posts a brake clear (roadmap R13 line 6). At most three
|
|
710
|
+
// pages of a hundred. A read that fails gives null, so no clear counts.
|
|
711
|
+
export function commitComments(gh, repo, sha) {
|
|
712
|
+
const texts = [];
|
|
713
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
714
|
+
const rows = gh('GET', `repos/${repo}/commits/${sha}/comments?per_page=100&page=${page}`);
|
|
715
|
+
if (!Array.isArray(rows)) return null;
|
|
716
|
+
for (const row of rows) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
|
|
717
|
+
if (rows.length < 100) break;
|
|
718
|
+
}
|
|
719
|
+
return texts;
|
|
720
|
+
}
|
|
721
|
+
|
|
708
722
|
// The base workflow files that define a job of one of `names`, read from git.
|
|
709
723
|
function definedAt(git, sha, names) {
|
|
710
724
|
const files = new Set();
|
|
@@ -753,7 +767,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
753
767
|
const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
|
|
754
768
|
if (what === 'check') {
|
|
755
769
|
const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
|
|
756
|
-
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo) } }) : null, named && number ? () => prComments(gh, repo, number) : null);
|
|
770
|
+
return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => prComments(gh, repo, number) : null);
|
|
757
771
|
}
|
|
758
772
|
if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
|
|
759
773
|
if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The kinds of change (roadmap R51, tracker item d192ec7d; design
|
|
2
|
+
// docs/design-docs/kinds-of-change/). Each repo lists its kinds in atlas.yaml,
|
|
3
|
+
// under `kinds_of_change`: a set of paths, the impact, the undo, and the day
|
|
4
|
+
// the undo was tried. The gate reads the list from the base, names the kind of
|
|
5
|
+
// each changed path, and sends to the owner a kind whose undo was never tried.
|
|
6
|
+
//
|
|
7
|
+
// kindsList(text) the list of one atlas.yaml text
|
|
8
|
+
// kindsAt(root, ref) the list of atlas.yaml at a commit
|
|
9
|
+
// nameKinds(list, paths) the kind of each path: { names, byKind, unlisted }
|
|
10
|
+
// kindRows(list, named) the gate rows of one pull request
|
|
11
|
+
// kindsOfCommit(root, list, sha) the kind names of one commit on main, or null
|
|
12
|
+
// startingLists(text) the starting list for each kind of repo
|
|
13
|
+
//
|
|
14
|
+
// Locked rule 2 of the target design: nothing runs alone until its undo is
|
|
15
|
+
// written, tried once and takes minutes. So a path that no kind holds, a kind
|
|
16
|
+
// with no undo, and an undo never tried all come to the owner. A repo with no
|
|
17
|
+
// list gets no kind rows: its rules stay as they were.
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
23
|
+
import { matcher } from './owner-only.mjs';
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
export const STARTING_FILE = join(HERE, '..', 'kinds-of-change.yaml');
|
|
27
|
+
export const REPO_KINDS = Object.freeze(['service', 'library', 'schema', 'cli', 'plugin']);
|
|
28
|
+
// The gate names at most this many paths with no kind, then counts the rest.
|
|
29
|
+
const NAMED_PATHS = 20;
|
|
30
|
+
|
|
31
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
|
+
const triedDay = (value) => {
|
|
33
|
+
const text = String(value ?? '').trim();
|
|
34
|
+
return DAY.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`)) ? text : null;
|
|
35
|
+
};
|
|
36
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
37
|
+
|
|
38
|
+
// One list from the entries of `kinds_of_change`. An entry with no name is
|
|
39
|
+
// skipped with a note. An entry with no paths holds no path.
|
|
40
|
+
function fromEntries(entries) {
|
|
41
|
+
if (!Array.isArray(entries)) return { listed: false, kinds: [], notes: [] };
|
|
42
|
+
const kinds = [];
|
|
43
|
+
const notes = [];
|
|
44
|
+
entries.forEach((entry, index) => {
|
|
45
|
+
const kind = entry && typeof entry === 'object' && !Array.isArray(entry) ? text(entry.kind) : null;
|
|
46
|
+
if (!kind) { notes.push(`kinds_of_change entry ${index + 1} has no kind, so the gate skips it`); return; }
|
|
47
|
+
const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path.trim()) : [];
|
|
48
|
+
if (!paths.length) notes.push(`${kind}: it lists no paths, so it holds no path`);
|
|
49
|
+
const except = Array.isArray(entry.except) ? entry.except.filter((path) => typeof path === 'string' && path.trim()) : [];
|
|
50
|
+
kinds.push({ kind, paths, except, undo: text(entry.undo), tried: triedDay(entry.tried), impact: text(entry.impact) });
|
|
51
|
+
});
|
|
52
|
+
// A list in which no kind names its paths is the form from before R51: it
|
|
53
|
+
// says nothing about paths, so the gate treats the repo as one with no list.
|
|
54
|
+
return { listed: kinds.some((one) => one.paths.length > 0), kinds, notes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function kindsList(source) {
|
|
58
|
+
let facts;
|
|
59
|
+
try { facts = parseYaml(source ?? '') ?? {}; } catch { facts = {}; }
|
|
60
|
+
return fromEntries(facts && typeof facts === 'object' ? facts.kinds_of_change : null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function git(root, args) {
|
|
64
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
65
|
+
return r.status === 0 ? r.stdout : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const kindsAt = (root, ref) => kindsList(git(root, ['show', `${ref}:atlas.yaml`]));
|
|
69
|
+
|
|
70
|
+
// Paths match with case here: a match grants the kind's tried undo.
|
|
71
|
+
const exact = (pattern) => matcher(pattern, { caseless: false });
|
|
72
|
+
|
|
73
|
+
// The first kind whose paths hold the path, and whose `except` does not.
|
|
74
|
+
export function kindOf(list, path) {
|
|
75
|
+
for (const one of list.kinds) {
|
|
76
|
+
if (one.paths.some((pattern) => exact(pattern)(path)) && !one.except.some((pattern) => exact(pattern)(path))) return one;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function nameKinds(list, paths) {
|
|
82
|
+
const byKind = new Map();
|
|
83
|
+
const unlisted = [];
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
const one = kindOf(list, path);
|
|
86
|
+
if (!one) { unlisted.push(path); continue; }
|
|
87
|
+
if (!byKind.has(one.kind)) byKind.set(one.kind, { kind: one, paths: [] });
|
|
88
|
+
byKind.get(one.kind).paths.push(path);
|
|
89
|
+
}
|
|
90
|
+
// The kinds in the order of the list, so the rows do not depend on the diff.
|
|
91
|
+
const order = (name) => list.kinds.findIndex((one) => one.kind === name);
|
|
92
|
+
const sorted = new Map([...byKind].sort(([a], [b]) => order(a) - order(b)));
|
|
93
|
+
return { names: [...sorted.keys()], byKind: sorted, unlisted };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The rows of one pull request: a note for each kind it changes, and an owner
|
|
97
|
+
// row for each kind that has no tried undo and for each path with no kind.
|
|
98
|
+
export function kindRows(list, named) {
|
|
99
|
+
if (!list.listed) return [];
|
|
100
|
+
const rows = list.notes.map((cause) => ({ kind: 'note', class: 'kind', cause }));
|
|
101
|
+
for (const { kind, paths } of named.byKind.values()) {
|
|
102
|
+
if (!kind.undo) rows.push({ kind: 'owner', class: 'kind', cause: `${kind.kind}: it names no undo (atlas.yaml kinds_of_change)` });
|
|
103
|
+
else if (!kind.tried) rows.push({ kind: 'owner', class: 'kind', cause: `${kind.kind}: its undo was never tried (atlas.yaml kinds_of_change)` });
|
|
104
|
+
rows.push({ kind: 'note', class: 'kind', cause: `${kind.kind}: ${paths.length} path(s)${kind.tried ? `; undo tried ${kind.tried}` : ''}` });
|
|
105
|
+
}
|
|
106
|
+
for (const path of named.unlisted.slice(0, NAMED_PATHS)) rows.push({ kind: 'owner', class: 'kind', path, cause: 'no kind of change holds this path, so it has no undo' });
|
|
107
|
+
if (named.unlisted.length > NAMED_PATHS) rows.push({ kind: 'owner', class: 'kind', cause: `${named.unlisted.length - NAMED_PATHS} more path(s) that no kind of change holds` });
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The kinds of one commit on main, by its change from its first parent. Null
|
|
112
|
+
// means the brake must hold every kind: the base lists no kinds, the commit
|
|
113
|
+
// has no parent here, or one of its paths has no kind.
|
|
114
|
+
export function kindsOfCommit(root, list, sha) {
|
|
115
|
+
if (!list.listed) return null;
|
|
116
|
+
const out = git(root, ['diff', '--name-only', '-z', '--no-renames', `${sha}^1`, sha, '--']);
|
|
117
|
+
if (out === null) return null;
|
|
118
|
+
const named = nameKinds(list, out.split('\0').filter(Boolean));
|
|
119
|
+
return named.unlisted.length || !named.names.length ? null : named.names;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The starting list for each kind of repo, as packages/atlas/door/kinds-of-change.yaml
|
|
123
|
+
// holds it. Onboarding (roadmap R63) copies one into the new repo's atlas.yaml.
|
|
124
|
+
export function startingLists(source = readFileSync(STARTING_FILE, 'utf8')) {
|
|
125
|
+
const facts = parseYaml(source) ?? {};
|
|
126
|
+
const starting = facts.starting && typeof facts.starting === 'object' ? facts.starting : {};
|
|
127
|
+
return Object.fromEntries(REPO_KINDS.map((repoKind) => [repoKind, fromEntries(starting[repoKind] ?? [])]));
|
|
128
|
+
}
|
package/door/lib/owner-only.mjs
CHANGED
|
@@ -85,8 +85,10 @@ const escape = (text) => text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
|
85
85
|
|
|
86
86
|
// A pattern of owner-only.yaml or atlas.yaml. `**/` is any depth, `*` is one
|
|
87
87
|
// name, `?` one character, and a trailing `/` is a folder. It matches with no
|
|
88
|
-
// case and on whole names: `door/` never matches `door-x/`.
|
|
89
|
-
|
|
88
|
+
// case and on whole names: `door/` never matches `door-x/`. The kinds of change
|
|
89
|
+
// pass `caseless: false`: there a match grants a tried undo, so `ENGINE/` must
|
|
90
|
+
// not take the undo of `engine/`.
|
|
91
|
+
export function matcher(pattern, { caseless = true } = {}) {
|
|
90
92
|
const folder = pattern.endsWith('/');
|
|
91
93
|
const body = clean(pattern);
|
|
92
94
|
let out = '';
|
|
@@ -98,7 +100,7 @@ export function matcher(pattern) {
|
|
|
98
100
|
if (character === '?') { out += '[^/]'; continue; }
|
|
99
101
|
out += escape(character);
|
|
100
102
|
}
|
|
101
|
-
const regex = new RegExp(`^${out}${folder ? '/.+' : '(?:/.+)?'}$`, 'i');
|
|
103
|
+
const regex = new RegExp(`^${out}${folder ? '/.+' : '(?:/.+)?'}$`, caseless ? 'i' : '');
|
|
102
104
|
return (path) => regex.test(clean(path));
|
|
103
105
|
}
|
|
104
106
|
|
package/package.json
CHANGED