From afb7617a894fd02bafb44aba4ff35bc1b74eea5b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:18:30 +0000 Subject: [PATCH 01/51] feat(contracts): add pull request contracts and RPCs Models a GitHub pull request for the upcoming pull requests page: list entries, detail with checks/comments/commits, diff, and the merge, ready, draft, close, reopen and comment operations. Two error shapes rather than one: PullRequestUnavailableError covers the states that switch the whole feature off (no gh, logged out, non-GitHub remote) so the UI can explain the fix, while PullRequestOperationError carries per-request failures. --- packages/contracts/src/index.ts | 1 + packages/contracts/src/pullRequest.ts | 221 ++++++++++++++++++++++++++ packages/contracts/src/rpc.ts | 59 +++++++ 3 files changed, 281 insertions(+) create mode 100644 packages/contracts/src/pullRequest.ts diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..ef6aef884b0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,7 @@ export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; +export * from "./pullRequest.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts new file mode 100644 index 00000000000..60d410041d6 --- /dev/null +++ b/packages/contracts/src/pullRequest.ts @@ -0,0 +1,221 @@ +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const PullRequestInvolvement = Schema.Literals(["all", "reviewing", "authored"]); +export type PullRequestInvolvement = typeof PullRequestInvolvement.Type; + +export const PullRequestState = Schema.Literals(["open", "closed", "merged"]); +export type PullRequestState = typeof PullRequestState.Type; + +export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]); +export type PullRequestMergeability = typeof PullRequestMergeability.Type; + +export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]); +export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type; + +export const PullRequestAction = Schema.Literals(["merge", "ready", "draft", "close", "reopen"]); +export type PullRequestAction = typeof PullRequestAction.Type; + +/** GitHub CLI JSON exposes no avatar URL, so actors render as login plus initials. */ +export const PullRequestActor = Schema.Struct({ + login: TrimmedNonEmptyString, + name: Schema.NullOr(Schema.String), +}); +export type PullRequestActor = typeof PullRequestActor.Type; + +export const PullRequestLabel = Schema.Struct({ + name: TrimmedNonEmptyString, + color: Schema.NullOr(Schema.String), +}); +export type PullRequestLabel = typeof PullRequestLabel.Type; + +export const PullRequestCheckStatus = Schema.Literals([ + "pending", + "success", + "failure", + "skipped", + "neutral", + "cancelled", +]); +export type PullRequestCheckStatus = typeof PullRequestCheckStatus.Type; + +export const PullRequestCheck = Schema.Struct({ + name: TrimmedNonEmptyString, + status: PullRequestCheckStatus, + description: Schema.NullOr(Schema.String), + url: Schema.NullOr(Schema.String), +}); +export type PullRequestCheck = typeof PullRequestCheck.Type; + +export const PullRequestCommentKind = Schema.Literals([ + "issue-comment", + "review-comment", + "review", +]); +export type PullRequestCommentKind = typeof PullRequestCommentKind.Type; + +export const PullRequestComment = Schema.Struct({ + id: TrimmedNonEmptyString, + kind: PullRequestCommentKind, + author: Schema.NullOr(PullRequestActor), + body: Schema.String, + createdAt: IsoDateTime, + url: Schema.NullOr(Schema.String), + path: Schema.NullOr(Schema.String), + reviewState: Schema.NullOr(Schema.String), +}); +export type PullRequestComment = typeof PullRequestComment.Type; + +export const PullRequestCommit = Schema.Struct({ + oid: TrimmedNonEmptyString, + messageHeadline: Schema.String, + committedDate: IsoDateTime, +}); +export type PullRequestCommit = typeof PullRequestCommit.Type; + +export const PullRequestMergeCapabilities = Schema.Struct({ + merge: Schema.Boolean, + squash: Schema.Boolean, + rebase: Schema.Boolean, +}); +export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; + +export const PullRequestListEntry = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(PullRequestActor), + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + state: PullRequestState, + isDraft: Schema.Boolean, + mergeability: PullRequestMergeability, + additions: NonNegativeInt, + deletions: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + viewerReviewRequested: Schema.Boolean, + labels: Schema.Array(PullRequestLabel), +}); +export type PullRequestListEntry = typeof PullRequestListEntry.Type; + +export const PullRequestListInput = Schema.Struct({ + state: PullRequestState, + involvement: Schema.optional(PullRequestInvolvement), + projectId: Schema.optional(ProjectId), + /** Rows to return per repository. The page raises it to load further results. */ + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 }))), +}); +export type PullRequestListInput = typeof PullRequestListInput.Type; + +/** One project whose repository could not be read; healthy projects still return entries. */ +export const PullRequestListProjectError = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type PullRequestListProjectError = typeof PullRequestListProjectError.Type; + +export const PullRequestListResult = Schema.Struct({ + viewer: Schema.NullOr(TrimmedNonEmptyString), + entries: Schema.Array(PullRequestListEntry), + errors: Schema.Array(PullRequestListProjectError), + /** At least one repository hit the per-repository listing cap. */ + truncated: Schema.Boolean, +}); +export type PullRequestListResult = typeof PullRequestListResult.Type; + +export const PullRequestRef = Schema.Struct({ + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type PullRequestRef = typeof PullRequestRef.Type; + +export const PullRequestDetail = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + body: Schema.String, + url: TrimmedNonEmptyString, + author: Schema.NullOr(PullRequestActor), + state: PullRequestState, + isDraft: Schema.Boolean, + mergeability: PullRequestMergeability, + additions: NonNegativeInt, + deletions: NonNegativeInt, + changedFiles: NonNegativeInt, + headBranch: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + mergedAt: Schema.NullOr(IsoDateTime), + closedAt: Schema.NullOr(IsoDateTime), + reviewers: Schema.Array(PullRequestActor), + labels: Schema.Array(PullRequestLabel), + checks: Schema.Array(PullRequestCheck), + comments: Schema.Array(PullRequestComment), + commentsTruncated: Schema.Boolean, + commits: Schema.Array(PullRequestCommit), + mergeCapabilities: PullRequestMergeCapabilities, +}); +export type PullRequestDetail = typeof PullRequestDetail.Type; + +export const PullRequestDiffResult = Schema.Struct({ + patch: Schema.String, + truncated: Schema.Boolean, +}); +export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; + +export const PullRequestActionInput = Schema.Struct({ + ...PullRequestRef.fields, + action: PullRequestAction, + mergeMethod: Schema.optional(PullRequestMergeMethod), +}); +export type PullRequestActionInput = typeof PullRequestActionInput.Type; + +export const PullRequestCommentInput = Schema.Struct({ + ...PullRequestRef.fields, + // GitHub rejects comment bodies past 65536 characters; enforcing it here keeps oversized + // payloads off the wire and out of subprocess plumbing entirely. + body: TrimmedNonEmptyString.check(Schema.isMaxLength(65_536)), +}); +export type PullRequestCommentInput = typeof PullRequestCommentInput.Type; + +export class PullRequestUnavailableError extends Schema.TaggedErrorClass()( + "PullRequestUnavailableError", + { + reason: Schema.Literals(["cli-missing", "cli-unauthenticated", "provider-unsupported"]), + detail: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class PullRequestOperationError extends Schema.TaggedErrorClass()( + "PullRequestOperationError", + { + operation: Schema.String, + detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Pull request operation ${this.operation} failed: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0701e15a668..d7a60ee8324 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -62,6 +62,17 @@ import { OrchestrationRpcSchemas, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + PullRequestActionInput, + PullRequestCommentInput, + PullRequestDetail, + PullRequestDiffResult, + PullRequestListInput, + PullRequestListResult, + PullRequestOperationError, + PullRequestRef, + PullRequestUnavailableError, +} from "./pullRequest.ts"; import { RelayClientInstallFailedError, RelayClientInstallProgressEventSchema, @@ -237,6 +248,13 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Pull request methods + pullRequestsList: "pullRequests.list", + pullRequestsDetail: "pullRequests.detail", + pullRequestsDiff: "pullRequests.diff", + pullRequestsRunAction: "pullRequests.runAction", + pullRequestsComment: "pullRequests.comment", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -394,6 +412,42 @@ export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackg error: EnvironmentAuthorizationError, }); +const PullRequestRpcError = Schema.Union([ + PullRequestUnavailableError, + PullRequestOperationError, + EnvironmentAuthorizationError, +]); + +export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { + payload: PullRequestListInput, + success: PullRequestListResult, + error: PullRequestRpcError, +}); + +export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { + payload: PullRequestRef, + success: PullRequestDetail, + error: PullRequestRpcError, +}); + +export const WsPullRequestsDiffRpc = Rpc.make(WS_METHODS.pullRequestsDiff, { + payload: PullRequestRef, + success: PullRequestDiffResult, + error: PullRequestRpcError, +}); + +export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { + payload: PullRequestActionInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + +export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { + payload: PullRequestCommentInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -775,6 +829,11 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsPullRequestsListRpc, + WsPullRequestsDetailRpc, + WsPullRequestsDiffRpc, + WsPullRequestsRunActionRpc, + WsPullRequestsCommentRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, From 12ae5c25cfd5c77c187732e02cb3224e600ffff1 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:18:40 +0000 Subject: [PATCH 02/51] feat(server): decode GitHub CLI pull request JSON Normalizes the gh payloads into the contract shapes. Enum-ish fields decode as plain strings and are mapped here, so a gh release that adds a check conclusion or review state cannot fail a whole payload, and a malformed row is skipped rather than blanking the batch. Also folds reviews into the comment list (dropping bodyless approvals, which the review decision already reports) and reads unresolved review threads, which gh pr view cannot return. --- .../pullRequest/gitHubPullRequestJson.test.ts | 156 +++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 439 ++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 apps/server/src/pullRequest/gitHubPullRequestJson.test.ts create mode 100644 apps/server/src/pullRequest/gitHubPullRequestJson.ts diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts new file mode 100644 index 00000000000..32cfc3d4bb4 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -0,0 +1,156 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodePullRequestDetailJson, + decodePullRequestListJson, + decodeReviewThreadsJson, +} from "./gitHubPullRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("pull request list decoding", () => { + it("treats a merge timestamp as merged even when the state still says closed", () => { + const [entry] = expectSuccess( + decodePullRequestListJson(listJson([{ state: "CLOSED", mergedAt: "2026-07-03T00:00:00Z" }])), + ); + expect(entry?.state).toBe("merged"); + }); + + it("normalizes mergeability and defaults unknown values", () => { + const entries = expectSuccess( + decodePullRequestListJson( + listJson([{ mergeable: "CONFLICTING" }, { mergeable: "SOMETHING_NEW" }, {}]), + ), + ); + expect(entries.map((entry) => entry.mergeability)).toEqual([ + "conflicting", + "unknown", + "unknown", + ]); + }); + + it("reads review requests from user logins and team slugs alike", () => { + const [entry] = expectSuccess( + decodePullRequestListJson( + listJson([{ reviewRequests: [{ login: "octocat" }, { slug: "web-platform" }] }]), + ), + ); + expect(entry?.reviewRequestLogins).toEqual(["octocat", "web-platform"]); + }); + + it("skips malformed entries instead of failing the batch", () => { + const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; + expect(expectSuccess(decodePullRequestListJson(raw))).toHaveLength(1); + }); +}); + +describe("pull request detail decoding", () => { + const detailJson = JSON.stringify({ + number: 7, + title: "Detail", + url: "https://github.com/pingdotgg/t3code/pull/7", + headRefName: "feat/detail", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-05T00:00:00Z", + body: "Body", + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "IN_PROGRESS" }, + { __typename: "CheckRun", name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + { __typename: "StatusContext", context: "ci/legacy", state: "SUCCESS" }, + ], + comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], + reviews: [ + { id: "r1", body: "first", state: "CHANGES_REQUESTED", submittedAt: "2026-07-03T00:00:00Z" }, + { id: "r2", body: " ", state: "APPROVED", submittedAt: "2026-07-06T00:00:00Z" }, + ], + }); + + it("maps check-run status and commit-status state onto one vocabulary", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["build", "pending"], + ["test", "failure"], + ["ci/legacy", "success"], + ]); + }); + + it("merges reviews with comments in time order and drops bodyless reviews", () => { + const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); + expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1"]); + }); +}); + +describe("review thread decoding", () => { + const threadsJson = ( + nodes: ReadonlyArray>, + totalCount = nodes.length, + ): string => + JSON.stringify({ + data: { repository: { pullRequest: { reviewThreads: { totalCount, nodes } } } }, + }); + + it("keeps unresolved threads and carries their file path", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + isResolved: false, + path: "apps/server/src/ws.ts", + comments: { + nodes: [{ id: "t1", body: "fix this", createdAt: "2026-07-01T00:00:00Z" }], + }, + }, + { + isResolved: true, + path: "apps/web/src/main.tsx", + comments: { nodes: [{ id: "t2", body: "done", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ]), + ), + ); + expect(result.comments).toHaveLength(1); + expect(result.comments[0]).toMatchObject({ + id: "t1", + kind: "review-comment", + path: "apps/server/src/ws.ts", + }); + }); + + it("reports truncation when GitHub has more threads than the page returned", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson( + [ + { + isResolved: false, + comments: { nodes: [{ id: "t1", createdAt: "2026-07-01T00:00:00Z" }] }, + }, + ], + 80, + ), + ), + ); + expect(result.truncated).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts new file mode 100644 index 00000000000..e6ec4574f41 --- /dev/null +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -0,0 +1,439 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeCapabilities, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` + * release that adds a conclusion or a review state must not fail the whole payload. + */ +const RawActorSchema = Schema.Struct({ + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewRequestSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawListItemSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + headRefName: Schema.String, + baseRefName: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + createdAt: Schema.String, + updatedAt: Schema.String, + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + labels: Schema.optional(Schema.Array(RawLabelSchema)), +}); + +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawReviewSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.String), + state: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommitSchema = Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, +}); + +const RawDetailSchema = Schema.Struct({ + ...RawListItemSchema.fields, + body: Schema.optional(Schema.String), + changedFiles: Schema.optional(Schema.Int), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), + comments: Schema.optional(Schema.Array(RawCommentSchema)), + reviews: Schema.optional(Schema.Array(RawReviewSchema)), + commits: Schema.optional(Schema.Array(RawCommitSchema)), +}); + +/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ +const RawReviewThreadsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + reviewThreads: Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + nodes: Schema.Array( + Schema.Struct({ + isResolved: Schema.optional(Schema.Boolean), + path: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.Struct({ nodes: Schema.Array(RawCommentSchema) }), + }), + ), + }), + }), + }), + }), +}); + +const RawMergeCapabilitiesSchema = Schema.Struct({ + mergeCommitAllowed: Schema.optional(Schema.Boolean), + squashMergeAllowed: Schema.optional(Schema.Boolean), + rebaseMergeAllowed: Schema.optional(Schema.Boolean), +}); + +export const PULL_REQUEST_LIST_JSON_FIELDS = + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; + +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup,comments,reviews,commits`; + +/** Root comments of the first page of review threads; deeper replies stay on GitHub. */ +export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 50) { + totalCount + nodes { + isResolved + path + comments(first: 1) { + nodes { id author { login } body createdAt url } + } + } + } + } + } +}`; + +export const REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS = + "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed"; + +export interface GitHubPullRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly checks: ReadonlyArray; + readonly comments: ReadonlyArray; + readonly commits: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.login); + return login === null ? null : { login, name: trimmed(raw?.name) }; +} + +function toState(raw: { + readonly state?: string | null | undefined; + readonly mergedAt?: string | null | undefined; +}): PullRequestState { + if (trimmed(raw.mergedAt) !== null) return "merged"; + const state = raw.state?.trim().toUpperCase(); + if (state === "MERGED") return "merged"; + if (state === "CLOSED") return "closed"; + return "open"; +} + +function toMergeability(value: string | null | undefined): PullRequestMergeability { + switch (value?.trim().toUpperCase()) { + case "MERGEABLE": + return "mergeable"; + case "CONFLICTING": + return "conflicting"; + default: + return "unknown"; + } +} + +function toLabels( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label.name); + return name === null ? [] : [{ name, color: trimmed(label.color) }]; + }); +} + +/** Team review requests carry a slug instead of a login; both identify a requested reviewer. */ +function toReviewRequestLogins( + raw: ReadonlyArray> | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((request) => { + const login = trimmed(request.login) ?? trimmed(request.slug) ?? trimmed(request.name); + return login === null ? [] : [login]; + }); +} + +function toCheckStatus(raw: Schema.Schema.Type): PullRequestCheckStatus { + // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` + // that only exists once the run has completed. + const status = raw.status?.trim().toUpperCase(); + if (status !== undefined && status !== "COMPLETED" && status !== "") { + return "pending"; + } + switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + case "TIMED_OUT": + case "STARTUP_FAILURE": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return "neutral"; + } +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((check) => { + const name = trimmed(check.name) ?? trimmed(check.context); + if (name === null) return []; + return [ + { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + ]; + }); +} + +function toComments( + raw: Schema.Schema.Type, +): ReadonlyArray { + const issueComments = (raw.comments ?? []).map( + (comment): PullRequestComment => ({ + id: comment.id, + kind: "issue-comment", + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + path: null, + reviewState: null, + }), + ); + // Reviews without a body are approvals/dismissals with nothing to read; they already show + // up as the review decision, so keeping them here would pad the timeline with empty cards. + const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray => { + const submittedAt = trimmed(review.submittedAt); + if (submittedAt === null || (review.body ?? "").trim().length === 0) return []; + return [ + { + id: review.id, + kind: "review", + author: toActor(review.author), + body: review.body ?? "", + createdAt: submittedAt, + url: trimmed(review.url), + path: null, + reviewState: trimmed(review.state), + }, + ]; + }); + return [...issueComments, ...reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); +} + +function toListItem(raw: Schema.Schema.Type): GitHubPullRequestListItem { + return { + number: raw.number, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + headBranch: raw.headRefName, + baseBranch: raw.baseRefName, + state: toState(raw), + isDraft: raw.isDraft ?? false, + mergeability: toMergeability(raw.mergeable), + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + return { + ...toListItem(raw), + body: raw.body ?? "", + changedFiles: raw.changedFiles ?? 0, + mergedAt: trimmed(raw.mergedAt), + closedAt: trimmed(raw.closedAt), + checks: toChecks(raw.statusCheckRollup), + comments: toComments(raw), + commits: (raw.commits ?? []).map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.messageHeadline ?? "", + committedDate: commit.committedDate, + })), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeMergeCapabilities = decodeJsonResult(RawMergeCapabilitiesSchema); +const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); + +type DecodeFailure = Cause.Cause; + +/** Malformed entries are skipped rather than failing the batch: one unexpected pull request + * must not blank the whole list. */ +export function decodePullRequestListJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitHubPullRequestListItem[] = []; + for (const entry of decoded.success) { + const item = decodeListEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + } + } + return Result.succeed(items); +} + +export function decodePullRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export interface GitHubReviewThreadComments { + readonly comments: ReadonlyArray; + readonly truncated: boolean; +} + +/** + * Unresolved review threads only: a resolved thread is finished work, and surfacing it + * again would put stale findings into the "fix findings" prompt. + */ +export function decodeReviewThreadsJson( + raw: string, +): Result.Result { + const decoded = decodeReviewThreads(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads = decoded.success.data.repository.pullRequest.reviewThreads; + const comments = threads.nodes.flatMap((thread): ReadonlyArray => { + const root = thread.comments.nodes[0]; + if (thread.isResolved === true || root === undefined) return []; + return [ + { + id: root.id, + kind: "review-comment", + author: toActor(root.author), + body: root.body ?? "", + createdAt: root.createdAt, + url: trimmed(root.url), + path: trimmed(thread.path), + reviewState: null, + }, + ]; + }); + return Result.succeed({ + comments, + truncated: (threads.totalCount ?? threads.nodes.length) > threads.nodes.length, + }); +} + +export function decodeRepositoryMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeMergeCapabilities(raw); + return Result.isSuccess(decoded) + ? Result.succeed({ + merge: decoded.success.mergeCommitAllowed ?? true, + squash: decoded.success.squashMergeAllowed ?? true, + rebase: decoded.success.rebaseMergeAllowed ?? true, + }) + : Result.fail(decoded.failure); +} From b80482e83d32117160b0d5dc43a0c6542c8440ab Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:18:51 +0000 Subject: [PATCH 03/51] feat(server): add pull request commands to the GitHub CLI wrapper Wraps the gh invocations the page needs, reusing the existing process wrapper rather than adding a second one. Two of them needed capabilities GitHubCli.execute did not expose: - comment bodies travel over stdin, because argv is visible in process listings and is echoed back inside process-runner failure messages - pr diff raises the output cap to 8 MiB and reports truncation instead of failing on a large pull request Review threads go through gh api graphql, since gh pr view --json has no field for them. --- .../src/pullRequest/GitHubPullRequestCli.ts | 319 ++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 5 + 2 files changed, 324 insertions(+) create mode 100644 apps/server/src/pullRequest/GitHubPullRequestCli.ts diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts new file mode 100644 index 00000000000..d023762e30b --- /dev/null +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -0,0 +1,319 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import type { + PullRequestAction, + PullRequestInvolvement, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestState, +} from "@t3tools/contracts"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { + decodePullRequestDetailJson, + decodePullRequestListJson, + decodeRepositoryMergeCapabilitiesJson, + decodeReviewThreadsJson, + PULL_REQUEST_DETAIL_JSON_FIELDS, + PULL_REQUEST_LIST_JSON_FIELDS, + REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS, + REVIEW_THREADS_GRAPHQL_QUERY, + type GitHubPullRequestDetail, + type GitHubPullRequestListItem, + type GitHubReviewThreadComments, +} from "./gitHubPullRequestJson.ts"; + +/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ +const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DIFF_TIMEOUT_MS = 60_000; + +export interface GitHubPullRequestListBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class GitHubPullRequestCli extends Context.Service< + GitHubPullRequestCli, + { + readonly getViewerLogin: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly listPullRequests: (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + }) => Effect.Effect; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + GitHubCli.GitHubCliError + >; + + readonly listReviewThreadComments: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly getRepositoryMergeCapabilities: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly runPullRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly commentOnPullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + } +>()("t3/pullRequest/GitHubPullRequestCli") {} + +/** `owner/repo`, optionally host-qualified for GitHub Enterprise (`host/owner/repo`). */ +export function isValidRepositorySelector(value: string): boolean { + return /^(?:[a-z0-9.-]+\/)?[\w.-]+\/[\w.-]+$/i.test(value.trim()); +} + +function involvementArgs(input: { + readonly state: PullRequestState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; +}): ReadonlyArray { + // `--state closed` includes merged pull requests, so the Closed tab additionally excludes + // them through search; `--author` and `review-requested:` are GitHub's own filters. + const searchTerms = [ + ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), + ...(input.state === "closed" ? ["is:unmerged"] : []), + ]; + return [ + ...(input.involvement === "authored" ? ["--author", input.viewer] : []), + ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), + ]; +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + switch (action) { + case "merge": + return ["merge", `--${mergeMethod ?? "merge"}`]; + case "ready": + return ["ready"]; + case "draft": + return ["ready", "--undo"]; + case "close": + return ["close"]; + case "reopen": + return ["reopen"]; + } +} + +export const make = Effect.gen(function* () { + const github = yield* GitHubCli.GitHubCli; + + const decodeError = (cwd: string, cause: unknown) => + new GitHubCli.GitHubPullRequestDecodeError({ command: "gh", cwd, cause }); + + const repositoryArgs = (repository: string) => ["--repo", repository]; + + return GitHubPullRequestCli.of({ + getViewerLogin: (input) => + github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( + Effect.flatMap((result) => { + const login = result.stdout.trim(); + return login.length > 0 + ? Effect.succeed(login) + : Effect.fail(decodeError(input.cwd, new Error("Empty viewer login."))); + }), + ), + + listPullRequests: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input.repository), + ...involvementArgs(input), + "--state", + input.state, + "--limit", + // One extra row reveals that the repository has more than the page shows. + String(input.limit + 1), + "--json", + PULL_REQUEST_LIST_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ items: [], truncated: false }); + } + const decoded = decodePullRequestListJson(raw); + return Result.isSuccess(decoded) + ? Effect.succeed({ + items: decoded.success.slice(0, input.limit), + truncated: decoded.success.length > input.limit, + }) + : Effect.fail(decodeError(input.cwd, decoded.failure)); + }), + ), + + getPullRequestDetail: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input.repository), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.cwd, decoded.failure)); + }), + ), + + getPullRequestDiff: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "diff", + String(input.number), + ...repositoryArgs(input.repository), + "--color", + "never", + "--patch", + ], + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }) + .pipe( + Effect.map((result) => ({ + patch: result.stdout, + truncated: result.stdoutTruncated, + })), + ), + + listReviewThreadComments: (input) => { + const [owner, name] = input.repository.split("/"); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "graphql", + "-f", + `owner=${owner ?? ""}`, + "-f", + `name=${name ?? ""}`, + "-F", + `number=${input.number}`, + "-f", + `query=${REVIEW_THREADS_GRAPHQL_QUERY}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeReviewThreadsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.cwd, decoded.failure)); + }), + ); + }, + + getRepositoryMergeCapabilities: (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + input.repository, + "--json", + REPOSITORY_MERGE_CAPABILITIES_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodeRepositoryMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(decodeError(input.cwd, decoded.failure)); + }), + ), + + runPullRequestAction: (input) => { + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + subcommand!, + String(input.number), + ...repositoryArgs(input.repository), + ...flags, + ], + }) + .pipe(Effect.asVoid); + }, + + commentOnPullRequest: (input) => + github + .execute({ + cwd: input.cwd, + // The body travels over stdin: argv is visible in process listings and is echoed + // back inside process-runner failure messages. + args: [ + "pr", + "comment", + String(input.number), + ...repositoryArgs(input.repository), + "--body-file", + "-", + ], + stdin: input.body, + }) + .pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitHubPullRequestCli, make); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..a705b0fb0b3 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -203,6 +203,9 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listOpenPullRequests: (input: { @@ -314,6 +317,8 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); From 082db6fbeb9cfa876b2f6cff6fcd181089c036bb Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:00 +0000 Subject: [PATCH 04/51] feat(server): serve pull requests over RPC Resolves each project to its GitHub repository through the repository identity already stored on the project, so no extra remote lookup is needed, and lists repositories once even when several worktrees share one. Failure handling is deliberately split: an unreachable repository becomes an entry in the result's errors so healthy repositories still render, while a missing or logged-out gh stops the whole listing, because it is not repository-specific. The repository travels through the client, so it is checked against the project's own remote before reaching gh --repo. --- apps/server/src/auth/RpcAuthorization.ts | 5 + .../src/pullRequest/PullRequestService.ts | 350 ++++++++++++++++++ apps/server/src/ws.ts | 29 ++ 3 files changed, 384 insertions(+) create mode 100644 apps/server/src/pullRequest/PullRequestService.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc9..41f8baa119f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -48,6 +48,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsDiff]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts new file mode 100644 index 00000000000..034e04d38cb --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -0,0 +1,350 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + PullRequestOperationError, + PullRequestUnavailableError, + type OrchestrationProjectShell, + type ProjectId, + type PullRequestActionInput, + type PullRequestCommentInput, + type PullRequestDetail, + type PullRequestDiffResult, + type PullRequestListEntry, + type PullRequestListInput, + type PullRequestListProjectError, + type PullRequestListResult, + type PullRequestRef, +} from "@t3tools/contracts"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestListItem } from "./gitHubPullRequestJson.ts"; + +/** + * Rows per repository when the client does not ask for a page size. `gh pr list` has no + * cursor, so "load more" re-reads a larger page rather than continuing from an offset — + * cheap at the sizes a pull request list reaches, and the CLI pages internally. + */ +const DEFAULT_REPOSITORY_LIST_LIMIT = 50; +const REPOSITORY_LIST_CONCURRENCY = 4; + +export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; + +export class PullRequestService extends Context.Service< + PullRequestService, + { + readonly list: ( + input: PullRequestListInput, + ) => Effect.Effect; + readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly diff: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly runAction: (input: PullRequestActionInput) => Effect.Effect; + readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + } +>()("t3/pullRequest/PullRequestService") {} + +interface GitHubProject { + readonly project: OrchestrationProjectShell; + readonly repository: string; +} + +interface RepositoryBatch { + readonly entries: ReadonlyArray; + readonly errors: ReadonlyArray; + readonly truncated: boolean; +} + +/** `gh` being absent or logged out disables the whole feature, so it is reported as such + * instead of being folded into a single project's error list. */ +function unavailableReason( + error: GitHubCli.GitHubCliError, +): PullRequestUnavailableError["reason"] | null { + if (error._tag === "GitHubCliUnavailableError") return "cli-missing"; + if (error._tag === "GitHubCliAuthenticationError") return "cli-unauthenticated"; + return null; +} + +function toPullRequestError( + operation: string, +): (error: GitHubCli.GitHubCliError) => PullRequestError { + return (error) => { + const reason = unavailableReason(error); + return reason === null + ? new PullRequestOperationError({ operation, detail: error.detail, cause: error }) + : new PullRequestUnavailableError({ reason, detail: error.detail }); + }; +} + +function gitHubProjectsFrom( + projects: ReadonlyArray, + projectId: ProjectId | undefined, +): ReadonlyArray { + const seenRepositories = new Set(); + const gitHubProjects: GitHubProject[] = []; + for (const project of projects) { + if (projectId !== undefined && project.id !== projectId) continue; + const identity = project.repositoryIdentity; + if (!identity || identity.provider !== "github" || !identity.owner || !identity.name) continue; + const repository = `${identity.owner}/${identity.name}`; + // Worktrees of the same repository are separate projects; listing the remote once keeps + // the page from repeating every pull request per local checkout. + if (seenRepositories.has(repository.toLowerCase())) continue; + seenRepositories.add(repository.toLowerCase()); + gitHubProjects.push({ project, repository }); + } + return gitHubProjects; +} + +function toListEntry(input: { + readonly project: OrchestrationProjectShell; + readonly repository: string; + readonly item: GitHubPullRequestListItem; + readonly viewer: string; +}): PullRequestListEntry { + const viewer = input.viewer.toLowerCase(); + return { + projectId: input.project.id, + projectTitle: input.project.title, + repository: input.repository, + number: input.item.number, + title: input.item.title, + url: input.item.url, + author: input.item.author, + headBranch: input.item.headBranch, + baseBranch: input.item.baseBranch, + state: input.item.state, + isDraft: input.item.isDraft, + mergeability: input.item.mergeability, + additions: input.item.additions, + deletions: input.item.deletions, + createdAt: input.item.createdAt, + updatedAt: input.item.updatedAt, + viewerReviewRequested: + input.item.author?.login.toLowerCase() !== viewer && + input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), + labels: input.item.labels, + }; +} + +export const make = Effect.gen(function* () { + const github = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const listGitHubProjects = ( + projectId: ProjectId | undefined, + ): Effect.Effect, PullRequestError> => + projections.getShellSnapshot().pipe( + Effect.map((snapshot) => gitHubProjectsFrom(snapshot.projects, projectId)), + Effect.mapError( + (error) => + new PullRequestOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause: error, + }), + ), + ); + + const requireGitHubProject = ( + ref: PullRequestRef, + ): Effect.Effect => + listGitHubProjects(ref.projectId).pipe( + Effect.flatMap((projects): Effect.Effect => { + const match = projects[0]; + if (!match) { + return Effect.fail( + new PullRequestUnavailableError({ + reason: "provider-unsupported", + detail: "This project is not backed by a GitHub repository.", + }), + ); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being passed to `gh --repo` verbatim. + if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + return Effect.fail( + new PullRequestOperationError({ + operation: "resolveRepository", + detail: "The pull request does not belong to the selected project.", + }), + ); + } + return Effect.succeed(match); + }), + ); + + const list: PullRequestService["Service"]["list"] = (input) => + Effect.gen(function* () { + const involvement = input.involvement ?? "all"; + const projects = yield* listGitHubProjects(input.projectId); + if (projects.length === 0) { + return { viewer: null, entries: [], errors: [], truncated: false }; + } + + const viewer = yield* github + .getViewerLogin({ cwd: projects[0]!.project.workspaceRoot }) + .pipe(Effect.mapError(toPullRequestError("viewer"))); + + const batches = yield* Effect.forEach( + projects, + ({ project, repository }): Effect.Effect => + github + .listPullRequests({ + cwd: project.workspaceRoot, + repository, + state: input.state, + involvement, + viewer, + limit: input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT, + }) + .pipe( + Effect.map( + (batch): RepositoryBatch => ({ + entries: batch.items.map((item) => + toListEntry({ project, repository, item, viewer }), + ), + errors: [], + truncated: batch.truncated, + }), + ), + // One unreachable repository must not blank the page, but a missing or + // logged-out CLI is not repository-specific and stops the whole listing. + Effect.catchIf( + (error) => unavailableReason(error) === null, + (error) => + Effect.succeed({ + entries: [], + errors: [ + { projectId: project.id, projectTitle: project.title, message: error.detail }, + ], + truncated: false, + }), + ), + Effect.mapError(toPullRequestError("list")), + ), + { concurrency: REPOSITORY_LIST_CONCURRENCY }, + ); + + return { + viewer, + entries: batches + .flatMap((batch) => batch.entries) + .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + errors: batches.flatMap((batch) => batch.errors), + truncated: batches.some((batch) => batch.truncated), + }; + }); + + const detail: PullRequestService["Service"]["detail"] = (input) => + requireGitHubProject(input).pipe( + Effect.flatMap(({ project, repository }) => + Effect.all( + [ + github.getPullRequestDetail({ + cwd: project.workspaceRoot, + repository, + number: input.number, + }), + github.getRepositoryMergeCapabilities({ cwd: project.workspaceRoot, repository }), + // Line comments live on review threads, which `gh pr view --json` cannot reach. + // A GraphQL hiccup must not blank the whole detail, so it degrades to "none". + github + .listReviewThreadComments({ + cwd: project.workspaceRoot, + repository, + number: input.number, + }) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: false }))), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(toPullRequestError("detail")), + Effect.map( + ([pullRequest, mergeCapabilities, reviewThreads]): PullRequestDetail => ({ + projectId: project.id, + projectTitle: project.title, + workspaceRoot: project.workspaceRoot, + repository, + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + url: pullRequest.url, + author: pullRequest.author, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + additions: pullRequest.additions, + deletions: pullRequest.deletions, + changedFiles: pullRequest.changedFiles, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + mergedAt: pullRequest.mergedAt, + closedAt: pullRequest.closedAt, + reviewers: pullRequest.reviewRequestLogins.map((login) => ({ login, name: null })), + labels: pullRequest.labels, + checks: pullRequest.checks, + comments: [...pullRequest.comments, ...reviewThreads.comments].toSorted( + (left, right) => left.createdAt.localeCompare(right.createdAt), + ), + // `gh pr view` returns the most recent page of conversation only. + commentsTruncated: pullRequest.comments.length >= 100 || reviewThreads.truncated, + commits: pullRequest.commits, + mergeCapabilities, + }), + ), + ), + ), + ); + + const diff: PullRequestService["Service"]["diff"] = (input) => + requireGitHubProject(input).pipe( + Effect.flatMap(({ project, repository }) => + github + .getPullRequestDiff({ + cwd: project.workspaceRoot, + repository, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("diff"))), + ), + ); + + const runAction: PullRequestService["Service"]["runAction"] = (input) => + requireGitHubProject(input).pipe( + Effect.flatMap(({ project, repository }) => + github + .runPullRequestAction({ + cwd: project.workspaceRoot, + repository, + number: input.number, + action: input.action, + ...(input.mergeMethod !== undefined ? { mergeMethod: input.mergeMethod } : {}), + }) + .pipe(Effect.mapError(toPullRequestError("runAction"))), + ), + ); + + const comment: PullRequestService["Service"]["comment"] = (input) => + requireGitHubProject(input).pipe( + Effect.flatMap(({ project, repository }) => + github + .commentOnPullRequest({ + cwd: project.workspaceRoot, + repository, + number: input.number, + body: input.body, + }) + .pipe(Effect.mapError(toPullRequestError("comment"))), + ), + ); + + return PullRequestService.of({ list, detail, diff, runAction, comment }); +}); + +export const layer = Layer.effect(PullRequestService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af80..0b7af0cd7b6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -101,6 +101,8 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as GitHubPullRequestCli from "./pullRequest/GitHubPullRequestCli.ts"; +import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -395,6 +397,7 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const pullRequests = yield* PullRequestService.PullRequestService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1510,6 +1513,26 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.pullRequestsList]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDetail]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsDiff]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsDiff, pullRequests.diff(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsRunAction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsComment]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -2051,6 +2074,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), + Layer.provide( + PullRequestService.layer.pipe( + Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubCli.layer))), + Layer.provide(VcsProcess.layer), + ), + ), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( From 99a34bde9d4511ce8719c9d0c416be4c4e5ae677 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:14 +0000 Subject: [PATCH 05/51] feat(client-runtime): add pull request atoms Reads reuse a recent result and refresh explicitly, since every one of them shells out to the GitHub CLI. Mutations run serially per environment: gh actions on the same pull request are order-sensitive and the detail view refetches after each one. --- apps/web/src/state/pullRequests.ts | 5 ++ packages/client-runtime/package.json | 4 ++ .../client-runtime/src/state/pullRequests.ts | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 apps/web/src/state/pullRequests.ts create mode 100644 packages/client-runtime/src/state/pullRequests.ts diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts new file mode 100644 index 00000000000..6d8d2751b8c --- /dev/null +++ b/apps/web/src/state/pullRequests.ts @@ -0,0 +1,5 @@ +import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e5..508f62a68a2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" }, + "./state/pull-requests": { + "types": "./src/state/pullRequests.ts", + "default": "./src/state/pullRequests.ts" + }, "./state/project-grouping": { "types": "./src/state/projectGrouping.ts", "default": "./src/state/projectGrouping.ts" diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts new file mode 100644 index 00000000000..b89547910b8 --- /dev/null +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -0,0 +1,53 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +/** + * Every read shells out to the GitHub CLI, so results are reused for a short while and + * refreshed explicitly. Mutations run serially per environment: `gh` actions on the same + * pull request are order-sensitive, and the detail view refetches after each one. + */ +export function createPullRequestEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + const serialPerEnvironment = { + mode: "serial", + key: ({ environmentId }: { readonly environmentId: string }) => environmentId, + } as const; + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:list", + tag: WS_METHODS.pullRequestsList, + staleTimeMs: 30_000, + }), + detail: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:detail", + tag: WS_METHODS.pullRequestsDetail, + staleTimeMs: 15_000, + }), + diff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:diff", + tag: WS_METHODS.pullRequestsDiff, + staleTimeMs: 60_000, + }), + runAction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:run-action", + tag: WS_METHODS.pullRequestsRunAction, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), + comment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:comment", + tag: WS_METHODS.pullRequestsComment, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), + }; +} From 7789485acf50dc10cbe07e60f6eb710002034d3b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:33 +0000 Subject: [PATCH 06/51] feat(web): add the pull request list surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One place resolves how a pull request state reads, so no surface can drift: draft outranks conflicts, because a draft is not heading for a merge yet. Involvement filtering and grouping run over the state's superset returned by the server, so switching between All, Reviewing and Authored never waits on the network. The unavailable state reads the server's message to name the fix — install gh, sign in — rather than reporting a generic failure. --- .../pullRequest/PullRequestListFilters.tsx | 118 ++++++++++ .../components/pullRequest/PullRequestRow.tsx | 55 +++++ .../PullRequestsUnavailableState.tsx | 63 +++++ .../pullRequest/pullRequestList.logic.test.ts | 112 +++++++++ .../pullRequest/pullRequestList.logic.ts | 82 +++++++ .../pullRequest/pullRequestPresentation.tsx | 216 ++++++++++++++++++ 6 files changed, 646 insertions(+) create mode 100644 apps/web/src/components/pullRequest/PullRequestListFilters.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestRow.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestList.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestList.logic.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestPresentation.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx new file mode 100644 index 00000000000..35d11569259 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -0,0 +1,118 @@ +import type { ProjectId } from "@t3tools/contracts"; +import { ListFilterIcon, SearchIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; + +/** + * Plain text pills with a chip behind the active option. Two groups sit on one row — + * involvement then state — so the row reads as "which pull requests, in which state". + */ +export function PullRequestFilterPills({ + value, + options, + onChange, +}: { + value: Value; + options: ReadonlyArray<{ readonly value: Value; readonly label: string }>; + onChange: (value: Value) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function PullRequestSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ + onChange(event.currentTarget.value)} + placeholder="Search pull requests" + aria-label="Search pull requests" + className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" + /> +
+ ); +} + +/** + * Project scope lives behind the filter icon so the row stays two controls wide. It is the + * same menu chrome as the detail panel's actions, which also owns its own spacing. + */ +const ALL_PROJECTS_VALUE = "all"; + +export function PullRequestProjectFilter({ + projects, + value, + onChange, +}: { + projects: ReadonlyArray<{ readonly id: ProjectId; readonly title: string }>; + value: ProjectId | undefined; + onChange: (projectId: ProjectId | undefined) => void; +}) { + const selectedTitle = projects.find((project) => project.id === value)?.title; + return ( + + + + {value !== undefined ? ( + + ) : null} + + + + onChange(next === ALL_PROJECTS_VALUE ? undefined : (next as ProjectId)) + } + > + All projects + {projects.map((project) => ( + + {project.title} + + ))} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx new file mode 100644 index 00000000000..b9e56647efd --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -0,0 +1,55 @@ +import type { PullRequestListEntry } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { + PullRequestDiffStat, + PullRequestMetaLine, + PullRequestStateGlyph, +} from "./pullRequestPresentation"; + +export function PullRequestRow({ + entry, + selected, + showProjectTitle, + onSelect, +}: { + entry: PullRequestListEntry; + selected: boolean; + showProjectTitle: boolean; + onSelect: (entry: PullRequestListEntry) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx new file mode 100644 index 00000000000..1846c84dc10 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -0,0 +1,63 @@ +import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; + +/** + * The three ways the feature can be switched off, told apart by the message the server sent + * so the user is pointed at the fix rather than at a generic failure. + */ +function describeUnavailable(error: string): { + readonly title: string; + readonly description: string; +} { + const normalized = error.toLowerCase(); + if (normalized.includes("not available on path")) { + return { + title: "GitHub CLI is not installed", + description: + "Install the GitHub CLI (`gh`) from https://cli.github.com/ and reload to browse pull requests.", + }; + } + if (normalized.includes("not authenticated")) { + return { + title: "GitHub CLI is not signed in", + description: "Run `gh auth login` in a terminal, then retry.", + }; + } + return { title: "Could not load pull requests", description: error }; +} + +export function PullRequestsUnavailableState({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}) { + const { title, description } = describeUnavailable(error); + return ( + + + + + + {title} + {description} + + + + + + ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts new file mode 100644 index 00000000000..dc3d73e3263 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -0,0 +1,112 @@ +import type { PullRequestListEntry } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + filterPullRequestsByInvolvement, + groupPullRequestsByInvolvement, + matchesPullRequestQuery, +} from "./pullRequestList.logic"; + +function entry(overrides: Partial & Pick) { + return { + projectId: "project-1", + projectTitle: "t3code", + repository: "pingdotgg/t3code", + title: "Add the pull requests page", + url: `https://github.com/pingdotgg/t3code/pull/${overrides.number}`, + author: { login: "octocat", name: null }, + headBranch: `feat/branch-${overrides.number}`, + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + viewerReviewRequested: false, + labels: [], + ...overrides, + } as PullRequestListEntry; +} + +describe("pull request involvement filtering", () => { + const entries = [ + entry({ number: 1, author: { login: "Bilal", name: null } }), + entry({ number: 2, viewerReviewRequested: true }), + entry({ number: 3 }), + ]; + + it("matches the viewer's own pull requests case-insensitively", () => { + expect( + filterPullRequestsByInvolvement(entries, "bilal", "authored").map((item) => item.number), + ).toEqual([1]); + }); + + it("returns nothing for Authored when the viewer is unknown", () => { + expect(filterPullRequestsByInvolvement(entries, null, "authored")).toEqual([]); + }); + + it("uses the server-computed review-request flag for Reviewing", () => { + expect( + filterPullRequestsByInvolvement(entries, "bilal", "reviewing").map((item) => item.number), + ).toEqual([2]); + }); + + it("leaves the superset untouched for All", () => { + expect(filterPullRequestsByInvolvement(entries, "bilal", "all")).toHaveLength(3); + }); +}); + +describe("pull request grouping", () => { + it("buckets authored above review-requested and drops empty groups", () => { + const groups = groupPullRequestsByInvolvement( + [ + entry({ number: 1, author: { login: "bilal", name: null } }), + entry({ number: 2, viewerReviewRequested: true }), + ], + "Bilal", + ); + expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ + ["reviewRequested", 1], + ["authored", 1], + ]); + }); + + it("puts everything in Others when the viewer is unknown", () => { + const groups = groupPullRequestsByInvolvement([entry({ number: 1 })], null); + expect(groups.map((group) => group.key)).toEqual(["others"]); + }); + + it("counts an authored pull request once, even with a review request on it", () => { + const groups = groupPullRequestsByInvolvement( + [entry({ number: 1, author: { login: "bilal", name: null }, viewerReviewRequested: true })], + "bilal", + ); + expect(groups.map((group) => group.key)).toEqual(["authored"]); + }); +}); + +describe("pull request search", () => { + const target = entry({ + number: 4711, + title: "Restore sidebar actions", + headBranch: "fix/sidebar", + }); + + it("matches the number with or without the leading hash", () => { + expect(matchesPullRequestQuery(target, "#4711")).toBe(true); + expect(matchesPullRequestQuery(target, "4711")).toBe(true); + }); + + it("matches title, branch and author case-insensitively", () => { + expect(matchesPullRequestQuery(target, "SIDEBAR")).toBe(true); + expect(matchesPullRequestQuery(target, "fix/side")).toBe(true); + expect(matchesPullRequestQuery(target, "octocat")).toBe(true); + }); + + it("ignores surrounding whitespace and rejects non-matches", () => { + expect(matchesPullRequestQuery(target, " ")).toBe(true); + expect(matchesPullRequestQuery(target, "kanban")).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts new file mode 100644 index 00000000000..ca205fa5b55 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -0,0 +1,82 @@ +import type { PullRequestInvolvement, PullRequestListEntry } from "@t3tools/contracts"; + +export type PullRequestGroupKey = "reviewRequested" | "authored" | "others"; + +export interface PullRequestGroup { + readonly key: PullRequestGroupKey; + readonly label: string; + readonly entries: ReadonlyArray; +} + +const GROUP_LABELS: Record = { + reviewRequested: "Review requested", + authored: "Authored", + others: "Others", +}; + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim().toLowerCase() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +/** Free-text filter over the fields a row actually shows, plus `#123` / `123`. */ +export function matchesPullRequestQuery(entry: PullRequestListEntry, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) return true; + return `#${entry.number} ${entry.title} ${entry.repository} ${entry.headBranch} ${entry.author?.login ?? ""}` + .toLowerCase() + .includes(normalizedQuery); +} + +/** + * The server returns the involvement superset for a state, so switching between the + * Reviewing and Authored tabs never waits on the network. + */ +export function filterPullRequestsByInvolvement( + entries: ReadonlyArray, + viewer: string | null, + involvement: PullRequestInvolvement, +): ReadonlyArray { + if (involvement === "reviewing") { + return entries.filter((entry) => entry.viewerReviewRequested); + } + if (involvement === "authored") { + const normalizedViewer = normalize(viewer); + return normalizedViewer === null + ? [] + : entries.filter((entry) => normalize(entry.author?.login) === normalizedViewer); + } + return entries; +} + +/** + * Only relationships the list data actually carries: no "previously reviewed" bucket is + * inferred, because the listing has no review history. + */ +export function groupPullRequestsByInvolvement( + entries: ReadonlyArray, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = normalize(viewer); + const buckets: Record = { + reviewRequested: [], + authored: [], + others: [], + }; + for (const entry of entries) { + if (normalizedViewer !== null && normalize(entry.author?.login) === normalizedViewer) { + buckets.authored.push(entry); + } else if (entry.viewerReviewRequested) { + buckets.reviewRequested.push(entry); + } else { + buckets.others.push(entry); + } + } + return (["reviewRequested", "authored", "others"] as const) + .filter((key) => buckets[key].length > 0) + .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); +} + +export function pullRequestEntryKey(entry: PullRequestListEntry): string { + return `${entry.repository}#${entry.number}`; +} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx new file mode 100644 index 00000000000..846012a196d --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -0,0 +1,216 @@ +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; +import { + CircleCheckIcon, + CircleDashedIcon, + CircleXIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + LoaderIcon, + TriangleAlertIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +interface StatePresentation { + readonly label: string; + readonly toneClassName: string; + readonly Icon: typeof GitPullRequestIcon; +} + +/** + * Single source of truth for how a pull request's state reads. Draft outranks conflicts: a + * draft is not heading for a merge yet, so conflicts only surface once it is real work. + */ +export function resolvePullRequestState(input: { + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability?: PullRequestMergeability; +}): StatePresentation { + if (input.state === "merged") { + return { + label: "Merged", + toneClassName: "text-violet-600 dark:text-violet-300/90", + Icon: GitMergeIcon, + }; + } + if (input.state === "closed") { + return { + label: "Closed", + toneClassName: "text-zinc-500 dark:text-zinc-400/80", + Icon: GitPullRequestClosedIcon, + }; + } + if (input.isDraft) { + return { + label: "Draft", + toneClassName: "text-zinc-500 dark:text-zinc-400/80", + Icon: GitPullRequestDraftIcon, + }; + } + if (input.mergeability === "conflicting") { + return { + label: "Has conflicts", + toneClassName: "text-destructive", + Icon: TriangleAlertIcon, + }; + } + return { + label: "Open", + toneClassName: "text-emerald-600 dark:text-emerald-300/90", + Icon: GitPullRequestIcon, + }; +} + +export function PullRequestStateGlyph({ + state, + isDraft, + mergeability, + className, +}: { + state: PullRequestState; + isDraft: boolean; + mergeability?: PullRequestMergeability; + className?: string; +}) { + const presentation = resolvePullRequestState({ + state, + isDraft, + ...(mergeability ? { mergeability } : {}), + }); + return ( + + ); +} + +const CHECK_STATUS_PRESENTATION = { + pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" }, + success: { + label: "Passed", + Icon: CircleCheckIcon, + toneClassName: "text-emerald-600 dark:text-emerald-300/90", + }, + failure: { label: "Failed", Icon: CircleXIcon, toneClassName: "text-destructive" }, + cancelled: { label: "Cancelled", Icon: CircleXIcon, toneClassName: "text-destructive" }, + skipped: { label: "Skipped", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, + neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, +} as const satisfies Record< + PullRequestCheckStatus, + { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } +>; + +export function pullRequestCheckStatusLabel(status: PullRequestCheckStatus): string { + return CHECK_STATUS_PRESENTATION[status].label; +} + +export function PullRequestCheckStatusIcon({ status }: { status: PullRequestCheckStatus }) { + const presentation = CHECK_STATUS_PRESENTATION[status]; + return ( + + ); +} + +/** GitHub attributes work from a deleted account to "ghost"; say the same word everywhere. */ +export function PullRequestActorLabel({ + actor, + className, +}: { + actor: PullRequestActor | null; + className?: string; +}) { + const login = actor?.login ?? "ghost"; + return ( + + + {login.slice(0, 1).toUpperCase()} + + {login} + + ); +} + +export function PullRequestDiffStat({ + additions, + deletions, + tone, + className, +}: { + additions: number; + deletions: number; + tone?: "muted" | "diff"; + className?: string; +}) { + const diffTone = tone === "diff"; + return ( + + + +{additions.toLocaleString()} + + + -{deletions.toLocaleString()} + + + ); +} + +/** Dot-separated metadata. Owns the separator so a `null` segment leaves no stray dot behind. */ +export function PullRequestMetaLine({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const segments = Array.isArray(children) ? children.filter(Boolean) : [children]; + return ( + + {segments.map((segment, index) => ( + // eslint-disable-next-line react/no-array-index-key -- segments are positional metadata + + {index > 0 ? ( + + · + + ) : null} + {segment} + + ))} + + ); +} + +export function summarizePullRequestChecks(checks: ReadonlyArray): string { + if (checks.length === 0) return "No checks reported"; + const failed = checks.filter( + (check) => check.status === "failure" || check.status === "cancelled", + ).length; + const pending = checks.filter((check) => check.status === "pending").length; + const passed = checks.filter((check) => check.status === "success").length; + if (failed > 0) return `${failed} of ${checks.length} failing`; + if (pending > 0) return `${pending} of ${checks.length} running`; + return passed === checks.length ? "All checks passed" : `${passed} of ${checks.length} passing`; +} From 3ea04afd3b182aacd17ada4ce578223dfaba60f4 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:19:47 +0000 Subject: [PATCH 07/51] feat(web): play videos embedded in pull request bodies The markdown renderer has no element for a video, so an embed would show up as a bare link. Splits a body into markdown runs and the two video shapes GitHub itself produces: a video or source tag, and a bare link on its own line to a video file or an uploaded attachment. Three cases stay markdown on purpose: anything inside fenced code, an image drop written as an image, and a source that is not http(s). --- .../pullRequest/PullRequestMarkdown.tsx | 42 +++++++++ .../pullRequestMarkdown.logic.test.ts | 62 +++++++++++++ .../pullRequest/pullRequestMarkdown.logic.ts | 90 +++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 apps/web/src/components/pullRequest/PullRequestMarkdown.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx new file mode 100644 index 00000000000..d1d48be0710 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -0,0 +1,42 @@ +import { cn } from "~/lib/utils"; + +import ChatMarkdown from "../ChatMarkdown"; +import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +/** + * A pull request body, rendered with the app's markdown renderer plus inline players for the + * videos GitHub embeds — the renderer has no element for those, so they would otherwise show + * up as a bare link. + */ +export function PullRequestMarkdown({ + text, + cwd, + className, +}: { + text: string; + cwd: string; + className?: string; +}) { + const segments = splitPullRequestBody(text); + return ( +
+ {segments.map((segment) => + segment.kind === "video" ? ( + + Open video + + + ) : ( + + ), + )} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts new file mode 100644 index 00000000000..6339abf0818 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +describe("pull request body segmentation", () => { + it("keeps a plain body as a single markdown run", () => { + expect(splitPullRequestBody("## What changed\n\nSome prose.")).toEqual([ + { id: "markdown:0", kind: "markdown", text: "## What changed\n\nSome prose." }, + ]); + }); + + it("plays a dropped attachment link and keeps the prose around it", () => { + expect( + splitPullRequestBody( + "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter", + ), + ).toEqual([ + { id: "markdown:0", kind: "markdown", text: "Before" }, + { + id: "video:1", + kind: "video", + url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456", + }, + { id: "markdown:2", kind: "markdown", text: "After" }, + ]); + }); + + it("plays a bare link to a video file", () => { + expect(splitPullRequestBody("https://example.com/demo.mp4?raw=1")).toEqual([ + { id: "video:0", kind: "video", url: "https://example.com/demo.mp4?raw=1" }, + ]); + }); + + it("reads the source out of a video tag, including a multi-line one", () => { + expect( + splitPullRequestBody( + '', + ), + ).toEqual([{ id: "video:0", kind: "video", url: "https://example.com/a.webm" }]); + }); + + it("leaves a dropped image as markdown so it still renders as an image", () => { + const body = "![shot](https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-ab)"; + expect(splitPullRequestBody(body)).toEqual([ + { id: "markdown:0", kind: "markdown", text: body }, + ]); + }); + + it("never turns a link inside fenced code into a player", () => { + const body = "```\nhttps://example.com/demo.mp4\n```"; + expect(splitPullRequestBody(body)).toEqual([ + { id: "markdown:0", kind: "markdown", text: body }, + ]); + }); + + it("refuses a non-http source rather than putting it in a player", () => { + const body = ''; + expect(splitPullRequestBody(body)).toEqual([ + { id: "markdown:0", kind: "markdown", text: body }, + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts new file mode 100644 index 00000000000..666e886e996 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -0,0 +1,90 @@ +/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ +export type PullRequestBodySegment = + | { readonly id: string; readonly kind: "markdown"; readonly text: string } + | { readonly id: string; readonly kind: "video"; readonly url: string }; + +const FENCE_PATTERN = /^\s{0,3}(?:`{3,}|~{3,})/u; +const BARE_URL_PATTERN = /^?$/u; +const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; +/** A dropped video becomes a bare asset link; a dropped image becomes `![alt](…)`. */ +const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; +const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; + +function isPlayableUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +function videoUrlFromLine(line: string): string | null { + const bare = BARE_URL_PATTERN.exec(line.trim())?.[1]; + if (bare === undefined) return null; + const isVideo = VIDEO_EXTENSION_PATTERN.test(bare) || GITHUB_ASSET_PATTERN.test(bare); + return isVideo && isPlayableUrl(bare) ? bare : null; +} + +/** + * Splits a pull request body into markdown runs and the videos embedded in it, which the + * markdown renderer has no element for. Two shapes are recognised, both of which GitHub + * produces itself: a `