From 934872631fcc254d873d028bb621ce4e2d715891 Mon Sep 17 00:00:00 2001 From: "touchless-bot[bot]" <271340427+touchless-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:56:09 -0500 Subject: [PATCH] sync from touchless-bot infra/bot-ci@58a1034ccd38 --- .synced-from | 2 +- README.md | 15 +++-- actions/ai-provenance/attestationOf.ts | 2 +- actions/ai-provenance/decideProvenance.ts | 2 +- actions/ai-provenance/gatherProvenance.ts | 8 ++- actions/ai-provenance/githubOf.ts | 2 +- actions/ai-provenance/index.ts | 2 +- actions/ai-provenance/reportOf.ts | 2 +- actions/ai-provenance/secondLookAdapter.ts | 4 -- actions/pr-lint/lintPullRequest.ts | 15 +++-- actions/pr-lint/skipTokensOf.ts | 9 +++ actions/second-look/betaScopeNames.ts | 10 ++- actions/second-look/checkRunBodyOf.ts | 5 +- .../second-look/dismissOwnChangeRequests.ts | 50 +++++++++++++++ actions/second-look/jiraTicketOf.ts | 10 ++- actions/second-look/notifyUnfinished.ts | 57 +++++++++++++---- actions/second-look/reviewContextOf.ts | 7 +- actions/second-look/reviewerLogins.ts | 7 ++ actions/second-look/rules.md | 3 +- actions/second-look/secondLookVerdictOf.ts | 15 ++--- actions/second-look/synthesisPrompt.ts | 5 +- actions/second-look/synthesize.ts | 13 +++- actions/second-look/testingRules.md | 11 ++-- scripts/jira-rules/acceptanceOf.ts | 64 +++++++++++++++++++ scripts/jira-rules/adfTextOf.ts | 8 +++ 25 files changed, 266 insertions(+), 62 deletions(-) delete mode 100644 actions/ai-provenance/secondLookAdapter.ts create mode 100644 actions/pr-lint/skipTokensOf.ts create mode 100644 actions/second-look/dismissOwnChangeRequests.ts create mode 100644 actions/second-look/reviewerLogins.ts create mode 100644 scripts/jira-rules/acceptanceOf.ts create mode 100644 scripts/jira-rules/adfTextOf.ts diff --git a/.synced-from b/.synced-from index 7a3b539..92c827a 100644 --- a/.synced-from +++ b/.synced-from @@ -1 +1 @@ -96c1dbc9f7a27d368f79c17702c0fbb7a799da04 +58a1034ccd38510d510ccf2f1e91e84769227c48 diff --git a/README.md b/README.md index 0c356f3..e77f741 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # bot-ci + The CI and guardrails for bot-built code in touchlesscode. Org rulesets make it apply to every repo's default branch without anyone adding files to the repo, and everything runs on Ubicloud runners. | check | runs on | what it does | |-------|---------|--------------| -| **PR Lint** | every PR; only checks the bot's PRs unless `PR_LINT_SCOPE=all` | exo's PR title and description rules | +| **PR Lint** | every PR; only checks the bot's PRs unless `PR_LINT_SCOPE=all` | exo's PR title and description rules, and no exo skip tokens in a bot PR's title | | **AI Provenance** | every PR; human-made PRs pass at once | PRs made by a coding agent must come through the touchless-bot MCP (`open_pr` / `update_pr`) | | **Build** | repos with no PR CI of their own | install, lint, typecheck, test, coverage, build; bundles Worker previews | -| **Previews** | after Build, when it bundled any | deploys staging previews to `--pr.touchlessapis.com` | +| **Previews** | after Build, when it bundled any | deploys staging previews to `-.touchlessapis.com` ("Previews" below) | | **E2E** | after Previews, when the repo has `test:e2e` | the repo's Playwright tests against each preview it deployed | -| **Second Look (beta)** | only when someone asks | AI testers plus a context-aware reviewer; see [second-look](https://github.com/touchlesscode/second-look) | +| **Second Look (beta)** | when someone asks, and on every push in its beta scope | AI testers plus a context-aware reviewer. Never a required check; in the beta scope its approval gates AI Provenance and the bot's Slack *Approve*. See [second-look](https://github.com/touchlesscode/second-look) | A second ruleset, `review-required`, needs one human approval given after the last push, and blocks force-pushes and deleting the default branch. The Touchless Bot App has no bypass, so it can open PRs but never merge one alone. -In practice Build and Previews only run in repos with no CI of their own, which today means the bot's `touchlesscode/apps` monorepo and repos made from `app-template`. exo and anything else with a `pull_request` workflow are left out of the `bot-ci-build` ruleset, so they never see them. +**What's required where.** The `bot-ci` ruleset requires PR Lint and AI Provenance on every repo. The `bot-ci-build` ruleset requires `ci.yml` (Plan, Build, Previews, E2E) only in repos with no CI of their own, which today means the bot's `touchlesscode/apps` monorepo and repos made from `app-template`. exo, exo-foundation, core-db and anything else with a `pull_request` workflow are left out of it, so they never see Build, Previews, E2E or the coverage gate: their own CI (exo's `cicd`) is what tests them, and bot-ci doesn't make it required. An org owner chooses each ruleset's enforcement: in evaluate mode the checks run and report but block no merge. ## PR Lint @@ -32,6 +33,8 @@ EXO-1234 (or a browse link, N/A, NO-TICKET) How a reviewer can check it by hand. ``` +A bot PR's title can't carry exo's skip tokens, `[skip-checks]` or `[skip e2e]` (any case): they'd skip exo's quality checks or e2e. People's PRs may, and the draft `[e2e]` opt-in is fine. + Dependabot, merge-queue and revert PRs are skipped. **Editing the title or body doesn't re-run it**, because rulesets ignore the `edited` event: push a commit, or close and reopen the PR. ## AI Provenance @@ -55,6 +58,7 @@ Before that, Plan reads which template the repo was made from. If it names a dif - Node comes from `.nvmrc`, `.node-version` or `engines.node`, else 24. ### Coverage + After the checks, the coverage gate (`actions/coverage/coverageGate.ts`) reads every `coverage/lcov.info` the tests wrote (`app-template` runs vitest with the `lcov` reporter). It reads the reports itself, so lowering the thresholds in a vitest config doesn't get a PR past it. @@ -86,6 +90,7 @@ The install step gets the org secret `PACKAGES_READ_TOKEN` (a classic PAT with o - Yarn and bun installs don't get the token (their installs run repo code), so they only see packages that list the repo under the package's Actions access. ## Previews + An affected app gets a preview when its `wrangler.jsonc` has an `env.feature` section, exactly as exo does it. `app-template` ships this layout: @@ -98,7 +103,7 @@ An affected app gets a preview when its `wrangler.jsonc` has an `env.feature` se } ``` -- `VERSION` is exo's `getVersionFromBranch`: the last 20 characters of `-pr`, lowercased, with `.`, `/` and `_` turned into `-`. +- The host is `-.touchlessapis.com`. `` is exo's `getVersionFromBranch`: `-pr`, lowercased, every character outside `a-z0-9-` turned into `-`, its last 20 characters, leading dashes dropped (`feat/EXO-1300_csv` → `feat-exo-1300-csv-pr`); a Dependabot branch keeps only its last segment, and `main` is `latest` (`actions/ci/deployPreviews.ts` `versionOf`). - Secrets listed in the app's `package.json` under `config.exo.secrets` are uploaded with the deploy itself, via `wrangler deploy --secrets-file`, so a new Worker whose config declares required secrets deploys on its first run. Their values come from the app's (or the repo's) `secrets/preview.enc.yaml`, a sops + age file that Previews decrypts with the org secret `SOPS_AGE_KEY_PREVIEW` (`app-template/secrets/README.md`), else from the repo's GitHub secrets with the same names. A listed secret that's in neither fails that app's preview. - The PR description gets the Preview Deployments table (🟢 deployed, 🔴 failed, ⚪ skipped) between its `deployment-links` markers, and a failed deploy also gets a comment. - Dependabot and fork PRs don't deploy. Apps with no `env.feature`, or with a `wrangler.toml`, are listed as skipped. diff --git a/actions/ai-provenance/attestationOf.ts b/actions/ai-provenance/attestationOf.ts index 197fe12..040e9d8 100644 --- a/actions/ai-provenance/attestationOf.ts +++ b/actions/ai-provenance/attestationOf.ts @@ -3,7 +3,7 @@ import { summaryFieldsOf } from "./summaryFieldsOf.ts" /** the check run the touchless-bot MCP (or a bot worker) puts on a head commit it submitted */ export const attestationName = "Touchless Bot MCP" -export type CheckRun = { name: string; head_sha: string; status?: string; conclusion: string | null; app?: { slug?: string; id?: number } | null; output?: { summary?: string | null } | null } +export type CheckRun = { name: string; head_sha: string; status?: string; conclusion: string | null; app?: { slug?: string; id?: number } | null; external_id?: string | null; output?: { summary?: string | null } | null } export type Attestation = { attested: true; lane?: string; requester?: string; ticket?: string; via?: string } | { attested: false; reason: string } diff --git a/actions/ai-provenance/decideProvenance.ts b/actions/ai-provenance/decideProvenance.ts index 7657da0..169be85 100644 --- a/actions/ai-provenance/decideProvenance.ts +++ b/actions/ai-provenance/decideProvenance.ts @@ -1,5 +1,5 @@ import type { Attestation } from "./attestationOf.ts" -import type { SecondLookVerdict } from "./secondLookAdapter.ts" +import type { SecondLookVerdict } from "../second-look/betaScopeTypes.ts" /** the failure text for a harness-made pr without a current attestation (exact; people search for it) */ export const outsideMcpFailure = "made by a personal harness outside the touchless-bot MCP: resubmit with open_pr / update_pr" diff --git a/actions/ai-provenance/gatherProvenance.ts b/actions/ai-provenance/gatherProvenance.ts index d39f7ee..f509774 100644 --- a/actions/ai-provenance/gatherProvenance.ts +++ b/actions/ai-provenance/gatherProvenance.ts @@ -1,8 +1,10 @@ +import { betaLabel } from "../second-look/betaScopeNames.ts" +import type { BetaSignals, SecondLookVerdict } from "../second-look/betaScopeTypes.ts" +import { secondLookVerdictOf } from "../second-look/secondLookVerdictOf.ts" import { attestationOf, type Attestation, type CheckRun } from "./attestationOf.ts" import { everLabeledOf } from "./everLabeledOf.ts" import type { GithubCall } from "./githubOf.ts" import { harnessSignalsOf, type PullCommit } from "./harnessSignalsOf.ts" -import { secondLookVerdictOf, type BetaSignals, type SecondLookVerdict } from "./secondLookAdapter.ts" export type Gathered = { headSha: string; author?: string; harnessSignals: string[]; attestation: Attestation; beta: BetaSignals; verdict: SecondLookVerdict } @@ -38,10 +40,10 @@ export const gatherProvenance = async ({ github, repository, number, appSlug, ap const reviewsResponse = await github("GET", `/repos/${repository}/pulls/${number}/reviews?per_page=100`) const reviews = reviewsResponse.status === 200 ? (reviewsResponse.json as { user?: { login?: string }; state?: string; commit_id?: string }[]).map((review) => ({ user: review.user?.login ?? "", state: review.state ?? "", commit_id: review.commit_id ?? "" })) : [] - const verdict = secondLookVerdictOf(checkRuns.map((run) => ({ name: run.name, conclusion: run.conclusion, head_sha: run.head_sha, app_slug: run.app?.slug })), reviews, headSha) + const verdict = secondLookVerdictOf(checkRuns.map((run) => ({ name: run.name, conclusion: run.conclusion, head_sha: run.head_sha, app_slug: run.app?.slug, external_id: run.external_id })), reviews, headSha) const events = await github("GET", `/repos/${repository}/issues/${number}/events?per_page=100`) - const recorded = events.status === 200 && everLabeledOf(events.json as { event?: string; label?: { name?: string } }[], "second-look-beta") + const recorded = events.status === 200 && everLabeledOf(events.json as { event?: string; label?: { name?: string } }[], betaLabel) let repoProperty = typeof repoProperties?.["second-look-mode"] === "string" ? (repoProperties["second-look-mode"] as string) : undefined if (repoProperty === undefined) { const properties = await github("GET", `/repos/${repository}/properties/values`) diff --git a/actions/ai-provenance/githubOf.ts b/actions/ai-provenance/githubOf.ts index c905763..59c6fae 100644 --- a/actions/ai-provenance/githubOf.ts +++ b/actions/ai-provenance/githubOf.ts @@ -1,4 +1,4 @@ -export type GithubCall = (method: "GET" | "POST", path: string, body?: unknown) => Promise<{ status: number; json: any }> +export type GithubCall = (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown) => Promise<{ status: number; json: any }> /** a minimal github rest client with the job token; network failures resolve as status 0 */ export const githubOf = (token: string, apiUrl = "https://api.github.com", fetchImpl: typeof fetch = fetch): GithubCall => async (method, path, body) => { diff --git a/actions/ai-provenance/index.ts b/actions/ai-provenance/index.ts index b25de69..397b63a 100644 --- a/actions/ai-provenance/index.ts +++ b/actions/ai-provenance/index.ts @@ -19,7 +19,7 @@ import { gatherProvenance } from "./gatherProvenance.ts" import { githubOf } from "./githubOf.ts" import { pullNumbersOf } from "./pullNumbersOf.ts" import { reportOf } from "./reportOf.ts" -import { isBetaEnabled } from "./secondLookAdapter.ts" +import { isBetaEnabled } from "../second-look/isBetaEnabled.ts" const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)) const oneLine = (text: string) => text.replace(/[\r\n]+/g, " ").replace(/::/g, ": :") diff --git a/actions/ai-provenance/reportOf.ts b/actions/ai-provenance/reportOf.ts index 877a646..93e61e7 100644 --- a/actions/ai-provenance/reportOf.ts +++ b/actions/ai-provenance/reportOf.ts @@ -1,6 +1,6 @@ import type { Attestation } from "./attestationOf.ts" import type { ProvenanceDecision, ProvenanceMode } from "./decideProvenance.ts" -import type { BetaSignals, SecondLookVerdict } from "./secondLookAdapter.ts" +import type { BetaSignals, SecondLookVerdict } from "../second-look/betaScopeTypes.ts" const icons = { success: "✅", failure: "❌", neutral: "⚪", pending: "⏳" } as const diff --git a/actions/ai-provenance/secondLookAdapter.ts b/actions/ai-provenance/secondLookAdapter.ts deleted file mode 100644 index 415ccae..0000000 --- a/actions/ai-provenance/secondLookAdapter.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** the shared second look gate functions (../second-look/), under the names this action uses */ -export { isBetaEnabled } from "../second-look/isBetaEnabled.ts" -export { secondLookVerdictOf } from "../second-look/secondLookVerdictOf.ts" -export type { BetaSignals, CheckRunLike as SecondLookCheckRun, ReviewLike as SecondLookReview, SecondLookVerdict } from "../second-look/betaScopeTypes.ts" diff --git a/actions/pr-lint/lintPullRequest.ts b/actions/pr-lint/lintPullRequest.ts index a86a1ba..a6a4340 100644 --- a/actions/pr-lint/lintPullRequest.ts +++ b/actions/pr-lint/lintPullRequest.ts @@ -4,6 +4,7 @@ * (apps/exo-cli/src/cicd/utils/runPrLinting.ts), without its dependencies: * title `type(scope): description | EXO-1234` (or `| NO-TICKET`) * body `## Summary`, `### Jira Ticket Reference` (a valid EXO key/link, N/A or NO-TICKET), `### Test Plan` + * plus one rule of its own: a pr the bot opened or drove can't carry exo's skip tokens (`[skip-checks]`, `[skip e2e]`) * * node lintPullRequest.ts (in the action; reads $GITHUB_EVENT_PATH) * @@ -11,8 +12,11 @@ * PR_LINT_BOT_LOGINS (comma-separated, default touchless-bot[bot]). */ import { appendFileSync, readFileSync } from "node:fs" +import { appLogin, reviewerLogin } from "../second-look/betaScopeNames.ts" +import { skipTokensOf } from "./skipTokensOf.ts" -export type PullRequestText = { title: string; body: string; branch: string } +/** a pr's text; `bot` (default true) is whether the bot opened or drove it, which bans exo's skip tokens */ +export type PullRequestText = { title: string; body: string; branch: string; bot?: boolean } export type LintResult = { skipped?: string; errors: { title: string; message: string }[] } @@ -22,7 +26,7 @@ const semanticTitle = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf)( const ticketSuffix = /\| (EXO-\d+|NO-TICKET)$/ const jiraReference = /^(EXO-\d+|\[EXO-\d+\]\(https?:\/\/[^/\s]+\/browse\/EXO-\d+\)|https?:\/\/[^/\s]+\/browse\/EXO-\d+|N\/A|NO-TICKET)$/i const harnessMarker = //i -const defaultBotLogins = ["touchless-bot[bot]", "clanker-in-chief"] +const defaultBotLogins = [appLogin, reviewerLogin] const sections: Section[] = [ { @@ -57,7 +61,7 @@ export const sectionContentOf = (body: string, heading: string) => body.match(new RegExp(`^${heading}[ \\t]*$\\s*(?:\\s*)*([\\s\\S]*?)(?=^## |^### |\\s*$)`, "m"))?.[1]?.trim() /** every title and description problem, or why the pr is exempt (dependabot, merge queue, reverts) */ -export const lintPullRequest = ({ title, body, branch }: PullRequestText): LintResult => { +export const lintPullRequest = ({ title, body, branch, bot = true }: PullRequestText): LintResult => { if (branch.startsWith("dependabot/")) return { skipped: "dependabot branch", errors: [] } if (branch.startsWith("gh-readonly-queue/")) return { skipped: "merge queue", errors: [] } if (branch.startsWith("revert") && body.includes("Reverts") && body.includes("#")) return { skipped: "revert", errors: [] } @@ -65,6 +69,7 @@ export const lintPullRequest = ({ title, body, branch }: PullRequestText): LintR const titleErrors = [ semanticTitle.test(title) ? undefined : { title: "PR Title Validation Error", message: "**PR Title Error**: PR title does not follow the semantic messaging format (e.g., \"feat: add new feature\")." }, ticketSuffix.test(title) ? undefined : { title: "PR Title Validation Error", message: "**PR Title Error**: PR title must end with \"| EXO-XXXX\", where \"XXXX\" is the ticket number. Example: \"feat: add new feature | EXO-1234\". If there is no ticket, use \"NO-TICKET\"." }, + ...(bot ? skipTokensOf(title) : []).map((token) => ({ title: "PR Title Validation Error", message: `**PR Title Error**: a bot PR can't skip checks: remove \`${token}\` from the title. Only a person may skip exo's checks.` })), ].filter((error) => error !== undefined) if (titleErrors.length) return { errors: titleErrors } @@ -96,7 +101,9 @@ if (import.meta.main) { console.log(`pr-lint: not a bot pull request (author ${pull.user?.login}); set the org variable PR_LINT_SCOPE=all to lint every PR`) process.exit(0) } - const result = lintPullRequest({ title: pull.title ?? "", body, branch: pull.head?.ref ?? "" }) + // without a scope, isLintInScope says whether the bot opened or drove the pr (PR_LINT_SCOPE=all lints people's prs too) + const bot = isLintInScope({ author: pull.user?.login, body, botLogins }) + const result = lintPullRequest({ title: pull.title ?? "", body, branch: pull.head?.ref ?? "", bot }) if (result.skipped) { console.log(`pr-lint: skipped (${result.skipped})`) process.exit(0) diff --git a/actions/pr-lint/skipTokensOf.ts b/actions/pr-lint/skipTokensOf.ts new file mode 100644 index 0000000..d42112f --- /dev/null +++ b/actions/pr-lint/skipTokensOf.ts @@ -0,0 +1,9 @@ +/** + * title tokens that make exo's `cicd` workflow skip its own checks (exo `.github/workflows/cicd.yml`): `[skip-checks]` + * skips the quality checks (nx affected build/lint/test), `[skip e2e]` skips every e2e suite. github's `contains()` + * ignores case, so this does too. the draft `[e2e]` opt-in only adds checks and isn't one + */ +const skipTokens = ["[skip-checks]", "[skip e2e]"] + +/** the exo skip tokens in a pr title, as written in exo's workflow; empty when it has none */ +export const skipTokensOf = (title: string) => skipTokens.filter((token) => title.toLowerCase().includes(token)) diff --git a/actions/second-look/betaScopeNames.ts b/actions/second-look/betaScopeNames.ts index 79ff7fa..79eda9f 100644 --- a/actions/second-look/betaScopeNames.ts +++ b/actions/second-look/betaScopeNames.ts @@ -1,5 +1,5 @@ /** - * the names second look's beta scope is built on, shared by the relay (scripts/bot-ci-relay), setup.sh's property + * the names second look's beta scope is built on, shared by the relay (factory/gate/src/bot-ci-relay), setup.sh's property * definition, and anything gating on second look's verdict (AI Provenance, the Slack Approve button) */ @@ -22,5 +22,11 @@ export const betaExternalId = "second-look-beta" /** the touchless bot github app, the only one whose check runs count */ export const appSlug = "touchless-bot" +/** the user second look reviews and replies as, the one people @mention or request as a reviewer */ +export const reviewerLogin = "clanker-in-chief" + +/** the App's own login, which posts in second look's place when clanker-in-chief's token is missing */ +export const appLogin = "touchless-bot[bot]" + /** the accounts second look reviews as: clanker-in-chief, or the App when its token is missing */ -export const reviewerLogins = ["clanker-in-chief", "touchless-bot[bot]"] +export const reviewerLogins = [reviewerLogin, appLogin] diff --git a/actions/second-look/checkRunBodyOf.ts b/actions/second-look/checkRunBodyOf.ts index 6a85df4..83608a3 100644 --- a/actions/second-look/checkRunBodyOf.ts +++ b/actions/second-look/checkRunBodyOf.ts @@ -25,12 +25,13 @@ export const checkStartOf = ({ headSha, detailsUrl, requestedBy, betaEnabled = f /** * the body that closes the check with the review's outcome. outside the beta scope the check is informational: a pass * is green ("no changes requested", never an approval) and anything else neutral, never red. on a beta-enabled pr it - * enforces: a pass is an approval, and a block (the review requests changes either way) or a failed review is red + * enforces: a pass is an approval and a block (the review requests changes either way) is red. a review that + * couldn't finish is neutral in both, like an abandoned run: it judged nothing, and red would read as changes requested */ export const checkEndOf = ({ verdict, error, betaEnabled, commentUrl }: { verdict?: Verdict; error?: string; betaEnabled: boolean; commentUrl?: string }) => { const link = commentUrl ? `\n\n[The review](${commentUrl})` : "" const notPassing = betaEnabled ? "failure" : "neutral" - if (error || !verdict) return { status: "completed", conclusion: notPassing, output: { title: "Couldn't review", summary: `${error ?? "no verdict"}${link}` } } + if (error || !verdict) return { status: "completed", conclusion: "neutral", output: { title: "Couldn't review", summary: `${error ?? "no verdict"}${link}` } } if (verdict.verdict === "pass") return { status: "completed", conclusion: "success", output: { title: betaEnabled ? "Approved" : "No changes requested", summary: `${verdict.summary}${link}` } } return { status: "completed", conclusion: notPassing, output: { title: "Changes requested", summary: `${verdict.summary}${link}` } } } diff --git a/actions/second-look/dismissOwnChangeRequests.ts b/actions/second-look/dismissOwnChangeRequests.ts new file mode 100644 index 0000000..011dfe4 --- /dev/null +++ b/actions/second-look/dismissOwnChangeRequests.ts @@ -0,0 +1,50 @@ +type Review = { id: number; state?: string; user?: { login?: string } | null } + +/** + * after a pass, dismisses `login`'s earlier change requests on the pr. a pass outside the beta scope is only a comment + * review, and github keeps a reviewer's change request in force until that reviewer approves or it's dismissed, so on + * `touchless-bot`, where the poster has write, the old block kept a fixed pr unmergeable. the reviews are read every page + * (oldest first, 100 a page, like issueCommentsOf.ts), and the listing and each dismissal try the tokens in order (the + * app, then the poster). resolves to the dismissed review ids; throws when the reviews can't be listed or one can't be + * dismissed + */ +export const dismissOwnChangeRequests = async ({ tokens, repository, number, login, headSha, fetchImpl = fetch }: { + tokens: string[] + repository: string + number: number + login: string + headSha: string + fetchImpl?: typeof fetch +}) => { + const usable = [...new Set(tokens.filter(Boolean))] + if (!usable.length) throw new Error(`no token to lift @${login}'s earlier change request; dismiss it on the PR to unblock the merge`) + const headers = (token: string) => ({ authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" }) + /** the first token's answer that's ok, else the last answer */ + const withTokens = async (url: string, init: RequestInit = {}) => { + let response: Response | undefined + for (const token of usable) { + response = await fetchImpl(url, { ...init, headers: headers(token) }) + if (response.ok) break + } + return response as Response + } + const pageOf = async (page: number): Promise => { + const response = await withTokens(`https://api.github.com/repos/${repository}/pulls/${number}/reviews?per_page=100&page=${page}`) + if (!response.ok) throw new Error(`listing reviews to lift @${login}'s earlier change request answered ${response.status}; dismiss it on the PR to unblock the merge`) + const reviews = (await response.json()) as Review[] + return reviews.length === 100 ? [...reviews, ...(await pageOf(page + 1))] : reviews + } + const stale = (await pageOf(1)).filter((review) => review.state === "CHANGES_REQUESTED" && review.user?.login?.toLowerCase() === login.toLowerCase()) + const dismissed: number[] = [] + const errors: string[] = [] + for (const { id } of stale) { + const response = await withTokens(`https://api.github.com/repos/${repository}/pulls/${number}/reviews/${id}/dismissals`, { + method: "PUT", + body: JSON.stringify({ event: "DISMISS", message: `Second Look passed on ${headSha.slice(0, 7)}, so this earlier change request no longer stands.` }), + }) + if (response.ok) dismissed.push(id) + else errors.push(`review ${id} answered ${response.status}`) + } + if (errors.length) throw new Error(`couldn't lift @${login}'s earlier change request (${errors.join("; ")}); dismiss it on the PR to unblock the merge`) + return dismissed +} diff --git a/actions/second-look/jiraTicketOf.ts b/actions/second-look/jiraTicketOf.ts index a1d4a74..b11b9f0 100644 --- a/actions/second-look/jiraTicketOf.ts +++ b/actions/second-look/jiraTicketOf.ts @@ -1,3 +1,4 @@ +import { acceptanceOf } from "../../scripts/jira-rules/acceptanceOf.ts" import { textOfHtml } from "./textOfHtml.ts" type Named = { name?: string } | null | undefined @@ -14,12 +15,14 @@ export type JiraIssue = { labels?: string[] parent?: LinkedIssue issuelinks?: { type?: { inward?: string; outward?: string }; inwardIssue?: LinkedIssue; outwardIssue?: LinkedIssue }[] + /** adf (api v3) */ + description?: unknown } renderedFields?: { description?: string | null; comment?: { comments?: { author?: { displayName?: string }; created?: string; body?: string }[] } } } -/** a ticket as the reviewer reads it, plus its raw text for finding figma links */ -export type TicketContext = { key: string; markdown: string; text: string } +/** a ticket as the reviewer reads it, its acceptance criteria (scripts/jira-rules/acceptanceOf), and its raw text for finding figma links */ +export type TicketContext = { key: string; markdown: string; text: string; acceptance: string[] } /** GET a jira rest path as the bot's jira user; throws on a non-2xx */ export type JiraGet = (path: string) => Promise @@ -54,5 +57,6 @@ export const jiraTicketOf = async ({ get, key }: { get: JiraGet; key: string }): const issue = (await get(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${fields}&expand=renderedFields`)) as JiraIssue const parent = issue.fields.parent ? ((await get(`/rest/api/3/issue/${encodeURIComponent(issue.fields.parent.key)}?fields=summary,status,issuetype,description&expand=renderedFields`).catch(() => undefined)) as JiraIssue | undefined) : undefined const raw = [issue.renderedFields?.description ?? "", ...(issue.renderedFields?.comment?.comments ?? []).map((comment) => comment.body ?? ""), parent?.renderedFields?.description ?? ""].join("\n") - return { key, markdown: ticketMarkdownOf(issue, parent), text: raw } + const acceptance = acceptanceOf(issue.fields.description ?? textOfHtml(issue.renderedFields?.description)) + return { key, markdown: ticketMarkdownOf(issue, parent), text: raw, acceptance } } diff --git a/actions/second-look/notifyUnfinished.ts b/actions/second-look/notifyUnfinished.ts index 280da55..e8d83a7 100644 --- a/actions/second-look/notifyUnfinished.ts +++ b/actions/second-look/notifyUnfinished.ts @@ -2,7 +2,8 @@ /** * second look's wrap-up when a run ended without a review (and wasn't cancelled): the relay already * told the pr a review is coming, so this says it isn't, with a link to the run, as a comment review - * (a plain pr comment when a review can't be posted). best effort. + * (a plain pr comment when a review can't be posted). a comment review clears a pending review request, + * which ends watch mode, so a poster that was requested is requested again after. best effort. * * node notifyUnfinished.ts * @@ -10,26 +11,54 @@ * SECOND_LOOK_REPOSITORY (validated by prepare), SECOND_LOOK_NUMBER, SECOND_LOOK_RESULT, SECOND_LOOK_STAGE, * GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID. */ +import { isRequestedReviewer } from "./isRequestedReviewer.ts" +import { requestReviewer } from "./requestReviewer.ts" +import { appLogin, posterLogin } from "./reviewerLogins.ts" import { unfinishedReplyOf } from "./unfinishedReplyOf.ts" +/** + * posts `body` on the pr as `author` (with `postToken`): a comment review, or a plain comment when that's refused. + * when `author` was a requested reviewer, it's requested again with `appToken` after the review, as synthesize.ts + * does. returns how it replied; throws when neither reply went through + */ +export const notifyUnfinished = async ({ postToken, appToken, author, repository, number, body, warn = (line: string) => console.log(`::warning title=Second Look (beta)::${line}`), fetchImpl = fetch }: { + postToken: string + appToken: string + author: string + repository: string + number: number + body: string + warn?: (line: string) => void + fetchImpl?: typeof fetch +}) => { + const post = (path: string, payload: unknown) => + fetchImpl(`https://api.github.com/repos/${repository}/${path}`, { + method: "POST", + headers: { authorization: `Bearer ${postToken}`, accept: "application/vnd.github+json", "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + const wasRequested = await isRequestedReviewer({ token: appToken, repository, number, login: author, fetchImpl }) + const review = await post(`pulls/${number}/reviews`, { event: "COMMENT", body }) + if (review.ok) { + if (wasRequested) await requestReviewer({ token: appToken, repository, number, login: author, fetchImpl }).catch((failure: Error) => warn(failure.message)) + return "review" as const + } + const comment = await post(`issues/${number}/comments`, { body }) + if (!comment.ok) throw new Error(`replying answered ${review.status}, then ${comment.status}`) + return "comment" as const +} + const main = async () => { - const token = process.env.SECOND_LOOK_POST_TOKEN || process.env.GITHUB_TOKEN || "" + const postToken = process.env.SECOND_LOOK_POST_TOKEN || process.env.GITHUB_TOKEN || "" + const appToken = process.env.GITHUB_TOKEN || postToken + const author = process.env.SECOND_LOOK_POST_TOKEN ? posterLogin : appLogin const repository = process.env.SECOND_LOOK_REPOSITORY ?? "" const number = process.env.SECOND_LOOK_NUMBER ?? "" - if (!token || !repository || !/^\d+$/.test(number)) return console.log("second look: no token or pr to tell, skipping the reply") - const api = (path: string, body?: unknown) => - fetch(`https://api.github.com/repos/${repository}/${path}`, { - method: body === undefined ? "GET" : "POST", - headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }) + if (!postToken || !repository || !/^\d+$/.test(number)) return console.log("second look: no token or pr to tell, skipping the reply") const detailsUrl = `${process.env.GITHUB_SERVER_URL || "https://github.com"}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` const body = unfinishedReplyOf({ result: process.env.SECOND_LOOK_RESULT || "failure", stage: process.env.SECOND_LOOK_STAGE || "review", detailsUrl }) - const review = await api(`pulls/${number}/reviews`, { event: "COMMENT", body }) - if (review.ok) return console.log(`second look: told ${repository}#${number} the run didn't finish (comment review)`) - const comment = await api(`issues/${number}/comments`, { body }) - if (!comment.ok) throw new Error(`replying answered ${review.status}, then ${comment.status}`) - console.log(`second look: told ${repository}#${number} the run didn't finish`) + const replied = await notifyUnfinished({ postToken, appToken, author, repository, number: Number(number), body }) + console.log(`second look: told ${repository}#${number} the run didn't finish${replied === "review" ? " (comment review)" : ""}`) } if (import.meta.main) main().catch((error: Error) => console.log(`::warning title=Second Look (beta)::couldn't tell the PR the run didn't finish: ${error.message}`)) diff --git a/actions/second-look/reviewContextOf.ts b/actions/second-look/reviewContextOf.ts index 954b4d6..419366f 100644 --- a/actions/second-look/reviewContextOf.ts +++ b/actions/second-look/reviewContextOf.ts @@ -1,12 +1,17 @@ import type { FigmaFrame } from "./figmaFramesOf.ts" import type { TicketContext } from "./jiraTicketOf.ts" -/** the context section of the reviewer's prompt: the tickets, the linked designs, and what couldn't be read */ +/** a ticket's acceptance criteria as numbered lines (`EXO-1234 AC1: …`), or a line saying it has none */ +const acceptanceLinesOf = ({ key, acceptance }: TicketContext) => + acceptance.length ? acceptance.map((criterion, index) => `- ${key} AC${index + 1}: ${criterion}`) : [`- ${key}: no acceptance criteria in the ticket (no "Acceptance criteria" list, no Given/When/Then lines)`] + +/** the context section of the reviewer's prompt: the tickets, their acceptance criteria, the linked designs, and what couldn't be read */ export const reviewContextOf = ({ keys, tickets, frames, notes }: { keys: string[]; tickets: TicketContext[]; frames: FigmaFrame[]; notes: string[] }) => [ "## Ticket", "", keys.length ? (tickets.length ? tickets.map((ticket) => ticket.markdown).join("\n\n") : `${keys.join(", ")}: couldn't be read (see notes).`) : "No Jira ticket on this PR (NO-TICKET, or none referenced). Judge its intent from the description.", + ...(tickets.length ? ["", "## Acceptance criteria", "", ...tickets.flatMap(acceptanceLinesOf)] : []), ...(frames.length ? ["", "## Designs linked from the PR or ticket", "", ...frames.map((frame) => `- ${frame.name}: ${frame.url}${frame.imagePath ? `\n image: ${frame.imagePath} (open it)` : ""}${frame.note ? `\n (${frame.note})` : ""}`)] : []), ...(notes.length ? ["", "## Context notes", "", ...notes.map((note) => `- ${note}`)] : []), ].join("\n") diff --git a/actions/second-look/reviewerLogins.ts b/actions/second-look/reviewerLogins.ts new file mode 100644 index 0000000..3955637 --- /dev/null +++ b/actions/second-look/reviewerLogins.ts @@ -0,0 +1,7 @@ +/** + * the accounts second look posts as: clanker-in-chief with its PAT, or the App without it. the same names as + * infra/bot-ci/actions/second-look/betaScopeNames.ts (`reviewerLogin`, `appLogin`); this directory is published + * on its own, so it can't import them + */ +export const posterLogin = "clanker-in-chief" +export const appLogin = "touchless-bot[bot]" diff --git a/actions/second-look/rules.md b/actions/second-look/rules.md index 229c86e..88b14a9 100644 --- a/actions/second-look/rules.md +++ b/actions/second-look/rules.md @@ -1,6 +1,7 @@ # Architecture rules (template) + -Copy to `.github/second-look/rules.md` and replace with the repo's real rules. Keep it short; Second Look's main reviewer reads all of it on every run, alongside the repo's docs. The org's testing rules (`testingRules.md`: acceptance criteria have tests, new code paths are tested, no skipped tests, e2e for user-facing changes) are added after these on every repo, so don't copy them here. +Copy to `.github/second-look/rules.md` and replace with the repo's real rules. Keep it short; Second Look's main reviewer reads all of it on every run, alongside the repo's docs. The org's testing rules (`testingRules.md`, the Definition of Done) are added after these on every repo, so don't copy them here. - Data access goes through the data layer (core-db / GraphQL); no raw SQL in app code. - Auth and secrets never appear in client bundles, logs or test fixtures. diff --git a/actions/second-look/secondLookVerdictOf.ts b/actions/second-look/secondLookVerdictOf.ts index efd1958..e3cf54c 100644 --- a/actions/second-look/secondLookVerdictOf.ts +++ b/actions/second-look/secondLookVerdictOf.ts @@ -9,11 +9,12 @@ const loginOf = (user: ReviewLike["user"]) => (typeof user === "string" ? user : * sha decides (github lists check runs newest first; only the touchless bot app's count), and its own review on that * sha can only make it stricter: * - no check on the sha, or one still running → pending (a capped or not-yet-dispatched head stays here) - * - green → approved, unless its review on the sha requested changes. a green check from a run that wasn't - * beta-enabled (titled "No changes requested", no beta external_id) is never an approval → pending - * - red (failure, action_required) → changes_requested; a review that couldn't finish is red in a beta run too - * - neutral, cancelled, skipped, timed out → pending - * `checkRuns` and `reviews` are github's objects or the flattened `{ app_slug, user: login }` shapes. + * - green → approved, unless its review on the sha requested changes. only a beta-enabled run's check (its + * `external_id` is `betaExternalId`) can approve; any other green check is informational → pending + * - red (failure, action_required) → changes_requested + * - neutral (a review that couldn't finish), cancelled, skipped, timed out → pending + * `checkRuns` and `reviews` are github's objects or the flattened `{ app_slug, user: login }` shapes; a flattened + * check run must keep `external_id`, or it never approves. */ export const secondLookVerdictOf = (checkRuns: CheckRunLike[], reviews: ReviewLike[], headSha: string): SecondLookVerdict => { const check = checkRuns.find((run) => run.name === secondLookCheckName && run.head_sha === headSha && [undefined, appSlug].includes(appSlugOf(run))) @@ -22,7 +23,5 @@ export const secondLookVerdictOf = (checkRuns: CheckRunLike[], reviews: ReviewLi if (check.conclusion === "failure" || check.conclusion === "action_required") return "changes_requested" if (check.conclusion !== "success") return "pending" if (review?.state === "CHANGES_REQUESTED") return "changes_requested" - const title = check.output?.title - if (check.external_id !== betaExternalId && title != null && title !== "Approved") return "pending" - return "approved" + return check.external_id === betaExternalId ? "approved" : "pending" } diff --git a/actions/second-look/synthesisPrompt.ts b/actions/second-look/synthesisPrompt.ts index 493d907..5aa4a67 100644 --- a/actions/second-look/synthesisPrompt.ts +++ b/actions/second-look/synthesisPrompt.ts @@ -27,11 +27,12 @@ Second Look is an opt-in reviewer that reviews like a person: a "block" requests The current directory is a read-only checkout of the base branch: the repo as it is before this PR. Its docs describe the intended architecture. Changes the PR makes to docs are in the diff, and they're proposals, not the baseline. The ticket and designs below were read for you; you have no network. Work in this order: -1. Intent. Read the ticket (and its parent and comments) and the PR description: what problem this solves, and what "done" means (acceptance criteria, designs). If there's no ticket, take the intent from the description and say so. +1. Intent. Read the ticket (and its parent and comments) and the PR description: what problem this solves, and what "done" means (acceptance criteria, designs). The criteria parsed from the ticket are listed under "Acceptance criteria" below. If there's no ticket, take the intent from the description and say so. 2. Context. Open the code around the change: the modules it touches, their callers and siblings, and how the same kind of thing is already done elsewhere in the repo. Know the app or package this lands in before judging the diff. 3. Architecture. Read the docs below: root docs and the ones nearest the changed files first, and skim the rest by title. Follow links that matter. Write down the intended architecture as 3-8 short bullets (layers, boundaries, data access, the conventions the docs state). Claim only what the docs say; if they're thin, say so in a bullet. 4. Review the diff against all of that and the rules below: - - does it do what the ticket asks, and only that (missing acceptance criteria, scope creep, unrelated changes)? If designs are linked, does the UI match them? + - does it do what the ticket asks, and only that? Every acceptance criterion is met, and the diff changes nothing the ticket doesn't call for: unrelated files, refactors or features are scope creep, a warning. If designs are linked, does the UI match them? + - criterion → test: the PR description maps each acceptance criterion to the test that proves it (testingRules.md). Check that every listed criterion is mapped, that the named test exists (in the diff or the repo) and asserts that criterion, and that a criterion marked manual says how it was checked. A missing mapping, or a test that doesn't prove its criterion, is a warning citing testingRules.md. - does it follow the repo's conventions: structure, naming, the existing patterns and helpers (duplicated, bypassed or contradicted ones), error handling, data access, dependencies? - anything weird: hacks, dead or commented-out code, debug leftovers, disabled checks or tests, hardcoded values or secrets, surprising files; - best practices: security, data handling, performance, accessibility for UI, and tests for new logic (CI runs them; you don't); diff --git a/actions/second-look/synthesize.ts b/actions/second-look/synthesize.ts index 3b10355..4c95662 100644 --- a/actions/second-look/synthesize.ts +++ b/actions/second-look/synthesize.ts @@ -8,7 +8,8 @@ * gets a reply and is resolved, a changed one gets a reply, and nothing is posted twice. * then it submits one review as clanker-in-chief: request changes on a block, a comment when it couldn't review, and on * a pass a comment, or an approval only when the relay dispatched the run for a beta-enabled pr (SECOND_LOOK_BETA_ENABLED, - * reviewEventOf.ts), with the new findings that sit on the diff as inline comments. then one sticky pr comment, and it + * reviewEventOf.ts), with the new findings that sit on the diff as inline comments. a pass also dismisses its own earlier + * change requests on the pr (dismissOwnChangeRequests.ts), since a comment review never lifts one. then one sticky pr comment, and it * closes the pr's check with the outcome: outside the beta scope informational (green on a pass, neutral otherwise, * never red), on a beta-enabled pr green on a pass and red otherwise. it never runs pr code. exits 1 only when it * crashes before closing the check, so the workflow's wrap-up closes it instead. @@ -34,6 +35,7 @@ import { cliCommandOf, defaultModels } from "./cliCommandOf.ts" import { commentBodyOf, commentMarker } from "./commentBodyOf.ts" import { commentableLinesOf } from "./commentableLinesOf.ts" import { directorySnapshot } from "./directorySnapshot.ts" +import { dismissOwnChangeRequests } from "./dismissOwnChangeRequests.ts" import { finalMessageOf } from "./finalMessageOf.ts" import { cappedDiffOf, gitDiffOf } from "./gitDiffOf.ts" import { earlierSectionOf } from "./earlierSectionOf.ts" @@ -46,6 +48,7 @@ import { headlineOf } from "./headlineOf.ts" import { postReview } from "./postReview.ts" import { postStickyComment } from "./postStickyComment.ts" import { requestReviewer } from "./requestReviewer.ts" +import { appLogin, posterLogin } from "./reviewerLogins.ts" import { reviewBodyOf } from "./reviewBodyOf.ts" import { reviewEventOf } from "./reviewEventOf.ts" import { routeFindingsOf } from "./routeFindingsOf.ts" @@ -68,8 +71,6 @@ import { verdictOf } from "./verdictOf.ts" const maxPromptDiffChars = 120_000 const families: Family[] = ["openai", "anthropic"] -const posterLogin = "clanker-in-chief" -const appLogin = "touchless-bot[bot]" const keyOf = (family: Family) => process.env[`SECOND_LOOK_${family.toUpperCase()}_API_KEY`] ?? "" const modelOf = (family: Family) => process.env[`SECOND_LOOK_${family.toUpperCase()}_MODEL`] || defaultModels[family] @@ -168,6 +169,12 @@ const main = async () => { if (posted !== null && event !== "COMMENT" && posted?.event !== event) warn(`GitHub wouldn't let @${author} ${event === "REQUEST_CHANGES" ? "request changes" : "approve"} on this PR (its own?), so the review only comments`) const reviewUrl = posted?.url || undefined if (reviewUrl) console.log(`second look review (${posted?.event}): ${reviewUrl}`) + // a pass that only comments never replaces this reviewer's earlier change request, which would still block the merge + if (posted !== null && verdict?.verdict === "pass") { + await dismissOwnChangeRequests({ tokens: [appToken, postToken], repository, number: pull.number, login: author, headSha: pull.head.sha }) + .then((ids) => ids.length && console.log(`lifted the earlier change request: ${ids.join(", ")}`)) + .catch((failure: Error) => warn(failure.message)) + } const onDiff = new Set(reviewUrl ? fresh.map(({ finding }) => finding) : []) const replied = followUps.some(({ status }) => status !== "open") || onThread.length > 0 if (wasRequested && (reviewUrl || replied)) await requestReviewer({ token: appToken, repository, number: pull.number, login: author }).catch((failure: Error) => warn(failure.message)) diff --git a/actions/second-look/testingRules.md b/actions/second-look/testingRules.md index 049f4d1..7519261 100644 --- a/actions/second-look/testingRules.md +++ b/actions/second-look/testingRules.md @@ -1,9 +1,12 @@ # Testing rules (org default, always applied) + -These apply on every repo, on top of the repo's own rules. Cite them as "testingRules.md". +These apply on every repo, on top of the repo's own rules. They are the Definition of Done for bot-built PRs. Cite them as "testingRules.md". -- **Acceptance criteria have tests.** Each acceptance criterion in the PR's ticket is checked by at least one test (unit, or e2e for behaviour a user sees). Name the criterion that has none. A criterion that can't be tested automatically says so in the PR's Test Plan, with how it was checked by hand. -- **New code paths are tested.** Every new branch, error path and exported function has a unit test that exercises it. Coverage that only comes from a test calling the code without asserting on what it did doesn't count. +- **Acceptance criteria have tests.** Each acceptance criterion in the PR's ticket (its "Acceptance criteria" list, else its Given/When/Then lines) is checked by at least one test: unit, or e2e for behaviour a user sees. Name the criterion that has none. +- **The PR maps each criterion to its test.** The Test Plan has one line per criterion, numbered in the ticket's order: `AC1 → › ""`. A criterion no test can check says `manual: `. A missing line, or a named test that doesn't assert its criterion, is a finding. +- **New code paths are tested.** Every new branch, error path and exported function has a unit test that exercises it. A test that calls the code without asserting what it did doesn't count. +- **User-facing changes get an e2e test.** A change to what a user sees or does (a page, a form, an API response a client renders) adds or updates a Playwright test that runs against the PR's preview: bot-ci's E2E job in repos made from app-template, exo's own e2e jobs in exo. A bot PR never skips them: no `[skip e2e]` or `[skip-checks]` in its title (PR Lint fails it), and it isn't a draft (exo runs no e2e on drafts). - **No skipped or focused tests.** No new `.skip`, `.only`, `xit`, `xdescribe`, `test.todo` or commented-out tests, and no widened coverage `exclude`, lowered thresholds or `/* v8 ignore */` / `istanbul ignore` comments, unless the PR says why. Skipping a whole e2e suite when no preview URL is set is fine. -- **User-facing changes get an e2e test.** A change to what a user sees or does (a page, a form, an API response a client renders) adds or updates a Playwright test under the app's `e2e/`, run against the preview by bot-ci's E2E job. - **Tests assert behaviour, not implementation.** Prefer asserting outputs and visible results over mocks of internals; a test that would still pass with the feature removed is a finding. +- **Coverage is a signal, not the bar.** Repos made from app-template hold 100% through their own Vitest thresholds; bot-ci's coverage gate only warns unless the org sets `BOT_CI_COVERAGE=enforce`; exo, exo-foundation and core-db have no bot-ci gate. A coverage number never stands in for a criterion's test. diff --git a/scripts/jira-rules/acceptanceOf.ts b/scripts/jira-rules/acceptanceOf.ts new file mode 100644 index 0000000..3213ee2 --- /dev/null +++ b/scripts/jira-rules/acceptanceOf.ts @@ -0,0 +1,64 @@ +import { adfTextOf } from "./adfTextOf.ts" + +const heading = /^\s*(#+\s*|\*\*)?acceptance criteria\b/i +const gherkin = /^\s*[-*•]?\s*(given|when|then|and)\b/i +const starts = (word: string) => new RegExp(`^\\s*[-*•]?\\s*${word}\\b`, "i") + +/** given/when/then lines, only when they form a scenario: from a Given line through its Then (prose that starts with "When" isn't a criterion) */ +const scenarioOf = (text: string) => { + const lines = text.split("\n") + const start = lines.findIndex((line) => starts("given").test(line)) + if (start < 0) return [] + const run: string[] = [] + for (const line of lines.slice(start)) { + if (!line.trim()) continue + if (!gherkin.test(line)) break + run.push(line) + } + return run.some((line) => starts("then").test(line)) ? run : [] +} + +/** Criteria in canonical ADF lists or the per-line bullet paragraphs effort-intake's Push writes. */ +const adfListOf = (doc: { content?: unknown[] }) => { + const blocks = (doc.content ?? []) as { type?: string; content?: unknown[] }[] + const start = blocks.findIndex((block) => (block.type === "heading" || block.type === "paragraph") && heading.test(adfTextOf(block))) + if (start < 0) return [] + const items: string[] = [] + for (const block of blocks.slice(start + 1)) { + if (block.type === "heading") break + if (block.type === "bulletList" || block.type === "orderedList") items.push(...(block.content ?? []).map(adfTextOf)) + else { + const text = adfTextOf(block) + const bullet = block.type === "paragraph" ? text.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/) : undefined + if (bullet) items.push(bullet[1]!) + else if (items.length || text.trim()) break + } + } + return items +} + +/** the "- " bullets under an "Acceptance criteria" line in plain text or markdown */ +const textListOf = (text: string) => { + const lines = text.split("\n") + const start = lines.findIndex((line) => heading.test(line)) + if (start < 0) return [] + const items: string[] = [] + for (const line of lines.slice(start + 1)) { + const bullet = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)$/) + if (bullet) items.push(bullet[1]) + else if (line.trim() && items.length) break + } + return items +} + +/** + * a ticket's acceptance criteria from its description (adf from jira, or plain text): the list under an + * "Acceptance criteria" heading, else its Given/When/Then lines. empty when it has none. one parser for the + * whole factory: effort-sync (what the loop may build) and Second Look (what the PR must prove) + */ +export const acceptanceOf = (description: unknown): string[] => { + const listed = typeof description === "string" ? textListOf(description) : description && typeof description === "object" ? adfListOf(description as { content?: unknown[] }) : [] + const text = typeof description === "string" ? description : adfTextOf(description) + const items = listed.length ? listed : scenarioOf(text) + return items.map((item) => item.replace(/^\s*[-*•]\s*/, "").replace(/^\s*\[[ xX]\]\s*/, "").trim()).filter(Boolean) +} diff --git a/scripts/jira-rules/adfTextOf.ts b/scripts/jira-rules/adfTextOf.ts new file mode 100644 index 0000000..7ed5510 --- /dev/null +++ b/scripts/jira-rules/adfTextOf.ts @@ -0,0 +1,8 @@ +/** an adf node's plain text: text leaves joined, hard breaks as newlines */ +export const adfTextOf = (node: unknown): string => { + if (!node || typeof node !== "object") return "" + const { type, text, content } = node as { type?: string; text?: string; content?: unknown[] } + if (type === "text") return text ?? "" + if (type === "hardBreak") return "\n" + return (content ?? []).map(adfTextOf).join(type === "listItem" || type === "doc" ? "\n" : "") +}