@davidvornholt/standards 0.19.0 → 0.21.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 +7 -5
- package/package.json +19 -1
- package/src/cli.ts +2 -2
- 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 +14 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +34 -11
- 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-naming.ts +13 -0
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +2 -11
- package/src/creds-plan-types.ts +19 -0
- package/src/creds-plan-unmanaged.ts +102 -0
- package/src/creds-plan.ts +25 -21
- package/src/creds-revoke.ts +162 -0
- 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 +105 -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 +28 -51
- package/src/creds-store.ts +14 -43
- package/src/dev-env-compose.ts +120 -0
- package/src/dev-env-destination-duplicates.ts +10 -10
- package/src/dev-env-destination-gitignore.ts +22 -0
- package/src/dev-env-destination.ts +52 -30
- package/src/dev-env-document.ts +19 -6
- package/src/dev-env-dotenv.ts +12 -3
- package/src/dev-env-reconciliation.ts +170 -0
- package/src/dev-env-secrets.ts +41 -0
- package/src/dev-env-transaction-recovery.ts +74 -11
- package/src/dev-env-transaction.ts +61 -15
- package/src/dev-env.ts +115 -63
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- 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/dev-env.ts
CHANGED
|
@@ -1,89 +1,107 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { composeDevEnv } from './dev-env-compose';
|
|
4
4
|
import {
|
|
5
|
+
type DevEnvRemoval,
|
|
5
6
|
type DevEnvWrite,
|
|
6
7
|
devEnvDestinationProblems,
|
|
7
8
|
} from './dev-env-destination';
|
|
9
|
+
import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
|
|
8
10
|
import { parseDevEnvDocument } from './dev-env-document';
|
|
9
11
|
import { renderDotenv } from './dev-env-dotenv';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
+
import { planDevEnvRemovals } from './dev-env-reconciliation';
|
|
13
|
+
import { DEV_SECRETS_FILE, readDevSecrets } from './dev-env-secrets';
|
|
14
|
+
import { applyDevEnvChanges } from './dev-env-transaction';
|
|
15
|
+
import { parseYaml } from './yaml-parse';
|
|
12
16
|
|
|
13
|
-
export const
|
|
17
|
+
export const DEV_CONFIG_FILE = 'config/dev.yaml';
|
|
18
|
+
export const DEV_LOCAL_FILE = 'config/dev.local.yaml';
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
export type DevEnvPlainInput = { readonly raw: unknown } | null;
|
|
16
21
|
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
export type DevEnvInputs = {
|
|
23
|
+
readonly config: DevEnvPlainInput;
|
|
24
|
+
readonly secrets: unknown;
|
|
25
|
+
readonly local: DevEnvPlainInput;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isMissingPathError = (error: unknown): boolean =>
|
|
29
|
+
(error as { readonly code?: unknown } | null)?.code === 'ENOENT';
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
const readPlainLayer = (consumer: string, rel: string) => {
|
|
32
|
+
const path = join(consumer, rel);
|
|
33
|
+
try {
|
|
34
|
+
lstatSync(path);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isMissingPathError(error)) {
|
|
37
|
+
return { input: null, present: false, problems: [] };
|
|
38
|
+
}
|
|
39
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
26
40
|
return {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
: `could not decrypt ${DEV_SECRETS_FILE}`,
|
|
41
|
+
input: null,
|
|
42
|
+
present: true,
|
|
43
|
+
problems: [`could not inspect ${rel}: ${detail}`],
|
|
31
44
|
};
|
|
32
45
|
}
|
|
46
|
+
let raw: string;
|
|
33
47
|
try {
|
|
34
|
-
|
|
48
|
+
raw = readFileSync(path, 'utf8');
|
|
35
49
|
} catch (error) {
|
|
50
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
36
51
|
return {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}`,
|
|
52
|
+
input: null,
|
|
53
|
+
present: true,
|
|
54
|
+
problems: [`could not read ${rel}: ${detail}`],
|
|
41
55
|
};
|
|
42
56
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// track them. `git check-ignore` is authoritative; anything but a clear
|
|
47
|
-
// "ignored" answer fails closed, including running outside a git checkout.
|
|
48
|
-
const gitIgnoreProblem = (consumer: string, rel: string): string | null => {
|
|
49
|
-
const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
|
|
50
|
-
cwd: consumer,
|
|
51
|
-
});
|
|
52
|
-
if (result.error !== undefined || result.status === null) {
|
|
53
|
-
return `cannot run git to verify ${rel} is gitignored`;
|
|
54
|
-
}
|
|
55
|
-
if (result.status === 0) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
if (result.status === 1) {
|
|
59
|
-
return `${rel} is not gitignored; ignore it before generating dev env files`;
|
|
57
|
+
const parsed = parseYaml(raw, rel);
|
|
58
|
+
if (parsed.problem !== null) {
|
|
59
|
+
return { input: null, present: true, problems: [parsed.problem] };
|
|
60
60
|
}
|
|
61
|
-
return
|
|
61
|
+
return { input: { raw: parsed.value ?? {} }, present: true, problems: [] };
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
const documentProblems = (
|
|
65
|
+
source: string,
|
|
66
|
+
input: DevEnvPlainInput,
|
|
67
|
+
): ReadonlyArray<string> =>
|
|
68
|
+
input === null ? [] : parseDevEnvDocument(input.raw, source).problems;
|
|
69
|
+
|
|
64
70
|
export type DevEnvPlan = {
|
|
65
71
|
readonly writes: ReadonlyArray<DevEnvWrite>;
|
|
72
|
+
readonly removals: ReadonlyArray<DevEnvRemoval>;
|
|
66
73
|
readonly problems: ReadonlyArray<string>;
|
|
67
74
|
};
|
|
68
75
|
|
|
69
|
-
export const
|
|
76
|
+
export const planDevEnvChanges = (
|
|
70
77
|
consumer: string,
|
|
71
|
-
|
|
78
|
+
inputs: DevEnvInputs,
|
|
72
79
|
): DevEnvPlan => {
|
|
73
|
-
const
|
|
74
|
-
|
|
80
|
+
const layer = (source: string, input: DevEnvPlainInput) =>
|
|
81
|
+
input === null
|
|
82
|
+
? null
|
|
83
|
+
: { source, document: parseDevEnvDocument(input.raw, source) };
|
|
84
|
+
const composed = composeDevEnv(
|
|
85
|
+
layer(DEV_CONFIG_FILE, inputs.config),
|
|
86
|
+
{
|
|
87
|
+
source: DEV_SECRETS_FILE,
|
|
88
|
+
document: parseDevEnvDocument(inputs.secrets, DEV_SECRETS_FILE),
|
|
89
|
+
},
|
|
90
|
+
layer(DEV_LOCAL_FILE, inputs.local),
|
|
91
|
+
);
|
|
92
|
+
const problems: Array<string> = [...composed.problems];
|
|
75
93
|
const writes: Array<DevEnvWrite> = [];
|
|
76
|
-
for (const target of
|
|
94
|
+
for (const target of composed.targets) {
|
|
77
95
|
const workspaceDir = `${target.group}/${target.workspace}`;
|
|
78
96
|
const rel = `${workspaceDir}/.env.local`;
|
|
79
97
|
if (existsSync(join(consumer, workspaceDir, 'package.json'))) {
|
|
80
|
-
const ignoreProblem =
|
|
98
|
+
const ignoreProblem = devEnvGitIgnoreProblem(consumer, rel);
|
|
81
99
|
if (ignoreProblem === null) {
|
|
82
100
|
writes.push({
|
|
83
101
|
rel,
|
|
84
102
|
content: renderDotenv(
|
|
85
103
|
`${target.group}.${target.workspace}`,
|
|
86
|
-
|
|
104
|
+
target.sources,
|
|
87
105
|
target.env,
|
|
88
106
|
),
|
|
89
107
|
});
|
|
@@ -92,36 +110,62 @@ export const planDevEnvWrites = (
|
|
|
92
110
|
}
|
|
93
111
|
} else {
|
|
94
112
|
problems.push(
|
|
95
|
-
`${
|
|
113
|
+
`${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
|
|
96
114
|
);
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
|
-
|
|
117
|
+
const removalPlan = planDevEnvRemovals(consumer, writes);
|
|
118
|
+
return {
|
|
119
|
+
writes,
|
|
120
|
+
removals: removalPlan.removals,
|
|
121
|
+
problems: [...problems, ...removalPlan.problems],
|
|
122
|
+
};
|
|
100
123
|
};
|
|
101
124
|
|
|
102
125
|
export const runDevEnv = async (consumer: string): Promise<boolean> => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
const config = readPlainLayer(consumer, DEV_CONFIG_FILE);
|
|
127
|
+
const secrets = readDevSecrets(consumer);
|
|
128
|
+
const local = readPlainLayer(consumer, DEV_LOCAL_FILE);
|
|
129
|
+
const localIgnoreProblem = local.present
|
|
130
|
+
? devEnvGitIgnoreProblem(consumer, DEV_LOCAL_FILE)
|
|
131
|
+
: null;
|
|
132
|
+
const inputProblems = [
|
|
133
|
+
...config.problems,
|
|
134
|
+
...(secrets.ok ? [] : [secrets.problem]),
|
|
135
|
+
...local.problems,
|
|
136
|
+
...(localIgnoreProblem === null ? [] : [localIgnoreProblem]),
|
|
137
|
+
...documentProblems(DEV_CONFIG_FILE, config.input),
|
|
138
|
+
...(secrets.ok
|
|
139
|
+
? parseDevEnvDocument(secrets.value, DEV_SECRETS_FILE).problems
|
|
140
|
+
: []),
|
|
141
|
+
...documentProblems(DEV_LOCAL_FILE, local.input),
|
|
142
|
+
];
|
|
143
|
+
if (inputProblems.length > 0 || !secrets.ok) {
|
|
144
|
+
console.error(`standards dev-env: ${inputProblems.length} problem(s):`);
|
|
145
|
+
console.error(inputProblems.map((problem) => ` - ${problem}`).join('\n'));
|
|
112
146
|
return false;
|
|
113
147
|
}
|
|
114
|
-
const plan =
|
|
148
|
+
const plan = planDevEnvChanges(consumer, {
|
|
149
|
+
config: config.input,
|
|
150
|
+
secrets: secrets.value,
|
|
151
|
+
local: local.input,
|
|
152
|
+
});
|
|
115
153
|
const problems = [
|
|
116
154
|
...plan.problems,
|
|
117
|
-
...(await devEnvDestinationProblems(consumer,
|
|
155
|
+
...(await devEnvDestinationProblems(consumer, [
|
|
156
|
+
...plan.writes,
|
|
157
|
+
...plan.removals,
|
|
158
|
+
])),
|
|
118
159
|
];
|
|
119
160
|
if (problems.length > 0) {
|
|
120
161
|
console.error(`standards dev-env: ${problems.length} problem(s):`);
|
|
121
162
|
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
122
163
|
return false;
|
|
123
164
|
}
|
|
124
|
-
const generated = await
|
|
165
|
+
const generated = await applyDevEnvChanges(consumer, [
|
|
166
|
+
...plan.writes,
|
|
167
|
+
...plan.removals,
|
|
168
|
+
]);
|
|
125
169
|
if (!generated.ok) {
|
|
126
170
|
console.error('standards dev-env: generation failed:');
|
|
127
171
|
console.error(
|
|
@@ -140,8 +184,16 @@ export const runDevEnv = async (consumer: string): Promise<boolean> => {
|
|
|
140
184
|
for (const write of plan.writes) {
|
|
141
185
|
console.log(` wrote ${write.rel}`);
|
|
142
186
|
}
|
|
187
|
+
for (const removal of plan.removals) {
|
|
188
|
+
console.log(` removed ${removal.rel}`);
|
|
189
|
+
}
|
|
190
|
+
const inputFiles = [
|
|
191
|
+
...(config.input === null ? [] : [DEV_CONFIG_FILE]),
|
|
192
|
+
DEV_SECRETS_FILE,
|
|
193
|
+
...(local.input === null ? [] : [DEV_LOCAL_FILE]),
|
|
194
|
+
];
|
|
143
195
|
console.log(
|
|
144
|
-
`standards dev-env: generated ${plan.writes.length} env file(s) from ${
|
|
196
|
+
`standards dev-env: generated ${plan.writes.length} and removed ${plan.removals.length} env file(s) from ${inputFiles.join(' + ')}`,
|
|
145
197
|
);
|
|
146
198
|
return true;
|
|
147
199
|
};
|
|
@@ -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
|
|
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
|
package/src/github-live-drift.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|