diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 48aa59cca17e8..c83692d287fa1 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -2756,6 +2756,38 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("lists only the head's workflow runs that have not finished", () => + Effect.gen(function* () { + mockedExecute.mockImplementation(() => + Effect.succeed( + output( + encodeJson({ + workflow_runs: ["queued", "in_progress", "completed", "action_required", null].map( + (status, index) => ({ id: index, name: `${status}`, html_url: null, status }), + ), + }), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const runs = yield* cli.listUnfinishedWorkflowRuns({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + headSha: "abc123", + }); + + expect(restCallsTo("actions/runs?")[0]?.path).toBe( + "repos/acme/web/actions/runs?head_sha=abc123&per_page=100", + ); + expect(runs).toEqual([ + { id: 0, name: "queued", url: null }, + { id: 1, name: "in_progress", url: null }, + ]); + }), + ); + it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 96b0b3d5fdd6a..45275bef0ae44 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -69,6 +69,7 @@ import { decodeCommitFilesJson, pullRequestChecksFromContexts, decodeWorkflowRunsJson, + decodeUnfinishedWorkflowRunsJson, type GitHubPullRequestCore, type GitHubPullRequestFileStat, type GitHubPullRequestWatchFingerprint, @@ -549,6 +550,14 @@ export class GitHubPullRequestApi extends Context.Service< readonly isCrossRepository: true; }) => Effect.Effect, GitHubPullRequestApiError>; + /** The head's workflow runs that have not finished, the latest 100. */ + readonly listUnfinishedWorkflowRuns: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headSha: string; + }) => Effect.Effect, GitHubPullRequestApiError>; + readonly getPullRequestStack: (input: { readonly cwd: string; readonly includeDetails?: boolean; @@ -2076,6 +2085,19 @@ export const make = Effect.gen(function* () { }, ), listWorkflowRunsRequiringApproval, + listUnfinishedWorkflowRuns: Effect.fn("GitHubPullRequestApi.listUnfinishedWorkflowRuns")( + function (input) { + const { owner, name } = parseRepositorySelector(input.repository); + // Read with every pull request detail, so revalidate it. + return revalidatedRestRead({ + cwd: input.cwd, + host: input.host, + operation: "listUnfinishedWorkflowRuns", + path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&per_page=100`, + decode: decodeUnfinishedWorkflowRunsJson, + }); + }, + ), getPullRequestStack: Effect.fn("GitHubPullRequestApi.getPullRequestStack")(function (input) { const { owner, name } = parseRepositorySelector(input.repository); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index d732ed214677b..caba3239bde17 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -415,6 +415,7 @@ describe("gitHubViewerPermissions", () => { comments: [], commits: [], }), + listUnfinishedWorkflowRuns: () => Effect.succeed([]), listWorkflowRunsRequiringApproval: () => Effect.succeed([ { @@ -470,7 +471,17 @@ const openDetail = { commits: [], }; -it.effect("does not classify same-repository gates as fork workflow approvals", () => +const sameRepositoryChecks = [ + { name: "CodeRabbit", status: "skipped" as const, description: null, url: null }, + { + name: "lint", + status: "pending" as const, + description: null, + url: "https://github.com/acme/web/actions/runs/201/job/1", + }, +]; + +it.effect("adds unstarted same-repository workflow runs as pending, not as approvals", () => Effect.gen(function* () { const provider = yield* make; const detail = yield* provider.getChangeRequest({ @@ -481,12 +492,27 @@ it.effect("does not classify same-repository gates as fork workflow approvals", }); expect(detail.workflowApprovalsRequired).toBe(0); - expect(detail.checks).toEqual([]); + expect(detail.checks).toEqual([ + ...sameRepositoryChecks, + { + name: "CI", + status: "pending", + description: "This workflow run has not finished.", + url: "https://github.com/acme/web/actions/runs/202", + }, + ]); }).pipe( Effect.provide( Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), + getPullRequestDetail: () => + Effect.succeed({ ...openDetail, isCrossRepository: false, checks: sameRepositoryChecks }), + // Run 201 is represented by its running job; run 202 is queued before any job exists. + listUnfinishedWorkflowRuns: () => + Effect.succeed([ + { id: 201, name: "Lint", url: "https://github.com/acme/web/actions/runs/201" }, + { id: 202, name: "CI", url: "https://github.com/acme/web/actions/runs/202" }, + ]), listWorkflowRunsRequiringApproval: () => Effect.die("same-repository pull requests must not probe fork workflow approvals"), getViewerAccess: () => @@ -528,6 +554,7 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), + listUnfinishedWorkflowRuns: () => Effect.succeed([]), listWorkflowRunsRequiringApproval: () => Effect.fail( new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 723f96769d760..4809efc68998b 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -161,6 +161,9 @@ function withAvatar( return avatarUrl === null ? actor : { ...actor, avatarUrl }; } +const workflowRunIdOf = (url: string | null) => + url?.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1] ?? null; + function withWorkflowApprovals( checks: ReadonlyArray, runs: ReadonlyArray, @@ -169,8 +172,8 @@ function withWorkflowApprovals( const representedRunIds = new Set(); for (const check of checks) { if (check.status !== "action-required" || check.url === null) continue; - const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; - if (id !== undefined) representedRunIds.add(Number(id)); + const id = workflowRunIdOf(check.url); + if (id !== null) representedRunIds.add(Number(id)); } const approvalChecks = runs .filter((run) => !representedRunIds.has(run.id)) @@ -197,6 +200,30 @@ function withWorkflowApprovals( ]; } +// GitHub adds a run's check runs only as its jobs start, so a queued run can have none yet. +function withUnfinishedWorkflowRuns( + checks: ReadonlyArray, + runs: ReadonlyArray, +): ReadonlyArray { + const runningRunIds = new Set(); + for (const check of checks) { + if (check.status !== "pending" && check.status !== "action-required") continue; + const id = workflowRunIdOf(check.url); + if (id !== null) runningRunIds.add(Number(id)); + } + return [ + ...checks, + ...runs + .filter((run) => !runningRunIds.has(run.id)) + .map((run): PullRequestCheck => ({ + name: run.name, + status: "pending", + description: "This workflow run has not finished.", + url: run.url, + })), + ]; +} + /** App logins have no profile page; guessed avatar URLs 404. */ export function loginAvatarUrl(login: string, host: string): string | null { return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; @@ -338,11 +365,24 @@ export const make = Effect.gen(function* () { }), ) ).pipe( - Effect.map((workflowApprovals) => ({ + Effect.zip( + pullRequest.state !== "open" || pullRequest.headSha == null + ? Effect.succeed([] as ReadonlyArray) + : cli.listUnfinishedWorkflowRuns({ ...input, headSha: pullRequest.headSha }).pipe( + Effect.catchIf( + (error) => + error._tag !== "GitHubApiRateLimitError" && + error._tag !== "SourceControlRateLimitPausedError", + () => Effect.succeed([] as ReadonlyArray), + ), + ), + { concurrent: true }, + ), + Effect.map(([workflowApprovals, unfinishedRuns]) => ({ ...pullRequest, author: withAvatar(pullRequest.author, new Map(), input.host), checks: withWorkflowApprovals( - pullRequest.checks, + withUnfinishedWorkflowRuns(pullRequest.checks, unfinishedRuns), workflowApprovals.runs, workflowApprovals.unavailable, ), diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 44f33caa3be34..b97454d1c74bb 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -216,6 +216,7 @@ const RawWorkflowRunsSchema = Schema.Struct({ id: Schema.Int, name: Schema.optional(Schema.NullOr(Schema.String)), html_url: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), }), ), }); @@ -1124,6 +1125,32 @@ export function decodeWorkflowRunsJson( }); } +// Approval-gated runs (action_required) are read separately as action-required checks. +const UNFINISHED_WORKFLOW_RUN_STATUSES = new Set([ + "requested", + "queued", + "pending", + "waiting", + "in_progress", +]); + +/** Workflow runs GitHub has not finished, which may have no check runs on the head yet. */ +export function decodeUnfinishedWorkflowRunsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeWorkflowRuns(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + return Result.succeed( + decoded.success.workflow_runs + .filter((run) => UNFINISHED_WORKFLOW_RUN_STATUSES.has(run.status ?? "")) + .map((run) => ({ + id: run.id, + name: trimmed(run.name) ?? `Workflow run ${run.id}`, + url: trimmed(run.html_url), + })), + ); +} + export function toPullRequestHeads(result: PullRequestHeads.Result): { readonly heads: ReadonlyArray; readonly nextCursor: string | null;