@davidvornholt/standards 0.10.2 → 0.11.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 +6 -1
- package/package.json +37 -1
- package/src/cli.ts +68 -11
- package/src/github-commands.ts +2 -0
- package/src/github-label-identity.ts +9 -0
- package/src/github-labels.ts +123 -0
- package/src/github-live-drift.ts +8 -0
- package/src/github-paginate.ts +37 -0
- package/src/github-settings-merge.ts +167 -0
- package/src/github-settings-parse.ts +82 -7
- package/src/github-settings.ts +5 -123
- package/src/poller-approval.ts +80 -0
- package/src/poller-claim.ts +154 -0
- package/src/poller-codex.ts +93 -0
- package/src/poller-commands.ts +78 -0
- package/src/poller-config.ts +188 -0
- package/src/poller-fix-outcome.ts +36 -0
- package/src/poller-fix-output.ts +135 -0
- package/src/poller-fix-publication.ts +187 -0
- package/src/poller-fix-run.ts +175 -0
- package/src/poller-fix-validation.ts +64 -0
- package/src/poller-github-publication.ts +23 -0
- package/src/poller-github-pulls.ts +189 -0
- package/src/poller-github-write.ts +87 -0
- package/src/poller-github.ts +190 -0
- package/src/poller-job-shared.ts +132 -0
- package/src/poller-outcome.ts +136 -0
- package/src/poller-output-integrity.ts +107 -0
- package/src/poller-prompts.ts +70 -0
- package/src/poller-protected-paths.ts +51 -0
- package/src/poller-protocol.ts +137 -0
- package/src/poller-review-artifacts.ts +119 -0
- package/src/poller-review-execution.ts +108 -0
- package/src/poller-review-output.ts +189 -0
- package/src/poller-review-publication.ts +153 -0
- package/src/poller-review-run.ts +160 -0
- package/src/poller-review-state.ts +126 -0
- package/src/poller-review-validation.ts +66 -0
- package/src/poller-schedule.ts +121 -0
- package/src/poller-tick.ts +144 -0
- package/src/poller-trust.ts +96 -0
- package/src/poller-units.ts +82 -0
- package/src/poller-workspace.ts +199 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Host-level poller configuration. The poller is host infrastructure, not
|
|
2
|
+
// repository state: one config file on the polling host lists every watched
|
|
3
|
+
// repository, and all workflow state lives in GitHub labels and comments so
|
|
4
|
+
// ticks are stateless and safe to re-run.
|
|
5
|
+
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
9
|
+
|
|
10
|
+
const REPO_PATTERN =
|
|
11
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/u;
|
|
12
|
+
|
|
13
|
+
const CONFIG_KEYS: ReadonlySet<string> = new Set([
|
|
14
|
+
'repos',
|
|
15
|
+
'model',
|
|
16
|
+
'reasoningEffort',
|
|
17
|
+
// biome-ignore lint/security/noSecrets: a config key name, not a credential.
|
|
18
|
+
'maxJobsPerTick',
|
|
19
|
+
'staleClaimHours',
|
|
20
|
+
'runTimeoutMinutes',
|
|
21
|
+
'cacheDir',
|
|
22
|
+
'extraCodexArgs',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_JOBS_PER_TICK = 1;
|
|
26
|
+
const DEFAULT_STALE_CLAIM_HOURS = 6;
|
|
27
|
+
// Thorough review runs legitimately take hours; the timeout exists to catch
|
|
28
|
+
// wedged agents, not to bound honest work.
|
|
29
|
+
const DEFAULT_RUN_TIMEOUT_MINUTES = 240;
|
|
30
|
+
const MINUTES_PER_HOUR = 60;
|
|
31
|
+
|
|
32
|
+
export type PollerConfig = {
|
|
33
|
+
readonly repos: ReadonlyArray<string>;
|
|
34
|
+
readonly model: string;
|
|
35
|
+
readonly reasoningEffort: string;
|
|
36
|
+
readonly maxJobsPerTick: number;
|
|
37
|
+
readonly staleClaimHours: number;
|
|
38
|
+
readonly runTimeoutMinutes: number;
|
|
39
|
+
readonly cacheDir: string;
|
|
40
|
+
readonly extraCodexArgs: ReadonlyArray<string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PollerConfigResult = {
|
|
44
|
+
readonly config: PollerConfig | null;
|
|
45
|
+
readonly problems: ReadonlyArray<string>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const expandHome = (path: string): string =>
|
|
49
|
+
path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path;
|
|
50
|
+
|
|
51
|
+
const parseRepos = (
|
|
52
|
+
raw: unknown,
|
|
53
|
+
problems: Array<string>,
|
|
54
|
+
): ReadonlyArray<string> => {
|
|
55
|
+
if (!(Array.isArray(raw) && raw.every((repo) => typeof repo === 'string'))) {
|
|
56
|
+
problems.push('poller config "repos" must be a string array');
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
if (raw.length === 0) {
|
|
60
|
+
problems.push('poller config "repos" must list at least one repository');
|
|
61
|
+
}
|
|
62
|
+
for (const repo of raw) {
|
|
63
|
+
if (!REPO_PATTERN.test(repo)) {
|
|
64
|
+
problems.push(
|
|
65
|
+
`poller config "repos" entries must be "owner/repo": ${repo}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (new Set(raw).size !== raw.length) {
|
|
70
|
+
problems.push('poller config "repos" entries must be unique');
|
|
71
|
+
}
|
|
72
|
+
return raw;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const parsePositiveInteger = (
|
|
76
|
+
raw: unknown,
|
|
77
|
+
field: string,
|
|
78
|
+
fallback: number,
|
|
79
|
+
problems: Array<string>,
|
|
80
|
+
): number => {
|
|
81
|
+
if (raw === undefined) {
|
|
82
|
+
return fallback;
|
|
83
|
+
}
|
|
84
|
+
if (!(typeof raw === 'number' && Number.isInteger(raw) && raw > 0)) {
|
|
85
|
+
problems.push(`poller config "${field}" must be a positive integer`);
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
return raw;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const parseRequiredString = (
|
|
92
|
+
raw: unknown,
|
|
93
|
+
field: string,
|
|
94
|
+
problems: Array<string>,
|
|
95
|
+
): string => {
|
|
96
|
+
if (!isNonEmptyString(raw)) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`poller config "${field}" must be a non-empty string; the model choice is deliberate and has no default`,
|
|
99
|
+
);
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
return raw;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const parseExtraCodexArgs = (
|
|
106
|
+
raw: unknown,
|
|
107
|
+
problems: Array<string>,
|
|
108
|
+
): ReadonlyArray<string> => {
|
|
109
|
+
if (raw === undefined) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (!(Array.isArray(raw) && raw.every((arg) => typeof arg === 'string'))) {
|
|
113
|
+
problems.push('poller config "extraCodexArgs" must be a string array');
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
return raw;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const parseCacheDir = (
|
|
120
|
+
raw: unknown,
|
|
121
|
+
configDir: string,
|
|
122
|
+
problems: Array<string>,
|
|
123
|
+
): string => {
|
|
124
|
+
if (raw === undefined) {
|
|
125
|
+
return join(homedir(), '.cache', 'standards-poller');
|
|
126
|
+
}
|
|
127
|
+
if (!isNonEmptyString(raw)) {
|
|
128
|
+
problems.push('poller config "cacheDir" must be a non-empty string');
|
|
129
|
+
return join(homedir(), '.cache', 'standards-poller');
|
|
130
|
+
}
|
|
131
|
+
const expanded = expandHome(raw);
|
|
132
|
+
return isAbsolute(expanded) ? expanded : resolve(configDir, expanded);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Reject unknown keys so a typo fails loudly instead of silently using a
|
|
136
|
+
// default, mirroring the sync policy and settings parsers.
|
|
137
|
+
export const parsePollerConfig = (
|
|
138
|
+
raw: unknown,
|
|
139
|
+
configDir: string,
|
|
140
|
+
): PollerConfigResult => {
|
|
141
|
+
if (!isRecord(raw)) {
|
|
142
|
+
return { config: null, problems: ['poller config must be a JSON object'] };
|
|
143
|
+
}
|
|
144
|
+
const problems: Array<string> = [];
|
|
145
|
+
for (const key of Object.keys(raw)) {
|
|
146
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
147
|
+
problems.push(`poller config has unknown key "${key}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const config: PollerConfig = {
|
|
151
|
+
repos: parseRepos(raw.repos, problems),
|
|
152
|
+
model: parseRequiredString(raw.model, 'model', problems),
|
|
153
|
+
reasoningEffort: parseRequiredString(
|
|
154
|
+
raw.reasoningEffort,
|
|
155
|
+
'reasoningEffort',
|
|
156
|
+
problems,
|
|
157
|
+
),
|
|
158
|
+
maxJobsPerTick: parsePositiveInteger(
|
|
159
|
+
raw.maxJobsPerTick,
|
|
160
|
+
// biome-ignore lint/security/noSecrets: a config key name, not a credential.
|
|
161
|
+
'maxJobsPerTick',
|
|
162
|
+
DEFAULT_MAX_JOBS_PER_TICK,
|
|
163
|
+
problems,
|
|
164
|
+
),
|
|
165
|
+
staleClaimHours: parsePositiveInteger(
|
|
166
|
+
raw.staleClaimHours,
|
|
167
|
+
'staleClaimHours',
|
|
168
|
+
DEFAULT_STALE_CLAIM_HOURS,
|
|
169
|
+
problems,
|
|
170
|
+
),
|
|
171
|
+
runTimeoutMinutes: parsePositiveInteger(
|
|
172
|
+
raw.runTimeoutMinutes,
|
|
173
|
+
'runTimeoutMinutes',
|
|
174
|
+
DEFAULT_RUN_TIMEOUT_MINUTES,
|
|
175
|
+
problems,
|
|
176
|
+
),
|
|
177
|
+
cacheDir: parseCacheDir(raw.cacheDir, configDir, problems),
|
|
178
|
+
extraCodexArgs: parseExtraCodexArgs(raw.extraCodexArgs, problems),
|
|
179
|
+
};
|
|
180
|
+
if (config.staleClaimHours * MINUTES_PER_HOUR <= config.runTimeoutMinutes) {
|
|
181
|
+
problems.push(
|
|
182
|
+
'poller config "staleClaimHours" must exceed "runTimeoutMinutes": a shorter stale sweep would release the claim of a job that is still running',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return problems.length > 0
|
|
186
|
+
? { config: null, problems }
|
|
187
|
+
: { config, problems: [] };
|
|
188
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FixPublication } from './poller-fix-publication';
|
|
2
|
+
import { createComment } from './poller-github-write';
|
|
3
|
+
import {
|
|
4
|
+
askQuestion,
|
|
5
|
+
failJob,
|
|
6
|
+
type JobLabels,
|
|
7
|
+
releaseLabels,
|
|
8
|
+
} from './poller-job-shared';
|
|
9
|
+
import type { FixOutcome } from './poller-protocol';
|
|
10
|
+
|
|
11
|
+
export const handleNonFixedOutcome = async (
|
|
12
|
+
job: FixPublication,
|
|
13
|
+
labels: JobLabels,
|
|
14
|
+
outcome: FixOutcome,
|
|
15
|
+
): Promise<string | null> => {
|
|
16
|
+
const { deps, issue } = job;
|
|
17
|
+
if (outcome.status === 'question') {
|
|
18
|
+
await askQuestion(deps, labels, issue.number, outcome.question ?? '');
|
|
19
|
+
return `#${issue.number}: asked a question`;
|
|
20
|
+
}
|
|
21
|
+
if (outcome.status === 'stale') {
|
|
22
|
+
await createComment(
|
|
23
|
+
deps.token,
|
|
24
|
+
deps.repo,
|
|
25
|
+
issue.number,
|
|
26
|
+
`The finding no longer reproduces on ${job.defaultBranch}: ${outcome.summary}\nClosing is left to a maintainer.`,
|
|
27
|
+
);
|
|
28
|
+
await releaseLabels(deps, labels, issue.number);
|
|
29
|
+
return `#${issue.number}: stale, needs human close`;
|
|
30
|
+
}
|
|
31
|
+
if (outcome.status === 'cannot-fix') {
|
|
32
|
+
await failJob(deps, labels, issue.number, outcome.summary);
|
|
33
|
+
return `#${issue.number}: cannot fix`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { isRecord } from './github-settings-parse';
|
|
3
|
+
import {
|
|
4
|
+
assertCleanOutputWorktree,
|
|
5
|
+
commitCountBetween,
|
|
6
|
+
isAncestor,
|
|
7
|
+
isGitObjectId,
|
|
8
|
+
singleParentOf,
|
|
9
|
+
} from './poller-output-integrity';
|
|
10
|
+
import { FIX_OUTPUT_MARKER } from './poller-protocol';
|
|
11
|
+
import { runGit } from './poller-workspace';
|
|
12
|
+
|
|
13
|
+
export type SealedFixOutput = {
|
|
14
|
+
readonly repo: string;
|
|
15
|
+
readonly issueNumber: number;
|
|
16
|
+
readonly approvalId: string;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly body: string;
|
|
19
|
+
readonly baseSha: string;
|
|
20
|
+
readonly commits: number;
|
|
21
|
+
readonly generatedHead: string;
|
|
22
|
+
readonly sealedHead: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const fixOutputMessage = (
|
|
26
|
+
output: Omit<SealedFixOutput, 'sealedHead'>,
|
|
27
|
+
): string =>
|
|
28
|
+
`${FIX_OUTPUT_MARKER}\n${Buffer.from(JSON.stringify(output)).toString('base64url')}`;
|
|
29
|
+
|
|
30
|
+
const parseFixOutputMessage = (
|
|
31
|
+
message: string,
|
|
32
|
+
sealedHead: string,
|
|
33
|
+
): SealedFixOutput | null => {
|
|
34
|
+
const [marker, encoded] = message.trim().split('\n');
|
|
35
|
+
if (marker !== FIX_OUTPUT_MARKER || encoded === undefined) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(
|
|
40
|
+
Buffer.from(encoded, 'base64url').toString('utf8'),
|
|
41
|
+
) as unknown;
|
|
42
|
+
if (
|
|
43
|
+
!isRecord(raw) ||
|
|
44
|
+
typeof raw.repo !== 'string' ||
|
|
45
|
+
typeof raw.issueNumber !== 'number' ||
|
|
46
|
+
typeof raw.approvalId !== 'string' ||
|
|
47
|
+
typeof raw.title !== 'string' ||
|
|
48
|
+
typeof raw.body !== 'string' ||
|
|
49
|
+
typeof raw.baseSha !== 'string' ||
|
|
50
|
+
typeof raw.commits !== 'number' ||
|
|
51
|
+
!Number.isInteger(raw.commits) ||
|
|
52
|
+
raw.commits < 1 ||
|
|
53
|
+
typeof raw.generatedHead !== 'string' ||
|
|
54
|
+
!isGitObjectId(raw.baseSha) ||
|
|
55
|
+
!isGitObjectId(raw.generatedHead)
|
|
56
|
+
) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { ...raw, sealedHead } as SealedFixOutput;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const sealFixOutput = (
|
|
66
|
+
workDir: string,
|
|
67
|
+
output: Omit<SealedFixOutput, 'generatedHead' | 'sealedHead'>,
|
|
68
|
+
): SealedFixOutput => {
|
|
69
|
+
assertCleanOutputWorktree(workDir);
|
|
70
|
+
const generatedHead = runGit(
|
|
71
|
+
['-C', workDir, 'rev-parse', 'HEAD'],
|
|
72
|
+
null,
|
|
73
|
+
).trim();
|
|
74
|
+
runGit(
|
|
75
|
+
[
|
|
76
|
+
'-C',
|
|
77
|
+
workDir,
|
|
78
|
+
'-c',
|
|
79
|
+
'user.name=standards-poller',
|
|
80
|
+
'-c',
|
|
81
|
+
'user.email=standards-poller@users.noreply.github.com',
|
|
82
|
+
'-c',
|
|
83
|
+
'commit.gpgSign=false',
|
|
84
|
+
'commit',
|
|
85
|
+
'--allow-empty',
|
|
86
|
+
'--only',
|
|
87
|
+
'-m',
|
|
88
|
+
fixOutputMessage({ ...output, generatedHead }),
|
|
89
|
+
],
|
|
90
|
+
null,
|
|
91
|
+
);
|
|
92
|
+
const sealedHead = runGit(['-C', workDir, 'rev-parse', 'HEAD'], null).trim();
|
|
93
|
+
return { ...output, generatedHead, sealedHead };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const readSealedFixOutput = (
|
|
97
|
+
cloneDir: string,
|
|
98
|
+
branch: string,
|
|
99
|
+
): SealedFixOutput | null => {
|
|
100
|
+
try {
|
|
101
|
+
const sealedHead = runGit(
|
|
102
|
+
['-C', cloneDir, 'rev-parse', `refs/heads/${branch}`],
|
|
103
|
+
null,
|
|
104
|
+
).trim();
|
|
105
|
+
const changed = runGit(
|
|
106
|
+
[
|
|
107
|
+
'-C',
|
|
108
|
+
cloneDir,
|
|
109
|
+
'diff-tree',
|
|
110
|
+
'--no-commit-id',
|
|
111
|
+
'--name-only',
|
|
112
|
+
'-r',
|
|
113
|
+
sealedHead,
|
|
114
|
+
],
|
|
115
|
+
null,
|
|
116
|
+
).trim();
|
|
117
|
+
const parsed = parseFixOutputMessage(
|
|
118
|
+
runGit(['-C', cloneDir, 'log', '-1', '--format=%B', sealedHead], null),
|
|
119
|
+
sealedHead,
|
|
120
|
+
);
|
|
121
|
+
if (
|
|
122
|
+
changed.length > 0 ||
|
|
123
|
+
parsed === null ||
|
|
124
|
+
singleParentOf(cloneDir, sealedHead) !== parsed.generatedHead ||
|
|
125
|
+
!isAncestor(cloneDir, parsed.baseSha, parsed.generatedHead) ||
|
|
126
|
+
commitCountBetween(cloneDir, parsed.baseSha, parsed.generatedHead) !==
|
|
127
|
+
parsed.commits
|
|
128
|
+
) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return parsed;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { issueRevision } from './poller-approval';
|
|
2
|
+
import { type ClaimBinding, validateClaim } from './poller-claim';
|
|
3
|
+
import { type SealedFixOutput, sealFixOutput } from './poller-fix-output';
|
|
4
|
+
import { validateSealedFixOutput } from './poller-fix-validation';
|
|
5
|
+
import { getIssue, type IssueItem } from './poller-github';
|
|
6
|
+
import {
|
|
7
|
+
createDraftPullRequest,
|
|
8
|
+
findOpenPullRequestForHead,
|
|
9
|
+
getPullRequest,
|
|
10
|
+
updatePullRequest,
|
|
11
|
+
} from './poller-github-pulls';
|
|
12
|
+
import { createComment } from './poller-github-write';
|
|
13
|
+
import {
|
|
14
|
+
failJob,
|
|
15
|
+
type JobDeps,
|
|
16
|
+
type JobLabels,
|
|
17
|
+
releaseLabels,
|
|
18
|
+
} from './poller-job-shared';
|
|
19
|
+
import {
|
|
20
|
+
changedWorkspaceQualityManifests,
|
|
21
|
+
lockedPathsOf,
|
|
22
|
+
} from './poller-protected-paths';
|
|
23
|
+
import {
|
|
24
|
+
APPROVED_FOR_REVIEW,
|
|
25
|
+
type FixOutcome,
|
|
26
|
+
forbiddenDiffPaths,
|
|
27
|
+
} from './poller-protocol';
|
|
28
|
+
import {
|
|
29
|
+
changedPaths,
|
|
30
|
+
commitCount,
|
|
31
|
+
pushBranch,
|
|
32
|
+
type Workspace,
|
|
33
|
+
} from './poller-workspace';
|
|
34
|
+
|
|
35
|
+
export type FixPublication = {
|
|
36
|
+
readonly deps: JobDeps;
|
|
37
|
+
readonly issue: IssueItem;
|
|
38
|
+
readonly defaultBranch: string;
|
|
39
|
+
readonly claim: ClaimBinding;
|
|
40
|
+
readonly branch: string;
|
|
41
|
+
readonly cloneDir: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const fixPullRequestBody = (output: SealedFixOutput): string =>
|
|
45
|
+
`<!-- standards-poller:fix issue=${output.issueNumber} approval=${output.approvalId} head=${output.sealedHead} -->\n${output.body}`;
|
|
46
|
+
|
|
47
|
+
export const validateFixClaim = async (
|
|
48
|
+
job: Pick<FixPublication, 'deps' | 'issue' | 'claim'>,
|
|
49
|
+
): Promise<void> => {
|
|
50
|
+
const current = await getIssue(
|
|
51
|
+
job.deps.token,
|
|
52
|
+
job.deps.repo,
|
|
53
|
+
job.issue.number,
|
|
54
|
+
);
|
|
55
|
+
const problem = await validateClaim(
|
|
56
|
+
{
|
|
57
|
+
token: job.deps.token,
|
|
58
|
+
repo: job.deps.repo,
|
|
59
|
+
issueNumber: job.issue.number,
|
|
60
|
+
},
|
|
61
|
+
job.claim,
|
|
62
|
+
issueRevision(current),
|
|
63
|
+
);
|
|
64
|
+
if (problem !== null) {
|
|
65
|
+
throw new Error(`publication blocked: ${problem}`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const publishFixedOutput = async (
|
|
70
|
+
job: FixPublication,
|
|
71
|
+
labels: JobLabels,
|
|
72
|
+
output: SealedFixOutput,
|
|
73
|
+
push: (() => void) | null,
|
|
74
|
+
): Promise<string> => {
|
|
75
|
+
const { deps, issue, branch } = job;
|
|
76
|
+
await validateSealedFixOutput(
|
|
77
|
+
{
|
|
78
|
+
deps: job.deps,
|
|
79
|
+
issueNumber: job.issue.number,
|
|
80
|
+
defaultBranch: job.defaultBranch,
|
|
81
|
+
approvalId: job.claim.approval.id,
|
|
82
|
+
cloneDir: job.cloneDir,
|
|
83
|
+
},
|
|
84
|
+
output,
|
|
85
|
+
);
|
|
86
|
+
await validateFixClaim(job);
|
|
87
|
+
push?.();
|
|
88
|
+
await validateFixClaim(job);
|
|
89
|
+
const expectedBody = fixPullRequestBody(output);
|
|
90
|
+
const existing = await findOpenPullRequestForHead(
|
|
91
|
+
deps.token,
|
|
92
|
+
deps.repo,
|
|
93
|
+
branch,
|
|
94
|
+
);
|
|
95
|
+
let prNumber: number;
|
|
96
|
+
if (existing === null) {
|
|
97
|
+
prNumber = await createDraftPullRequest(deps.token, deps.repo, {
|
|
98
|
+
title: output.title,
|
|
99
|
+
body: expectedBody,
|
|
100
|
+
head: branch,
|
|
101
|
+
base: job.defaultBranch,
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
const pr = await getPullRequest(deps.token, deps.repo, existing);
|
|
105
|
+
if (
|
|
106
|
+
pr.headRepo !== deps.repo ||
|
|
107
|
+
pr.headRef !== branch ||
|
|
108
|
+
pr.headSha !== output.sealedHead ||
|
|
109
|
+
pr.baseRef !== job.defaultBranch ||
|
|
110
|
+
!pr.body.startsWith(
|
|
111
|
+
`<!-- standards-poller:fix issue=${issue.number} approval=${job.claim.approval.id} `,
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`existing PR #${existing} does not prove ownership of ${branch}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
await validateFixClaim(job);
|
|
119
|
+
await updatePullRequest(deps.token, deps.repo, existing, {
|
|
120
|
+
title: output.title,
|
|
121
|
+
body: expectedBody,
|
|
122
|
+
});
|
|
123
|
+
prNumber = existing;
|
|
124
|
+
}
|
|
125
|
+
await validateFixClaim(job);
|
|
126
|
+
await createComment(
|
|
127
|
+
deps.token,
|
|
128
|
+
deps.repo,
|
|
129
|
+
issue.number,
|
|
130
|
+
`Opened draft PR #${prNumber} for this issue. Apply \`${APPROVED_FOR_REVIEW}\` on the PR to run the automated review-fix pass, or review it directly.`,
|
|
131
|
+
);
|
|
132
|
+
await validateFixClaim(job);
|
|
133
|
+
await releaseLabels(deps, labels, issue.number);
|
|
134
|
+
return `#${issue.number}: opened draft PR #${prNumber}`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const finishFixedJob = async (
|
|
138
|
+
job: FixPublication & { readonly workspace: Workspace },
|
|
139
|
+
labels: JobLabels,
|
|
140
|
+
outcome: FixOutcome,
|
|
141
|
+
): Promise<string> => {
|
|
142
|
+
const { deps, issue, workspace, branch } = job;
|
|
143
|
+
if (commitCount(workspace.dir, workspace.baseSha) === 0) {
|
|
144
|
+
await failJob(
|
|
145
|
+
deps,
|
|
146
|
+
labels,
|
|
147
|
+
issue.number,
|
|
148
|
+
'run reported "fixed" but produced no commits',
|
|
149
|
+
);
|
|
150
|
+
return `#${issue.number}: failed (fixed without commits)`;
|
|
151
|
+
}
|
|
152
|
+
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
|
+
];
|
|
161
|
+
if (forbidden.length > 0) {
|
|
162
|
+
await failJob(
|
|
163
|
+
deps,
|
|
164
|
+
labels,
|
|
165
|
+
issue.number,
|
|
166
|
+
`the fix modified protected paths, which automation must never do:\n${forbidden.map((path) => `- ${path}`).join('\n')}`,
|
|
167
|
+
);
|
|
168
|
+
return `#${issue.number}: failed (protected paths: ${forbidden.join(', ')})`;
|
|
169
|
+
}
|
|
170
|
+
const sealed = sealFixOutput(workspace.dir, {
|
|
171
|
+
repo: deps.repo,
|
|
172
|
+
issueNumber: issue.number,
|
|
173
|
+
approvalId: job.claim.approval.id,
|
|
174
|
+
title: outcome.prTitle ?? '',
|
|
175
|
+
body: outcome.prBody ?? '',
|
|
176
|
+
baseSha: workspace.baseSha,
|
|
177
|
+
commits: commitCount(workspace.dir, workspace.baseSha),
|
|
178
|
+
});
|
|
179
|
+
return publishFixedOutput(job, labels, sealed, () =>
|
|
180
|
+
pushBranch(workspace.dir, {
|
|
181
|
+
repo: deps.repo,
|
|
182
|
+
branch,
|
|
183
|
+
token: deps.token,
|
|
184
|
+
expectedRemoteSha: '',
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
};
|