@davidvornholt/standards 0.18.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.
- package/README.md +5 -4
- package/package.json +12 -1
- package/src/cli.ts +283 -73
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +5 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +21 -9
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +98 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +27 -51
- package/src/creds-store.ts +14 -43
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
package/src/managed-files.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
readdir,
|
|
4
|
+
readFile,
|
|
5
|
+
readlink,
|
|
6
|
+
rm,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
2
10
|
import { join, relative, sep } from 'node:path';
|
|
3
11
|
|
|
4
12
|
// Never mirrored, even under a managed directory path: build output, VCS
|
|
@@ -16,34 +24,76 @@ const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
|
|
|
16
24
|
|
|
17
25
|
const toPosix = (path: string): string => path.split(sep).join('/');
|
|
18
26
|
|
|
27
|
+
// A managed path is a file or a symlink. A symlink's identity is its target,
|
|
28
|
+
// never the content it resolves to: mirroring must reproduce the link itself,
|
|
29
|
+
// and following it would copy the whole target tree into every consumer.
|
|
30
|
+
export type ManagedEntry =
|
|
31
|
+
| { readonly kind: 'file'; readonly absolutePath: string }
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'symlink';
|
|
34
|
+
readonly absolutePath: string;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
}
|
|
37
|
+
// Only ever observed when inspecting a destination a consumer replaced; the
|
|
38
|
+
// upstream walk descends into directories instead of recording them.
|
|
39
|
+
| { readonly kind: 'directory'; readonly absolutePath: string };
|
|
40
|
+
|
|
41
|
+
// Built rather than written as an escape so this source file stays plain ASCII
|
|
42
|
+
// and greppable. File digests remain plain content hashes, so existing consumer
|
|
43
|
+
// locks keep validating; the markers only need to be unreachable content for a
|
|
44
|
+
// canonical file, and no text file begins with a NUL byte.
|
|
45
|
+
const nul = String.fromCharCode(0);
|
|
46
|
+
const SYMLINK_DIGEST_PREFIX = `${nul}standards-symlink${nul}`;
|
|
47
|
+
const DIRECTORY_DIGEST = `${nul}standards-directory${nul}`;
|
|
48
|
+
|
|
49
|
+
export const inspectManagedPath = async (
|
|
50
|
+
path: string,
|
|
51
|
+
): Promise<ManagedEntry | null> => {
|
|
52
|
+
const info = await lstat(path).catch(() => null);
|
|
53
|
+
if (info === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (info.isSymbolicLink()) {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'symlink',
|
|
59
|
+
absolutePath: path,
|
|
60
|
+
target: toPosix(await readlink(path)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (info.isDirectory()) {
|
|
64
|
+
return { kind: 'directory', absolutePath: path };
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'file', absolutePath: path };
|
|
67
|
+
};
|
|
68
|
+
|
|
19
69
|
const walkManagedFiles = async (
|
|
20
70
|
path: string,
|
|
21
71
|
repositoryRoot: string,
|
|
22
|
-
files: Map<string,
|
|
72
|
+
files: Map<string, ManagedEntry>,
|
|
23
73
|
): Promise<void> => {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
74
|
+
const entry = await inspectManagedPath(path);
|
|
75
|
+
if (entry === null) {
|
|
26
76
|
return;
|
|
27
77
|
}
|
|
28
|
-
if (
|
|
78
|
+
if (entry.kind === 'directory') {
|
|
29
79
|
const entries = await readdir(path);
|
|
30
80
|
await Promise.all(
|
|
31
81
|
entries
|
|
32
|
-
.filter((
|
|
33
|
-
.map((
|
|
34
|
-
walkManagedFiles(join(path,
|
|
82
|
+
.filter((name) => !IGNORED_DIRECTORIES.has(name))
|
|
83
|
+
.map((name) =>
|
|
84
|
+
walkManagedFiles(join(path, name), repositoryRoot, files),
|
|
35
85
|
),
|
|
36
86
|
);
|
|
37
87
|
return;
|
|
38
88
|
}
|
|
39
|
-
files.set(toPosix(relative(repositoryRoot, path)),
|
|
89
|
+
files.set(toPosix(relative(repositoryRoot, path)), entry);
|
|
40
90
|
};
|
|
41
91
|
|
|
42
92
|
export const listManagedFiles = async (
|
|
43
93
|
repositoryRoot: string,
|
|
44
94
|
paths: ReadonlyArray<string>,
|
|
45
|
-
): Promise<ReadonlyMap<string,
|
|
46
|
-
const files = new Map<string,
|
|
95
|
+
): Promise<ReadonlyMap<string, ManagedEntry>> => {
|
|
96
|
+
const files = new Map<string, ManagedEntry>();
|
|
47
97
|
await Promise.all(
|
|
48
98
|
paths.map((path) =>
|
|
49
99
|
walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
|
|
@@ -52,12 +102,64 @@ export const listManagedFiles = async (
|
|
|
52
102
|
return files;
|
|
53
103
|
};
|
|
54
104
|
|
|
105
|
+
// What the lock hashes for an entry: file content, or the marker-prefixed link
|
|
106
|
+
// target. Retargeting a link therefore reads as drift the same way an edited
|
|
107
|
+
// file does.
|
|
108
|
+
export const managedEntryDigestInput = (
|
|
109
|
+
entry: ManagedEntry,
|
|
110
|
+
): Promise<Buffer> => {
|
|
111
|
+
if (entry.kind === 'symlink') {
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
Buffer.from(`${SYMLINK_DIGEST_PREFIX}${entry.target}`),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (entry.kind === 'directory') {
|
|
117
|
+
return Promise.resolve(Buffer.from(DIRECTORY_DIGEST));
|
|
118
|
+
}
|
|
119
|
+
return readFile(entry.absolutePath);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Plant one managed entry, replacing a file or a link that occupies the
|
|
123
|
+
// destination. Never recursive: a directory at the destination is unbounded
|
|
124
|
+
// consumer content, so removing it is the caller's decision to make explicitly
|
|
125
|
+
// once it has proven the path was engine-owned.
|
|
126
|
+
export const writeManagedEntry = async (
|
|
127
|
+
dest: string,
|
|
128
|
+
entry: ManagedEntry,
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
if (entry.kind === 'directory') {
|
|
131
|
+
throw new Error(`cannot mirror a directory as a managed path: ${dest}`);
|
|
132
|
+
}
|
|
133
|
+
const existing = await inspectManagedPath(dest);
|
|
134
|
+
if (existing?.kind === 'directory') {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`refusing to replace the directory ${dest} with a managed ${entry.kind}; move or delete it first`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// `symlink` refuses an existing path, and writing a file through a stale link
|
|
140
|
+
// would clobber that link's target instead of replacing the managed path.
|
|
141
|
+
if (
|
|
142
|
+
existing !== null &&
|
|
143
|
+
(entry.kind === 'symlink' || existing.kind === 'symlink')
|
|
144
|
+
) {
|
|
145
|
+
await rm(dest, { force: true });
|
|
146
|
+
}
|
|
147
|
+
if (entry.kind === 'symlink') {
|
|
148
|
+
await symlink(entry.target, dest);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await writeFile(dest, await readFile(entry.absolutePath));
|
|
152
|
+
};
|
|
153
|
+
|
|
55
154
|
export const findManagedFilesContainingBiomeDirectiveToken = async (
|
|
56
|
-
files: ReadonlyMap<string,
|
|
155
|
+
files: ReadonlyMap<string, ManagedEntry>,
|
|
57
156
|
): Promise<ReadonlyArray<string>> => {
|
|
58
157
|
const matches = await Promise.all(
|
|
59
|
-
[...files].map(async ([path,
|
|
60
|
-
|
|
158
|
+
[...files].map(async ([path, entry]) =>
|
|
159
|
+
entry.kind === 'file' &&
|
|
160
|
+
(await readFile(entry.absolutePath)).includes(directiveToken)
|
|
161
|
+
? path
|
|
162
|
+
: null,
|
|
61
163
|
),
|
|
62
164
|
);
|
|
63
165
|
return matches.filter((path): path is string => path !== null).sort();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Ownership questions about a destination path, asked before init/sync writes
|
|
2
|
+
// to it or deletes it. Both answers come from the lock: it is the only record of
|
|
3
|
+
// what this engine put in a consumer, so anything absent from it is consumer
|
|
4
|
+
// work that a mirror must never destroy.
|
|
5
|
+
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { inspectManagedPath, type ManagedEntry } from './managed-files';
|
|
9
|
+
|
|
10
|
+
const collectPaths = async (
|
|
11
|
+
root: string,
|
|
12
|
+
rel: string,
|
|
13
|
+
found: Array<string>,
|
|
14
|
+
): Promise<void> => {
|
|
15
|
+
const entry = await inspectManagedPath(join(root, rel));
|
|
16
|
+
if (entry === null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (entry.kind === 'directory') {
|
|
20
|
+
const names = await readdir(join(root, rel));
|
|
21
|
+
await Promise.all(
|
|
22
|
+
names.map((name) => collectPaths(root, `${rel}/${name}`, found)),
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
found.push(rel);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Paths inside `rel` that the lock never recorded, so a caller can tell an
|
|
30
|
+
// engine-owned tree from consumer work. An older CLI that followed a canonical
|
|
31
|
+
// link materialized its whole target tree and locked every path in it; a
|
|
32
|
+
// consumer's own file under the same directory is absent from the lock. Nothing
|
|
33
|
+
// here follows a symlink: every entry is judged where it sits.
|
|
34
|
+
export const unlockedPathsUnder = async (
|
|
35
|
+
root: string,
|
|
36
|
+
rel: string,
|
|
37
|
+
locked: ReadonlySet<string>,
|
|
38
|
+
): Promise<ReadonlyArray<string>> => {
|
|
39
|
+
const found: Array<string> = [];
|
|
40
|
+
await collectPaths(root, rel, found);
|
|
41
|
+
return found.filter((path) => !locked.has(path)).sort();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// An ancestor that is not a directory, so nothing can sit below it under the
|
|
45
|
+
// name the lock recorded. The kind is part of the answer: below a symlink the
|
|
46
|
+
// name still resolves, to somebody else's content, while below a file it
|
|
47
|
+
// resolves to nothing at all.
|
|
48
|
+
export type InterposedAncestor = {
|
|
49
|
+
readonly rel: string;
|
|
50
|
+
readonly kind: 'file' | 'symlink';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The shallowest ancestor of `rel` inside `root` that is not a directory, or
|
|
54
|
+
// null when the whole parent chain is directories.
|
|
55
|
+
//
|
|
56
|
+
// `planned` is the payload this run mirrors, and it wins over the disk: every
|
|
57
|
+
// path in it has the kind upstream gives it once the mirror has finished, and
|
|
58
|
+
// the caller runs after the mirror. A dry run, which writes nothing, therefore
|
|
59
|
+
// reaches the same answer the matching real run does.
|
|
60
|
+
//
|
|
61
|
+
// A path whose parent chain crosses a link does not name what the caller thinks
|
|
62
|
+
// it names: reading or removing it reaches into the link's target tree, which
|
|
63
|
+
// is somebody else's content.
|
|
64
|
+
export const interposedAncestor = async (
|
|
65
|
+
root: string,
|
|
66
|
+
rel: string,
|
|
67
|
+
planned: ReadonlyMap<string, ManagedEntry>,
|
|
68
|
+
): Promise<InterposedAncestor | null> => {
|
|
69
|
+
const segments = rel.split('/').slice(0, -1);
|
|
70
|
+
const prefixes = segments.map((_, depth) =>
|
|
71
|
+
segments.slice(0, depth + 1).join('/'),
|
|
72
|
+
);
|
|
73
|
+
const entries = await Promise.all(
|
|
74
|
+
prefixes.map(
|
|
75
|
+
(prefix) => planned.get(prefix) ?? inspectManagedPath(join(root, prefix)),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
for (const [index, prefix] of prefixes.entries()) {
|
|
79
|
+
const entry = entries[index] ?? null;
|
|
80
|
+
if (entry !== null && entry.kind !== 'directory') {
|
|
81
|
+
return { rel: prefix, kind: entry.kind };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
package/src/poller-fix-run.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
54
|
-
|
|
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')}`,
|
package/src/poller-outcome.ts
CHANGED
|
@@ -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
|
|
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
|
-
});
|
package/src/poller-protocol.ts
CHANGED
|
@@ -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,
|