@christang/keel 5.20.0 → 5.44.0

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.
@@ -7,7 +7,13 @@ const path = require("path");
7
7
  // closed so an entry outside it can be reported by name: a free-form grant
8
8
  // cannot tell a typo from a decision, and silently dropping one leaves the
9
9
  // author believing they authorized something they did not.
10
- const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
10
+ const STANDING_AUTHORIZATION_ACTIONS = [
11
+ "commit",
12
+ "push",
13
+ "release",
14
+ "archive",
15
+ "continuation",
16
+ ];
11
17
 
12
18
  // The closed vocabulary of capability tiers a repository may declare for a
13
19
  // delegated task. The names describe the capability the work requires, never
@@ -45,14 +51,127 @@ function configList(repo, key) {
45
51
  return entries;
46
52
  }
47
53
 
54
+ // The sub-keys a `triage:` block may declare. Closed for the same reason the
55
+ // authorization actions are: an entry outside it can then be reported by name
56
+ // instead of being silently dropped from a policy about what may run unattended.
57
+ const TRIAGE_SOURCES = ["labels", "issues"];
58
+
59
+ // The lines belonging to one top-level block, handed back unclassified. The
60
+ // list and map readers each stop at the first line they cannot use, which is
61
+ // right for a flat shape and wrong here: a `triage:` block may hold two shapes,
62
+ // and telling them apart — or refusing a mixture — needs to see all of it.
63
+ // Returns null when the key is not declared at all, which is not the same as a
64
+ // key declared with nothing under it.
65
+ function configBlockLines(repo, key) {
66
+ const configPath = path.join(repo, "keel", "config.yaml");
67
+ if (!fs.existsSync(configPath)) return null;
68
+ const opener = new RegExp(`^${key}\\s*:\\s*(.*)$`);
69
+ const lines = [];
70
+ let inBlock = false;
71
+ let inline = null;
72
+ for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
73
+ if (/^\s*#/.test(line)) continue;
74
+ if (!inBlock) {
75
+ const opened = line.match(opener);
76
+ if (!opened) continue;
77
+ inBlock = true;
78
+ // `triage: { issues: [62] }` is the flow style, which this reader does
79
+ // not parse. Keeping the text lets the caller name it rather than
80
+ // reporting an undeclared policy for a declaration plainly present.
81
+ if (opened[1].trim() !== "") inline = opened[1].trim();
82
+ continue;
83
+ }
84
+ if (line.trim() === "") continue;
85
+ // A line at column zero is the next top-level key, whatever its shape.
86
+ if (!/^\s/.test(line)) break;
87
+ lines.push(line);
88
+ }
89
+ if (!inBlock) return null;
90
+ return { lines, inline };
91
+ }
92
+
48
93
  // Which issues may start work without asking. This is a declaration and never
49
94
  // an inference: "should this issue be done" sits in the materiality categories
50
95
  // that require asking, and a precedent may never move a decision out of them.
