@davidvornholt/standards 0.19.0 → 0.20.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.
@@ -1,12 +1,17 @@
1
- // Broker store lock: a mkdir-based mutex serializing read-modify-write
2
- // cycles on broker.yaml. Store writes are sub-second, so a lock directory
3
- // older than the staleness window can only belong to a dead process and is
4
- // broken automatically; the timeout error names the directory and the manual
5
- // remediation for the pathological rest.
1
+ // Broker store lock: a fully initialized generation is atomically renamed into
2
+ // place before its operation starts. A worker-thread lease remains live while
3
+ // synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
4
+ // generation token before removing the directory, so an old owner cannot
5
+ // remove a replacement.
6
6
 
7
- import { mkdir, rmdir, stat } from 'node:fs/promises';
7
+ import { mkdir } from 'node:fs/promises';
8
8
  import { dirname } from 'node:path';
9
- import { isRecord } from './github-settings-parse';
9
+ import {
10
+ type HeldBrokerLock,
11
+ releaseBrokerLock,
12
+ tryAcquireBrokerLock,
13
+ } from './creds-store-lock-holder';
14
+ import { inspectBrokerLock } from './creds-store-lock-inspection';
10
15
 
11
16
  const OWNER_ONLY_DIR_MODE = 0o700;
12
17
 
@@ -23,59 +28,30 @@ const DEFAULT_STALE_MS = 30_000;
23
28
  const sleep = (ms: number): Promise<void> =>
24
29
  new Promise((resolve) => setTimeout(resolve, ms));
25
30
 
26
- const tryAcquire = async (lockPath: string): Promise<boolean> => {
27
- try {
28
- await mkdir(lockPath, { mode: OWNER_ONLY_DIR_MODE });
29
- return true;
30
- } catch (error) {
31
- if (isRecord(error) && error.code === 'EEXIST') {
32
- return false;
33
- }
34
- throw error;
35
- }
36
- };
37
-
38
- // True when the contender may immediately retry: the lock vanished, or it
39
- // was stale and this process removed it (losing the removal race to another
40
- // contender also lands here — the retry's mkdir arbitrates).
41
- const breakIfStale = async (
42
- lockPath: string,
43
- staleMs: number,
44
- ): Promise<boolean> => {
45
- try {
46
- const info = await stat(lockPath);
47
- if (Date.now() - info.mtimeMs < staleMs) {
48
- return false;
49
- }
50
- await rmdir(lockPath);
51
- return true;
52
- } catch {
53
- return true;
54
- }
55
- };
56
-
57
31
  // One acquisition attempt: acquired, or cleared a stale/vanished lock (retry
