@davidvornholt/standards 0.15.1 → 0.16.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 +2 -2
- package/package.json +6 -1
- package/src/cli.ts +27 -2
- package/src/poller-acknowledgements.ts +159 -0
- package/src/poller-claim.ts +60 -28
- package/src/poller-commands.ts +6 -2
- package/src/poller-comment-metadata.ts +25 -0
- package/src/poller-fix-run.ts +2 -0
- package/src/poller-github-write.ts +27 -1
- package/src/poller-protocol.ts +2 -1
- package/src/poller-queue-marker.ts +194 -0
- package/src/poller-review-eligibility.ts +33 -0
- package/src/poller-review-run.ts +11 -23
- package/src/poller-status.ts +133 -0
- package/src/poller-trust.ts +3 -3
- package/src/poller-units.ts +38 -7
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ standards poller --print-units --config <path>
|
|
|
35
35
|
|
|
36
36
|
`structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
|
|
37
37
|
|
|
38
|
-
`poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, grants Codex write access to that worktree and its exact Git common directory so commits can update the linked repository metadata, hands results back through a structured outcome file, and then verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits
|
|
38
|
+
`poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. A separate acknowledgement tick validates pending approvals and posts one deduplicated queued message per approval generation; it never claims or starts work, so it can keep giving maintainers prompt feedback while the worker is occupied. When a worker claims a request, it posts a concise start message. The exact approval and distributed claim records remain in hidden comment metadata, and losing claim contenders remove their duplicate comments. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, grants Codex write access to that worktree and its exact Git common directory so commits can update the linked repository metadata, hands results back through a structured outcome file, and then verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits four correctly escaped systemd units for the polling host's infrastructure repository to adapt and deploy: the worker service and timer plus an independent lightweight acknowledgement service and timer. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The worker service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
|
|
39
39
|
|
|
40
40
|
`creds` is the credential broker: it mints scoped Cloudflare tokens, rotates and revokes those tokens, and places GitHub App credentials into SOPS-encrypted targets — secret values never touch stdout, argv, or an agent's context; commands print only metadata (token name, scopes, expiry, destination). Cloudflare reconciliation has exactly two sources of truth: the plaintext key structure of the SOPS files and the provider token list under the deterministic naming scheme `standards/<owner>/<repo>/<sops-target>/<dotted.key>`; there is no separate credential manifest. `plan` and `apply` reconcile Cloudflare only: each run uses the stored bootstrap credential to verify its current provider token ID, excludes that ID from reconciliation regardless of its current name, and aborts if the identity cannot be correlated with the complete account token list; a brokered token whose SOPS key was deleted is revoked, while one nearing expiry (30 days) is renewed by creating a fresh-expiry replacement with the live token's policy, durably writing and verifying the new value, and only then revoking the old token; secrets the broker did not mint are never touched. Without a manifest there is no desired-policy record, so live policy drift is not detected, and a repository rename or transfer changes the deterministic namespace and requires the visible old token to be re-minted under the new name and revoked. `add cloudflare` mints an account-owned API token (default TTL 90 days) scoped to the named permission groups (`permissions` lists the valid names) and writes it to `--dest <target>:<dotted.key>`, where `<target>` resolves like the canonical `just secrets` targets (`ci` → `secrets/ci.yaml`, `prod-1` → `infra/hosts/prod-1/secrets.yaml`); zone-scoped groups such as DNS Write are rejected because the current command supplies an account resource, not a zone resource. `--bucket <name>` switches the policy resource to a single R2 bucket; `--jurisdiction default|eu` selects the provider jurisdiction and defaults to `default`, producing `com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>` or `com.cloudflare.edge.r2.bucket.<account_id>_eu_<bucket>`. FedRAMP jurisdictions are not supported. Bucket destinations accept only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write, and account-scoped groups are rejected. `--s3` requires `--bucket` and stores the S3-compatible credential pair Cloudflare derives from an API token — access key ID is the token ID, secret access key is the SHA-256 hex digest of the token value — as `<dotted.key>.access_key_id` and `<dotted.key>.secret_access_key` under the destination instead of the raw token value; the default endpoint is `https://<account_id>.r2.cloudflarestorage.com`, while EU uses `https://<account_id>.eu.r2.cloudflarestorage.com`. Reconciliation infers each destination's concrete write footprint from the plaintext SOPS key structure — a bearer owns the leaf at its key, while an S3 token owns both pair leaves — and aborts before mutation when managed footprints intersect. `plan` and `apply` renew and revoke S3 destinations like any other brokered token, but renewal first proves the stored access key ID matches the current provider token; an incomplete pair, unreadable value, or identity mismatch is surfaced as a finding and never mutated automatically. `add github` writes the broker GitHub App's `app_id` and `private_key` under the destination key; durable GitHub tokens cannot be created via API (PAT and OAuth-app creation are UI-only), so workflows mint short-lived installation tokens from the App at runtime (`actions/create-github-app-token`), scoped per repository and permission. The canonical weekly sync expects `bun standards creds add github --dest ci:ci.broker_app`, then mints a current-repository token with Contents read and Pull requests write; missing credentials or minting failures stop it without a fallback. Bootstrap is once per machine: `login github` runs the App manifest flow (a localhost page posts the pre-filled manifest; one "Create GitHub App" click; credentials return through the conversion endpoint — nothing is copied by hand) and opens the installation page, where the App must be bound to selected repositories rather than all repositories; `login cloudflare` guides the one dashboard paste Cloudflare requires for the first account token (named `standards-broker` to match the App, created from the "Create additional tokens" token template because its "Account API Tokens: Edit" permission is unavailable in the custom token builder, and holding that permission only), verifies that it is active, correlates its verified ID with the complete account token list and an active list record, and stores it; a bootstrap token named inside the minted `standards/` namespace is rejected before storage to keep its role unambiguous, while any other divergence from the recommended name only warns. `plan` and `apply` do not rotate GitHub App keys; create a replacement key on the App's settings page, atomically replace `github.private_key` in the protected machine-global broker store while preserving its `0600` mode, then update every intended SOPS destination, verify workflows can mint installation tokens, and revoke the old key manually. Root credentials live in the machine-global broker store (see Configuration); SOPS writes go through `sops edit` with a non-interactive editor, preserving comments and reusing the file's data key.
|
|
41
41
|
|
|
@@ -46,7 +46,7 @@ standards poller --print-units --config <path>
|
|
|
46
46
|
- **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
|
|
47
47
|
- **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run; deliberate syncs then run locally because the secret-bearing workflow is schedule-only. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
|
|
48
48
|
- **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
|
|
49
|
-
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum
|
|
49
|
+
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum and five-minute acknowledgement cooldown keep supported mixed fix/review queues and persisted review-plan recovery below the 5,000-request/hour GitHub API budget with at least 1,400 requests/hour reserved for pagination, active jobs, writes, and other token users; the compound regression bound covers four acknowledged fixes plus four acknowledged reviews with persisted plans in every repository at no more than 3,312 requests/hour. Existing configs above the repository limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the maximum number of new Codex runs the sequential worker may start in one tick), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
|
|
50
50
|
- **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding the GitHub App identity (id, slug, client id, private key) and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite each other; `creds status` warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the two login commands and revoke the old App key and bootstrap token. On an additional machine, do not create a second App: generate an additional private key from the existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key, not the setup.
|
|
51
51
|
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — ruleset `bypass_actors` requires repository Administration read, and private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml`: a repository-scoped fine-grained PAT with read-only "Administration" and "Issues" access.
|
|
52
52
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -78,9 +78,11 @@
|
|
|
78
78
|
"src/github-settings.ts",
|
|
79
79
|
"src/managed-files.ts",
|
|
80
80
|
"src/poller-approval.ts",
|
|
81
|
+
"src/poller-acknowledgements.ts",
|
|
81
82
|
"src/poller-claim.ts",
|
|
82
83
|
"src/poller-codex.ts",
|
|
83
84
|
"src/poller-commands.ts",
|
|
85
|
+
"src/poller-comment-metadata.ts",
|
|
84
86
|
"src/poller-config.ts",
|
|
85
87
|
"src/poller-fix-outcome.ts",
|
|
86
88
|
"src/poller-fix-output.ts",
|
|
@@ -97,13 +99,16 @@
|
|
|
97
99
|
"src/poller-prompts.ts",
|
|
98
100
|
"src/poller-protected-paths.ts",
|
|
99
101
|
"src/poller-protocol.ts",
|
|
102
|
+
"src/poller-queue-marker.ts",
|
|
100
103
|
"src/poller-review-publication.ts",
|
|
104
|
+
"src/poller-review-eligibility.ts",
|
|
101
105
|
"src/poller-review-output.ts",
|
|
102
106
|
"src/poller-review-artifacts.ts",
|
|
103
107
|
"src/poller-review-execution.ts",
|
|
104
108
|
"src/poller-review-state.ts",
|
|
105
109
|
"src/poller-review-validation.ts",
|
|
106
110
|
"src/poller-schedule.ts",
|
|
111
|
+
"src/poller-status.ts",
|
|
107
112
|
"src/poller-review-run.ts",
|
|
108
113
|
"src/poller-tick.ts",
|
|
109
114
|
"src/poller-trust.ts",
|
package/src/cli.ts
CHANGED
|
@@ -88,6 +88,7 @@ type CliOptions = {
|
|
|
88
88
|
readonly profile: StructureProfile;
|
|
89
89
|
readonly config: string | undefined;
|
|
90
90
|
readonly printUnits: boolean;
|
|
91
|
+
readonly acknowledgeOnly: boolean;
|
|
91
92
|
};
|
|
92
93
|
|
|
93
94
|
const sha256 = (buf: Buffer): string =>
|
|
@@ -755,7 +756,9 @@ Options:
|
|
|
755
756
|
--apply With github: converge the live repository (needs admin auth)
|
|
756
757
|
--write With dependabot: regenerate the composed .github/dependabot.yml
|
|
757
758
|
--config <path> With poller: the host-level poller config file (required)
|
|
758
|
-
--print-units With poller: print declarative systemd unit content without touching the host
|
|
759
|
+
--print-units With poller: print declarative systemd unit content without touching the host
|
|
760
|
+
--acknowledge-only
|
|
761
|
+
With poller: acknowledge queued requests without starting work`;
|
|
759
762
|
|
|
760
763
|
const commandFromArg = (arg: string): Command => {
|
|
761
764
|
if (
|
|
@@ -811,6 +814,7 @@ type ParsedFlags = {
|
|
|
811
814
|
readonly profile: StructureProfile | undefined;
|
|
812
815
|
readonly config: string | undefined;
|
|
813
816
|
readonly printUnits: boolean;
|
|
817
|
+
readonly acknowledgeOnly: boolean;
|
|
814
818
|
};
|
|
815
819
|
|
|
816
820
|
const assertPollerOptionUsage = (flags: ParsedFlags): void => {
|
|
@@ -820,6 +824,14 @@ const assertPollerOptionUsage = (flags: ParsedFlags): void => {
|
|
|
820
824
|
if (flags.printUnits && flags.command !== 'poller') {
|
|
821
825
|
throw new Error('--print-units is only valid with the poller command');
|
|
822
826
|
}
|
|
827
|
+
if (flags.acknowledgeOnly && flags.command !== 'poller') {
|
|
828
|
+
throw new Error('--acknowledge-only is only valid with the poller command');
|
|
829
|
+
}
|
|
830
|
+
if (flags.acknowledgeOnly && flags.printUnits) {
|
|
831
|
+
throw new Error(
|
|
832
|
+
'poller accepts only one of --acknowledge-only or --print-units',
|
|
833
|
+
);
|
|
834
|
+
}
|
|
823
835
|
};
|
|
824
836
|
|
|
825
837
|
// Every option is only meaningful with specific commands; reject the rest so a
|
|
@@ -864,6 +876,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
864
876
|
let profile: StructureProfile | undefined;
|
|
865
877
|
let config: string | undefined;
|
|
866
878
|
let printUnits = false;
|
|
879
|
+
let acknowledgeOnly = false;
|
|
867
880
|
|
|
868
881
|
for (let index = 0; index < argv.length; index += 1) {
|
|
869
882
|
const arg = argv[index];
|
|
@@ -871,6 +884,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
871
884
|
case '--apply':
|
|
872
885
|
apply = true;
|
|
873
886
|
break;
|
|
887
|
+
case '--acknowledge-only':
|
|
888
|
+
acknowledgeOnly = true;
|
|
889
|
+
break;
|
|
874
890
|
case '--config':
|
|
875
891
|
config = nextOptionValue(argv, index);
|
|
876
892
|
index += 1;
|
|
@@ -921,6 +937,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
921
937
|
profile,
|
|
922
938
|
config,
|
|
923
939
|
printUnits,
|
|
940
|
+
acknowledgeOnly,
|
|
924
941
|
});
|
|
925
942
|
|
|
926
943
|
return {
|
|
@@ -934,6 +951,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
934
951
|
profile: profile ?? 'consumer',
|
|
935
952
|
config,
|
|
936
953
|
printUnits,
|
|
954
|
+
acknowledgeOnly,
|
|
937
955
|
};
|
|
938
956
|
};
|
|
939
957
|
|
|
@@ -1164,6 +1182,7 @@ const main = async (): Promise<void> => {
|
|
|
1164
1182
|
profile,
|
|
1165
1183
|
config,
|
|
1166
1184
|
printUnits,
|
|
1185
|
+
acknowledgeOnly,
|
|
1167
1186
|
} = parseArgs(process.argv.slice(2));
|
|
1168
1187
|
|
|
1169
1188
|
if (command === undefined) {
|
|
@@ -1194,7 +1213,13 @@ const main = async (): Promise<void> => {
|
|
|
1194
1213
|
}
|
|
1195
1214
|
|
|
1196
1215
|
if (command === 'poller') {
|
|
1197
|
-
if (
|
|
1216
|
+
if (
|
|
1217
|
+
!(await runPollerCommand({
|
|
1218
|
+
configPath: config,
|
|
1219
|
+
printUnits,
|
|
1220
|
+
acknowledgeOnly,
|
|
1221
|
+
}))
|
|
1222
|
+
) {
|
|
1198
1223
|
process.exitCode = 1;
|
|
1199
1224
|
}
|
|
1200
1225
|
return;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
issueRevision,
|
|
3
|
+
prRevision,
|
|
4
|
+
readApprovalBinding,
|
|
5
|
+
} from './poller-approval';
|
|
6
|
+
import type { PollerConfig } from './poller-config';
|
|
7
|
+
import { type IssueItem, listOpenIssuesWithLabel } from './poller-github';
|
|
8
|
+
import { getPullRequest } from './poller-github-pulls';
|
|
9
|
+
import type { JobDeps } from './poller-job-shared';
|
|
10
|
+
import { APPROVED_FOR_FIX, APPROVED_FOR_REVIEW } from './poller-protocol';
|
|
11
|
+
import {
|
|
12
|
+
inspectQueuedAcknowledgement,
|
|
13
|
+
type PollerJobKind,
|
|
14
|
+
} from './poller-queue-marker';
|
|
15
|
+
import { reviewEligibility } from './poller-review-eligibility';
|
|
16
|
+
import { readReviewPlan } from './poller-review-state';
|
|
17
|
+
import { acknowledgeQueuedJob } from './poller-status';
|
|
18
|
+
import type { RoleCache } from './poller-trust';
|
|
19
|
+
|
|
20
|
+
export type AcknowledgementReport = {
|
|
21
|
+
readonly lines: ReadonlyArray<string>;
|
|
22
|
+
readonly problems: ReadonlyArray<string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const approvalFor = async (
|
|
26
|
+
deps: JobDeps,
|
|
27
|
+
item: IssueItem,
|
|
28
|
+
kind: PollerJobKind,
|
|
29
|
+
): Promise<Awaited<ReturnType<typeof readApprovalBinding>> | null> => {
|
|
30
|
+
let target: string;
|
|
31
|
+
if (kind === 'fix') {
|
|
32
|
+
target = issueRevision(item);
|
|
33
|
+
if (
|
|
34
|
+
await inspectQueuedAcknowledgement({
|
|
35
|
+
deps,
|
|
36
|
+
item,
|
|
37
|
+
kind,
|
|
38
|
+
target,
|
|
39
|
+
})
|
|
40
|
+
) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const pr = await getPullRequest(deps.token, deps.repo, item.number);
|
|
45
|
+
const currentTarget = prRevision(pr.baseRef, pr.baseSha, pr.headSha);
|
|
46
|
+
if (
|
|
47
|
+
pr.draft &&
|
|
48
|
+
pr.headRepo === deps.repo &&
|
|
49
|
+
(await inspectQueuedAcknowledgement({
|
|
50
|
+
deps,
|
|
51
|
+
item,
|
|
52
|
+
kind,
|
|
53
|
+
target: currentTarget,
|
|
54
|
+
}))
|
|
55
|
+
) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const plan = await readReviewPlan(deps, pr);
|
|
59
|
+
if (
|
|
60
|
+
reviewEligibility({
|
|
61
|
+
repo: deps.repo,
|
|
62
|
+
pr,
|
|
63
|
+
hasPlan: plan !== null,
|
|
64
|
+
}).kind !== 'eligible'
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
target = currentTarget;
|
|
69
|
+
}
|
|
70
|
+
return readApprovalBinding(
|
|
71
|
+
{
|
|
72
|
+
token: deps.token,
|
|
73
|
+
repo: deps.repo,
|
|
74
|
+
issueNumber: item.number,
|
|
75
|
+
},
|
|
76
|
+
kind === 'fix' ? APPROVED_FOR_FIX : APPROVED_FOR_REVIEW,
|
|
77
|
+
target,
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const acknowledgeItems = async (options: {
|
|
82
|
+
readonly deps: JobDeps;
|
|
83
|
+
readonly items: ReadonlyArray<IssueItem>;
|
|
84
|
+
readonly kind: PollerJobKind;
|
|
85
|
+
readonly lines: Array<string>;
|
|
86
|
+
readonly problems: Array<string>;
|
|
87
|
+
}): Promise<void> => {
|
|
88
|
+
const { deps, items, kind, lines, problems } = options;
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
try {
|
|
91
|
+
// biome-ignore lint/performance/noAwaitInLoops: acknowledgement writes are deliberately serialized to avoid GitHub secondary rate limits.
|
|
92
|
+
const approval = await approvalFor(deps, item, kind);
|
|
93
|
+
if (
|
|
94
|
+
approval !== null &&
|
|
95
|
+
typeof approval !== 'string' &&
|
|
96
|
+
(await acknowledgeQueuedJob(deps, item.number, approval, kind))
|
|
97
|
+
) {
|
|
98
|
+
lines.push(
|
|
99
|
+
`${deps.repo} ${kind === 'fix' ? `#${item.number}` : `PR #${item.number}`}: acknowledged as queued`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
problems.push(
|
|
104
|
+
`${deps.repo}#${item.number}: ${error instanceof Error ? error.message : String(error)}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const acknowledgeRepository = async (
|
|
111
|
+
deps: JobDeps,
|
|
112
|
+
lines: Array<string>,
|
|
113
|
+
problems: Array<string>,
|
|
114
|
+
): Promise<void> => {
|
|
115
|
+
const reviews = (
|
|
116
|
+
await listOpenIssuesWithLabel(deps.token, deps.repo, APPROVED_FOR_REVIEW)
|
|
117
|
+
).filter((item) => item.isPullRequest);
|
|
118
|
+
const fixes = (
|
|
119
|
+
await listOpenIssuesWithLabel(deps.token, deps.repo, APPROVED_FOR_FIX)
|
|
120
|
+
).filter((item) => !item.isPullRequest);
|
|
121
|
+
await acknowledgeItems({
|
|
122
|
+
deps,
|
|
123
|
+
items: reviews,
|
|
124
|
+
kind: 'review',
|
|
125
|
+
lines,
|
|
126
|
+
problems,
|
|
127
|
+
});
|
|
128
|
+
await acknowledgeItems({
|
|
129
|
+
deps,
|
|
130
|
+
items: fixes,
|
|
131
|
+
kind: 'fix',
|
|
132
|
+
lines,
|
|
133
|
+
problems,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const runPollerAcknowledgementTick = async (
|
|
138
|
+
config: PollerConfig,
|
|
139
|
+
token: string | null,
|
|
140
|
+
): Promise<AcknowledgementReport> => {
|
|
141
|
+
const lines: Array<string> = [];
|
|
142
|
+
const problems: Array<string> = [];
|
|
143
|
+
const roleCache: RoleCache = new Map();
|
|
144
|
+
for (const repo of config.repos) {
|
|
145
|
+
try {
|
|
146
|
+
// biome-ignore lint/performance/noAwaitInLoops: repository scans share a rate-limited GitHub token and keep writes serialized.
|
|
147
|
+
await acknowledgeRepository(
|
|
148
|
+
{ config, token, repo, roleCache },
|
|
149
|
+
lines,
|
|
150
|
+
problems,
|
|
151
|
+
);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
problems.push(
|
|
154
|
+
`${repo}: ${error instanceof Error ? error.message : String(error)}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { lines, problems };
|
|
159
|
+
};
|
package/src/poller-claim.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { hasLabel } from './github-label-identity';
|
|
3
|
+
import { isRecord } from './github-settings-parse';
|
|
3
4
|
import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
|
|
5
|
+
import {
|
|
6
|
+
hiddenCommentMetadata,
|
|
7
|
+
parseHiddenCommentMetadata,
|
|
8
|
+
} from './poller-comment-metadata';
|
|
4
9
|
import {
|
|
5
10
|
collaboratorRole,
|
|
6
11
|
getIssue,
|
|
@@ -8,8 +13,13 @@ import {
|
|
|
8
13
|
lastLabelEvent,
|
|
9
14
|
listIssueComments,
|
|
10
15
|
} from './poller-github';
|
|
11
|
-
import { createComment } from './poller-github-write';
|
|
12
|
-
import {
|
|
16
|
+
import { createComment, deleteComment } from './poller-github-write';
|
|
17
|
+
import {
|
|
18
|
+
CLAIM_METADATA_MARKER,
|
|
19
|
+
FIX_IN_PROGRESS,
|
|
20
|
+
isTrustedRole,
|
|
21
|
+
REVIEW_IN_PROGRESS,
|
|
22
|
+
} from './poller-protocol';
|
|
13
23
|
|
|
14
24
|
export type ClaimBinding = {
|
|
15
25
|
readonly approval: ApprovalBinding;
|
|
@@ -28,38 +38,56 @@ type ClaimContext = {
|
|
|
28
38
|
readonly issueNumber: number;
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
const startedMessage = (claimLabel: string) => {
|
|
42
|
+
if (claimLabel === FIX_IN_PROGRESS) {
|
|
43
|
+
return '**Fix started**\n\nThe poller is working on this issue now. It’ll open a draft pull request when the fix is ready, or ask here if it needs your input.';
|
|
44
|
+
}
|
|
45
|
+
if (claimLabel === REVIEW_IN_PROGRESS) {
|
|
46
|
+
return '**Review started**\n\nThe poller is reviewing this pull request now. It’ll post the results and take the pull request out of draft when it’s finished, or ask here if it needs your input.';
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`unsupported poller claim label: ${claimLabel}`);
|
|
49
|
+
};
|
|
50
|
+
|
|
31
51
|
const markerBody = (marker: ClaimMarker): string =>
|
|
32
|
-
`${
|
|
52
|
+
`${startedMessage(marker.claimLabel)}\n\n${hiddenCommentMetadata(
|
|
53
|
+
CLAIM_METADATA_MARKER,
|
|
54
|
+
marker,
|
|
55
|
+
)}`;
|
|
56
|
+
|
|
57
|
+
const isApprovalBinding = (value: unknown): value is ApprovalBinding =>
|
|
58
|
+
isRecord(value) &&
|
|
59
|
+
typeof value.id === 'string' &&
|
|
60
|
+
typeof value.repo === 'string' &&
|
|
61
|
+
typeof value.issueNumber === 'number' &&
|
|
62
|
+
typeof value.eventId === 'number' &&
|
|
63
|
+
typeof value.label === 'string' &&
|
|
64
|
+
typeof value.actorLogin === 'string' &&
|
|
65
|
+
typeof value.approvedAt === 'string' &&
|
|
66
|
+
typeof value.target === 'string';
|
|
33
67
|
|
|
34
68
|
const parseMarker = (comment: IssueComment): ClaimMarker | null => {
|
|
35
|
-
|
|
69
|
+
const payload = parseHiddenCommentMetadata(
|
|
70
|
+
comment.body,
|
|
71
|
+
CLAIM_METADATA_MARKER,
|
|
72
|
+
);
|
|
73
|
+
if (!isRecord(payload)) {
|
|
36
74
|
return null;
|
|
37
75
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
typeof raw.claimLabel !== 'string' ||
|
|
47
|
-
typeof raw.claimEpoch !== 'string' ||
|
|
48
|
-
typeof raw.nonce !== 'string' ||
|
|
49
|
-
raw.approval === undefined ||
|
|
50
|
-
typeof raw.approval.id !== 'string'
|
|
51
|
-
) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
approval: raw.approval,
|
|
56
|
-
claimLabel: raw.claimLabel,
|
|
57
|
-
claimEpoch: raw.claimEpoch,
|
|
58
|
-
nonce: raw.nonce,
|
|
59
|
-
};
|
|
60
|
-
} catch {
|
|
76
|
+
const raw = payload;
|
|
77
|
+
if (
|
|
78
|
+
typeof raw.claimLabel !== 'string' ||
|
|
79
|
+
typeof raw.claimEpoch !== 'string' ||
|
|
80
|
+
typeof raw.nonce !== 'string' ||
|
|
81
|
+
!isApprovalBinding(raw.approval)
|
|
82
|
+
) {
|
|
61
83
|
return null;
|
|
62
84
|
}
|
|
85
|
+
return {
|
|
86
|
+
approval: raw.approval,
|
|
87
|
+
claimLabel: raw.claimLabel,
|
|
88
|
+
claimEpoch: raw.claimEpoch,
|
|
89
|
+
nonce: raw.nonce,
|
|
90
|
+
};
|
|
63
91
|
};
|
|
64
92
|
|
|
65
93
|
const winningMarkerId = async (
|
|
@@ -120,7 +148,11 @@ export const acquireClaim = async (
|
|
|
120
148
|
markerBody({ ...provisional, nonce }),
|
|
121
149
|
);
|
|
122
150
|
const winner = await winningMarkerId(context, provisional);
|
|
123
|
-
|
|
151
|
+
if (winner !== markerId) {
|
|
152
|
+
await deleteComment(context.token, context.repo, markerId);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return { ...provisional, markerId };
|
|
124
156
|
};
|
|
125
157
|
|
|
126
158
|
export const validateClaim = async (
|
package/src/poller-commands.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
7
|
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { resolveToken } from './github-api';
|
|
9
|
+
import { runPollerAcknowledgementTick } from './poller-acknowledgements';
|
|
9
10
|
import { type PollerConfig, parsePollerConfig } from './poller-config';
|
|
10
11
|
import { runPollerTick } from './poller-tick';
|
|
11
12
|
import { runPollerPrintUnits } from './poller-units';
|
|
@@ -13,6 +14,7 @@ import { runPollerPrintUnits } from './poller-units';
|
|
|
13
14
|
export type PollerCommandOptions = {
|
|
14
15
|
readonly configPath: string | undefined;
|
|
15
16
|
readonly printUnits: boolean;
|
|
17
|
+
readonly acknowledgeOnly: boolean;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const loadConfig = async (configPath: string): Promise<PollerConfig> => {
|
|
@@ -62,7 +64,9 @@ export const runPollerCommand = async (
|
|
|
62
64
|
);
|
|
63
65
|
return false;
|
|
64
66
|
}
|
|
65
|
-
const { lines, problems } =
|
|
67
|
+
const { lines, problems } = options.acknowledgeOnly
|
|
68
|
+
? await runPollerAcknowledgementTick(config, token)
|
|
69
|
+
: await runPollerTick(config, token, Date.now());
|
|
66
70
|
for (const line of lines) {
|
|
67
71
|
console.log(` ${line}`);
|
|
68
72
|
}
|
|
@@ -72,7 +76,7 @@ export const runPollerCommand = async (
|
|
|
72
76
|
return false;
|
|
73
77
|
}
|
|
74
78
|
console.log(
|
|
75
|
-
`standards poller: tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
|
|
79
|
+
`standards poller: ${options.acknowledgeOnly ? 'acknowledgement ' : ''}tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
|
|
76
80
|
);
|
|
77
81
|
return true;
|
|
78
82
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const commentStart = (marker: string): string => `<!-- ${marker}\n`;
|
|
2
|
+
const COMMENT_END = '\n-->';
|
|
3
|
+
|
|
4
|
+
export const hiddenCommentMetadata = (marker: string, value: unknown): string =>
|
|
5
|
+
`${commentStart(marker)}${JSON.stringify(value)}${COMMENT_END}`;
|
|
6
|
+
|
|
7
|
+
export const parseHiddenCommentMetadata = (
|
|
8
|
+
body: string,
|
|
9
|
+
marker: string,
|
|
10
|
+
): unknown | null => {
|
|
11
|
+
const start = body.indexOf(commentStart(marker));
|
|
12
|
+
if (start === -1) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const payloadStart = start + commentStart(marker).length;
|
|
16
|
+
const end = body.indexOf(COMMENT_END, payloadStart);
|
|
17
|
+
if (end === -1) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(body.slice(payloadStart, end)) as unknown;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
package/src/poller-fix-run.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
FIX_FAILED,
|
|
32
32
|
FIX_IN_PROGRESS,
|
|
33
33
|
} from './poller-protocol';
|
|
34
|
+
import { acknowledgeQueuedJob } from './poller-status';
|
|
34
35
|
import {
|
|
35
36
|
createWorktree,
|
|
36
37
|
ensureCacheClone,
|
|
@@ -86,6 +87,7 @@ export const runFixJob = async (
|
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
if (sealed === null && !allowCodex) {
|
|
90
|
+
await acknowledgeQueuedJob(deps, issue.number, preamble.approval, 'fix');
|
|
89
91
|
return {
|
|
90
92
|
lines: [`#${issue.number}: waiting for run capacity`],
|
|
91
93
|
ranCodex: false,
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Write-side label and comment operations for the poller. Kept apart from the
|
|
2
2
|
// read helpers so each module stays a single reviewable concern.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
apiError,
|
|
6
|
+
HTTP_CREATED,
|
|
7
|
+
HTTP_NO_CONTENT,
|
|
8
|
+
HTTP_OK,
|
|
9
|
+
request,
|
|
10
|
+
} from './github-api';
|
|
5
11
|
import { isRecord } from './github-settings-parse';
|
|
6
12
|
|
|
7
13
|
const HTTP_NOT_FOUND = 404;
|
|
@@ -85,3 +91,23 @@ export const createComment = async (
|
|
|
85
91
|
}
|
|
86
92
|
return response.body.id;
|
|
87
93
|
};
|
|
94
|
+
|
|
95
|
+
export const deleteComment = async (
|
|
96
|
+
token: string | null,
|
|
97
|
+
repo: string,
|
|
98
|
+
commentId: number,
|
|
99
|
+
): Promise<void> => {
|
|
100
|
+
const response = await request(
|
|
101
|
+
token,
|
|
102
|
+
'DELETE',
|
|
103
|
+
`/repos/${repo}/issues/comments/${commentId}`,
|
|
104
|
+
);
|
|
105
|
+
if (
|
|
106
|
+
response.status !== HTTP_NO_CONTENT &&
|
|
107
|
+
response.status !== HTTP_NOT_FOUND
|
|
108
|
+
) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
apiError(`delete comment ${commentId} in ${repo}`, response),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
package/src/poller-protocol.ts
CHANGED
|
@@ -16,7 +16,8 @@ export const DEFERRED_FINDING = 'deferred-finding';
|
|
|
16
16
|
// questions and failure reports apart from human conversation.
|
|
17
17
|
export const QUESTION_MARKER = '<!-- standards-poller:question -->';
|
|
18
18
|
export const FAILURE_MARKER = '<!-- standards-poller:failure -->';
|
|
19
|
-
export const
|
|
19
|
+
export const CLAIM_METADATA_MARKER = 'standards-poller:claim';
|
|
20
|
+
export const QUEUE_METADATA_MARKER = 'standards-poller:queue';
|
|
20
21
|
export const FIX_OUTPUT_MARKER = 'standards-poller:fix-output';
|
|
21
22
|
|
|
22
23
|
// Repository roles trusted to approve automation and answer its questions.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { hasLabel } from './github-label-identity';
|
|
2
|
+
import type { ApprovalBinding } from './poller-approval';
|
|
3
|
+
import { parseHiddenCommentMetadata } from './poller-comment-metadata';
|
|
4
|
+
import {
|
|
5
|
+
type IssueItem,
|
|
6
|
+
lastLabelEvent,
|
|
7
|
+
listIssueComments,
|
|
8
|
+
} from './poller-github';
|
|
9
|
+
import type { JobDeps } from './poller-job-shared';
|
|
10
|
+
import {
|
|
11
|
+
APPROVED_FOR_FIX,
|
|
12
|
+
APPROVED_FOR_REVIEW,
|
|
13
|
+
FIX_IN_PROGRESS,
|
|
14
|
+
isTrustedRole,
|
|
15
|
+
NEEDS_CLARIFICATION,
|
|
16
|
+
QUEUE_METADATA_MARKER,
|
|
17
|
+
REVIEW_IN_PROGRESS,
|
|
18
|
+
} from './poller-protocol';
|
|
19
|
+
import { cachedCollaboratorRole } from './poller-trust';
|
|
20
|
+
|
|
21
|
+
export type PollerJobKind = 'fix' | 'review';
|
|
22
|
+
|
|
23
|
+
type ApprovalCheckpoint = {
|
|
24
|
+
readonly actorLogin: string;
|
|
25
|
+
readonly approvedAt: string;
|
|
26
|
+
readonly eventId: number;
|
|
27
|
+
readonly target: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type QueueMarker = {
|
|
31
|
+
readonly approvalId: string;
|
|
32
|
+
readonly kind: PollerJobKind;
|
|
33
|
+
readonly approval: ApprovalCheckpoint | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const inProgressLabelFor = (kind: PollerJobKind) =>
|
|
37
|
+
kind === 'fix' ? FIX_IN_PROGRESS : REVIEW_IN_PROGRESS;
|
|
38
|
+
|
|
39
|
+
export const approvalLabelFor = (kind: PollerJobKind) =>
|
|
40
|
+
kind === 'fix' ? APPROVED_FOR_FIX : APPROVED_FOR_REVIEW;
|
|
41
|
+
|
|
42
|
+
export const queueMarkerFor = (
|
|
43
|
+
approval: ApprovalBinding,
|
|
44
|
+
kind: PollerJobKind,
|
|
45
|
+
): QueueMarker => ({
|
|
46
|
+
approvalId: approval.id,
|
|
47
|
+
kind,
|
|
48
|
+
approval: {
|
|
49
|
+
actorLogin: approval.actorLogin,
|
|
50
|
+
approvedAt: approval.approvedAt,
|
|
51
|
+
eventId: approval.eventId,
|
|
52
|
+
target: approval.target,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const queueMarker = (body: string): QueueMarker | null => {
|
|
57
|
+
const raw = parseHiddenCommentMetadata(body, QUEUE_METADATA_MARKER);
|
|
58
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const candidate = raw as {
|
|
62
|
+
readonly approvalId?: unknown;
|
|
63
|
+
readonly approval?: unknown;
|
|
64
|
+
readonly kind?: unknown;
|
|
65
|
+
};
|
|
66
|
+
if (
|
|
67
|
+
typeof candidate.approvalId !== 'string' ||
|
|
68
|
+
(candidate.kind !== 'fix' && candidate.kind !== 'review')
|
|
69
|
+
) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (candidate.approval === undefined) {
|
|
73
|
+
return {
|
|
74
|
+
approvalId: candidate.approvalId,
|
|
75
|
+
kind: candidate.kind,
|
|
76
|
+
approval: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (typeof candidate.approval !== 'object' || candidate.approval === null) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const approval = candidate.approval as {
|
|
83
|
+
readonly actorLogin?: unknown;
|
|
84
|
+
readonly approvedAt?: unknown;
|
|
85
|
+
readonly eventId?: unknown;
|
|
86
|
+
readonly target?: unknown;
|
|
87
|
+
};
|
|
88
|
+
if (
|
|
89
|
+
typeof approval.actorLogin !== 'string' ||
|
|
90
|
+
typeof approval.approvedAt !== 'string' ||
|
|
91
|
+
typeof approval.eventId !== 'number' ||
|
|
92
|
+
typeof approval.target !== 'string'
|
|
93
|
+
) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
approvalId: candidate.approvalId,
|
|
98
|
+
kind: candidate.kind,
|
|
99
|
+
approval: {
|
|
100
|
+
actorLogin: approval.actorLogin,
|
|
101
|
+
approvedAt: approval.approvedAt,
|
|
102
|
+
eventId: approval.eventId,
|
|
103
|
+
target: approval.target,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const matchingTrustedQueueCommentIds = async (
|
|
109
|
+
deps: JobDeps,
|
|
110
|
+
issueNumber: number,
|
|
111
|
+
approvalId: string,
|
|
112
|
+
kind: PollerJobKind,
|
|
113
|
+
): Promise<ReadonlyArray<number>> => {
|
|
114
|
+
const comments = await listIssueComments(deps.token, deps.repo, issueNumber);
|
|
115
|
+
const ids: Array<number> = [];
|
|
116
|
+
for (const comment of comments) {
|
|
117
|
+
const marker = queueMarker(comment.body);
|
|
118
|
+
if (marker?.approvalId === approvalId && marker.kind === kind) {
|
|
119
|
+
// biome-ignore lint/performance/noAwaitInLoops: role reads use the shared per-tick cache; marker authorship is a trust boundary.
|
|
120
|
+
const role = await cachedCollaboratorRole(
|
|
121
|
+
{
|
|
122
|
+
token: deps.token,
|
|
123
|
+
repo: deps.repo,
|
|
124
|
+
issueNumber,
|
|
125
|
+
roleCache: deps.roleCache,
|
|
126
|
+
},
|
|
127
|
+
comment.authorLogin,
|
|
128
|
+
);
|
|
129
|
+
if (isTrustedRole(role)) {
|
|
130
|
+
ids.push(comment.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const inspectQueuedAcknowledgement = async (options: {
|
|
138
|
+
readonly deps: JobDeps;
|
|
139
|
+
readonly item: IssueItem;
|
|
140
|
+
readonly kind: PollerJobKind;
|
|
141
|
+
readonly target: string;
|
|
142
|
+
}): Promise<boolean> => {
|
|
143
|
+
const { deps, item, kind, target } = options;
|
|
144
|
+
const expectedLabel = approvalLabelFor(kind);
|
|
145
|
+
if (
|
|
146
|
+
!hasLabel(item.labels, expectedLabel) ||
|
|
147
|
+
hasLabel(item.labels, inProgressLabelFor(kind)) ||
|
|
148
|
+
hasLabel(item.labels, NEEDS_CLARIFICATION)
|
|
149
|
+
) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const comments = await listIssueComments(deps.token, deps.repo, item.number);
|
|
153
|
+
const checkpoints: Array<ApprovalCheckpoint> = [];
|
|
154
|
+
for (const comment of comments) {
|
|
155
|
+
const marker = queueMarker(comment.body);
|
|
156
|
+
if (
|
|
157
|
+
marker?.kind === kind &&
|
|
158
|
+
marker.approval !== null &&
|
|
159
|
+
marker.approval.target === target
|
|
160
|
+
) {
|
|
161
|
+
// biome-ignore lint/performance/noAwaitInLoops: role reads use the shared per-tick cache; marker authorship is a trust boundary.
|
|
162
|
+
const role = await cachedCollaboratorRole(
|
|
163
|
+
{
|
|
164
|
+
token: deps.token,
|
|
165
|
+
repo: deps.repo,
|
|
166
|
+
issueNumber: item.number,
|
|
167
|
+
roleCache: deps.roleCache,
|
|
168
|
+
},
|
|
169
|
+
comment.authorLogin,
|
|
170
|
+
);
|
|
171
|
+
if (isTrustedRole(role)) {
|
|
172
|
+
checkpoints.push(marker.approval);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (checkpoints.length === 0) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const event = await lastLabelEvent(
|
|
180
|
+
deps.token,
|
|
181
|
+
deps.repo,
|
|
182
|
+
item.number,
|
|
183
|
+
expectedLabel,
|
|
184
|
+
);
|
|
185
|
+
return (
|
|
186
|
+
event !== null &&
|
|
187
|
+
checkpoints.some(
|
|
188
|
+
(checkpoint) =>
|
|
189
|
+
checkpoint.eventId === event.id &&
|
|
190
|
+
checkpoint.actorLogin === event.actorLogin &&
|
|
191
|
+
checkpoint.approvedAt === event.createdAt,
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PullRequest } from './poller-github-pulls';
|
|
2
|
+
|
|
3
|
+
export type ReviewEligibility =
|
|
4
|
+
| { readonly kind: 'eligible' }
|
|
5
|
+
| { readonly kind: 'recovering' }
|
|
6
|
+
| {
|
|
7
|
+
readonly kind: 'rejected';
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly result: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const reviewEligibility = (options: {
|
|
13
|
+
readonly repo: string;
|
|
14
|
+
readonly pr: PullRequest;
|
|
15
|
+
readonly hasPlan: boolean;
|
|
16
|
+
}): ReviewEligibility => {
|
|
17
|
+
const { repo, pr, hasPlan } = options;
|
|
18
|
+
if (!(pr.draft || hasPlan)) {
|
|
19
|
+
return {
|
|
20
|
+
kind: 'rejected',
|
|
21
|
+
message: 'automated review requires a draft PR',
|
|
22
|
+
result: 'rejected (not draft)',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (pr.headRepo !== repo) {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'rejected',
|
|
28
|
+
message: `this PR's head branch lives in ${pr.headRepo || 'an unknown repository'}; automated review runs only support same-repository branches`,
|
|
29
|
+
result: 'rejected (fork head)',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { kind: hasPlan ? 'recovering' : 'eligible' };
|
|
33
|
+
};
|
package/src/poller-review-run.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
REVIEW_FAILED,
|
|
22
22
|
REVIEW_IN_PROGRESS,
|
|
23
23
|
} from './poller-protocol';
|
|
24
|
+
import { reviewEligibility } from './poller-review-eligibility';
|
|
24
25
|
import { executeReviewJob } from './poller-review-execution';
|
|
25
26
|
import {
|
|
26
27
|
readSealedReviewPlan,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
} from './poller-review-output';
|
|
29
30
|
import { resumeReviewedJob } from './poller-review-publication';
|
|
30
31
|
import { publishReviewPlan, readReviewPlan } from './poller-review-state';
|
|
32
|
+
import { acknowledgeQueuedJob } from './poller-status';
|
|
31
33
|
import { ensureCacheClone, localBranchExists } from './poller-workspace';
|
|
32
34
|
|
|
33
35
|
const REVIEW_LABELS: JobLabels = {
|
|
@@ -73,30 +75,15 @@ export const runReviewJob = async (
|
|
|
73
75
|
ranCodex: false,
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
return {
|
|
84
|
-
lines: [`PR #${pr.number}: rejected (not draft)`],
|
|
85
|
-
ranCodex: false,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
// Fork PRs are out of scope: their head branch lives in a repository the
|
|
89
|
-
// poller must not push to, and pushing a same-named branch to the base repo
|
|
90
|
-
// would fake a review. Fail explicitly instead of pretending.
|
|
91
|
-
if (pr.headRepo !== repo) {
|
|
92
|
-
await failJob(
|
|
93
|
-
deps,
|
|
94
|
-
REVIEW_LABELS,
|
|
95
|
-
pr.number,
|
|
96
|
-
`this PR's head branch lives in ${pr.headRepo || 'an unknown repository'}; automated review runs only support same-repository branches`,
|
|
97
|
-
);
|
|
78
|
+
const eligibility = reviewEligibility({
|
|
79
|
+
repo,
|
|
80
|
+
pr,
|
|
81
|
+
hasPlan: plan !== null,
|
|
82
|
+
});
|
|
83
|
+
if (eligibility.kind === 'rejected') {
|
|
84
|
+
await failJob(deps, REVIEW_LABELS, pr.number, eligibility.message);
|
|
98
85
|
return {
|
|
99
|
-
lines: [`PR #${pr.number}:
|
|
86
|
+
lines: [`PR #${pr.number}: ${eligibility.result}`],
|
|
100
87
|
ranCodex: false,
|
|
101
88
|
};
|
|
102
89
|
}
|
|
@@ -113,6 +100,7 @@ export const runReviewJob = async (
|
|
|
113
100
|
throw new Error(`sealed output on ${outputBranch} is invalid`);
|
|
114
101
|
}
|
|
115
102
|
if (plan === null && sealed === null && !allowCodex) {
|
|
103
|
+
await acknowledgeQueuedJob(deps, pr.number, preamble.approval, 'review');
|
|
116
104
|
return {
|
|
117
105
|
lines: [`PR #${pr.number}: waiting for run capacity`],
|
|
118
106
|
ranCodex: false,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { hasLabel } from './github-label-identity';
|
|
2
|
+
import {
|
|
3
|
+
type ApprovalBinding,
|
|
4
|
+
issueRevision,
|
|
5
|
+
prRevision,
|
|
6
|
+
readApprovalBinding,
|
|
7
|
+
} from './poller-approval';
|
|
8
|
+
import { hiddenCommentMetadata } from './poller-comment-metadata';
|
|
9
|
+
import { getIssue } from './poller-github';
|
|
10
|
+
import { getPullRequest } from './poller-github-pulls';
|
|
11
|
+
import { createComment, deleteComment } from './poller-github-write';
|
|
12
|
+
import type { JobDeps } from './poller-job-shared';
|
|
13
|
+
import { NEEDS_CLARIFICATION, QUEUE_METADATA_MARKER } from './poller-protocol';
|
|
14
|
+
import {
|
|
15
|
+
approvalLabelFor,
|
|
16
|
+
inProgressLabelFor,
|
|
17
|
+
matchingTrustedQueueCommentIds,
|
|
18
|
+
type PollerJobKind,
|
|
19
|
+
queueMarkerFor,
|
|
20
|
+
} from './poller-queue-marker';
|
|
21
|
+
import { reviewEligibility } from './poller-review-eligibility';
|
|
22
|
+
|
|
23
|
+
const queuedMessage = (kind: PollerJobKind) =>
|
|
24
|
+
kind === 'fix'
|
|
25
|
+
? '**Fix queued**\n\nThe poller has picked this up and will start work as soon as a run is available. You don’t need to do anything else.'
|
|
26
|
+
: '**Review queued**\n\nThe poller has picked this up and will start the review as soon as a run is available. You don’t need to do anything else.';
|
|
27
|
+
|
|
28
|
+
const isStillQueueable = async (
|
|
29
|
+
deps: JobDeps,
|
|
30
|
+
issueNumber: number,
|
|
31
|
+
approval: ApprovalBinding,
|
|
32
|
+
kind: PollerJobKind,
|
|
33
|
+
): Promise<boolean> => {
|
|
34
|
+
const expectedApprovalLabel = approvalLabelFor(kind);
|
|
35
|
+
if (
|
|
36
|
+
approval.repo !== deps.repo ||
|
|
37
|
+
approval.issueNumber !== issueNumber ||
|
|
38
|
+
approval.label !== expectedApprovalLabel
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const issue = await getIssue(deps.token, deps.repo, issueNumber);
|
|
43
|
+
if (
|
|
44
|
+
!hasLabel(issue.labels, expectedApprovalLabel) ||
|
|
45
|
+
hasLabel(issue.labels, inProgressLabelFor(kind)) ||
|
|
46
|
+
hasLabel(issue.labels, NEEDS_CLARIFICATION)
|
|
47
|
+
) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
let target = issueRevision(issue);
|
|
51
|
+
if (kind === 'review') {
|
|
52
|
+
const pr = await getPullRequest(deps.token, deps.repo, issueNumber);
|
|
53
|
+
if (
|
|
54
|
+
reviewEligibility({
|
|
55
|
+
repo: deps.repo,
|
|
56
|
+
pr,
|
|
57
|
+
hasPlan: false,
|
|
58
|
+
}).kind !== 'eligible'
|
|
59
|
+
) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
target = prRevision(pr.baseRef, pr.baseSha, pr.headSha);
|
|
63
|
+
}
|
|
64
|
+
if (target !== approval.target) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const currentApproval = await readApprovalBinding(
|
|
68
|
+
{
|
|
69
|
+
token: deps.token,
|
|
70
|
+
repo: deps.repo,
|
|
71
|
+
issueNumber,
|
|
72
|
+
},
|
|
73
|
+
expectedApprovalLabel,
|
|
74
|
+
target,
|
|
75
|
+
);
|
|
76
|
+
return (
|
|
77
|
+
typeof currentApproval !== 'string' && currentApproval.id === approval.id
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const acknowledgeQueuedJob = async (
|
|
82
|
+
deps: JobDeps,
|
|
83
|
+
issueNumber: number,
|
|
84
|
+
approval: ApprovalBinding,
|
|
85
|
+
kind: PollerJobKind,
|
|
86
|
+
): Promise<boolean> => {
|
|
87
|
+
const expected = queueMarkerFor(approval, kind);
|
|
88
|
+
const current = await getIssue(deps.token, deps.repo, issueNumber);
|
|
89
|
+
if (
|
|
90
|
+
hasLabel(current.labels, inProgressLabelFor(kind)) ||
|
|
91
|
+
hasLabel(current.labels, NEEDS_CLARIFICATION)
|
|
92
|
+
) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
(
|
|
97
|
+
await matchingTrustedQueueCommentIds(
|
|
98
|
+
deps,
|
|
99
|
+
issueNumber,
|
|
100
|
+
expected.approvalId,
|
|
101
|
+
kind,
|
|
102
|
+
)
|
|
103
|
+
).length > 0
|
|
104
|
+
) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const markerId = await createComment(
|
|
108
|
+
deps.token,
|
|
109
|
+
deps.repo,
|
|
110
|
+
issueNumber,
|
|
111
|
+
`${queuedMessage(kind)}\n\n${hiddenCommentMetadata(
|
|
112
|
+
QUEUE_METADATA_MARKER,
|
|
113
|
+
expected,
|
|
114
|
+
)}`,
|
|
115
|
+
);
|
|
116
|
+
const [winner] = [
|
|
117
|
+
...(await matchingTrustedQueueCommentIds(
|
|
118
|
+
deps,
|
|
119
|
+
issueNumber,
|
|
120
|
+
expected.approvalId,
|
|
121
|
+
kind,
|
|
122
|
+
)),
|
|
123
|
+
].sort((left, right) => left - right);
|
|
124
|
+
if (winner !== markerId) {
|
|
125
|
+
await deleteComment(deps.token, deps.repo, markerId);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (!(await isStillQueueable(deps, issueNumber, approval, kind))) {
|
|
129
|
+
await deleteComment(deps.token, deps.repo, markerId);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
};
|
package/src/poller-trust.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type TrustContext = {
|
|
|
19
19
|
readonly roleCache: RoleCache;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
export const cachedCollaboratorRole = async (
|
|
23
23
|
context: TrustContext,
|
|
24
24
|
username: string,
|
|
25
25
|
): Promise<string> => {
|
|
@@ -53,7 +53,7 @@ const lastTrustedQuestionIndex = async (
|
|
|
53
53
|
const comment = comments[index];
|
|
54
54
|
if (comment !== undefined && hasQuestionMarker(comment)) {
|
|
55
55
|
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
56
|
-
const role = await
|
|
56
|
+
const role = await cachedCollaboratorRole(context, comment.authorLogin);
|
|
57
57
|
if (isTrustedRole(role)) {
|
|
58
58
|
return index;
|
|
59
59
|
}
|
|
@@ -87,7 +87,7 @@ export const answerState = async (
|
|
|
87
87
|
);
|
|
88
88
|
for (const comment of candidates) {
|
|
89
89
|
// biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
|
|
90
|
-
const role = await
|
|
90
|
+
const role = await cachedCollaboratorRole(context, comment.authorLogin);
|
|
91
91
|
if (isTrustedRole(role)) {
|
|
92
92
|
answers.push(comment.body);
|
|
93
93
|
}
|
package/src/poller-units.ts
CHANGED
|
@@ -8,11 +8,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import type { PollerConfig } from './poller-config';
|
|
9
9
|
|
|
10
10
|
const SERVICE_NAME = 'standards-poller';
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
const
|
|
11
|
+
// OnUnitInactiveSec starts counting when each oneshot service finishes.
|
|
12
|
+
// Keep worker pickup prompt while giving the acknowledgement observer enough
|
|
13
|
+
// API headroom for mixed queues, recovery plans, pagination, and worker calls.
|
|
14
|
+
const WORKER_INTERVAL_MINUTES = 1;
|
|
15
|
+
export const ACKNOWLEDGEMENT_INTERVAL_MINUTES = 5;
|
|
16
16
|
// Non-agent tick work: stale sweeps, clone/fetch, GitHub reads and writes.
|
|
17
17
|
const TICK_OVERHEAD_MINUTES = 30;
|
|
18
18
|
const MAX_ASCII_CONTROL_CODE = 31;
|
|
@@ -50,7 +50,12 @@ const tickBudgetMinutes = (config: PollerConfig): number =>
|
|
|
50
50
|
export const renderUnits = (
|
|
51
51
|
configPath: string,
|
|
52
52
|
config: PollerConfig,
|
|
53
|
-
): {
|
|
53
|
+
): {
|
|
54
|
+
readonly service: string;
|
|
55
|
+
readonly timer: string;
|
|
56
|
+
readonly acknowledgementService: string;
|
|
57
|
+
readonly acknowledgementTimer: string;
|
|
58
|
+
} => ({
|
|
54
59
|
service: `[Unit]
|
|
55
60
|
Description=Standards fix poller tick
|
|
56
61
|
After=network-online.target
|
|
@@ -66,7 +71,29 @@ Description=Run the standards fix poller on an interval
|
|
|
66
71
|
|
|
67
72
|
[Timer]
|
|
68
73
|
OnBootSec=2min
|
|
69
|
-
OnUnitInactiveSec=${
|
|
74
|
+
OnUnitInactiveSec=${WORKER_INTERVAL_MINUTES}min
|
|
75
|
+
AccuracySec=15s
|
|
76
|
+
Persistent=true
|
|
77
|
+
|
|
78
|
+
[Install]
|
|
79
|
+
WantedBy=timers.target
|
|
80
|
+
`,
|
|
81
|
+
acknowledgementService: `[Unit]
|
|
82
|
+
Description=Acknowledge queued standards poller requests
|
|
83
|
+
After=network-online.target
|
|
84
|
+
Wants=network-online.target
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=oneshot
|
|
88
|
+
ExecStart=${quoteSystemdExecArg(process.execPath)} ${quoteSystemdExecArg(cliEntryPath())} poller --acknowledge-only --config ${quoteSystemdExecArg(configPath)}
|
|
89
|
+
TimeoutStartSec=${TICK_OVERHEAD_MINUTES}min
|
|
90
|
+
`,
|
|
91
|
+
acknowledgementTimer: `[Unit]
|
|
92
|
+
Description=Check for newly queued standards poller requests
|
|
93
|
+
|
|
94
|
+
[Timer]
|
|
95
|
+
OnBootSec=1min
|
|
96
|
+
OnUnitInactiveSec=${ACKNOWLEDGEMENT_INTERVAL_MINUTES}min
|
|
70
97
|
AccuracySec=15s
|
|
71
98
|
Persistent=true
|
|
72
99
|
|
|
@@ -84,4 +111,8 @@ export const runPollerPrintUnits = (
|
|
|
84
111
|
console.log(units.service);
|
|
85
112
|
console.log(`# ${SERVICE_NAME}.timer`);
|
|
86
113
|
console.log(units.timer);
|
|
114
|
+
console.log(`# ${SERVICE_NAME}-acknowledgements.service`);
|
|
115
|
+
console.log(units.acknowledgementService);
|
|
116
|
+
console.log(`# ${SERVICE_NAME}-acknowledgements.timer`);
|
|
117
|
+
console.log(units.acknowledgementTimer);
|
|
87
118
|
};
|