Repository navigation
fix(previews): reap unlabelled stages and stop skipped runs cancelling teardown #1586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import { previewStateOf } from "./pr-state.ts" | ||
|
|
||
| describe("previewStateOf", () => { | ||
| test("an open PR keeps its preview only while it carries the label", () => { | ||
| expect(previewStateOf({ state: "open", labels: [{ name: "bug" }, { name: "preview" }] })).toBe("live") | ||
| expect(previewStateOf({ state: "open", labels: [{ name: "preview:collector" }] })).toBe("retired") | ||
| expect(previewStateOf({ state: "open", labels: [] })).toBe("retired") | ||
| }) | ||
|
|
||
| test("a closed PR is retired even if the label is still on it", () => { | ||
| expect(previewStateOf({ state: "closed", labels: [{ name: "preview" }] })).toBe("retired") | ||
| }) | ||
|
|
||
| test("an unrecognised payload is unknown, so nothing is deleted", () => { | ||
| expect(previewStateOf({})).toBe("unknown") | ||
| expect(previewStateOf({ state: "draft", labels: [{ name: "preview" }] })).toBe("unknown") | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,74 @@ | ||
| /** | ||
| * The PR-state gate shared by the preview orphan sweeps (cleanup-preview-orphans.yml). | ||
| * The preview-state gate shared by the preview orphan sweeps (cleanup-preview-orphans.yml). | ||
| * | ||
| * Auth: GITHUB_REPOSITORY + GITHUB_TOKEN/GH_TOKEN. | ||
| */ | ||
|
|
||
| export type PrState = "open" | "closed" | "unknown" | ||
| /** `live`: open and labelled `preview`. `retired`: closed, or open without the label. */ | ||
| export type PreviewState = "live" | "retired" | "unknown" | ||
|
|
||
| export const PREVIEW_LABEL = "preview" | ||
|
|
||
| export const hasPrStateCredentials = (): boolean => | ||
| Boolean( | ||
| process.env.GITHUB_REPOSITORY?.trim() && (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN)?.trim(), | ||
| ) | ||
|
|
||
| /** An open PR that lost its label is retired too: a cancelled teardown leaves its stage up. */ | ||
| export const previewStateOf = (pull: { | ||
| state?: string | ||
| labels?: ReadonlyArray<{ name?: string }> | ||
| }): PreviewState => { | ||
| if (pull.state === "closed") return "retired" | ||
| if (pull.state !== "open") return "unknown" | ||
| return pull.labels?.some((label) => label.name === PREVIEW_LABEL) ? "live" : "retired" | ||
| } | ||
|
|
||
| /** | ||
| * Whether a preview deploy run for this PR is queued or running, so a sweep never destroys a | ||
| * stage that a restored label is redeploying. "unknown" when the API call fails. | ||
| */ | ||
| export const hasActivePreviewRun = async (prNumber: string): Promise<boolean | "unknown"> => { | ||
| const repo = process.env.GITHUB_REPOSITORY?.trim() | ||
| const token = (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN)?.trim() | ||
| if (!repo || !token) return "unknown" | ||
| try { | ||
| for (const status of ["queued", "in_progress"]) { | ||
| const response = await fetch( | ||
| `https://api.github.com/repos/${repo}/actions/workflows/deploy-pr-preview.yml/runs?status=${status}&per_page=100`, | ||
| { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| Accept: "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| }, | ||
| }, | ||
| ) | ||
| if (!response.ok) { | ||
| console.log(`⚠ Could not list ${status} preview runs (HTTP ${response.status})`) | ||
| return "unknown" | ||
| } | ||
| const { workflow_runs: runs = [] } = (await response.json()) as { | ||
| workflow_runs?: ReadonlyArray<{ pull_requests?: ReadonlyArray<{ number?: number }> }> | ||
| } | ||
| if (runs.some((run) => run.pull_requests?.some((pull) => String(pull.number) === prNumber))) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
|
Comment on lines
+36
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: set -eu
printf '%s\n' '--- scripts/lib/pr-state.ts ---'
nl -ba scripts/lib/pr-state.ts | sed -n '1,150p'
printf '%s\n' '--- relevant tests and pagination code ---'
rg -n -F --glob '*.ts' -e 'hasActivePreviewRun' -e 'fetchPreviewState' -e 'Link' -e 'rel="next"' scripts .github || test "$?" -eq 1
printf '%s\n' '--- package context ---'
rg -n -F --glob 'package.json' -e '"typescript"' -e '"bun"' -e '"test"' .Repository: MapleTechLabs/maple Length of output: 11774 🏁 Script executed: set -eu
printf '%s\n' '--- stage sweep control flow ---'
nl -ba scripts/preview-stage-orphan-sweep.ts | sed -n '270,335p'
printf '%s\n' '--- destroyStage definition and callers ---'
rg -n -F --glob '*.ts' -e 'destroyStage' scripts
printf '%s\n' '--- deploy workflow relevant jobs and stage operations ---'
nl -ba .github/workflows/deploy-pr-preview.yml | sed -n '1,125p'Repository: MapleTechLabs/maple Length of output: 10945 🏁 Script executed: set -eu
printf '%s\n' '--- destroyStage implementation ---'
nl -ba scripts/preview-stage-orphan-sweep.ts | sed -n '175,215p'
printf '%s\n' '--- deployment and teardown steps ---'
nl -ba .github/workflows/deploy-pr-preview.yml | sed -n '112,190p'Repository: MapleTechLabs/maple Length of output: 6414 Follow every
Suggested fix--- "a/scripts/lib/pr-state.ts"
+++ "b/scripts/lib/pr-state.ts"
@@ -33,28 +33,30 @@
const token = (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN)?.trim()
if (!repo || !token) return "unknown"
try {
for (const status of ["queued", "in_progress"]) {
- const response = await fetch(
+ let nextUrl =
`https://api.github.com/repos/${repo}/actions/workflows/deploy-pr-preview.yml/runs?status=${status}&per_page=100`,
- {
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/vnd.github+json",
- "X-GitHub-Api-Version": "2022-11-28",
- },
- },
- )
- if (!response.ok) {
- console.log(`⚠ Could not list ${status} preview runs (HTTP ${response.status})`)
- return "unknown"
- }
- const { workflow_runs: runs = [] } = (await response.json()) as {
- workflow_runs?: ReadonlyArray<{ pull_requests?: ReadonlyArray<{ number?: number }> }>
- }
- if (runs.some((run) => run.pull_requests?.some((pull) => String(pull.number) === prNumber))) {
- return true
+ while (nextUrl) {
+ const response = await fetch(nextUrl, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ })
+ if (!response.ok) {
+ console.log(`⚠ Could not list ${status} preview runs (HTTP ${response.status})`)
+ return "unknown"
+ }
+ const { workflow_runs: runs = [] } = (await response.json()) as {
+ workflow_runs?: ReadonlyArray<{ pull_requests?: ReadonlyArray<{ number?: number }> }>
+ }
+ if (runs.some((run) => run.pull_requests?.some((pull) => String(pull.number) === prNumber))) {
+ return true
+ }
+ nextUrl = response.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? ""
}
}
return false
} catch (error) {
console.log(🤖 Prompt for AI Agents |
||
| } catch (error) { | ||
| console.log( | ||
| `⚠ Could not list preview runs (${error instanceof Error ? error.message : String(error)})`, | ||
| ) | ||
| return "unknown" | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * PR state via the GitHub REST API. Returns "unknown" when the API call | ||
| * fails; callers must treat "unknown" as "don't delete", never as "closed". | ||
| * Preview state via the GitHub REST API. Returns "unknown" when the API call | ||
| * fails; callers must treat "unknown" as "don't delete", never as "retired". | ||
| */ | ||
| export const fetchPrState = async (prNumber: string): Promise<PrState> => { | ||
| export const fetchPreviewState = async (prNumber: string): Promise<PreviewState> => { | ||
| const repo = process.env.GITHUB_REPOSITORY?.trim() | ||
| const token = (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN)?.trim() | ||
| if (!repo || !token) return "unknown" | ||
|
|
@@ -31,8 +84,9 @@ export const fetchPrState = async (prNumber: string): Promise<PrState> => { | |
| console.log(`⚠ Could not look up PR #${prNumber} state (HTTP ${response.status})`) | ||
| return "unknown" | ||
| } | ||
| const parsed = (await response.json()) as { state?: string } | ||
| return parsed.state === "open" ? "open" : parsed.state === "closed" ? "closed" : "unknown" | ||
| return previewStateOf( | ||
| (await response.json()) as { state?: string; labels?: ReadonlyArray<{ name?: string }> }, | ||
| ) | ||
| } catch (error) { | ||
| console.log( | ||
| `⚠ Could not look up PR #${prNumber} state (${error instanceof Error ? error.message : String(error)})`, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.