Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"--limit",
String(input.limit ?? 1),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
Expand Down Expand Up @@ -571,7 +571,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
"view",
input.reference,
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
}).pipe(
Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
Expand Down Expand Up @@ -1071,7 +1071,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
state: "open",
});
expect(ghCalls).toContain(
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
);
}),
20_000,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import * as ServerSettings from "../serverSettings.ts";
import type { GitManagerServiceError } from "@t3tools/contracts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import type { ChangeRequest } from "@t3tools/contracts";
import type { ChangeRequest, ChangeRequestChecks } from "@t3tools/contracts";

export interface GitActionProgressReporter {
readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
Expand Down Expand Up @@ -114,6 +114,7 @@ interface OpenPrInfo {
interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
state: "open" | "closed" | "merged";
updatedAt: Option.Option<DateTime.Utc>;
checks?: ChangeRequestChecks | null;
}

const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
Expand Down Expand Up @@ -317,6 +318,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
...(summary.headRepositoryOwnerLogin !== undefined
? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
: {}),
...(summary.checks !== undefined ? { checks: summary.checks } : {}),
};
}

Expand Down Expand Up @@ -461,6 +463,7 @@ function toStatusPr(pr: PullRequestInfo): {
baseRef: string;
headRef: string;
state: "open" | "closed" | "merged";
checks?: ChangeRequestChecks | null;
} {
return {
number: pr.number,
Expand All @@ -469,6 +472,7 @@ function toStatusPr(pr: PullRequestInfo): {
baseRef: pr.baseRefName,
headRef: pr.headRefName,
state: pr.state,
...(pr.checks !== undefined ? { checks: pr.checks } : {}),
};
}

Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import * as Layer from "effect/Layer";
import {
GitManagerError,
GitCommandError,
type GitDivergedError,
type VcsFetchResult,
type VcsPushResult,
type VcsSyncInput,
type VcsSyncResult,
type VcsSwitchRefInput,
type VcsSwitchRefResult,
type VcsCreateRefInput,
Expand Down Expand Up @@ -49,6 +54,12 @@ export class GitWorkflowService extends Context.Service<
readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
readonly fetchCurrentBranch: (cwd: string) => Effect.Effect<VcsFetchResult, GitCommandError>;
readonly pushCurrentBranch: (cwd: string) => Effect.Effect<VcsPushResult, GitCommandError>;
readonly syncCurrentBranch: (
cwd: string,
options?: { readonly mode?: VcsSyncInput["mode"] },
) => Effect.Effect<VcsSyncResult, GitCommandError | GitDivergedError>;
readonly runStackedAction: (
input: GitRunStackedActionInput,
options?: GitManager.GitRunStackedActionOptions,
Expand Down Expand Up @@ -277,6 +288,28 @@ export const make = Effect.gen(function* () {
ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
Effect.andThen(git.pullCurrentBranch(cwd)),
),
fetchCurrentBranch: (cwd) =>
ensureGitCommand("GitWorkflowService.fetchCurrentBranch", cwd).pipe(
Effect.andThen(git.fetchCurrentBranch(cwd)),
),
pushCurrentBranch: (cwd) =>
ensureGitCommand("GitWorkflowService.pushCurrentBranch", cwd).pipe(
// Reuse the exact driver call the stacked-action push path uses so the
// standalone push and the bundled push share upstream-setting logic.
Effect.andThen(git.pushCurrentBranch(cwd, null)),
Effect.map(
(result): VcsPushResult => ({
status: result.status,
refName: result.branch,
upstreamRef: result.upstreamBranch ?? null,
setUpstream: result.setUpstream ?? false,
}),
),
),
syncCurrentBranch: (cwd, options) =>
ensureGitCommand("GitWorkflowService.syncCurrentBranch", cwd).pipe(
Effect.andThen(git.syncCurrentBranch(cwd, options)),
),
runStackedAction: (input, options) =>
ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
Effect.andThen(gitManager.runStackedAction(input, options)),
Expand Down
91 changes: 90 additions & 1 deletion apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,103 @@ describe("GitHubCli.layer", () => {
"view",
"#42",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("derives a failing checks summary from statusCheckRollup", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
number: 42,
title: "Add PR thread creation",
url: "https://github.com/pingdotgg/codething-mvp/pull/42",
baseRefName: "main",
headRefName: "feature/pr-threads",
state: "OPEN",
mergedAt: null,
statusCheckRollup: [
{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" },
{ __typename: "CheckRun", status: "COMPLETED", conclusion: "FAILURE" },
{ __typename: "StatusContext", state: "ERROR" },
{ __typename: "CheckRun", status: "IN_PROGRESS", conclusion: null },
],
}),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" });

assert.deepStrictEqual(result.checks, { state: "failing", failingCount: 2 });
}).pipe(Effect.provide(layer)),
);

it.effect("derives a pending checks summary when a run is still in progress", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
number: 42,
title: "Add PR thread creation",
url: "https://github.com/pingdotgg/codething-mvp/pull/42",
baseRefName: "main",
headRefName: "feature/pr-threads",
state: "OPEN",
mergedAt: null,
statusCheckRollup: [
{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" },
{ __typename: "CheckRun", status: "IN_PROGRESS", conclusion: null },
],
}),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" });

assert.deepStrictEqual(result.checks, { state: "pending", failingCount: 0 });
}).pipe(Effect.provide(layer)),
);

