@arjunkhera/atlas 0.4.28 → 0.4.30
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/package.json +1 -1
- package/skills/learnings/SKILL.md +20 -5
- package/skills/sdlc-task/SKILL.md +2 -2
- package/work/lib/lessons.mjs +133 -0
- package/work/lib/numbers.mjs +57 -19
- package/work/lib/roundup.mjs +23 -8
- package/work/lib/verbs.mjs +5 -2
- package/work/mcp.mjs +7 -0
package/package.json
CHANGED
|
@@ -22,21 +22,35 @@ and makes it hard to miss again.
|
|
|
22
22
|
|
|
23
23
|
1. **Name the lesson** in one plain sentence. Say what went wrong and
|
|
24
24
|
what is right.
|
|
25
|
-
2. **
|
|
25
|
+
2. **Look for a repeat.** Call `lessons` for the product. Read each line.
|
|
26
|
+
When an earlier lesson says the same thing, this lesson is a repeat.
|
|
27
|
+
Keep that lesson's id, and read its fix: the fix did not hold, so make
|
|
28
|
+
the new fix stronger.
|
|
29
|
+
3. **Pick one home.**
|
|
26
30
|
- The lesson holds only for this repo: it becomes a change here.
|
|
27
31
|
- The lesson holds for every repo: it becomes an Atlas item.
|
|
28
|
-
|
|
32
|
+
4. **For an Atlas lesson,** propose an item in the Atlas tracker, with
|
|
29
33
|
the Atlas tools. Put the owner's words in it, word for word. Tag it
|
|
30
34
|
`learning`. Link it to the repo where the miss happened. Tell the owner
|
|
31
35
|
the item and that an Atlas release, on their word, closes it.
|
|
32
|
-
|
|
36
|
+
5. **Pick the strongest form that fits,** in this order:
|
|
33
37
|
1. a check that fails when the miss happens again;
|
|
34
38
|
2. a tool that makes the right way the easy way;
|
|
35
39
|
3. a skill step;
|
|
36
40
|
4. a line in a doc, with a short note.
|
|
37
|
-
|
|
41
|
+
6. **Prune.** A new rule retires or merges each rule it replaces. Name
|
|
38
42
|
the rules you removed in the pull request.
|
|
39
|
-
|
|
43
|
+
7. **Make the change** in its home, through the normal flow.
|
|
44
|
+
8. **Record the lesson.** Call `lesson_record` once, when the fix exists:
|
|
45
|
+
- `line`: the sentence of step 1;
|
|
46
|
+
- `home`: `repo` with the repo id, or `atlas`;
|
|
47
|
+
- `form`: `check`, `tool`, `skill` or `doc`;
|
|
48
|
+
- `fix`: the pull request, or the Atlas item of step 4;
|
|
49
|
+
- `source`: `owner`, `revert`, `bug` or `machine`, and in `source_ref`
|
|
50
|
+
the step-in, the revert or the bug;
|
|
51
|
+
- `repeats`: the id that step 2 found, if any.
|
|
52
|
+
9. **Link the step-in.** When the owner's message started the lesson, call
|
|
53
|
+
`step_in_name` on that step-in with the lesson id as `lesson`.
|
|
40
54
|
|
|
41
55
|
## Rules
|
|
42
56
|
|
|
@@ -46,3 +60,4 @@ and makes it hard to miss again.
|
|
|
46
60
|
2. A lesson about one repo's product stays in that repo. It never goes
|
|
47
61
|
into an Atlas release.
|
|
48
62
|
3. One lesson is one fix. Do not batch unrelated lessons.
|
|
63
|
+
4. Every lesson leaves one record, a repo lesson too. Never skip step 8.
|
|
@@ -205,8 +205,8 @@ next action. One short paragraph.
|
|
|
205
205
|
and quote the exact test. One decision accepts one red check. Then pass
|
|
206
206
|
`accepted_red` to `item_done`; it needs the owner present. Any other red
|
|
207
207
|
check still refuses the mark.
|
|
208
|
-
2. If anything was a struggle,
|
|
209
|
-
|
|
208
|
+
2. If anything was a struggle, run the `atlas:learnings` skill. It picks the
|
|
209
|
+
home and the form of the fix, and records the lesson.
|
|
210
210
|
3. Rebuild the design's page and the hub, so both read "Shipped". The
|
|
211
211
|
design folder does not change: the tracker holds the delivery. A design
|
|
212
212
|
in the older format is one file, not a folder. Keep its page, and rebuild
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Roadmap R55 (item 8d87c567): every lesson leaves one record. A lesson is
|
|
2
|
+
// a mark of kind lesson, so the tracker needs no new type. It holds one line,
|
|
3
|
+
// the home, the form, the fix link, the source and the lesson it repeats.
|
|
4
|
+
// The learnings skill reads the lessons before it records one, and records a
|
|
5
|
+
// match as a repeat. The roundup counts them per week.
|
|
6
|
+
//
|
|
7
|
+
// Three rules hold it together:
|
|
8
|
+
// 1. A mark is never edited. A lesson that repeats another names it; the
|
|
9
|
+
// first lesson stays as it was.
|
|
10
|
+
// 2. A repeat names a lesson of the same product. A lesson home in a repo
|
|
11
|
+
// names a registered repo of that product.
|
|
12
|
+
// 3. A failed read throws. It never reads as zero lessons.
|
|
13
|
+
import { refuse, text, oneOf, url } from './verb-fields.mjs';
|
|
14
|
+
import { oneLine, scrubSecrets } from './questions.mjs';
|
|
15
|
+
import { standingOf, isStepIn } from './step-ins.mjs';
|
|
16
|
+
import { isRevert } from './numbers.mjs';
|
|
17
|
+
|
|
18
|
+
export const LESSON_WRITE_VERBS = Object.freeze(['lesson_record']);
|
|
19
|
+
export const LESSON_READ_VERBS = Object.freeze(['lessons']);
|
|
20
|
+
export const LESSON_KIND = 'lesson';
|
|
21
|
+
// Where the fix lives: this repo, or Atlas for every repo.
|
|
22
|
+
export const LESSON_HOMES = Object.freeze(['repo', 'atlas']);
|
|
23
|
+
// The strongest form that fits, in the order of the learnings skill.
|
|
24
|
+
export const LESSON_FORMS = Object.freeze(['check', 'tool', 'skill', 'doc']);
|
|
25
|
+
// What found the miss: the owner's correction, a revert, a bug after a
|
|
26
|
+
// merge, or a machine (a red main branch, a revert job) with no owner words.
|
|
27
|
+
export const LESSON_SOURCES = Object.freeze(['owner', 'revert', 'bug', 'machine']);
|
|
28
|
+
const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const MAX_LISTED = 100;
|
|
30
|
+
|
|
31
|
+
// A link is a URL, or the id of a tracker record such as the item a lesson
|
|
32
|
+
// became.
|
|
33
|
+
function linkOf(value, name, { optional = false } = {}) {
|
|
34
|
+
if (value === undefined && optional) return '';
|
|
35
|
+
const plain = text(value, name, { max: 2000 }).trim();
|
|
36
|
+
if (RECORD_ID.test(plain)) return plain.toLowerCase();
|
|
37
|
+
return url(plain, name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// One rule for a revert and one for a step-in, shared with the numbers of
|
|
41
|
+
// R16: a revert pull request (numbers.mjs), and a step-in that still counts,
|
|
42
|
+
// an owner message whose newest name gives no class (step-ins.mjs).
|
|
43
|
+
export { isRevert };
|
|
44
|
+
export const countedStepIns = (marks) => standingOf(marks).filter(isStepIn).map(({ mark }) => mark);
|
|
45
|
+
|
|
46
|
+
const rowOf = (mark, repeatedBy) => ({
|
|
47
|
+
lesson: mark.id, at: mark.fields.marked_at, line: mark.fields.line, home: mark.fields.home, repo: mark.fields.repo || null,
|
|
48
|
+
form: mark.fields.form, fix: mark.fields.fix, source: mark.fields.source, source_ref: mark.fields.source_ref || null,
|
|
49
|
+
repeats: mark.fields.repeats || null, item: mark.fields.item || null, repeated_by: repeatedBy.get(mark.id) ?? [],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function createLessonVerbs(core) {
|
|
53
|
+
const { client, now, typeNamed, registry, requireProduct, requireItem, create, edge, landed } = core;
|
|
54
|
+
|
|
55
|
+
async function lessonMarks(product) {
|
|
56
|
+
const type = await typeNamed('mark');
|
|
57
|
+
const rows = await client.listAllEntities({ type: type.name, state: 'active' });
|
|
58
|
+
return rows.filter((one) => one.fields?.kind === LESSON_KIND && (!product || one.fields?.product === product));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
// R55 line 1: one lesson, one mark. The learnings skill calls it once the
|
|
63
|
+
// fix exists, so the fix link is never empty.
|
|
64
|
+
async lesson_record(args, requestId, meta) {
|
|
65
|
+
const reg = await registry();
|
|
66
|
+
const product = (await requireProduct(args.product, reg)).fields.registry_id;
|
|
67
|
+
const scrubbed = scrubSecrets(text(args.line, 'line', { max: 300 }));
|
|
68
|
+
const line = oneLine(scrubbed.text, 300);
|
|
69
|
+
const home = oneOf(args.home, 'home', LESSON_HOMES);
|
|
70
|
+
let repo = '';
|
|
71
|
+
if (home === 'repo') {
|
|
72
|
+
repo = text(args.repo, 'repo', { max: 200 });
|
|
73
|
+
const record = reg.repos.get(repo);
|
|
74
|
+
if (!record) refuse(`unknown repo id: ${repo}. Known: ${[...reg.repos.keys()].sort().join(', ') || 'none'}`);
|
|
75
|
+
if (record.fields?.product !== product) refuse(`repo ${repo} belongs to product ${record.fields?.product}, not ${product}`);
|
|
76
|
+
} else if (args.repo !== undefined) refuse('repo goes with home "repo" only; an Atlas lesson holds for every repo');
|
|
77
|
+
const form = oneOf(args.form, 'form', LESSON_FORMS);
|
|
78
|
+
const fix = linkOf(args.fix, 'fix');
|
|
79
|
+
const source = oneOf(args.source, 'source', LESSON_SOURCES);
|
|
80
|
+
const sourceRef = linkOf(args.source_ref, 'source_ref', { optional: true });
|
|
81
|
+
const item = args.item_id === undefined ? null : await requireItem(args.item_id);
|
|
82
|
+
let repeated = null;
|
|
83
|
+
if (args.repeats !== undefined) {
|
|
84
|
+
const id = text(args.repeats, 'repeats', { max: 200 });
|
|
85
|
+
let entity;
|
|
86
|
+
try { entity = await client.getEntity(id); }
|
|
87
|
+
catch (error) { if (error?.status === 404) refuse(`unknown lesson id: ${id}`); throw error; }
|
|
88
|
+
const type = await typeNamed('mark');
|
|
89
|
+
if (entity.type_id !== type.id || entity.fields?.kind !== LESSON_KIND) refuse(`entity ${id} is not a lesson mark`);
|
|
90
|
+
if (entity.fields?.product !== product) refuse(`lesson ${id} belongs to product ${entity.fields?.product}, not ${product}`);
|
|
91
|
+
repeated = entity;
|
|
92
|
+
}
|
|
93
|
+
const type = await typeNamed('mark');
|
|
94
|
+
const prior = await landed(type, requestId, meta);
|
|
95
|
+
const mark = prior ?? await create(type, requestId, 'lesson_record', {
|
|
96
|
+
digest: meta?.digest,
|
|
97
|
+
title: oneLine(`Lesson${repeated ? ' (repeat)' : ''} · ${line}`, 200),
|
|
98
|
+
body: `${line}\n\nHome: ${home === 'repo' ? repo : 'Atlas'}. Form: ${form}. Fix: ${fix}. Source: ${source}${sourceRef ? ` (${sourceRef})` : ''}.${repeated ? ` It repeats lesson ${repeated.id}.` : ''}`,
|
|
99
|
+
fields: {
|
|
100
|
+
kind: LESSON_KIND, verdict: repeated ? 'repeat' : 'new', item: item?.id ?? '', product, marked_at: now(),
|
|
101
|
+
line, home, repo, form, fix, source, source_ref: sourceRef, repeats: repeated?.id ?? '',
|
|
102
|
+
},
|
|
103
|
+
tags: ['atlas-mark', 'lesson', `product:${product}`, ...(repeated ? ['repeat'] : [])],
|
|
104
|
+
});
|
|
105
|
+
if (repeated) await edge(mark, repeated, 'references', requestId);
|
|
106
|
+
if (item) await edge(mark, item, 'references', requestId);
|
|
107
|
+
return {
|
|
108
|
+
verb: 'lesson_record', lesson: mark.id, product, line, home, repo: repo || null, form, fix, source, source_ref: sourceRef || null,
|
|
109
|
+
repeats: repeated?.id ?? null, item: item?.id ?? null, ...(scrubbed.redactions ? { redactions: scrubbed.redactions } : {}), ...(prior ? { replayed_from_graph: true } : {}),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// R55 line 2: a product's lessons, newest first. Each row lists the
|
|
114
|
+
// lessons that repeat it, so the learnings skill sees what came back.
|
|
115
|
+
async lessons(args = {}) {
|
|
116
|
+
const reg = await registry();
|
|
117
|
+
const product = (await requireProduct(args.product, reg)).fields.registry_id;
|
|
118
|
+
const since = args.since === undefined ? null : text(args.since, 'since', { max: 40 });
|
|
119
|
+
if (since !== null && Number.isNaN(Date.parse(since))) refuse('since must be an ISO date or time');
|
|
120
|
+
const limit = args.limit === undefined ? 20 : Number(args.limit);
|
|
121
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LISTED) refuse(`limit must be a whole number from 1 to ${MAX_LISTED}`);
|
|
122
|
+
const marks = await lessonMarks(product);
|
|
123
|
+
const repeatedBy = new Map();
|
|
124
|
+
for (const mark of marks) if (mark.fields.repeats) repeatedBy.set(mark.fields.repeats, [...(repeatedBy.get(mark.fields.repeats) ?? []), mark.id]);
|
|
125
|
+
const rows = marks.filter((mark) => !since || String(mark.fields.marked_at) >= new Date(since).toISOString())
|
|
126
|
+
.sort((a, b) => String(b.fields.marked_at).localeCompare(String(a.fields.marked_at)));
|
|
127
|
+
return {
|
|
128
|
+
verb: 'lessons', product, total: rows.length, repeats: rows.filter((mark) => mark.fields.repeats).length,
|
|
129
|
+
rows: rows.slice(0, limit).map((mark) => rowOf(mark, repeatedBy)),
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/work/lib/numbers.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { deliveryFacts, FATAL_CONCLUSIONS } from './delivery.mjs';
|
|
|
16
16
|
import { standingOf, isOwnerMessage, isStepIn, senderOfMark } from './step-ins.mjs';
|
|
17
17
|
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
18
18
|
import { groupOf } from '../../door/lib/changed-tests.mjs';
|
|
19
|
+
import { SIGNERS_FILE, parseSigners, verifyTexts, findAct } from '../../door/lib/signed-act.mjs';
|
|
19
20
|
|
|
20
21
|
export const NUMBERS_MAX_DAYS = 92;
|
|
21
22
|
export const NUMBERS_DEFAULT_DAYS = 28;
|
|
@@ -24,20 +25,21 @@ export const PAGE = 100;
|
|
|
24
25
|
// never cut.
|
|
25
26
|
export const MAX_PULL_PAGES = 10;
|
|
26
27
|
const MAX_FILE_PAGES = 30;
|
|
28
|
+
const MAX_COMMENT_PAGES = 10;
|
|
27
29
|
const CONCURRENCY = 8;
|
|
28
30
|
const DAY = 86_400_000;
|
|
29
31
|
// The second check of a changed test (roadmap R73). A signed act (R72) is
|
|
30
|
-
// the other one
|
|
32
|
+
// the other one.
|
|
31
33
|
export const SECOND_CHECK = 'passes-after';
|
|
32
34
|
// A changed or removed file of these groups is an old test that changed. A
|
|
33
35
|
// package.json or a runner config changes for many reasons, so it is not.
|
|
34
36
|
const TEST_GROUPS = Object.freeze(['unit-test', 'check-block', 'scenario-words', 'test-helper', 'kit-manifest']);
|
|
35
37
|
// The GitHub counts and the tracker counts of one row.
|
|
36
|
-
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes']);
|
|
38
|
+
export const GITHUB_COUNTS = Object.freeze(['merges', 'not_on_green', 'reverts', 'unchecked_test_changes', 'signed_acts']);
|
|
37
39
|
export const GRAPH_COUNTS = Object.freeze(['reopens', 'step_ins', 'owner_messages', 'platform_messages']);
|
|
38
|
-
// Line 2: the
|
|
40
|
+
// Line 2: the count that waits on an item not built yet. It is named, never
|
|
39
41
|
// shown as zero.
|
|
40
|
-
export const WAITING = Object.freeze({
|
|
42
|
+
export const WAITING = Object.freeze({ audit_unsigned_merges: 'waits on R14 line 3, the unsigned-merge finding of the audit job' });
|
|
41
43
|
|
|
42
44
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
45
|
const dayOf = (value) => String(value ?? '').slice(0, 10);
|
|
@@ -106,6 +108,29 @@ async function readAllFiles(repo, number, readGitHub) {
|
|
|
106
108
|
throw new Error(`pull request ${number} changed more than ${MAX_FILE_PAGES * PAGE} files`);
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// Line 2: the comment texts of one pull request, every page. More than
|
|
112
|
+
// MAX_COMMENT_PAGES pages is refused, never cut.
|
|
113
|
+
export async function readAllComments(repo, number, readGitHub) {
|
|
114
|
+
const texts = [];
|
|
115
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
116
|
+
const rows = await readGitHub(`repos/${repo}/issues/${number}/comments?per_page=${PAGE}&page=${page}`);
|
|
117
|
+
if (!Array.isArray(rows)) throw new Error(`the comments of pull request ${number} are not a list`);
|
|
118
|
+
texts.push(...rows.map((row) => String(row?.body ?? '')));
|
|
119
|
+
if (rows.length < PAGE) return texts;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`pull request ${number} has more than ${MAX_COMMENT_PAGES * PAGE} comments`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Line 2: the owner's signed act for the merged head of one pull request, or
|
|
125
|
+
// null. The key comes from atlas/owner-signers at the first parent of the
|
|
126
|
+
// merge, as the gate reads it from the base. The act must name this repo,
|
|
127
|
+
// this pull request, its base branch and its last head. The gate checked the
|
|
128
|
+
// merge base when it let the head pass; this count does not read it again.
|
|
129
|
+
export function signedActOf({ repo, pull, texts, keys }) {
|
|
130
|
+
const found = findAct(verifyTexts(texts, keys), { kind: 'merge', repo, pr: Number(pull.number), base: pull.base?.ref, head: pull.head?.sha });
|
|
131
|
+
return found ? { signed_at: found.signed_at, key: found.key } : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
109
134
|
// Run each job, at most `limit` at a time. The first failure stops the rest
|
|
110
135
|
// and is thrown, so a count is whole or refused.
|
|
111
136
|
async function pool(jobs, limit = CONCURRENCY) {
|
|
@@ -151,8 +176,9 @@ export function notOnGreenReasons({ required = [], runs = [], mergedAt }) {
|
|
|
151
176
|
}
|
|
152
177
|
|
|
153
178
|
// A merged pull request that undoes another: GitHub's revert button writes
|
|
154
|
-
// both of these.
|
|
155
|
-
|
|
179
|
+
// both of these. The roundup's rows keep the body inside names
|
|
180
|
+
// (delivery.mjs).
|
|
181
|
+
export const isRevert = (pull) => /^revert\b/i.test(String(pull.title ?? '').trim()) || /^Reverts [A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#\d+/m.test(String(pull.body ?? pull.names ?? ''));
|
|
156
182
|
|
|
157
183
|
// The old tests that one merge changed or removed. An added test is new, so
|
|
158
184
|
// it is not here.
|
|
@@ -179,38 +205,48 @@ export function secondCheckPassed(runs = [], mergedAt) {
|
|
|
179
205
|
export async function readMerges({ repo, defaultBranch, period, readGitHub }) {
|
|
180
206
|
const pulls = await readAllPulls(repo, readGitHub, period.start);
|
|
181
207
|
const merges = pulls.filter((pull) => pull.merged_at && time(pull.merged_at) >= period.start && time(pull.merged_at) < period.end && (!defaultBranch || pull.base?.ref === defaultBranch));
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
208
|
+
const cache = new Map();
|
|
209
|
+
// One file at one commit, read once. A file that is not there is null.
|
|
210
|
+
const textAt = (path, sha) => {
|
|
211
|
+
const key = `${path}@${sha}`;
|
|
212
|
+
if (!cache.has(key)) cache.set(key, (async () => {
|
|
185
213
|
let file;
|
|
186
|
-
try { file = await readGitHub(`repos/${repo}/contents
|
|
187
|
-
catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return
|
|
188
|
-
if (!file || typeof file.content !== 'string') return
|
|
189
|
-
return
|
|
214
|
+
try { file = await readGitHub(`repos/${repo}/contents/${path}?ref=${sha}`); }
|
|
215
|
+
catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; }
|
|
216
|
+
if (!file || typeof file.content !== 'string') return null;
|
|
217
|
+
return Buffer.from(file.content, 'base64').toString('utf8');
|
|
190
218
|
})());
|
|
191
|
-
return
|
|
219
|
+
return cache.get(key);
|
|
220
|
+
};
|
|
221
|
+
const requiredAt = async (sha) => {
|
|
222
|
+
const text = await textAt('atlas.yaml', sha);
|
|
223
|
+
return text === null ? [] : deliveryFacts(text).required;
|
|
192
224
|
};
|
|
193
225
|
return pool(merges.map((pull) => async () => {
|
|
194
226
|
const head = pull.head?.sha, merge = pull.merge_commit_sha;
|
|
195
227
|
if (!/^[0-9a-f]{40}$/i.test(String(head)) || !/^[0-9a-f]{40}$/i.test(String(merge))) throw new Error(`GitHub names no head or merge commit for pull request ${pull.number}`);
|
|
196
|
-
const
|
|
228
|
+
const before = async () => {
|
|
197
229
|
const parent = (await readGitHub(`repos/${repo}/commits/${merge}`))?.parents?.[0]?.sha;
|
|
198
230
|
if (!/^[0-9a-f]{40}$/i.test(String(parent))) throw new Error(`GitHub names no parent of the merge commit of pull request ${pull.number}`);
|
|
199
|
-
|
|
231
|
+
const [required, signers] = await Promise.all([requiredAt(parent), textAt(SIGNERS_FILE, parent)]);
|
|
232
|
+
return { required, signers };
|
|
200
233
|
};
|
|
201
|
-
const [checks, files, required] = await Promise.all([
|
|
234
|
+
const [checks, files, { required, signers }] = await Promise.all([
|
|
202
235
|
readGitHub(`repos/${repo}/commits/${head}/check-runs?per_page=${PAGE}`),
|
|
203
236
|
readAllFiles(repo, pull.number, readGitHub),
|
|
204
|
-
|
|
237
|
+
before(),
|
|
205
238
|
]);
|
|
206
239
|
if (!Array.isArray(checks?.check_runs)) throw new Error(`the check runs of pull request ${pull.number} are not a list`);
|
|
207
240
|
const runs = checks.check_runs;
|
|
208
241
|
if (Number(checks?.total_count ?? runs.length) > runs.length) throw new Error(`the head of pull request ${pull.number} has more than ${PAGE} check runs`);
|
|
209
242
|
const tests = changedOldTests(files);
|
|
243
|
+
// With no owner key at the merge, no act can count: the comments are not read.
|
|
244
|
+
const keys = parseSigners(signers ?? '').keys;
|
|
245
|
+
const signed = keys.length ? signedActOf({ repo, pull, texts: await readAllComments(repo, pull.number, readGitHub), keys }) : null;
|
|
210
246
|
return {
|
|
211
247
|
repo, number: pull.number, url: pull.html_url ?? `https://github.com/${repo}/pull/${pull.number}`, title: oneLine(pull.title ?? '', 200), merged_at: pull.merged_at,
|
|
212
248
|
not_on_green: notOnGreenReasons({ required, runs, mergedAt: pull.merged_at }), revert: isRevert(pull),
|
|
213
|
-
changed_tests: tests, unchecked: tests.length > 0 && !secondCheckPassed(runs, pull.merged_at),
|
|
249
|
+
changed_tests: tests, signed, unchecked: tests.length > 0 && !signed && !secondCheckPassed(runs, pull.merged_at),
|
|
214
250
|
};
|
|
215
251
|
}));
|
|
216
252
|
}
|
|
@@ -283,6 +319,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
283
319
|
if (merge.not_on_green.length) events.push(['not_on_green', merge.merged_at]);
|
|
284
320
|
if (merge.revert) events.push(['reverts', merge.merged_at]);
|
|
285
321
|
if (merge.unchecked) events.push(['unchecked_test_changes', merge.merged_at]);
|
|
322
|
+
if (merge.signed) events.push(['signed_acts', merge.merged_at]);
|
|
286
323
|
}
|
|
287
324
|
for (const mark of reopens) if (itemRepos.get(mark.fields.item).includes(repo.id)) events.push(['reopens', mark.fields.marked_at]);
|
|
288
325
|
for (const pair of pairs) {
|
|
@@ -304,6 +341,7 @@ export async function readNumbers({ product, repos, items, marks, period, readGi
|
|
|
304
341
|
not_on_green: refused ? null : merges.filter((merge) => merge.not_on_green.length).map((merge) => ({ pr: merge.url, merged_at: merge.merged_at, reasons: merge.not_on_green })),
|
|
305
342
|
reverts: refused ? null : merges.filter((merge) => merge.revert).map((merge) => ({ pr: merge.url, title: merge.title })),
|
|
306
343
|
unchecked_test_changes: refused ? null : merges.filter((merge) => merge.unchecked).map((merge) => ({ pr: merge.url, tests: merge.changed_tests })),
|
|
344
|
+
signed_acts: refused ? null : merges.filter((merge) => merge.signed).map((merge) => ({ pr: merge.url, signed_at: merge.signed.signed_at, key: merge.signed.key })),
|
|
307
345
|
});
|
|
308
346
|
}
|
|
309
347
|
const delivered = deliveredRows({ marks: marks.filter((mark) => mark.fields?.product === product), items, period });
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// but not marked, open pull requests that name no item,
|
|
13
13
|
// and questions that wait longer than STUCK_DAYS
|
|
14
14
|
// 5. Trend per week, for TREND_WEEKS weeks: merges, deliveries,
|
|
15
|
-
// new items
|
|
15
|
+
// new items, lessons, repeats, step-ins and reverts
|
|
16
|
+
// (roadmap R55); and the open questions with the
|
|
17
|
+
// oldest age. A failed read of the marks is an error.
|
|
16
18
|
// 6. The numbers per repo, per week and per month: merges, merges not
|
|
17
19
|
// on green, reverts, reopens, unchecked test changes,
|
|
18
20
|
// step-ins and owner messages; and the time from input
|
|
@@ -24,6 +26,7 @@ import { refuse, isoDate, LIVE_LIFECYCLES } from './verb-fields.mjs';
|
|
|
24
26
|
import { oneLine, ROW_LINE_MAX } from './questions.mjs';
|
|
25
27
|
import { readPullsByRepo, pullsOfItem, daysBetween, listLine } from './delivery.mjs';
|
|
26
28
|
import { readNumbers, periodOf } from './numbers.mjs';
|
|
29
|
+
import { LESSON_KIND, countedStepIns, isRevert } from './lessons.mjs';
|
|
27
30
|
|
|
28
31
|
export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
|
|
29
32
|
|
|
@@ -174,17 +177,29 @@ export function createRoundupVerbs(core) {
|
|
|
174
177
|
const audits = await readAudits(repoIds, readGitHub, readAt);
|
|
175
178
|
|
|
176
179
|
// 5. Trend, per week; week 1 is the last seven days.
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
// Lessons, repeats and step-ins come from the marks; reverts from the
|
|
181
|
+
// merged pull requests. When GitHub did not answer for a repo, the
|
|
182
|
+
// reverts read null, never 0 (R55 line 4).
|
|
183
|
+
// One read of the marks serves part 5 and part 6.
|
|
184
|
+
const markType = await typeNamed('mark');
|
|
185
|
+
const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
|
|
186
|
+
const productMarks = marks.filter((row) => row.fields?.product === product);
|
|
187
|
+
const revertsKnown = !repoErrors.length;
|
|
188
|
+
const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0, lessons: 0, repeats: 0, step_ins: 0, reverts: revertsKnown ? 0 : null }));
|
|
189
|
+
const bump = (at, key) => { const week = weekOf(at, readAt); if (week && weeks[week - 1][key] !== null) weeks[week - 1][key] += 1; };
|
|
179
190
|
for (const pull of pulls) if (pull.state === 'merged') bump(pull.merged_at, 'merged');
|
|
191
|
+
for (const pull of pulls) if (pull.state === 'merged' && isRevert(pull)) bump(pull.merged_at, 'reverts');
|
|
180
192
|
for (const row of flight.delivered ?? []) bump(row.delivered_at, 'delivered');
|
|
181
193
|
for (const row of items) bump(row.created_at, 'new_items');
|
|
194
|
+
for (const mark of productMarks.filter((row) => row.fields?.kind === LESSON_KIND)) {
|
|
195
|
+
bump(mark.fields.marked_at, 'lessons');
|
|
196
|
+
if (mark.fields.repeats) bump(mark.fields.marked_at, 'repeats');
|
|
197
|
+
}
|
|
198
|
+
for (const mark of countedStepIns(productMarks)) bump(mark.fields.marked_at, 'step_ins');
|
|
182
199
|
const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
|
|
183
200
|
|
|
184
201
|
// 6. The numbers. They read every pull request of the period, not only
|
|
185
202
|
// the newest 100, and the marks of the tracker.
|
|
186
|
-
const markType = await typeNamed('mark');
|
|
187
|
-
const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
|
|
188
203
|
const numbers = await readNumbers({
|
|
189
204
|
product, items, marks, period, readGitHub,
|
|
190
205
|
repos: repoIds.map((id) => ({ id, default_branch: reg.repos.get(id)?.fields?.default_branch ?? null })),
|
|
@@ -241,7 +256,7 @@ function render(view) {
|
|
|
241
256
|
out.push(stuck.join('\n'));
|
|
242
257
|
|
|
243
258
|
out.push([`5. Trend, per week (week 1 is the last seven days)`,
|
|
244
|
-
...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')}`),
|
|
259
|
+
...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')} · ${plural(week.lessons, 'lesson')} (${plural(week.repeats, 'repeat')}) · ${plural(week.step_ins, 'step-in')} · ${week.reverts === null ? 'reverts unknown' : plural(week.reverts, 'revert')}`),
|
|
245
260
|
` waiting on you: ${plural(needRows.length, 'question')}${needRows.length ? `, oldest ${plural(oldest, 'day')}` : ''}`].join('\n'));
|
|
246
261
|
|
|
247
262
|
out.push(renderNumbers(numbers, more));
|
|
@@ -251,7 +266,7 @@ function render(view) {
|
|
|
251
266
|
}
|
|
252
267
|
|
|
253
268
|
// Part 6. A refused count reads "refused", never 0.
|
|
254
|
-
const COLUMNS = [['merges', 'merges'], ['not_on_green', 'not on green'], ['reverts', 'reverts'], ['unchecked_test_changes', 'unchecked test changes'], ['reopens', 'reopens'], ['step_ins', 'step-ins'], ['owner_messages', 'owner messages'], ['platform_messages', 'platform messages']];
|
|
269
|
+
const COLUMNS = [['merges', 'merges'], ['not_on_green', 'not on green'], ['reverts', 'reverts'], ['unchecked_test_changes', 'unchecked test changes'], ['signed_acts', 'signed acts'], ['reopens', 'reopens'], ['step_ins', 'step-ins'], ['owner_messages', 'owner messages'], ['platform_messages', 'platform messages']];
|
|
255
270
|
const countsLine = (row) => COLUMNS.map(([key, word]) => `${row[key] === null ? 'refused' : row[key]} ${word}`).join(' · ');
|
|
256
271
|
function renderNumbers(numbers, more) {
|
|
257
272
|
const out = [`6. The numbers, ${numbers.from} to ${numbers.to} (UTC)`];
|
|
@@ -269,7 +284,7 @@ function renderNumbers(numbers, more) {
|
|
|
269
284
|
out.push(` input to live: ${numbers.delivered.length} delivered ${numbers.delivered.length === 1 ? 'item' : 'items'}${median === null ? '' : `, median ${median} days`}`);
|
|
270
285
|
for (const row of numbers.delivered.slice(0, SHOWN)) out.push(listLine(row.title, `${row.item.slice(0, 8)} · ${row.days ?? '?'} days · live ${dayOf(row.live_at)}`, ' '));
|
|
271
286
|
out.push(...more(numbers.delivered));
|
|
272
|
-
out.push(` owner acts:
|
|
287
|
+
out.push(` owner acts: unsigned merges ${numbers.owner_acts.audit_unsigned_merges}`);
|
|
273
288
|
if (numbers.not_counted_sessions) out.push(` ${numbers.not_counted_sessions} sessions ran with the hooks off, so their messages were not counted`);
|
|
274
289
|
return out.join('\n');
|
|
275
290
|
}
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capabilit
|
|
|
22
22
|
import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
|
|
23
23
|
import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
|
|
24
24
|
import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
|
|
25
|
+
import { createLessonVerbs, LESSON_WRITE_VERBS, LESSON_READ_VERBS } from './lessons.mjs';
|
|
25
26
|
|
|
26
27
|
export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
|
|
27
28
|
export const MANIFEST_VERSION = '0.7.0';
|
|
@@ -754,9 +755,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
754
755
|
// through record and notCounted, which no tool exposes.
|
|
755
756
|
const stepIns = createStepInVerbs(core);
|
|
756
757
|
Object.assign(verbs, stepIns.verbs);
|
|
758
|
+
// Roadmap R55: every lesson leaves one record, a mark of kind lesson.
|
|
759
|
+
Object.assign(verbs, createLessonVerbs(core));
|
|
757
760
|
|
|
758
|
-
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
|
|
759
|
-
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
|
|
761
|
+
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS, ...LESSON_WRITE_VERBS]);
|
|
762
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS, ...LESSON_READ_VERBS]);
|
|
760
763
|
|
|
761
764
|
async function run(verb, args = {}) {
|
|
762
765
|
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|
package/work/mcp.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { createActionStore } from './lib/action-store.mjs';
|
|
|
23
23
|
import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
|
|
24
24
|
import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
|
|
25
25
|
import { STEP_IN_CLASSES, stepInFolder, flushStepIns } from './lib/step-ins.mjs';
|
|
26
|
+
import { LESSON_HOMES, LESSON_FORMS, LESSON_SOURCES } from './lib/lessons.mjs';
|
|
26
27
|
import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
|
|
27
28
|
import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
28
29
|
import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
|
|
@@ -61,6 +62,8 @@ export const TOOLS = [
|
|
|
61
62
|
{ name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
|
|
62
63
|
{ name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
|
|
63
64
|
{ name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
|
|
65
|
+
{ name: 'lesson_record', description: `Record one lesson as a mark of kind lesson (roadmap R55): one line, its home, its form, the fix link, the source and the lesson it repeats. A mark is never edited. When: the learnings skill made the fix; call lessons first, and pass repeats when an earlier lesson says the same thing. The line goes through the secret filter. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'product', 'line', 'home', 'form', 'fix', 'source'], properties: { request_id: REQUEST_ID, product: S('Product id.'), line: S('The lesson in one plain sentence: what went wrong and what is right. At most 300 characters.'), home: { type: 'string', enum: [...LESSON_HOMES], description: 'repo: the lesson holds for one repo. atlas: it holds for every repo.' }, repo: S('For home repo: the registered repo id of this product.'), form: { type: 'string', enum: [...LESSON_FORMS], description: 'The form of the fix: a check, a tool, a skill step or a doc line.' }, fix: S('The fix: a pull request URL, or the id of the tracker item the lesson became.'), source: { type: 'string', enum: [...LESSON_SOURCES], description: 'What found the miss: owner (a correction), revert, bug (after a merge), or machine (no owner words).' }, source_ref: S('Where the miss shows: the step-in mark id, the revert pull request, or the bug issue.'), repeats: S('The id of an earlier lesson of this product that this lesson repeats.'), item_id: S('The work item the lesson is about.') }, additionalProperties: false } },
|
|
66
|
+
{ name: 'lessons', description: 'List the lessons of one product, newest first (roadmap R55). Each row gives the line, the home, the form, the fix, the source, the lesson it repeats and the lessons that repeat it. total counts the lessons; repeats counts the repeats. A failed read is an error, never zero lessons. When: the learnings skill calls it before lesson_record, to find a match. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), since: S('ISO date or time: only lessons from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
|
|
64
67
|
{ name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
|
|
65
68
|
{ name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. Any in-session agent may call it; no owner flag. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
|
|
66
69
|
{ name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
|
|
@@ -216,6 +219,10 @@ export const GITHUB_READS = Object.freeze([
|
|
|
216
219
|
new RegExp(`^repos/${REPO}/pulls/\\d{1,9}/files\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
217
220
|
// Roadmap R14 line 6: the newest run of the daily audit, for the roundup.
|
|
218
221
|
new RegExp(`^repos/${REPO}/actions/workflows/atlas-jobs\\.yml/runs\\?per_page=\\d{1,3}$`),
|
|
222
|
+
// Roadmap R16 line 2: the comments of a merge, for the owner's signed act,
|
|
223
|
+
// and the owner key file at the first parent of the merge.
|
|
224
|
+
new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=\\d{1,3}&page=\\d{1,3}$`),
|
|
225
|
+
new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=${SHA}$`),
|
|
219
226
|
]);
|
|
220
227
|
export function githubReader() {
|
|
221
228
|
return async (path) => {
|