51
- // A label is the unit because a human applies one to a specific issue, so the
52
- // policy authorizes a class the owner curates one issue at a time rather than a
53
- // guess about which issues look easy.
96
+ //
97
+ // Two sources, either sufficient alone. A label is applied by hand to one
98
+ // issue; so is an issue number, so both curate a class one issue at a time
99
+ // rather than guessing which issues look easy. They differ in where the
100
+ // owner's decision is written down — the label writes it on the issue, where
101
+ // the person who reported it can see an operational switch in a vocabulary
102
+ // they were asked to classify with, and the number writes it in a file only a
103
+ // committer can change (#62).
54
104
  function readTriagePolicy(repo) {
55
- return { labels: configList(repo, "triage") };
105
+ const block = configBlockLines(repo, "triage");
106
+ const empty = { labels: [], issues: [], unreadable: [] };
107
+ if (block === null) return empty;
108
+
109
+ const unreadable = [];
110
+ if (block.inline !== null) unreadable.push(block.inline);
111
+
112
+ const sections = new Map();
113
+ // Entries written directly under `triage:` with no sub-key. This is the
114
+ // shape every repository declared before a second source existed, and it
115
+ // still means labels — a bare token is never read as a number, because
116
+ // reclassifying one would move an authorization boundary in a repository
117
+ // nobody edited.
118
+ const bare = [];
119
+ let current = null;
120
+ for (const line of block.lines) {
121
+ const opened = line.match(/^\s+([A-Za-z_]\w*)\s*:\s*$/);
122
+ if (opened) {
123
+ current = opened[1];
124
+ if (!TRIAGE_SOURCES.includes(current)) unreadable.push(current);
125
+ else if (!sections.has(current)) sections.set(current, []);
126
+ continue;
127
+ }
128
+ const entry = line.match(/^\s+-\s*(\S.*?)\s*$/);
129
+ if (!entry) {
130
+ unreadable.push(line.trim());
131
+ continue;
132
+ }
133
+ if (current === null) bare.push(entry[1]);
134
+ // Under a sub-key Keel could not read; the sub-key is already reported.
135
+ else if (!sections.has(current)) unreadable.push(entry[1]);
136
+ else sections.get(current).push(entry[1]);
137
+ }
138
+ // One shape or the other. A block written both ways has no reading that is
139
+ // obviously what its author meant, and guessing at one is how a policy comes
140
+ // to admit something nobody declared.
141
+ if (bare.length > 0 && sections.size > 0) unreadable.push(...bare);
142
+
143
+ const labels = bare.length > 0 ? bare : sections.get("labels") || [];
144
+ const issues = [];
145
+ for (const entry of sections.get("issues") || []) {
146
+ // A bare positive integer and nothing else. `#62` is reported by name
147
+ // rather than guessed at, because a declaration Keel half-understands is
148
+ // how an owner comes to believe they admitted something they did not.
149
+ if (/^[1-9]\d*$/.test(entry)) issues.push(entry);
150
+ else unreadable.push(entry);
151
+ }
152
+
153
+ // Fail closed, exactly as an unrecognized `authorize:` action does: a
154
+ // declaration Keel cannot fully read admits nothing, because the entries
155
+ // beside a typo were not the ones its author meant to grant either.
156
+ if (unreadable.length > 0) return { labels: [], issues: [], unreadable };
157
+ return { labels, issues, unreadable };
158
+ }
159
+
160
+ // `change-close --action sync|archive` prints `sync` beside `archive`, and a
161
+ // reader who copies from that help text into `authorize:` reasonably copies
162
+ // both — but only `archive` is a name this vocabulary accepts (#93). Naming
163
+ // that confusion only when `sync` is the entry present keeps every other
164
+ // unrecognized name (a genuine typo) unchanged.
165
+ function standingAuthorizationUnknownMessage(unknown) {
166
+ const base = `keel/config.yaml declares unrecognized ${
167
+ unknown.length === 1 ? "action" : "actions"
168
+ }: ${unknown.join(", ")}; accepted names are `
169
+ + `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
170
+ + "authorizes nothing until it is corrected.";
171
+ if (!unknown.includes("sync")) return base;
172
+ return `${base} \`sync\` is a value of \`change-close --action\`, not a `
173
+ + "name `authorize:` accepts; declare `archive` if you mean to authorize "
174
+ + "the gate that runs it.";
56
175
  }
57
176
 