it.effect("omits checks when statusCheckRollup is empty", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
number: 42,
title: "Add PR thread creation",
url: "https://github.com/pingdotgg/codething-mvp/pull/42",
baseRefName: "main",
headRefName: "feature/pr-threads",
state: "OPEN",
mergedAt: null,
statusCheckRollup: [],
}),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" });

expect(result.checks).toBeUndefined();
}).pipe(Effect.provide(layer)),
);

it.effect("trims pull request fields decoded from gh json", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema";

import {
TrimmedNonEmptyString,
type ChangeRequestChecks,
type SourceControlRepositoryVisibility,
type VcsError,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -188,6 +189,7 @@ export interface GitHubPullRequestSummary {
readonly isCrossRepository?: boolean;
readonly headRepositoryNameWithOwner?: string | null;
readonly headRepositoryOwnerLogin?: string | null;
readonly checks?: ChangeRequestChecks | null;
}

export interface GitHubRepositoryCloneUrls {
Expand Down Expand Up @@ -332,7 +334,7 @@ export const make = Effect.gen(function* () {
"--limit",
String(input.limit ?? 1),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Expand Down Expand Up @@ -366,7 +368,7 @@ export const make = Effect.gen(function* () {
"view",
input.reference,
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () =
"--limit",
"10",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
]);
assert.strictEqual(changeRequests[0]?.provider, "github");
assert.strictEqual(changeRequests[0]?.state, "merged");
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
...(summary.headRepositoryOwnerLogin !== undefined
? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
: {}),
...(summary.checks !== undefined ? { checks: summary.checks } : {}),
};
}

Expand Down Expand Up @@ -139,7 +140,7 @@ export const make = Effect.gen(function* () {
"--limit",
String(input.limit ?? 20),
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner,statusCheckRollup",
],
})
.pipe(
Expand Down
75 changes: 74 additions & 1 deletion apps/server/src/sourceControl/gitHubPullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
import { PositiveInt, TrimmedNonEmptyString, type ChangeRequestChecks } from "@t3tools/contracts";
import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";

export interface NormalizedGitHubPullRequestRecord {
Expand All @@ -18,6 +18,7 @@ export interface NormalizedGitHubPullRequestRecord {
readonly isCrossRepository?: boolean;
readonly headRepositoryNameWithOwner?: string | null;
readonly headRepositoryOwnerLogin?: string | null;
readonly checks?: ChangeRequestChecks | null;
}

const GitHubPullRequestSchema = Schema.Struct({
Expand All @@ -44,8 +45,78 @@ const GitHubPullRequestSchema = Schema.Struct({
}),
),
),
statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
});

const FAILING_CHECK_CONCLUSIONS = new Set([
"FAILURE",
"ERROR",
"CANCELLED",
"TIMED_OUT",
"ACTION_REQUIRED",
"STARTUP_FAILURE",
]);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const PENDING_CHECK_STATUSES = new Set([
"QUEUED",
"IN_PROGRESS",
"PENDING",
"WAITING",
"REQUESTED",
]);

function readUpperString(record: Record<string, unknown>, key: string): string {
const value = record[key];
return typeof value === "string" ? value.trim().toUpperCase() : "";
}

/**
* Classify a single `statusCheckRollup` entry. Entries are either GitHub Actions
* `CheckRun`s (status + conclusion) or legacy `StatusContext`s (state).
*/
function classifyCheckRollupEntry(entry: unknown): "passing" | "failing" | "pending" {
if (typeof entry !== "object" || entry === null) {
return "pending";
}
const record = entry as Record<string, unknown>;
const state = readUpperString(record, "state");
if (state.length > 0) {
if (state === "FAILURE" || state === "ERROR") return "failing";
if (state === "SUCCESS") return "passing";
return "pending";
}
const conclusion = readUpperString(record, "conclusion");
if (FAILING_CHECK_CONCLUSIONS.has(conclusion)) return "failing";
const status = readUpperString(record, "status");
if (PENDING_CHECK_STATUSES.has(status)) return "pending";
if (conclusion.length === 0) return "pending";
return "passing";
}

/**
* Derive an overall CI summary from a `statusCheckRollup` array. Returns null
* when there are no checks (no CI configured), so the UI renders nothing.
*/
function deriveChecksSummary(
rollup: ReadonlyArray<unknown> | null | undefined,
): ChangeRequestChecks | null {
if (!rollup || rollup.length === 0) {
return null;
}
let failingCount = 0;
let anyPending = false;
for (const entry of rollup) {
const classification = classifyCheckRollupEntry(entry);
if (classification === "failing") {
failingCount += 1;
} else if (classification === "pending") {
anyPending = true;
}
}
if (failingCount > 0) return { state: "failing", failingCount };
if (anyPending) return { state: "pending", failingCount: 0 };
return { state: "passing", failingCount: 0 };
}

function trimOptionalString(value: string | null | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
Expand Down Expand Up @@ -77,6 +148,7 @@ function normalizeGitHubPullRequestRecord(
(typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
? (headRepositoryNameWithOwner.split("/")[0] ?? null)
: null);
const checks = deriveChecksSummary(raw.statusCheckRollup);

return {
number: raw.number,
Expand All @@ -91,6 +163,7 @@ function normalizeGitHubPullRequestRecord(
: {}),
...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
...(checks ? { checks } : {}),
};
}

Expand Down
Loading
Loading