58
- // immediately), or waited one retry slot; a live lock past the deadline
59
- // throws with the remediation hint.
32
+ // immediately), or waited one retry slot. Proven-live generations do not
33
+ // consume the recovery deadline; incomplete or ambiguous generations do.
60
34
  const attemptAcquire = async (
61
35
  lockPath: string,
62
36
  staleMs: number,
63
37
  retryMs: number,
64
38
  deadline: number,
65
- ): Promise<boolean> => {
66
- if (await tryAcquire(lockPath)) {
67
- return true;
39
+ ): Promise<HeldBrokerLock | null> => {
40
+ const held = await tryAcquireBrokerLock(lockPath, staleMs);
41
+ if (held !== null) {
42
+ return held;
68
43
  }
69
- if (await breakIfStale(lockPath, staleMs)) {
70
- return false;
44
+ const availability = await inspectBrokerLock(lockPath, staleMs);
45
+ if (availability === 'retry') {
46
+ return null;
71
47
  }
72
- if (Date.now() >= deadline) {
48
+ if (availability === 'blocked' && Date.now() >= deadline) {
73
49
  throw new Error(
74
50
  `lock timeout: another creds process holds ${lockPath}; if none is running, remove that directory and retry`,
75
51
  );
76
52
  }
77
53
  await sleep(retryMs);
78
- return false;
54
+ return null;
79
55
  };
80
56
 
81
57
  export const withBrokerLock = async <T>(
@@ -89,14 +65,14 @@ export const withBrokerLock = async <T>(
89
65
  const lockPath = `${path}.lock`;
90
66
  await mkdir(dirname(path), { recursive: true, mode: OWNER_ONLY_DIR_MODE });
91
67
  const deadline = Date.now() + timeoutMs;
92
- let acquired = false;
93
- while (!acquired) {
68
+ let held: HeldBrokerLock | null = null;
69
+ while (held === null) {
94
70
  // biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
95
- acquired = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
71
+ held = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
96
72
  }
97
73
  try {
98
74
  return await operation();
99
75
  } finally {
100
- await rmdir(lockPath);
76
+ await releaseBrokerLock(held);
101
77
  }
102
78
  };
@@ -6,23 +6,24 @@ import { basename, dirname, join } from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
8
8
  import { isCloudflareAccountId } from './creds-cloudflare-account';
9
+ import {
10
+ type GithubBrokerApp,
11
+ githubStoreDocument,
12
+ parseGithubApps,
13
+ validateGithubApps,
14
+ } from './creds-store-github';
9
15
  import { withBrokerLock } from './creds-store-lock';
10
16
  import { isNonEmptyString, isRecord } from './github-settings-parse';
11
- export type GithubBrokerApp = {
12
- readonly appId: number;
13
- readonly slug: string;
14
- readonly htmlUrl: string;
15
- readonly clientId: string;
16
- readonly privateKey: string;
17
- };
17
+
18
+ export type { GithubBrokerApp } from './creds-store-github';
18
19
  export type CloudflareBrokerAccount = Readonly<
19
20
  Record<'accountId' | 'token', string>
20
21
  >;
21
22
  export type BrokerStore = {
22
- readonly github: GithubBrokerApp | null;
23
+ readonly github: ReadonlyArray<GithubBrokerApp>;
23
24
  readonly cloudflare: ReadonlyArray<CloudflareBrokerAccount>;
24
25
  };
25
- export const EMPTY_BROKER_STORE: BrokerStore = { github: null, cloudflare: [] };
26
+ export const EMPTY_BROKER_STORE: BrokerStore = { github: [], cloudflare: [] };
26
27
  const OWNER_ONLY_FILE_MODE = 0o600;
27
28
  const OWNER_ONLY_DIR_MODE = 0o700;
28
29
  const FILE_MODE_MODULUS = 0o1000;
@@ -48,29 +49,6 @@ export const resolveBrokerPath = (): string =>
48
49
  process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'),
49
50
  'standards/broker.yaml',
50
51
  );
51
- const parseGithub = (raw: unknown): GithubBrokerApp | null => {
52
- if (raw === undefined || raw === null) {
53
- return null;
54
- }
55
- if (
56
- !isRecord(raw) ||
57
- typeof raw.app_id !== 'number' ||
58
- !Number.isInteger(raw.app_id) ||
59
- !isNonEmptyString(raw.slug) ||
60
- !isNonEmptyString(raw.html_url) ||
61
- !isNonEmptyString(raw.client_id) ||
62
- !isNonEmptyString(raw.private_key)
63
- ) {
64
- throw new Error('invalid github: run `standards creds login github`');
65
- }
66
- return {
67
- appId: raw.app_id,
68
- slug: raw.slug,
69
- htmlUrl: raw.html_url,
70
- clientId: raw.client_id,
71
- privateKey: raw.private_key,
72
- };
73
- };
74
52
  const validateCloudflareAccounts = (
75
53
  accounts: ReadonlyArray<CloudflareBrokerAccount>,
76
54
  path: string,
@@ -131,7 +109,7 @@ export const readBrokerStore = async (path: string): Promise<BrokerStore> => {
131
109
  throw new Error(`${path} must contain a YAML mapping`);
132
110
  }
133
111
  return {
134
- github: parseGithub(raw.github),
112
+ github: parseGithubApps(raw.github, path),
135
113
  cloudflare: parseCloudflare(raw.cloudflare, path),
136
114
  };
137
115
  };
@@ -142,21 +120,14 @@ export const updateBrokerStore = async (
142
120
  ): Promise<void> =>
143
121
  withBrokerLock(path, async () => {
144
122
  const store = await update(await readBrokerStore(path));
123
+ validateGithubApps(store.github, path);
145
124
  validateCloudflareAccounts(store.cloudflare, path);
146
125
  await writeBrokerStoreUnlocked(path, store, sync);
147
126
  });
148
127
  const storeDocument = (store: BrokerStore): unknown => ({
149
- ...(store.github === null
128
+ ...(store.github.length === 0
150
129
  ? {}
151
- : {
152
- github: {
153
- app_id: store.github.appId,
154
- slug: store.github.slug,
155
- html_url: store.github.htmlUrl,
156
- client_id: store.github.clientId,
157
- private_key: store.github.privateKey,
158
- },
159
- }),
130
+ : { github: githubStoreDocument(store.github) }),
160
131
  ...(store.cloudflare.length === 0
161
132
  ? {}
162
133
  : {
@@ -33,7 +33,14 @@ export const optOutEligibilityProblem = (
33
33
  : null;
34
34
 
35
35
  export const ADMIN_VISIBILITY_ADVICE =
36
- 'Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth';
36
+ 'Use a user-scoped token with read access to repository administration, or verify locally with admin auth; a CI token cannot hold that access, so widening what CI reads with is not the fix';
37
+
38
+ // Merge settings have a different remedy from everything else this advice used
39
+ // to cover. GraphQL serves them to read-only tokens by design, so reaching this
40
+ // message means the fallback request itself failed — pointing the reader at a
41
+ // permission they cannot obtain would send them after the wrong cause.
42
+ export const MERGE_SETTINGS_VISIBILITY_ADVICE =
43
+ 'REST serves repository merge settings only to write-level viewers, and the GraphQL fallback that answers them for a read-only token did not respond, so this is more likely a failed request than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth';
37
44
 
38
45
  // Declared state the token cannot see is a gate failure, not a pass with a
39
46
  // log line: a gate that cannot perform its comparison fails closed. The advice
@@ -13,8 +13,8 @@ import {
13
13
  } from './github-api';
14
14
  import { resolveHiddenBypassActors } from './github-bypass-actors';
15
15
  import {
16
- ADMIN_VISIBILITY_ADVICE,
17
16
  enforceableRepositorySettings,
17
+ MERGE_SETTINGS_VISIBILITY_ADVICE,
18
18
  optOutEligibilityProblem,
19
19
  unverifiableProblem,
20
20
  } from './github-command-shared';
@@ -35,7 +35,7 @@ import {
35
35
  import { type GithubSettings, isRecord } from './github-settings-parse';
36
36
 
37
37
  const LABEL_VISIBILITY_PROBLEM =
38
- 'declared labels not visible to this token, so the gate cannot verify them. In CI, use a valid ci.github_settings_read_token from secrets/ci.yaml with read-only "Issues" access (or "Pull requests" read); locally use a token with one of those permissions';
38
+ 'declared labels not visible to this token, so the gate cannot verify them. Label reads need issues: read (or pull-requests: read); the canonical github-settings job already grants issues: read, so in CI this points at a stale synced workflow or an organization policy restricting the workflow token rather than anything to change in the declaration. Locally, use a token with one of those permissions';
39
39
  const PERMISSION_DENIAL_MESSAGES: ReadonlySet<string> = new Set([
40
40
  'Resource not accessible by integration',
41
41
  'Resource not accessible by personal access token',
@@ -87,7 +87,7 @@ const repositoryDrift = async (
87
87
  ...unverifiableProblem(
88
88
  'repository setting(s)',
89
89
  rediff.unverifiable,
90
- ADMIN_VISIBILITY_ADVICE,
90
+ MERGE_SETTINGS_VISIBILITY_ADVICE,
91
91
  ),
92
92
  ];
93
93
  };
@@ -0,0 +1,179 @@
1
+ const CLOSING_KEYWORD_PATTERN = String.raw`(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?):?[ \t]+#`;
2
+ const FENCE_OPEN_PATTERN = /^ {0,3}(?<marker>`{3,}|~{3,})(?<info>[^\r\n]*)$/u;
3
+ const BODY_LINE_PATTERN = /\r?\n/u;
4
+ const INDENTED_CODE_PATTERN = /^ {0,3}\t|^ {4}/u;
5
+ const COMMENT_START = '<!--';
6
+ const COMMENT_END = '-->';
7
+
8
+ type BacktickRun = {
9
+ readonly start: number;
10
+ readonly end: number;
11
+ };
12
+
13
+ const isEscaped = (line: string, index: number): boolean => {
14
+ let backslashes = 0;
15
+ for (
16
+ let cursor = index - 1;
17
+ cursor >= 0 && line[cursor] === '\\';
18
+ cursor -= 1
19
+ ) {
20
+ backslashes += 1;
21
+ }
22
+ return backslashes % 2 === 1;
23
+ };
24
+
25
+ const nextBacktickRun = (line: string, from: number): BacktickRun | null => {
26
+ let start = line.indexOf('`', from);
27
+ while (start !== -1) {
28
+ let end = start + 1;
29
+ while (line[end] === '`') {
30
+ end += 1;
31
+ }
32
+ if (!isEscaped(line, start)) {
33
+ return { start, end };
34
+ }
35
+ start = line.indexOf('`', end);
36
+ }
37
+ return null;
38
+ };
39
+
40
+ const matchingBacktickRun = (
41
+ line: string,
42
+ opener: BacktickRun,
43
+ ): BacktickRun | null => {
44
+ const ticks = opener.end - opener.start;
45
+ let candidate = nextBacktickRun(line, opener.end);
46
+ while (candidate !== null) {
47
+ if (candidate.end - candidate.start === ticks) {
48
+ return candidate;
49
+ }
50
+ candidate = nextBacktickRun(line, candidate.end);
51
+ }
52
+ return null;
53
+ };
54
+
55
+ const nextActiveCommentStart = (line: string, from: number): number => {
56
+ let cursor = from;
57
+ while (cursor < line.length) {
58
+ const comment = line.indexOf(COMMENT_START, cursor);
59
+ const opener = nextBacktickRun(line, cursor);
60
+ if (
61
+ comment !== -1 &&
62
+ !isEscaped(line, comment) &&
63
+ (opener === null || comment < opener.start)
64
+ ) {
65
+ return comment;
66
+ }
67
+ if (opener === null) {
68
+ return -1;
69
+ }
70
+ const closer = matchingBacktickRun(line, opener);
71
+ cursor = closer === null ? opener.end : closer.end;
72
+ }
73
+ return -1;
74
+ };
75
+
76
+ const commentIsOpenAfter = (line: string, initiallyOpen: boolean): boolean => {
77
+ let open = initiallyOpen;
78
+ let cursor = 0;
79
+ while (cursor < line.length) {
80
+ const marker = open
81
+ ? line.indexOf(COMMENT_END, cursor)
82
+ : nextActiveCommentStart(line, cursor);
83
+ if (marker === -1) {
84
+ return open;
85
+ }
86
+ open = !open;
87
+ cursor = marker + (open ? COMMENT_START.length : COMMENT_END.length);
88
+ }
89
+ return open;
90
+ };
91
+
92
+ const isIndentedCodeLine = (
93
+ lines: ReadonlyArray<string>,
94
+ index: number,
95
+ ): boolean => {
96
+ if (!INDENTED_CODE_PATTERN.test(lines[index] ?? '')) {
97
+ return false;
98
+ }
99
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
100
+ const previous = lines[cursor] ?? '';
101
+ if (previous.trim().length === 0) {
102
+ return true;
103
+ }
104
+ if (!INDENTED_CODE_PATTERN.test(previous)) {
105
+ return false;
106
+ }
107
+ }
108
+ return true;
109
+ };
110
+
111
+ const commentStateAfterContentLine = (
112
+ lines: ReadonlyArray<string>,
113
+ index: number,
114
+ initiallyOpen: boolean,
115
+ ): boolean => {
116
+ if (!initiallyOpen && isIndentedCodeLine(lines, index)) {
117
+ return false;
118
+ }
119
+ return commentIsOpenAfter(lines[index] ?? '', initiallyOpen);
120
+ };
121
+
122
+ const fenceCloseForLine = (line: string): RegExp | null => {
123
+ const groups = FENCE_OPEN_PATTERN.exec(line)?.groups;
124
+ const marker = groups?.marker;
125
+ if (
126
+ marker === undefined ||
127
+ (marker[0] === '`' && (groups?.info ?? '').includes('`'))
128
+ ) {
129
+ return null;
130
+ }
131
+ return new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*$`, 'u');
132
+ };
133
+
134
+ type MarkdownState = {
135
+ readonly fenceClose: RegExp | null;
136
+ readonly commentOpen: boolean;
137
+ };
138
+
139
+ const markdownStateAfterContentLine = (
140
+ lines: ReadonlyArray<string>,
141
+ index: number,
142
+ state: MarkdownState,
143
+ ): MarkdownState => {
144
+ const line = lines[index] ?? '';
145
+ if (state.fenceClose !== null) {
146
+ return {
147
+ fenceClose: state.fenceClose.test(line) ? null : state.fenceClose,
148
+ commentOpen: state.commentOpen,
149
+ };
150
+ }
151
+ const fenceClose = state.commentOpen ? null : fenceCloseForLine(line);
152
+ return {
153
+ fenceClose,
154
+ commentOpen:
155
+ fenceClose === null
156
+ ? commentStateAfterContentLine(lines, index, state.commentOpen)
157
+ : state.commentOpen,
158
+ };
159
+ };
160
+
161
+ export const hasClosingReferenceToIssue = (
162
+ body: string,
163
+ issueNumber: number,
164
+ ): boolean => {
165
+ const reference = new RegExp(
166
+ `^${CLOSING_KEYWORD_PATTERN}${issueNumber}$`,
167
+ 'iu',
168
+ );
169
+ const lines = body.split(BODY_LINE_PATTERN);
170
+ if (!reference.test(lines.at(-1) ?? '')) {
171
+ return false;
172
+ }
173
+ let state: MarkdownState = { fenceClose: null, commentOpen: false };
174
+ const content = lines.slice(0, -1);
175
+ for (const [index] of content.entries()) {
176
+ state = markdownStateAfterContentLine(content, index, state);
177
+ }
178
+ return state.fenceClose === null && !state.commentOpen;
179
+ };
@@ -1,3 +1,4 @@
1
+ import type { CodexRunResult } from './poller-codex';
1
2
  import type { FixPublication } from './poller-fix-publication';
2
3
  import { createComment } from './poller-github-write';
3
4
  import {
@@ -6,8 +7,16 @@ import {
6
7
  type JobLabels,
7
8
  releaseLabels,
8
9
  } from './poller-job-shared';
10
+ import { readFixOutcome } from './poller-outcome';
9
11
  import type { FixOutcome } from './poller-protocol';
10
12
 
13
+ export const readFixRunOutcome = (
14
+ run: CodexRunResult,
15
+ workDir: string,
16
+ issueNumber: number,
17
+ ): Promise<FixOutcome | null> =>
18
+ run.succeeded ? readFixOutcome(workDir, issueNumber) : Promise.resolve(null);
19
+
11
20
  export const handleNonFixedOutcome = async (
12
21
  job: FixPublication,
13
22
  labels: JobLabels,
@@ -16,10 +16,7 @@ import {
16
16
  type JobLabels,
17
17
  releaseLabels,
18
18
  } from './poller-job-shared';
19
- import {
20
- changedWorkspaceQualityManifests,
21
- lockedPathsOf,
22
- } from './poller-protected-paths';
19
+ import { lockedPathsOf } from './poller-protected-paths';
23
20
  import {
24
21
  APPROVED_FOR_REVIEW,
25
22
  type FixOutcome,
@@ -150,14 +147,10 @@ export const finishFixedJob = async (
150
147
  return `#${issue.number}: failed (fixed without commits)`;
151
148
  }
152
149
  const paths = changedPaths(workspace.dir, workspace.baseSha);
153
- const forbidden = [
154
- ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
155
- ...changedWorkspaceQualityManifests(
156
- workspace.dir,
157
- workspace.baseSha,
158
- paths,
159
- ),
160
- ];
150
+ const forbidden = forbiddenDiffPaths(
151
+ paths,
152
+ await lockedPathsOf(workspace.dir),
153
+ );
161
154
  if (forbidden.length > 0) {
162
155
  await failJob(
163
156
  deps,
@@ -5,7 +5,7 @@ import { hasLabel } from './github-label-identity';
5
5
  import { approvalIsCurrent, issueRevision } from './poller-approval';
6
6
  import { startClaim, withClaimReleasedOnFailure } from './poller-claim';
7
7
  import { runCodex } from './poller-codex';
8
- import { handleNonFixedOutcome } from './poller-fix-outcome';
8
+ import { handleNonFixedOutcome, readFixRunOutcome } from './poller-fix-outcome';
9
9
  import { readSealedFixOutput } from './poller-fix-output';
10
10
  import {
11
11
  type FixPublication,
@@ -21,7 +21,6 @@ import {
21
21
  type JobResult,
22
22
  jobPreamble,
23
23
  } from './poller-job-shared';
24
- import { readFixOutcome } from './poller-outcome';
25
24
  import { fixPrompt } from './poller-prompts';
26
25
  import {
27
26
  APPROVED_FOR_FIX,
@@ -170,7 +169,7 @@ export const runFixJob = async (
170
169
  config,
171
170
  });
172
171
  await validateFixClaim(job);
173
- const outcome = run.succeeded ? await readFixOutcome(workspace.dir) : null;
172
+ const outcome = await readFixRunOutcome(run, workspace.dir, issue.number);
174
173
  if (outcome === null) {
175
174
  await failJob(
176
175
  deps,
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { hasClosingReferenceToIssue } from './poller-closing-reference';
2
3
  import type { SealedFixOutput } from './poller-fix-output';
3
4
  import type { JobDeps } from './poller-job-shared';
4
5
  import {
@@ -7,10 +8,7 @@ import {
7
8
  createValidationWorktree,
8
9
  isAncestor,
9
10
  } from './poller-output-integrity';
10
- import {
11
- changedWorkspaceQualityManifests,
12
- lockedPathsOf,
13
- } from './poller-protected-paths';
11
+ import { lockedPathsOf } from './poller-protected-paths';
14
12
  import { forbiddenDiffPaths } from './poller-protocol';
15
13
 
16
14
  export const validateSealedFixOutput = async (
@@ -27,6 +25,7 @@ export const validateSealedFixOutput = async (
27
25
  output.repo !== job.deps.repo ||
28
26
  output.issueNumber !== job.issueNumber ||
29
27
  output.approvalId !== job.approvalId ||
28
+ !hasClosingReferenceToIssue(output.body, job.issueNumber) ||
30
29
  !isAncestor(job.cloneDir, output.baseSha, job.defaultBranch) ||
31
30
  !isAncestor(job.cloneDir, output.baseSha, output.generatedHead) ||
32
31
  commitCountBetween(job.cloneDir, output.baseSha, output.generatedHead) !==
@@ -49,10 +48,10 @@ export const validateSealedFixOutput = async (
49
48
  ),
50
49
  );
51
50
  try {
52
- const forbidden = [
53
- ...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
54
- ...changedWorkspaceQualityManifests(workspace.dir, output.baseSha, paths),
55
- ];
51
+ const forbidden = forbiddenDiffPaths(
52
+ paths,
53
+ await lockedPathsOf(workspace.dir),
54
+ );
56
55
  if (forbidden.length > 0) {
57
56
  throw new Error(
58
57
  `sealed fix output modified protected paths:\n${forbidden.join('\n')}`,
@@ -7,6 +7,7 @@ import { existsSync, rmSync } from 'node:fs';
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { isNonEmptyString, isRecord } from './github-settings-parse';
10
+ import { hasClosingReferenceToIssue } from './poller-closing-reference';
10
11
  import {
11
12
  type FixOutcome,
12
13
  OUTCOME_FILE,
@@ -59,6 +60,7 @@ const PR_TITLE_PATTERN = /^[a-z]+(?:\([^)]+\))?!?: .+/u;
59
60
 
60
61
  export const readFixOutcome = async (
61
62
  workDir: string,
63
+ issueNumber: number,
62
64
  ): Promise<FixOutcome | null> => {
63
65
  const raw = await readOutcomeRaw(workDir);
64
66
  if (
@@ -78,7 +80,8 @@ export const readFixOutcome = async (
78
80
  !(
79
81
  isNonEmptyString(raw.prTitle) &&
80
82
  PR_TITLE_PATTERN.test(raw.prTitle) &&
81
- isNonEmptyString(raw.prBody)
83
+ isNonEmptyString(raw.prBody) &&
84
+ hasClosingReferenceToIssue(raw.prBody, issueNumber)
82
85
  )
83
86
  ) {
84
87
  return null;
@@ -1,11 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isRecord } from './github-settings-parse';
5
- import { changesWorkspaceQualityScripts } from './poller-protocol';
6
- import { runGit } from './poller-workspace';
7
-
8
- const WORKSPACE_MANIFEST = /^(?:apps|packages)\/[^/]+\/package\.json$/u;
9
5
 
10
6
  export const lockedPathsOf = async (
11
7
  workDir: string,
@@ -29,23 +25,3 @@ export const lockedPathsOf = async (
29
25
  );
30
26
  }
31
27
  };
32
-
33
- export const changedWorkspaceQualityManifests = (
34
- workDir: string,
35
- baseSha: string,
36
- paths: ReadonlyArray<string>,
37
- ): ReadonlyArray<string> =>
38
- paths.filter((path) => {
39
- if (!WORKSPACE_MANIFEST.test(path)) {
40
- return false;
41
- }
42
- try {
43
- return changesWorkspaceQualityScripts(
44
- path,
45
- runGit(['-C', workDir, 'show', `${baseSha}:${path}`], null),
46
- readFileSync(join(workDir, path), 'utf8'),
47
- );
48
- } catch {
49
- return true;
50
- }
51
- });
@@ -76,8 +76,6 @@ const GATE_WIRING_FILES: ReadonlySet<string> = new Set([
76
76
  const HOST_SECRETS_FILE = /(?:^|\/)secrets\.yaml$/u;
77
77
  const WORKSPACE_GATE_CONFIG =
78
78
  /(?:^|\/)(?:biome\.jsonc|turbo\.json|tsconfig(?:\.[^.]+)?\.json|vitest\.config\.[cm]?[jt]s|playwright\.config\.[cm]?[jt]s)$/u;
79
- const QUALITY_SCRIPT = /^(?:check|lint|test|typecheck)(?::|$)/u;
80
- const WORKSPACE_MANIFEST = /^(?:apps|packages)\/[^/]+\/package\.json$/u;
81
79
 
82
80
  const isEncryptedSecret = (path: string): boolean =>
83
81
  !path.endsWith('.example.yaml') &&
@@ -101,46 +99,6 @@ export const forbiddenDiffPaths = (
101
99
  );
102
100
  };
103
101
 
104
- const qualityScripts = (
105
- manifest: unknown,
106
- ): Readonly<Record<string, string>> => {
107
- if (
108
- typeof manifest !== 'object' ||
109
- manifest === null ||
110
- !('scripts' in manifest) ||
111
- typeof manifest.scripts !== 'object' ||
112
- manifest.scripts === null
113
- ) {
114
- return {};
115
- }
116
- return Object.fromEntries(
117
- Object.entries(manifest.scripts)
118
- .filter(
119
- (entry): entry is [string, string] =>
120
- QUALITY_SCRIPT.test(entry[0]) && typeof entry[1] === 'string',
121
- )
122
- .sort(([left], [right]) => left.localeCompare(right)),
123
- );
124
- };
125
-
126
- export const changesWorkspaceQualityScripts = (
127
- path: string,
128
- before: string,
129
- after: string,
130
- ): boolean => {
131
- if (path === 'package.json' || !WORKSPACE_MANIFEST.test(path)) {
132
- return false;
133
- }
134
- try {
135
- return (
136
- JSON.stringify(qualityScripts(JSON.parse(before) as unknown)) !==
137
- JSON.stringify(qualityScripts(JSON.parse(after) as unknown))
138
- );
139
- } catch {
140
- return true;
141
- }
142
- };
143
-
144
102
  export const branchNameForIssue = (
145
103
  issueNumber: number,
146
104
  approvalId: string,