58
177
  function readStandingAuthorization(repo) {
@@ -65,8 +184,14 @@ function readStandingAuthorization(repo) {
65
184
  // Fail closed. A declaration Keel cannot fully read authorizes nothing,
66
185
  // because the alternative is granting the entries beside a typo while the
67
186
  // author believes they granted the typo too.
68
- if (unknown.length > 0) return { declared: [], unknown };
69
- return { declared, unknown };
187
+ if (unknown.length > 0) {
188
+ return {
189
+ declared: [],
190
+ unknown,
191
+ message: standingAuthorizationUnknownMessage(unknown),
192
+ };
193
+ }
194
+ return { declared, unknown, message: null };
70
195
  }
71
196
 
72
197
  // A nested block of `name: value` entries under one top-level key. Delegation
@@ -172,17 +297,48 @@ function readPrecedentStore(repo) {
172
297
  return { declared, path: resolved, precedents };
173
298
  }
174
299
 
175
- // Evaluate a declared policy against labels handed in. Keel never fetches the
176
- // issue: the agent reads it with `gh` and passes what it found, which keeps this
177
- // local, offline, deterministic, and testable without a network.
178
- function triageIssue(repo, labels) {
179
- const { labels: accepted } = readTriagePolicy(repo);
300
+ // The sentence every verdict ends on. Admission is a start and nothing else,
301
+ // and the one place a reader meets that is the reason line.
302
+ const ADMISSION_STARTS_ONLY =
303
+ "; admission starts work and decides nothing after it — every later gate "
304
+ + "still applies and a material decision still stops for the owner.";
305
+
306
+ // Evaluate a declared policy against the issue attributes handed in. Keel never
307
+ // fetches the issue: the agent reads it with `gh` and passes what it found,
308
+ // which keeps this local, offline, deterministic, and testable without a
309
+ // network. `issue` is the issue's number, or null when the caller has none.
310
+ function triageIssue(repo, labels, issue = null) {
311
+ const policy = readTriagePolicy(repo);
312
+ const { labels: accepted, issues: acceptedIssues, unreadable } = policy;
180
313
  const carried = labels.filter((label) => label);
181
- if (accepted.length === 0) {
314
+ const number = issue === null || issue === undefined ? null : String(issue);
315
+ const base = {
316
+ accepted,
317
+ acceptedIssues,
318
+ labels: carried,
319
+ issue: number,
320
+ sources: [],
321
+ };
322
+ if (unreadable.length > 0) {
182
323
  return {
324
+ ...base,
325
+ status: "refuse",
326
+ unreadable,
327
+ reason:
328
+ "this repository's `triage:` declaration could not be read, so no "
329
+ + "issue starts work unattended. Keel could not read "
330
+ + `${unreadable.map((entry) => `\`${entry}\``).join(", ")}. Declare `
331
+ + "accepted labels under `labels:` and issue numbers under `issues:` "
332
+ + "as bare numbers, or a bare list of labels directly under `triage:`. "
333
+ + "A declaration that is only partly readable admits nothing, because "
334
+ + "the entries beside a mistake are not the ones its author meant to "
335
+ + "grant. This is not a judgement about the issue.",
336
+ };
337
+ }
338
+ if (accepted.length === 0 && acceptedIssues.length === 0) {
339
+ return {
340
+ ...base,
183
341
  status: "refuse",
184
- accepted,
185
- labels: carried,
186
342
  reason:
187
343
  "this repository declares no triage policy, so no issue starts work "
188
344
  + "unattended; declare accepted labels under `triage:` in "
@@ -191,25 +347,41 @@ function triageIssue(repo, labels) {
191
347
  };
192
348
  }
193
349
  const matched = carried.filter((label) => accepted.includes(label));
194
- if (matched.length > 0) {
350
+ const matchedIssue =
351
+ number !== null && acceptedIssues.includes(number) ? number : null;
352
+ if (matched.length > 0 || matchedIssue !== null) {
353
+ const by = [];
354
+ if (matched.length > 0) by.push(`declared label ${matched.join(", ")}`);
355
+ if (matchedIssue !== null) {
356
+ by.push(`issue number ${matchedIssue}, listed in keel/config.yaml`);
357
+ }
195
358
  return {
359
+ ...base,
196
360
  status: "admit",
197
- accepted,
198
- labels: carried,
199
361
  matched,
200
- reason:
201
- `admitted by declared label ${matched.join(", ")}; admission starts `
202
- + "work and decides nothing after it — every later gate still applies "
203
- + "and a material decision still stops for the owner.",
362
+ matchedIssue,
363
+ sources: [
364
+ ...(matched.length > 0 ? ["label"] : []),
365
+ ...(matchedIssue !== null ? ["issue"] : []),
366
+ ],
367
+ reason: `admitted by ${by.join(" and by ")}${ADMISSION_STARTS_ONLY}`,
204
368
  };
205
369
  }
370
+ // A source the repository did not declare is left out of both halves of the
371
+ // sentence. Naming it would read as a policy that exists and did not match,
372
+ // which is the distinction the refusal is here to draw.
373
+ let subject =
374
+ `the issue carries ${carried.length > 0 ? carried.join(", ") : "no labels"}`;
375
+ if (number !== null) subject += ` and is numbered ${number}`;
376
+ const acceptedParts = [];
377
+ if (accepted.length > 0) acceptedParts.push(accepted.join(", "));
378
+ if (acceptedIssues.length > 0) {
379
+ acceptedParts.push(`issues ${acceptedIssues.join(", ")}`);
380
+ }
206
381
  return {
382
+ ...base,
207
383
  status: "refuse",
208
- accepted,
209
- labels: carried,
210
- reason:
211
- `the issue carries ${carried.length > 0 ? carried.join(", ") : "no labels"}`
212
- + ` and this repository accepts ${accepted.join(", ")}.`,
384
+ reason: `${subject} and this repository accepts ${acceptedParts.join(", and ")}.`,
213
385
  };
214
386
  }
215
387
 
@@ -11,6 +11,7 @@ const {
11
11
  field,
12
12
  parseTasks,
13
13
  } = require("./task-contract");
14
+ const { readStandingAuthorization } = require("./config");
14
15
 
15
16
  const NEXT_ACTIONS = new Set([
16
17
  "discuss",
@@ -506,6 +507,14 @@ function resolveContext(repo, options) {
506
507
  context = handoff ? resolveHandoff(repo, handoff) : inferContext(repo);
507
508
  }
508
509
  context.warnings.push(...gitWarnings(repo));
510
+ // A broken `authorize:` declaration is otherwise reported only by
511
+ // `keel --doctor`, an explicitly-invoked diagnostic — surfaced here too so a
512
+ // session that runs `keel context` first (per `AGENTS.md`) learns the
513
+ // declaration authorizes nothing without a separate call (#93).
514
+ const authorization = readStandingAuthorization(repo);
515
+ if (authorization.unknown.length > 0) {
516
+ context.warnings.push(authorization.message);
517
+ }
509
518
  // Set here rather than by the caller, so every consumer of the projection —
510
519
  // text, JSON, and any host reading it — carries the version without having
511
520
  // to know to add it.