@davidvornholt/standards 0.10.1 → 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 +85 -13
- 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,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
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// One fix job: a maintainer-approved issue becomes a verified draft PR, a
|
|
2
|
+
// precise question, or an explicit failure — never silence. Every transition
|
|
3
|
+
// is written back to GitHub so the next tick (or a human) resumes from there.
|
|
4
|
+
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { issueRevision } from './poller-approval';
|
|
7
|
+
import { acquireClaim } from './poller-claim';
|
|
8
|
+
import { runCodex } from './poller-codex';
|
|
9
|
+
import { handleNonFixedOutcome } from './poller-fix-outcome';
|
|
10
|
+
import { readSealedFixOutput } from './poller-fix-output';
|
|
11
|
+
import {
|
|
12
|
+
type FixPublication,
|
|
13
|
+
finishFixedJob,
|
|
14
|
+
publishFixedOutput,
|
|
15
|
+
validateFixClaim,
|
|
16
|
+
} from './poller-fix-publication';
|
|
17
|
+
import { getIssue, type IssueItem } from './poller-github';
|
|
18
|
+
import { addLabels } from './poller-github-write';
|
|
19
|
+
import {
|
|
20
|
+
failJob,
|
|
21
|
+
type JobDeps,
|
|
22
|
+
type JobLabels,
|
|
23
|
+
type JobResult,
|
|
24
|
+
jobPreamble,
|
|
25
|
+
} from './poller-job-shared';
|
|
26
|
+
import { readFixOutcome } from './poller-outcome';
|
|
27
|
+
import { fixPrompt } from './poller-prompts';
|
|
28
|
+
import {
|
|
29
|
+
APPROVED_FOR_FIX,
|
|
30
|
+
branchNameForIssue,
|
|
31
|
+
FIX_FAILED,
|
|
32
|
+
FIX_IN_PROGRESS,
|
|
33
|
+
} from './poller-protocol';
|
|
34
|
+
import {
|
|
35
|
+
createWorktree,
|
|
36
|
+
ensureCacheClone,
|
|
37
|
+
localBranchExists,
|
|
38
|
+
type Workspace,
|
|
39
|
+
} from './poller-workspace';
|
|
40
|
+
|
|
41
|
+
const FIX_LABELS: JobLabels = {
|
|
42
|
+
approved: APPROVED_FOR_FIX,
|
|
43
|
+
inProgress: FIX_IN_PROGRESS,
|
|
44
|
+
failed: FIX_FAILED,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type FixJob = FixPublication & {
|
|
48
|
+
readonly workspace: Workspace;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const hasInvalidLocalOutput = (
|
|
52
|
+
sealed: ReturnType<typeof readSealedFixOutput>,
|
|
53
|
+
cloneDir: string,
|
|
54
|
+
branch: string,
|
|
55
|
+
): boolean => sealed === null && localBranchExists(cloneDir, branch);
|
|
56
|
+
|
|
57
|
+
export const runFixJob = async (
|
|
58
|
+
deps: JobDeps,
|
|
59
|
+
issue: IssueItem,
|
|
60
|
+
defaultBranch: string,
|
|
61
|
+
allowCodex = true,
|
|
62
|
+
): Promise<JobResult> => {
|
|
63
|
+
const { config, token, repo } = deps;
|
|
64
|
+
const currentIssue = await getIssue(token, repo, issue.number);
|
|
65
|
+
const preamble = await jobPreamble(
|
|
66
|
+
deps,
|
|
67
|
+
currentIssue,
|
|
68
|
+
FIX_LABELS,
|
|
69
|
+
issueRevision(currentIssue),
|
|
70
|
+
);
|
|
71
|
+
if (preamble.kind === 'rejected') {
|
|
72
|
+
return { lines: [`#${issue.number}: approval rejected`], ranCodex: false };
|
|
73
|
+
}
|
|
74
|
+
if (preamble.kind === 'waiting') {
|
|
75
|
+
return {
|
|
76
|
+
lines: [`#${issue.number}: waiting on an answer`],
|
|
77
|
+
ranCodex: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const cacheClone = ensureCacheClone(config.cacheDir, repo, token);
|
|
81
|
+
const branch = branchNameForIssue(issue.number, preamble.approval.id);
|
|
82
|
+
const sealed = readSealedFixOutput(cacheClone, branch);
|
|
83
|
+
if (hasInvalidLocalOutput(sealed, cacheClone, branch)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`refusing to overwrite ${branch}: it is not valid sealed output for this approval`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (sealed === null && !allowCodex) {
|
|
89
|
+
return {
|
|
90
|
+
lines: [`#${issue.number}: waiting for run capacity`],
|
|
91
|
+
ranCodex: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
await addLabels(token, repo, issue.number, [FIX_IN_PROGRESS]);
|
|
95
|
+
const claim = await acquireClaim(
|
|
96
|
+
{ token, repo, issueNumber: issue.number },
|
|
97
|
+
preamble.approval,
|
|
98
|
+
FIX_IN_PROGRESS,
|
|
99
|
+
);
|
|
100
|
+
if (claim === null) {
|
|
101
|
+
return {
|
|
102
|
+
lines: [`#${issue.number}: another poller owns the claim`],
|
|
103
|
+
ranCodex: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const resumableJob = {
|
|
107
|
+
deps,
|
|
108
|
+
issue: currentIssue,
|
|
109
|
+
defaultBranch,
|
|
110
|
+
claim,
|
|
111
|
+
branch,
|
|
112
|
+
cloneDir: cacheClone,
|
|
113
|
+
};
|
|
114
|
+
if (sealed !== null) {
|
|
115
|
+
if (
|
|
116
|
+
sealed.issueNumber !== issue.number ||
|
|
117
|
+
sealed.approvalId !== claim.approval.id
|
|
118
|
+
) {
|
|
119
|
+
throw new Error(`sealed output on ${branch} has invalid ownership`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
lines: [await publishFixedOutput(resumableJob, FIX_LABELS, sealed, null)],
|
|
123
|
+
ranCodex: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const workspace = createWorktree(
|
|
127
|
+
cacheClone,
|
|
128
|
+
defaultBranch,
|
|
129
|
+
branch,
|
|
130
|
+
join(
|
|
131
|
+
config.cacheDir,
|
|
132
|
+
'work',
|
|
133
|
+
`${repo.replace('/', '--')}-issue-${issue.number}`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
const job: FixJob = {
|
|
137
|
+
...resumableJob,
|
|
138
|
+
issue: currentIssue,
|
|
139
|
+
workspace,
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
const run = runCodex(
|
|
143
|
+
workspace.dir,
|
|
144
|
+
fixPrompt({
|
|
145
|
+
repo,
|
|
146
|
+
issueNumber: issue.number,
|
|
147
|
+
title: currentIssue.title,
|
|
148
|
+
body: currentIssue.body,
|
|
149
|
+
answers: preamble.answers,
|
|
150
|
+
}),
|
|
151
|
+
config,
|
|
152
|
+
);
|
|
153
|
+
await validateFixClaim(job);
|
|
154
|
+
const outcome = run.succeeded ? await readFixOutcome(workspace.dir) : null;
|
|
155
|
+
if (outcome === null) {
|
|
156
|
+
await failJob(
|
|
157
|
+
deps,
|
|
158
|
+
FIX_LABELS,
|
|
159
|
+
issue.number,
|
|
160
|
+
run.failure ?? 'run wrote no valid outcome file',
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
lines: [`#${issue.number}: failed (no valid outcome)`],
|
|
164
|
+
ranCodex: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const nonFixed = await handleNonFixedOutcome(job, FIX_LABELS, outcome);
|
|
168
|
+
return {
|
|
169
|
+
lines: [nonFixed ?? (await finishFixedJob(job, FIX_LABELS, outcome))],
|
|
170
|
+
ranCodex: true,
|
|
171
|
+
};
|
|
172
|
+
} finally {
|
|
173
|
+
workspace.cleanup();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import type { SealedFixOutput } from './poller-fix-output';
|
|
3
|
+
import type { JobDeps } from './poller-job-shared';
|
|
4
|
+
import {
|
|
5
|
+
changedPathsBetween,
|
|
6
|
+
commitCountBetween,
|
|
7
|
+
createValidationWorktree,
|
|
8
|
+
isAncestor,
|
|
9
|
+
} from './poller-output-integrity';
|
|
10
|
+
import {
|
|
11
|
+
changedWorkspaceQualityManifests,
|
|
12
|
+
lockedPathsOf,
|
|
13
|
+
} from './poller-protected-paths';
|
|
14
|
+
import { forbiddenDiffPaths } from './poller-protocol';
|
|
15
|
+
|
|
16
|
+
export const validateSealedFixOutput = async (
|
|
17
|
+
job: {
|
|
18
|
+
readonly deps: JobDeps;
|
|
19
|
+
readonly issueNumber: number;
|
|
20
|
+
readonly defaultBranch: string;
|
|
21
|
+
readonly approvalId: string;
|
|
22
|
+
readonly cloneDir: string;
|
|
23
|
+
},
|
|
24
|
+
output: SealedFixOutput,
|
|
25
|
+
): Promise<void> => {
|
|
26
|
+
if (
|
|
27
|
+
output.repo !== job.deps.repo ||
|
|
28
|
+
output.issueNumber !== job.issueNumber ||
|
|
29
|
+
output.approvalId !== job.approvalId ||
|
|
30
|
+
!isAncestor(job.cloneDir, output.baseSha, job.defaultBranch) ||
|
|
31
|
+
!isAncestor(job.cloneDir, output.baseSha, output.generatedHead) ||
|
|
32
|
+
commitCountBetween(job.cloneDir, output.baseSha, output.generatedHead) !==
|
|
33
|
+
output.commits
|
|
34
|
+
) {
|
|
35
|
+
throw new Error('sealed fix output does not match this job');
|
|
36
|
+
}
|
|
37
|
+
const paths = changedPathsBetween(
|
|
38
|
+
job.cloneDir,
|
|
39
|
+
output.baseSha,
|
|
40
|
+
output.generatedHead,
|
|
41
|
+
);
|
|
42
|
+
const workspace = createValidationWorktree(
|
|
43
|
+
job.cloneDir,
|
|
44
|
+
output.generatedHead,
|
|
45
|
+
join(
|
|
46
|
+
job.deps.config.cacheDir,
|
|
47
|
+
'work',
|
|
48
|
+
`${job.deps.repo.replace('/', '--')}-issue-${job.issueNumber}-validate`,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
try {
|
|
52
|
+
const forbidden = [
|
|
53
|
+
...forbiddenDiffPaths(paths, await lockedPathsOf(workspace.dir)),
|
|
54
|
+
...changedWorkspaceQualityManifests(workspace.dir, output.baseSha, paths),
|
|
55
|
+
];
|
|
56
|
+
if (forbidden.length > 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`sealed fix output modified protected paths:\n${forbidden.join('\n')}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
workspace.cleanup();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { listAllPages } from './github-paginate';
|
|
2
|
+
import { isRecord } from './github-settings-parse';
|
|
3
|
+
|
|
4
|
+
export const repositoryIssueMarkerAuthors = async (
|
|
5
|
+
token: string | null,
|
|
6
|
+
repo: string,
|
|
7
|
+
marker: string,
|
|
8
|
+
): Promise<ReadonlyArray<string>> => {
|
|
9
|
+
const items = await listAllPages(
|
|
10
|
+
token,
|
|
11
|
+
`/repos/${repo}/issues?state=all`,
|
|
12
|
+
`list ${repo} issues for publication marker`,
|
|
13
|
+
);
|
|
14
|
+
return items.flatMap((item) =>
|
|
15
|
+
isRecord(item) &&
|
|
16
|
+
typeof item.body === 'string' &&
|
|
17
|
+
item.body.includes(marker) &&
|
|
18
|
+
isRecord(item.user) &&
|
|
19
|
+
typeof item.user.login === 'string'
|
|
20
|
+
? [item.user.login]
|
|
21
|
+
: [],
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Pull-request operations for the poller: creating the draft PR that carries
|
|
2
|
+
// a fix, reading PR state for review jobs, posting the review report, and
|
|
3
|
+
// flipping draft to ready (GraphQL: REST cannot change the draft flag).
|
|
4
|
+
|
|
5
|
+
import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
|
|
6
|
+
import { listAllPages } from './github-paginate';
|
|
7
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
8
|
+
|
|
9
|
+
export type PullRequest = {
|
|
10
|
+
readonly number: number;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly headRef: string;
|
|
14
|
+
readonly headSha: string;
|
|
15
|
+
readonly headRepo: string;
|
|
16
|
+
readonly baseRef: string;
|
|
17
|
+
readonly baseSha: string;
|
|
18
|
+
readonly nodeId: string;
|
|
19
|
+
readonly draft: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const findOpenPullRequestForHead = async (
|
|
23
|
+
token: string | null,
|
|
24
|
+
repo: string,
|
|
25
|
+
head: string,
|
|
26
|
+
): Promise<number | null> => {
|
|
27
|
+
const [owner = ''] = repo.split('/');
|
|
28
|
+
const response = await request(
|
|
29
|
+
token,
|
|
30
|
+
'GET',
|
|
31
|
+
`/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}`,
|
|
32
|
+
);
|
|
33
|
+
if (response.status !== HTTP_OK || !Array.isArray(response.body)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const [first] = response.body;
|
|
37
|
+
return isRecord(first) && typeof first.number === 'number'
|
|
38
|
+
? first.number
|
|
39
|
+
: null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const createDraftPullRequest = async (
|
|
43
|
+
token: string | null,
|
|
44
|
+
repo: string,
|
|
45
|
+
options: {
|
|
46
|
+
readonly title: string;
|
|
47
|
+
readonly body: string;
|
|
48
|
+
readonly head: string;
|
|
49
|
+
readonly base: string;
|
|
50
|
+
},
|
|
51
|
+
): Promise<number> => {
|
|
52
|
+
const response = await request(token, 'POST', `/repos/${repo}/pulls`, {
|
|
53
|
+
title: options.title,
|
|
54
|
+
body: options.body,
|
|
55
|
+
head: options.head,
|
|
56
|
+
base: options.base,
|
|
57
|
+
draft: true,
|
|
58
|
+
});
|
|
59
|
+
if (
|
|
60
|
+
response.status === HTTP_CREATED &&
|
|
61
|
+
isRecord(response.body) &&
|
|
62
|
+
typeof response.body.number === 'number'
|
|
63
|
+
) {
|
|
64
|
+
return response.body.number;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(apiError(`create draft PR in ${repo}`, response));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const updatePullRequest = async (
|
|
70
|
+
token: string | null,
|
|
71
|
+
repo: string,
|
|
72
|
+
prNumber: number,
|
|
73
|
+
options: { readonly title: string; readonly body: string },
|
|
74
|
+
): Promise<void> => {
|
|
75
|
+
const response = await request(
|
|
76
|
+
token,
|
|
77
|
+
'PATCH',
|
|
78
|
+
`/repos/${repo}/pulls/${prNumber}`,
|
|
79
|
+
options,
|
|
80
|
+
);
|
|
81
|
+
if (response.status !== HTTP_OK) {
|
|
82
|
+
throw new Error(apiError(`update ${repo}#${prNumber}`, response));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const getPullRequest = async (
|
|
87
|
+
token: string | null,
|
|
88
|
+
repo: string,
|
|
89
|
+
prNumber: number,
|
|
90
|
+
): Promise<PullRequest> => {
|
|
91
|
+
const response = await request(
|
|
92
|
+
token,
|
|
93
|
+
'GET',
|
|
94
|
+
`/repos/${repo}/pulls/${prNumber}`,
|
|
95
|
+
);
|
|
96
|
+
const { body } = response;
|
|
97
|
+
if (
|
|
98
|
+
response.status !== HTTP_OK ||
|
|
99
|
+
!isRecord(body) ||
|
|
100
|
+
!isRecord(body.head) ||
|
|
101
|
+
!isRecord(body.base) ||
|
|
102
|
+
!isNonEmptyString(body.node_id)
|
|
103
|
+
) {
|
|
104
|
+
throw new Error(apiError(`read ${repo}#${prNumber}`, response));
|
|
105
|
+
}
|
|
106
|
+
const headRepo = isRecord(body.head.repo)
|
|
107
|
+
? body.head.repo
|
|
108
|
+
: ({} as Record<string, unknown>);
|
|
109
|
+
return {
|
|
110
|
+
number: prNumber,
|
|
111
|
+
title: isNonEmptyString(body.title) ? body.title : '',
|
|
112
|
+
body: isNonEmptyString(body.body) ? body.body : '',
|
|
113
|
+
headRef: isNonEmptyString(body.head.ref) ? body.head.ref : '',
|
|
114
|
+
headSha: isNonEmptyString(body.head.sha) ? body.head.sha : '',
|
|
115
|
+
headRepo: isNonEmptyString(headRepo.full_name) ? headRepo.full_name : '',
|
|
116
|
+
baseRef: isNonEmptyString(body.base.ref) ? body.base.ref : '',
|
|
117
|
+
baseSha: isNonEmptyString(body.base.sha) ? body.base.sha : '',
|
|
118
|
+
nodeId: body.node_id,
|
|
119
|
+
draft: body.draft === true,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const createPullRequestReview = async (options: {
|
|
124
|
+
readonly token: string | null;
|
|
125
|
+
readonly repo: string;
|
|
126
|
+
readonly prNumber: number;
|
|
127
|
+
readonly body: string;
|
|
128
|
+
readonly commitId: string;
|
|
129
|
+
}): Promise<void> => {
|
|
130
|
+
const { token, repo, prNumber, body, commitId } = options;
|
|
131
|
+
const response = await request(
|
|
132
|
+
token,
|
|
133
|
+
'POST',
|
|
134
|
+
`/repos/${repo}/pulls/${prNumber}/reviews`,
|
|
135
|
+
{
|
|
136
|
+
event: 'COMMENT',
|
|
137
|
+
body,
|
|
138
|
+
// biome-ignore lint/style/useNamingConvention: GitHub's REST field is snake_case.
|
|
139
|
+
commit_id: commitId,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
if (response.status !== HTTP_OK) {
|
|
143
|
+
throw new Error(apiError(`post review on ${repo}#${prNumber}`, response));
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const pullRequestReviewMarkerAuthors = async (options: {
|
|
148
|
+
readonly token: string | null;
|
|
149
|
+
readonly repo: string;
|
|
150
|
+
readonly prNumber: number;
|
|
151
|
+
readonly marker: string;
|
|
152
|
+
readonly commitId: string;
|
|
153
|
+
}): Promise<ReadonlyArray<string>> => {
|
|
154
|
+
const { token, repo, prNumber, marker, commitId } = options;
|
|
155
|
+
const reviews = await listAllPages(
|
|
156
|
+
token,
|
|
157
|
+
`/repos/${repo}/pulls/${prNumber}/reviews`,
|
|
158
|
+
`list reviews on ${repo}#${prNumber}`,
|
|
159
|
+
);
|
|
160
|
+
return reviews.flatMap((review) =>
|
|
161
|
+
isRecord(review) &&
|
|
162
|
+
review.commit_id === commitId &&
|
|
163
|
+
typeof review.body === 'string' &&
|
|
164
|
+
review.body.includes(marker) &&
|
|
165
|
+
isRecord(review.user) &&
|
|
166
|
+
typeof review.user.login === 'string'
|
|
167
|
+
? [review.user.login]
|
|
168
|
+
: [],
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const markPullRequestReady = async (
|
|
173
|
+
token: string | null,
|
|
174
|
+
pullRequestNodeId: string,
|
|
175
|
+
): Promise<void> => {
|
|
176
|
+
const response = await request(token, 'POST', '/graphql', {
|
|
177
|
+
query:
|
|
178
|
+
// biome-ignore lint/security/noSecrets: a GraphQL mutation string, not a credential.
|
|
179
|
+
'mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { isDraft } } }',
|
|
180
|
+
variables: { id: pullRequestNodeId },
|
|
181
|
+
});
|
|
182
|
+
const succeeded =
|
|
183
|
+
response.status === HTTP_OK &&
|
|
184
|
+
isRecord(response.body) &&
|
|
185
|
+
response.body.errors === undefined;
|
|
186
|
+
if (!succeeded) {
|
|
187
|
+
throw new Error(apiError('mark PR ready for review', response));
|
|
188
|
+
}
|
|
189
|
+
};
|