diff --git a/.github/fork-features.yml b/.github/fork-features.yml index 48a5d6855e8e1..9b978a92925cf 100644 --- a/.github/fork-features.yml +++ b/.github/fork-features.yml @@ -4013,6 +4013,43 @@ features: - https://github.com/pingdotgg/t3code/pull/9496 retire_when: Upstream's automatic pull request refresh reads fresh host state rather than the held detail value, without unnecessary activity reads or invalidating cached diffs. + - id: pull-request-merge-commit-checks + title: Inspect checks on a merged GitHub pull request's resulting commit + status: maintained + prs: [1074] + invariants: + - A merged GitHub pull request reads the resulting SHA reported after merging, including squash and rebase, without substituting a base tip or temporary test merge. + - Merge commit checks, commit links, and check details remain distinct from source-branch checks on web, desktop, and mobile; web and desktop switch between the two scopes in one shared checklist, and a merged status does not imply passing checks. + - Pending checks, no reported checks, unavailable commits, and unreadable or incomplete checks remain distinct; visible views refresh through merging and later check updates. + implementation_paths: + - apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx + - apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.ts + - apps/server/src/pullRequest/PullRequestService.ts + - apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx + - apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.tsx + - packages/client-runtime/src/pullRequestMergeCommit.ts + - packages/contracts/src/pullRequest.ts + - packages/source-control-github/src/server/GitHubPullRequestApi.ts + - packages/source-control-github/src/server/pullRequestReads.graphql + upstream_paths: + - apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx + - apps/server/src/pullRequest/PullRequestService.ts + - apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx + - packages/contracts/src/pullRequest.ts + - packages/source-control-github/src/server/GitHubPullRequestApi.ts + - packages/source-control-github/src/server/pullRequestReads.graphql + tests: + - apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.test.tsx + - apps/server/src/pullRequest/PullRequestService.test.ts + - apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.browser.test.tsx + - packages/client-runtime/src/pullRequestMergeCommit.test.ts + - packages/contracts/src/pullRequest.test.ts + - packages/source-control-github/src/server/GitHubPullRequestApi.test.ts + upstream: + status: unassessed + tracking: [] + retire_when: Upstream separately displays and refreshes the actual resulting commit's checks for merged GitHub pull requests on web, desktop, and mobile, preserving unavailable states. + - id: pull-request-merge-controls-during-refresh title: Keep pull request merge controls visible while the stack lookup refreshes status: maintained diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 10d8c17584c7f..123a62e6db1a6 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -34,6 +34,7 @@ import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; import { usePullRequestAction } from "./usePullRequestAction"; +import { usePullRequestMergeCommitRefresh } from "./usePullRequestMergeCommitRefresh"; import { annotatePullRequestDiff, reviewPosition } from "./pullRequestDiffComments"; import { usePullRequestFileFilter } from "./usePullRequestFileFilter"; import { isPullRequestTestFile } from "./pullRequestFileFilters"; @@ -113,6 +114,7 @@ import { PullRequestMarkdownRepository } from "./PullRequestMarkdown"; import { PullRequestStatusMenu, PullRequestChecksRow, + PullRequestMergeCommitRow, PullRequestBranchRow, PullRequestReviewersRow, } from "./PullRequestStatusRows"; @@ -256,6 +258,7 @@ function PullRequestOverview(props: { + @@ -348,6 +351,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps refreshDetail()); const pr = result.data; + usePullRequestMergeCommitRefresh(params, pr?.provider === "github" && pr.state !== "closed"); const summary = params.initialSummary; const identity = pr ?? summary; const permissions = usePullRequestPermissions(params, pr); diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index 28034207cfaa2..80b3652c0277f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -1,4 +1,8 @@ import { usePullRequestConnected } from "./usePullRequestConnected"; +import { + mergedPullRequestCommit, + mergeCommitChecksSummary, +} from "@t3tools/client-runtime/pull-request-merge-commit"; import { PullRequestMergeIntentIcon } from "./PullRequestMergeIntentIcon"; import { useAtomValue } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; @@ -21,6 +25,7 @@ import { checkStatus, pullRequestBranchSummary, pullRequestChecksSummary, + pullRequestChecksStatus, pullRequestReviewSummary, pullRequestStatus, } from "./pullRequestStatus"; @@ -110,7 +115,12 @@ export function PullRequestChecksRow(props: RowProps) { return ( (); + const status = summary.state === null ? null : pullRequestChecksStatus(summary.state); + return ( + + setExpanded(!expanded) : undefined} + /> + {expanded ? ( + + {commit ? ( + void Linking.openURL(commit.url)} + /> + ) : null} + {commit?.checks?.map((check) => { + const status = checkStatus(check); + const url = check.url; + const keyBase = `${check.name}:${url}:${check.status}:${check.description}`; + const occurrence = keyOccurrences.get(keyBase) ?? 0; + keyOccurrences.set(keyBase, occurrence + 1); + return ( + void Linking.openURL(url) : undefined} + /> + ); + })} + + ) : null} + + ); +} + export function PullRequestBranchRow(props: RowProps) { const action = usePullRequestAction(props.params, props.pr); const agent = usePullRequestAgentActions(props.params, props.pr); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.test.tsx new file mode 100644 index 0000000000000..12c0a4f736ff7 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { act, useCallback, useEffect, useSyncExternalStore } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const appListeners = new Set<(state: string) => void>(); + const state = { focused: true, app: "active" }; + return { + state, + listeners, + appListeners, + invalidate: vi.fn(), + refresh: vi.fn(), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + focus: (focused: boolean) => { + state.focused = focused; + for (const listener of listeners) listener(); + }, + app: (app: string) => { + state.app = app; + for (const listener of appListeners) listener(app); + }, + }; +}); +vi.mock("@react-navigation/native", () => ({ + useFocusEffect: (callback: () => (() => void) | undefined) => { + const focused = useSyncExternalStore(harness.subscribe, () => harness.state.focused); + useEffect(useCallback(() => (focused ? callback() : undefined), [focused, callback])); + }, +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return harness.state.app; + }, + addEventListener: (_: string, listener: (state: string) => void) => { + harness.appListeners.add(listener); + return { + remove: () => { + harness.appListeners.delete(listener); + }, + }; + }, + }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomRefresh: () => harness.refresh })); +vi.mock("../../state/pull-requests", () => ({ + composerPullRequests: { detail: () => null, invalidate: {} }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => harness.invalidate })); +vi.mock("./usePullRequestDiff", () => ({ + pullRequestTarget: (params: Record) => { + const { environmentId, ...input } = params; + return { environmentId, input }; + }, +})); + +import { usePullRequestMergeCommitRefresh } from "./usePullRequestMergeCommitRefresh"; + +const params = { + environmentId: "environment", + projectId: "project", + repository: "acme/web", + number: 7, +}; +function Probe({ enabled }: { enabled: boolean }) { + usePullRequestMergeCommitRefresh(params, enabled); + return null; +} +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + harness.state.focused = true; + harness.state.app = "active"; + harness.invalidate.mockResolvedValue({ _tag: "Success" }); + container = document.createElement("div"); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("reads fresh host detail while focused, including merged results, and stops when hidden", async () => { + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTime(60_000)); + expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith({ + environmentId: "environment", + input: { + reference: { projectId: "project", repository: "acme/web", number: 7 }, + scope: "detail", + }, + }); + expect(harness.refresh).toHaveBeenCalledOnce(); + await act(async () => harness.app("background")); + await act(async () => vi.advanceTimersByTime(120_000)); + expect(harness.refresh).toHaveBeenCalledOnce(); + await act(async () => harness.app("active")); + expect(harness.refresh).toHaveBeenCalledTimes(2); + await act(async () => harness.focus(false)); + await act(async () => vi.advanceTimersByTime(120_000)); + expect(harness.refresh).toHaveBeenCalledTimes(2); + expect(harness.appListeners.size).toBe(0); + await act(async () => harness.focus(true)); + await act(async () => vi.advanceTimersByTime(60_000)); + expect(harness.refresh).toHaveBeenCalledTimes(3); + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTime(120_000)); + expect(harness.refresh).toHaveBeenCalledTimes(3); +}); + +it("does not overlap host requests or refresh a route left during an in-flight read", async () => { + let resolve: () => void = () => {}; + harness.invalidate.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTime(180_000)); + expect(harness.invalidate).toHaveBeenCalledOnce(); + expect(harness.refresh).not.toHaveBeenCalled(); + await act(async () => harness.focus(false)); + await act(async () => resolve()); + expect(harness.refresh).not.toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.ts b/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.ts new file mode 100644 index 0000000000000..11988aa0d6512 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/usePullRequestMergeCommitRefresh.ts @@ -0,0 +1,40 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { useCallback } from "react"; +import { AppState } from "react-native"; +import { useAtomRefresh } from "@effect/atom-react"; + +import { composerPullRequests } from "../../state/pull-requests"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; + +export function usePullRequestMergeCommitRefresh(params: PullRequestParams, enabled: boolean) { + const invalidate = useAtomCommand(composerPullRequests.invalidate, { reportFailure: false }); + const refresh = useAtomRefresh(composerPullRequests.detail(pullRequestTarget(params))); + useFocusEffect( + useCallback(() => { + if (!enabled) return; + let mounted = true; + let pending = false; + const read = async () => { + if (!mounted || pending || AppState.currentState !== "active") return; + pending = true; + const target = pullRequestTarget(params); + await invalidate({ + environmentId: target.environmentId, + input: { reference: target.input, scope: "detail" }, + }); + if (mounted) refresh(); + pending = false; + }; + const timer = setInterval(() => void read(), 60_000); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void read(); + }); + return () => { + mounted = false; + clearInterval(timer); + subscription.remove(); + }; + }, [enabled, invalidate, params, refresh]), + ); +} diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 5d5637b5abeb0..76dc8c1bf4601 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -178,6 +178,66 @@ it.effect("refreshes deployment status and environment links with PR detail", () }), ); +it.effect( + "refreshes a merged PR's resulting commit checks independently of unchanged passing PR checks", + () => + Effect.gen(function* () { + let phase: "open" | "no-checks" | "pending" | "failure" | "unavailable" = "open"; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => + Effect.succeed({ + ...changeRequestDetail(1), + state: phase === "open" ? "open" : "merged", + checks: [{ name: "Tests", status: "success", description: null, url: null }], + ...(phase === "open" + ? {} + : { + mergeCommit: + phase === "unavailable" + ? null + : { + oid: "b".repeat(40), + url: "https://github.com/acme/web/commit/" + "b".repeat(40), + checks: + phase === "no-checks" + ? [] + : [ + { + name: "Deploy", + status: phase, + description: null, + url: null, + }, + ], + }, + }), + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.detail(reference)).mergeCommit, undefined); + for (const next of ["no-checks", "pending", "failure", "unavailable"] as const) { + phase = next; + yield* service.invalidate({ reference, scope: "detail" }); + const detail = yield* service.detail(reference); + assert.strictEqual(detail.state, "merged"); + assert.strictEqual(detail.checks[0]?.status, "success"); + if (next === "unavailable") assert.strictEqual(detail.mergeCommit, null); + else + assert.deepStrictEqual( + detail.mergeCommit?.checks?.map((check) => check.status), + next === "no-checks" ? [] : [next], + ); + } + }), +); + it.effect("caches narrow previews and invalidates them after refresh or mutation", () => Effect.gen(function* () { let reads = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 369dfd5e4cca0..f4d2963dd90fd 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1637,6 +1637,9 @@ export const make = Effect.gen(function* () { reviewers: changeRequest.reviewers, labels: changeRequest.labels, checks: changeRequest.checks, + ...(changeRequest.mergeCommit === undefined + ? {} + : { mergeCommit: changeRequest.mergeCommit }), ...(changeRequest.deployments === undefined ? {} : { deployments: changeRequest.deployments }), diff --git a/apps/server/src/telemetry/ClientFeatureUsage.test.ts b/apps/server/src/telemetry/ClientFeatureUsage.test.ts index ed927170159f7..d6d56d2ce9b8f 100644 --- a/apps/server/src/telemetry/ClientFeatureUsage.test.ts +++ b/apps/server/src/telemetry/ClientFeatureUsage.test.ts @@ -10,6 +10,57 @@ import * as ClientFeatureUsage from "./ClientFeatureUsage.ts"; const decode = Schema.decodeUnknownEffect(ForkClientUsageInput); const isUsage = Schema.is(ForkClientUsageInput); +it.effect( + "records merge commit links once with an outcome and excludes commit and check content", + () => + Effect.gen(function* () { + const events: unknown[] = []; + const report = yield* ClientFeatureUsage.reporter({ surface: "web" }).pipe( + Effect.provideService(AnalyticsService.AnalyticsService, { + record: (event, properties) => + Effect.sync(() => { + events.push({ event, properties }); + }), + flush: Effect.void, + }), + ); + for (const action of ["open_commit", "open_check"] as const) { + for (const outcome of ["success", "failure"] as const) { + yield* report( + yield* decode({ + feature: "pull-request-merge-commit-checks", + action, + outcome, + oid: "b".repeat(40), + url: "https://example.com/check", + name: "Deploy", + number: 7, + }), + ); + expect(events.at(-1)).toEqual({ + event: "feature.used", + properties: { + feature: "pull-request-merge-commit-checks", + action, + outcome, + source: "user", + surface: "web", + forkClientUsageVersion: FORK_CLIENT_USAGE_VERSION, + }, + }); + } + } + expect(events).toHaveLength(4); + expect( + isUsage({ + feature: "pull-request-merge-commit-checks", + action: "rerun", + outcome: "success", + }), + ).toBe(false); + }), +); + it.effect( "records deployment interactions with bounded outcomes and no PR or environment content", () => diff --git a/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx b/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx index b547d424493d6..8e69ff0d53855 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksTab.tsx @@ -208,6 +208,7 @@ export function PullRequestChecksTab({ pendingFinding, fixCheckLabel = "Fix", onFixFinding, + onOpenCheck, }: { checks: ReadonlyArray; /** Newer status contradicts `checks`; the runs are hidden until the detail is refreshed. */ @@ -229,6 +230,7 @@ export function PullRequestChecksTab({ pendingFinding?: string | null; fixCheckLabel?: string; onFixFinding?: (finding: PullRequestFinding) => void; + onOpenCheck?: (url: string) => void; }) { const state = pullRequestChecksState(checks); // Null for a set nobody can call passed or failed — every run skipped, say. The list still @@ -274,7 +276,8 @@ export function PullRequestChecksTab({ const keyOccurrences = new Map(); const openCheck = (url: string) => { - void readLocalApi()?.shell.openExternal(url); + if (onOpenCheck) onOpenCheck(url); + else void readLocalApi()?.shell.openExternal(url); }; if (stale) { @@ -385,7 +388,7 @@ export function PullRequestChecksTab({

No checks reported.

- Runs appear here as soon as the host reports one for this branch. + Runs appear here as soon as the host reports one.

) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1711e49adedad..d8909db8b449b 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -92,6 +92,12 @@ import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; +import { mergedPullRequestCommit } from "@t3tools/client-runtime/pull-request-merge-commit"; +import { + PullRequestMergeCommitChecks, + PullRequestMergeCommitStatus, + type PullRequestChecksScope, +} from "./PullRequestMergeCommitChecks"; import { pullRequestEnvironment, pullRequestListEntryToSummary, @@ -678,6 +684,20 @@ function PullRequestDetailPanelBody({ const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [openReviewRequest, setOpenReviewRequest] = useState(0); const [tab, setTab] = useState("summary"); + const [checksScopeSelection, setChecksScopeSelection] = useState<{ + pullRequestKey: string; + scope: PullRequestChecksScope; + } | null>(null); + const checksScope = + checksScopeSelection?.pullRequestKey === pullRequestKey + ? checksScopeSelection.scope + : "merge-commit"; + const selectChecksScope = (scope: PullRequestChecksScope) => + setChecksScopeSelection({ pullRequestKey, scope }); + const openMergeCommitChecks = () => { + selectChecksScope("merge-commit"); + setTab("checks"); + }; const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ readonly pullRequestKey: string; @@ -914,6 +934,7 @@ function PullRequestDetailPanelBody({ // different pull request every time it is opened. useLiveRefresh(() => void refreshDetailFromHost("detail"), { key: `pull-request:${environmentId}:${pullRequestKey}`, + ...(detail?.provider === "github" && detail.state !== "closed" ? { intervalMs: 60_000 } : {}), }); const [isInvalidating, setIsInvalidating] = useState(false); const refreshing = isInvalidating || detailQuery.isPending; @@ -1595,14 +1616,16 @@ function PullRequestDetailPanelBody({ (entry) => entry.outcome === "approved" && !entry.stale, ).length : 0; - const mergeVerdict = detail - ? pullRequestMergeVerdict({ - checks: detail.checks, - isDraft: detail.isDraft, - mergeReadiness: detail.mergeReadiness, - compact: true, - }) - : null; + const mergeCommit = detail === null ? undefined : mergedPullRequestCommit(detail); + const mergeVerdict = + detail && detail.state !== "merged" + ? pullRequestMergeVerdict({ + checks: detail.checks, + isDraft: detail.isDraft, + mergeReadiness: detail.mergeReadiness, + compact: true, + }) + : null; const checksMark = detail && checksState !== null ? ( {detail.title} - {mergeVerdict ? ( + {mergeCommit !== undefined ? ( + + ) : mergeVerdict ? ( {mergeVerdict.policy === null ? ( checksMark @@ -2777,13 +2805,20 @@ function PullRequestDetailPanelBody({ {tab === "summary" ? ( {approveWorkflowsButton} - setTab("checks")} - /> + {mergeCommit !== undefined ? ( + + ) : ( + setTab("checks")} + /> + )} ) : tab === "timeline" ? (
@@ -2927,20 +2962,28 @@ function PullRequestDetailPanelBody({ ) : null} {mountedTabs.has("checks") ? (
- void perform("update-branch", undefined, method)} - pendingFinding={handoff} - fixCheckLabel={handoffLabels.fixCheck} - {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} - /> + + void perform("update-branch", undefined, method)} + pendingFinding={handoff} + fixCheckLabel={handoffLabels.fixCheck} + {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} + /> +
) : null} {mountedTabs.has("guide") ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.browser.test.tsx new file mode 100644 index 0000000000000..be08904d0cce5 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.browser.test.tsx @@ -0,0 +1,140 @@ +import { EnvironmentId, type PullRequestMergeCommit } from "@t3tools/contracts"; +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +const { openLink, report, openSourceCheck } = vi.hoisted(() => ({ + openLink: vi.fn(), + report: vi.fn(), + openSourceCheck: vi.fn(), +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => openLink })); +vi.mock("~/forkClientUsage", () => ({ reportForkClientUsage: report })); +import { PullRequestChecksTab } from "./PullRequestChecksTab"; +import { + PullRequestMergeCommitChecks, + type PullRequestChecksScope, +} from "./PullRequestMergeCommitChecks"; + +const environmentId = EnvironmentId.make("merge-checks-test"); +const commit: PullRequestMergeCommit = { + oid: "b".repeat(40), + url: `https://github.com/acme/web/commit/${"b".repeat(40)}`, + checks: [ + { name: "Deploy", status: "pending", description: null, url: "https://example.com/runs/1" }, + ], +}; +function Harness({ value }: { value: PullRequestMergeCommit | null | undefined }) { + const [scope, setScope] = useState("merge-commit"); + return ( + + + + ); +} +let container: HTMLDivElement; +let root: Root; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + openLink.mockResolvedValue(undefined); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +const render = (value: PullRequestMergeCommit | null | undefined) => + act(async () => root.render()); +const scopeButton = (label: string) => + Array.from(container.querySelectorAll("button")).find((button) => button.textContent === label)!; +const checkButton = (name: string) => + container.querySelector(`[aria-label="Open ${name} on the host"]`)!; + +it("switches scopes without mixing their check rows, SHAs or links", async () => { + await render(commit); + expect(container.textContent).toContain("Deploy"); + expect(container.textContent).toContain("bbbbbbb"); + expect(container.textContent).not.toContain("Source tests"); + await act(async () => userEvent.click(container.querySelector("a")!)); + expect(openLink).toHaveBeenLastCalledWith(commit.url); + await act(async () => userEvent.click(scopeButton("Pull request"))); + expect(container.textContent).toContain("Source tests"); + expect(container.textContent).toContain("aaaaaaa"); + expect(container.textContent).not.toContain("Deploy"); + await act(async () => userEvent.click(checkButton("Source tests"))); + expect(openSourceCheck).toHaveBeenCalledExactlyOnceWith("https://example.com/source"); + expect(report).toHaveBeenCalledTimes(1); + await act(async () => userEvent.click(scopeButton("Merge commit"))); + await render({ + ...commit, + checks: [{ ...commit.checks![0]!, status: "failure", description: "Deployment failed" }], + }); + expect(container.textContent).toContain("Failed"); + expect(container.textContent).not.toContain("Running"); + await act(async () => userEvent.click(checkButton("Deploy"))); + expect(openLink).toHaveBeenLastCalledWith("https://example.com/runs/1"); + expect(report.mock.calls).toEqual([ + [ + environmentId, + { feature: "pull-request-merge-commit-checks", action: "open_commit", outcome: "success" }, + ], + [ + environmentId, + { feature: "pull-request-merge-commit-checks", action: "open_check", outcome: "success" }, + ], + ]); + openLink.mockRejectedValueOnce(new Error("Unavailable")); + await act(async () => userEvent.click(checkButton("Deploy"))); + expect(report).toHaveBeenLastCalledWith(environmentId, { + feature: "pull-request-merge-commit-checks", + action: "open_check", + outcome: "failure", + }); +}); + +it("keeps unavailable and empty merge results distinct while retaining source checks and commit links", async () => { + await render(null); + expect(container.textContent).toContain("Merge commit unavailable"); + expect(container.querySelector("a")).toBeNull(); + await act(async () => userEvent.click(scopeButton("Pull request"))); + expect(container.textContent).toContain("Source tests"); + await act(async () => userEvent.click(scopeButton("Merge commit"))); + await render({ ...commit, checks: null }); + expect(container.textContent).toContain("Checks unavailable"); + expect(container.textContent).not.toContain("No checks reported"); + await act(async () => userEvent.click(container.querySelector("a")!)); + expect(openLink).toHaveBeenLastCalledWith(commit.url); + await render({ ...commit, checks: [] }); + expect(container.textContent).toContain("No checks reported"); + await render(commit); + expect(container.textContent).toContain("Deploy"); + expect(container.textContent).toContain("Running"); +}); + +it("preserves ordinary pull request checks when merge commit data is unsupported", async () => { + await render(undefined); + expect(container.textContent).toContain("Source tests"); + expect(container.textContent).not.toContain("Merge commit"); + expect(container.querySelector("a")).toBeNull(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.tsx b/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.tsx new file mode 100644 index 0000000000000..d5d64b6aac5df --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMergeCommitChecks.tsx @@ -0,0 +1,140 @@ +import { mergeCommitChecksSummary } from "@t3tools/client-runtime/pull-request-merge-commit"; +import type { EnvironmentId, PullRequestMergeCommit } from "@t3tools/contracts"; +import { CircleHelpIcon, CircleDotIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { useOpenLink } from "~/browser/useOpenLink"; +import { reportForkClientUsage } from "~/forkClientUsage"; +import { cn } from "~/lib/utils"; + +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestChecksTab } from "./PullRequestChecksTab"; +import { pullRequestChecksStatePresentation } from "./pullRequestPresentation"; + +export type PullRequestChecksScope = "merge-commit" | "pull-request"; + +export function PullRequestMergeCommitStatus({ + commit, + onSelect, +}: { + commit: PullRequestMergeCommit | null; + onSelect: () => void; +}) { + const summary = mergeCommitChecksSummary(commit); + const presentation = + summary.state === null ? null : pullRequestChecksStatePresentation(summary.state); + const Icon = + presentation?.Icon ?? + (commit?.checks === null || commit === null ? CircleHelpIcon : CircleDotIcon); + return ( + + ); +} + +export function PullRequestMergeCommitChecks({ + commit, + headSha, + environmentId, + scope, + onScopeChange, + children, +}: { + commit: PullRequestMergeCommit | null | undefined; + headSha: string | null; + environmentId?: EnvironmentId | undefined; + scope: PullRequestChecksScope; + onScopeChange: (scope: PullRequestChecksScope) => void; + children: ReactNode; +}) { + const openLink = useOpenLink(null); + const open = (url: string, action: "open_commit" | "open_check") => { + void openLink(url).then( + () => + reportForkClientUsage(environmentId, { + feature: "pull-request-merge-commit-checks", + action, + outcome: "success", + }), + () => + reportForkClientUsage(environmentId, { + feature: "pull-request-merge-commit-checks", + action, + outcome: "failure", + }), + ); + }; + if (commit === undefined) return children; + const mergeSelected = scope === "merge-commit"; + const sha = mergeSelected ? commit?.oid : headSha; + return ( +
+
+ { + if (next[0] === "merge-commit" || next[0] === "pull-request") onScopeChange(next[0]); + }} + > + Merge commit + Pull request + + {sha ? ( + + { + event.preventDefault(); + open(commit.url, "open_commit"); + }} + className="font-mono text-xs text-muted-foreground hover:text-foreground hover:underline" + /> + ) : ( + + ) + } + > + {sha.slice(0, 7)} + + {sha} + + ) : null} +
+
+ {!mergeSelected ? ( + children + ) : commit === null || commit.checks === null ? ( +
+ + {commit === null ? "Merge commit unavailable" : "Checks unavailable"} +
+ ) : ( + open(url, "open_check")} + /> + )} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index ccadd6ff94214..5c22ec5f2322b 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -23,7 +23,6 @@ import { useRef, useState, type ReactNode } from "react"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { cn } from "~/lib/utils"; -import { useOpenLink } from "~/browser/useOpenLink"; import { RelativeTime } from "~/components/RelativeTime"; import { Button } from "../ui/button"; @@ -456,14 +455,6 @@ export function PullRequestSummaryTab({ })), ]; - const openLink = useOpenLink(threadRef); - const openCheck = (url: string) => { - void openLink(url).catch((error: unknown) => { - console.error(error); - toastManager.add({ type: "error", title: "Unable to open check details" }); - }); - }; - const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { reportFailure: false, diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 491b4a8d7ba0f..14614938c1b9b 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -407,7 +407,13 @@ function CommitEvent({ ); } -function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) { +function LifecycleEvent({ + event, + onOpen, +}: { + event: PullRequestTimelineEvent; + onOpen: (url: string) => void; +}) { const presentation = event.kind === "opened" ? { @@ -417,7 +423,7 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) { : event.kind === "merged" ? { icon: , - label: "Pull request merged", + label: event.title, } : { icon: , @@ -430,7 +436,19 @@ function LifecycleEvent({ event }: { event: PullRequestTimelineEvent }) {
{event.actor ? : null} - {presentation.label} + {event.url ? ( + + ) : ( + {presentation.label} + )}
@@ -600,7 +618,7 @@ export function PullRequestTimelineTab({ /> ); } - return ; + return ; })}
diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 9e0587376a2b2..9be64a18d5c0a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -581,6 +581,23 @@ describe("review verdicts", () => { }); describe("pull request timeline", () => { + it("links the merged lifecycle event to the resulting commit even when its checks failed", () => { + const mergeCommit = { + oid: "b".repeat(40), + url: `https://github.com/acme/web/commit/${"b".repeat(40)}`, + checks: [{ name: "Deploy", status: "failure" as const, description: null, url: null }], + }; + const merged = buildPullRequestTimeline({ + ...TIMELINE_SOURCE, + mergedAt: "2026-07-03T00:00:00Z", + baseBranch: "main", + mergeCommit, + }).find((event) => event.kind === "merged"); + expect(merged).toMatchObject({ + title: "merged commit bbbbbbb into main", + url: mergeCommit.url, + }); + }); it("orders creation, commits and comments newest first", () => { expect(buildPullRequestTimeline(TIMELINE_SOURCE).map((event) => event.id)).toEqual([ "c1", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 3794eb9102929..eab5d5ab644f2 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -543,8 +543,9 @@ export function visibleBody(body: string): string | null { export function buildPullRequestTimeline( detail: Pick< PullRequestDetailView, - "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" - >, + "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" | "mergeCommit" + > & + Partial>, ): ReadonlyArray { return [ { @@ -601,10 +602,12 @@ export function buildPullRequestTimeline( id: "merged", at: detail.mergedAt, kind: "merged" as const, - title: "Pull request merged", + title: detail.mergeCommit + ? `merged commit ${detail.mergeCommit.oid.slice(0, 7)}${detail.baseBranch ? ` into ${detail.baseBranch}` : ""}` + : "Pull request merged", body: null, markdown: false, - url: null, + url: detail.mergeCommit?.url ?? null, actor: null, commitAuthors: [], additions: null, diff --git a/docs/operations/feature-usage.md b/docs/operations/feature-usage.md index 76b2454f73feb..e7f28fd1b550a 100644 --- a/docs/operations/feature-usage.md +++ b/docs/operations/feature-usage.md @@ -12,6 +12,8 @@ Upstream's `mcp.tool.invoked` records one event per agent MCP tool call with `to Additional measured operations are: +- `pull-request-merge-commit-checks`, `source = user`: `open_commit` and `open_check` record explicit links from the merge commit section on web and desktop with `outcome = success | failure`. Success means the browser accepted the handoff. Reports require client vocabulary version 11; display and refresh do not count. Only the action, outcome, and existing client attribution are sent. Mobile does not report these interactions, so these counts cannot establish whole-feature adoption. + - `pull-request-deployments`, `source = user`: `open_environment`, `open_logs`, and `show_all` record explicit deployment panel actions with `outcome = success | failure`. Link success means the browser accepted the handoff, not that the destination loaded. Display, polling, rerenders and Show less do not emit events. Reports require client vocabulary version 2 and go to the PR's environment without names, URLs, repositories, PR numbers or commit hashes. - `devices`, `source = user`: Device panel open, close, shutdown and device actions from a client that reports its surface. Requests forwarded from another environment and older clients without a surface remain unattributed instead of being counted again as manual use. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 770f3bb92dd12..e12fb840e7da5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -204,6 +204,13 @@ GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding after auto-merge is on are merged too, unless the host turns auto-merge off. GitHub also supports approving waiting workflows and opening a revert pull request for a merged change. +For a merged GitHub pull request, **Merge commit** shows the resulting commit and its own checks +in Checks on web and desktop, and in the mobile overview. Use **Merge commit** and **Pull request** +to switch between the two sets of checks on web and desktop. Open the commit SHA or a +check to inspect it on GitHub. Pull request checks remain separate: their passing +result does not say whether the merge commit passed. Squash and rebase merges show the resulting +commit GitHub reports. Missing commit data or unreadable checks are shown as unavailable. + GitHub holds some workflow runs until someone with write access approves them: runs from fork pull requests, depending on the repository's settings, and runs on a pull request that a workflow opened or updated with `GITHUB_TOKEN`, such as a Changesets or release-please release pull request. diff --git a/docs/user/styal-differences.md b/docs/user/styal-differences.md index 7c0ba8f9bab3f..d16eab229780b 100644 --- a/docs/user/styal-differences.md +++ b/docs/user/styal-differences.md @@ -161,6 +161,11 @@ works. This overview focuses on desktop and web; the mobile app is a work in pro update methods. [Code review](./source-control.md#review-and-merge). Compared with [T3 Code revision a493946](https://github.com/pingdotgg/t3code/tree/a493946bb42ab16e1d18285dac3e9e4603651330), checked September 23, 2026. +- **Checks on the merge commit.** Merged GitHub pull requests show the resulting commit and its + checks separately from the pull request's checks on web, desktop, and mobile, with links to the + commit and individual checks. [Code review](./source-control.md#review-and-merge). + Compared with [T3 Code revision d0b9e9d](https://github.com/pingdotgg/t3code/tree/d0b9e9d3c72b83bb92670fc1fa3afac5bf60e4e8), + checked October 25, 2026, which reads the last pull request commit's checks. - **Workflows waiting for approval on release pull requests.** T3 Code shows and approves fork workflows waiting for a maintainer. styal also finds runs GitHub holds on pull requests a workflow opened with `GITHUB_TOKEN`, such as Changesets release pull requests, offers diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 51dd7b5973c16..800826f1accb0 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -73,6 +73,8 @@ Each time the desktop app starts, it records how long its local server and windo Pull request deployment controls record whether someone opens an environment, opens deployment logs, or expands the environment list, and whether the action succeeded. Environment names, URLs, repositories, pull request numbers, and commit hashes are not collected. +On web and desktop, links in a pull request's merge commit section record whether someone opens the commit or a check's details, and whether the browser accepted the link. Commit hashes, check names, URLs, repositories, and pull request numbers are not collected. + Usage events do not include prompts, responses, file contents, authentication tokens, conversation IDs, raw provider events, or child-agent output. Child-agent token use is excluded from the totals. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4c4d43663cf32..674c29fabed4c 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -470,6 +470,10 @@ "./pull-request-query": { "types": "./src/pullRequestQuery.ts", "default": "./src/pullRequestQuery.ts" + }, + "./pull-request-merge-commit": { + "types": "./src/pullRequestMergeCommit.ts", + "default": "./src/pullRequestMergeCommit.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/pullRequestMergeCommit.test.ts b/packages/client-runtime/src/pullRequestMergeCommit.test.ts new file mode 100644 index 0000000000000..41e41131c221e --- /dev/null +++ b/packages/client-runtime/src/pullRequestMergeCommit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + SourceControlProviderKind, + type PullRequestCheck, + type PullRequestDetail, +} from "@t3tools/contracts"; +import { mergedPullRequestCommit, mergeCommitChecksSummary } from "./pullRequestMergeCommit.ts"; + +const check = (status: PullRequestCheck["status"]): PullRequestCheck => ({ + name: "Deploy", + status, + description: null, + url: null, +}); +const commit = { + oid: "b".repeat(40), + url: `https://github.com/acme/web/commit/${"b".repeat(40)}`, + checks: [check("failure")], +}; +const pr = { + state: "merged", + provider: SourceControlProviderKind.make("github"), + checks: [check("success")], + mergeCommit: commit, +} satisfies Pick; + +describe("merge commit checks", () => { + it("keeps passing PR checks out of the resulting commit's failed rollup", () => { + const selected = mergedPullRequestCommit(pr); + expect(selected).toBe(commit); + expect(mergeCommitChecksSummary(selected!)).toEqual({ + label: "1 check failed", + state: "failing", + }); + }); + it("only exposes a resulting commit after merging, including an unavailable GitHub result", () => { + expect(mergedPullRequestCommit({ ...pr, state: "open" })).toBeUndefined(); + expect(mergedPullRequestCommit({ ...pr, state: "closed" })).toBeUndefined(); + expect(mergedPullRequestCommit({ ...pr, mergeCommit: undefined })).toBeNull(); + expect( + mergedPullRequestCommit({ + ...pr, + provider: SourceControlProviderKind.make("gitlab"), + mergeCommit: undefined, + }), + ).toBeUndefined(); + }); + it("preserves missing commit, unreadable checks, no checks, pending, and terminal outcomes", () => { + expect(mergeCommitChecksSummary(null)).toEqual({ + label: "Merge commit unavailable", + state: null, + }); + expect(mergeCommitChecksSummary({ ...commit, checks: null })).toEqual({ + label: "Checks unavailable", + state: null, + }); + expect(mergeCommitChecksSummary({ ...commit, checks: [] })).toEqual({ + label: "No checks reported", + state: null, + }); + expect( + mergeCommitChecksSummary({ ...commit, checks: [check("success"), check("pending")] }), + ).toEqual({ label: "1 check pending, 1 check passed", state: "pending" }); + expect( + mergeCommitChecksSummary({ ...commit, checks: [check("success"), check("cancelled")] }), + ).toEqual({ label: "1 check cancelled, 1 check passed", state: "failing" }); + expect( + mergeCommitChecksSummary({ ...commit, checks: [check("skipped"), check("neutral")] }), + ).toEqual({ label: "1 check skipped, 1 check neutral", state: null }); + expect( + mergeCommitChecksSummary({ ...commit, checks: [check("success"), check("success")] }), + ).toEqual({ label: "2 checks passed", state: "passing" }); + }); +}); diff --git a/packages/client-runtime/src/pullRequestMergeCommit.ts b/packages/client-runtime/src/pullRequestMergeCommit.ts new file mode 100644 index 0000000000000..2b2fb535c78a8 --- /dev/null +++ b/packages/client-runtime/src/pullRequestMergeCommit.ts @@ -0,0 +1,49 @@ +import type { + PullRequestChecksState, + PullRequestDetail, + PullRequestMergeCommit, +} from "@t3tools/contracts"; + +/** Undefined hides unsupported hosts; null keeps an unavailable GitHub result visible. */ +export function mergedPullRequestCommit( + pr: Pick, +) { + if (pr.state !== "merged") return undefined; + return pr.provider === "github" || pr.mergeCommit !== undefined + ? (pr.mergeCommit ?? null) + : undefined; +} + +export function mergeCommitChecksSummary(commit: PullRequestMergeCommit | null): { + label: string; + state: PullRequestChecksState | null; +} { + if (commit === null) return { label: "Merge commit unavailable", state: null }; + const checks = commit.checks; + if (checks === null) return { label: "Checks unavailable", state: null }; + if (checks.length === 0) return { label: "No checks reported", state: null }; + const counts = { + failed: checks.filter((check) => check.status === "failure").length, + cancelled: checks.filter((check) => check.status === "cancelled").length, + pending: checks.filter((check) => check.status === "pending" || check.status === "expected") + .length, + "action required": checks.filter((check) => check.status === "action-required").length, + passed: checks.filter((check) => check.status === "success").length, + skipped: checks.filter((check) => check.status === "skipped").length, + neutral: checks.filter((check) => check.status === "neutral").length, + }; + return { + label: Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([status, count]) => `${count} ${count === 1 ? "check" : "checks"} ${status}`) + .join(", "), + state: + counts.failed || counts.cancelled + ? "failing" + : counts.pending || counts["action required"] + ? "pending" + : counts.passed + ? "passing" + : null, + }; +} diff --git a/packages/contracts/src/forkClientUsage.ts b/packages/contracts/src/forkClientUsage.ts index ec6f0ec5cfcc8..66d4c5c7bb7fd 100644 --- a/packages/contracts/src/forkClientUsage.ts +++ b/packages/contracts/src/forkClientUsage.ts @@ -127,6 +127,11 @@ export type WebBuildUpdateUsageAction = typeof WebBuildUpdateUsageAction.Type; /** Closed UI observations only: content, paths and identifiers stay in the client. */ export const ForkClientUsageInput = Schema.Union([ + Schema.Struct({ + feature: Schema.Literal("pull-request-merge-commit-checks"), + action: Schema.Literals(["open_commit", "open_check"]), + outcome: Schema.Literals(["success", "failure"]), + }), Schema.Struct({ feature: Schema.Literal("pull-request-deployments"), action: Schema.Literals(["open_environment", "open_logs", "show_all"]), diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 7ea0807d9045d..c0dc6a35b2316 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestDetail, PullRequestInvalidateInput, PullRequestFilesViewedResult, PullRequestListInput, @@ -23,6 +24,23 @@ const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput); const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult); +it("preserves unsupported, unavailable and empty merge checks through the RPC codec", () => { + const schema = Schema.Struct({ mergeCommit: PullRequestDetail.fields.mergeCommit }); + const codec = Schema.toCodecJson(schema); + const commit = { + oid: "a".repeat(40), + url: "https://github.com/acme/web/commit/" + "a".repeat(40), + }; + for (const value of [ + {}, + { mergeCommit: null }, + { mergeCommit: { ...commit, checks: null } }, + { mergeCommit: { ...commit, checks: [] } }, + ]) { + expect(Schema.decodeSync(codec)(Schema.encodeUnknownSync(codec)(value))).toEqual(value); + } +}); + const LIST_RESULT: PullRequestListResult = { viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" }, providers: [ diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index ed7393705c427..21702cd52907b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -128,6 +128,15 @@ export const PullRequestCheck = Schema.Struct({ }); export type PullRequestCheck = typeof PullRequestCheck.Type; +export const PullRequestMergeCommit = Schema.Struct({ + /** The resulting commit reported by the host after merging, including squash and rebase. */ + oid: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + /** Null when checks could not be read completely; an empty array means no checks reported. */ + checks: Schema.NullOr(Schema.Array(PullRequestCheck)), +}); +export type PullRequestMergeCommit = typeof PullRequestMergeCommit.Type; + export const PullRequestReactionContent = Schema.Literals([ "thumbs-up", "thumbs-down", @@ -636,6 +645,8 @@ export const PullRequestDetail = Schema.Struct({ reviewers: Schema.Array(PullRequestActor), labels: Schema.Array(PullRequestLabel), checks: Schema.Array(PullRequestCheck), + /** Absent on unsupported hosts/older servers; null when the resulting commit is unavailable. */ + mergeCommit: Schema.optional(Schema.NullOr(PullRequestMergeCommit)), /** Latest deployment per environment, where the host reports deployments for this PR. */ deployments: Schema.optional(Schema.Array(PullRequestDeployment)), deploymentsTruncated: Schema.optional(Schema.Boolean), diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 90b06fdeebd90..6a64fc2888c6c 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -19,6 +19,7 @@ import type { PullRequestListFilters, PullRequestListState, PullRequestMergeCapabilities, + PullRequestMergeCommit, PullRequestMergeMethod, PullRequestMergeReadiness, PullRequestMergeability, @@ -186,6 +187,7 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly closedAt: string | null; readonly reviewers: ReadonlyArray; readonly checks: ReadonlyArray; + readonly mergeCommit?: PullRequestMergeCommit | null; readonly deployments?: ReadonlyArray; readonly deploymentsTruncated?: boolean; readonly mergeCapabilities: PullRequestMergeCapabilities; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index a4f25ea47dcd5..15f7413c8cddf 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -767,6 +767,312 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { + const mergedOid = "b".repeat(40); + const mergeInput = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + const commitChecksPage = (checks: ReadonlyArray, after: string | null = null) => ({ + data: { + repository: { + object: { + __typename: "Commit", + oid: mergedOid, + statusCheckRollup: { + contexts: { + nodes: checks, + pageInfo: { hasNextPage: after !== null, endCursor: after }, + }, + }, + }, + }, + }, + }); + + it.effect( + "keeps checks on the resulting merge, squash or rebase SHA separate from a deleted source branch", + () => + Effect.gen(function* () { + for (const method of ["merge", "squash", "rebase"]) { + const oid = { merge: "b", squash: "c", rebase: "d" }[method]!.repeat(40); + mockedExecute.mockImplementation((call) => { + if (call.kind === "rest") + return Effect.succeed(output(encodeJson({ merged: true, merge_commit_sha: oid }))); + if (call.operationName === "CommitCheckContexts") + return Effect.succeed( + output( + encodeJson({ + data: { + repository: { + object: { + ...commitChecksPage([ + checkRun({ + name: "Deploy", + conclusion: "FAILURE", + detailsUrl: "https://example.com/runs/merge", + }), + ]).data.repository.object, + oid, + }, + }, + }, + }), + ), + ); + return Effect.succeed( + output( + encodeJson( + coreResponse({ + state: "MERGED", + mergedAt: "2026-07-02T00:00:00Z", + headRef: null, + baseRef: null, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [checkRun({ name: "Tests", conclusion: "SUCCESS" })], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + ], + }, + }), + ), + ), + ); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail(mergeInput); + expect(detail.state).toBe("merged"); + expect(detail.checks.map((check) => check.status)).toEqual(["success"]); + expect(detail.mergeCommit).toEqual({ + oid, + url: `https://github.com/acme/web/commit/${oid}`, + checks: [ + { + name: "Deploy", + status: "failure", + description: null, + url: "https://example.com/runs/merge", + }, + ], + }); + expect(variablesOf("CommitCheckContexts").at(-1)?.["oid"]).toBe(oid); + } + expect( + mockedExecute.mock.calls + .filter(([call]) => call.kind === "rest") + .every( + ([call]) => + call.kind === "rest" && + call.path === "repos/acme/web/pulls/7" && + (call.method === undefined || call.method === "GET"), + ), + ).toBe(true); + }), + ); + + it.effect("reads merge checks after an open PR merges and checks are reported later", () => + Effect.gen(function* () { + let merged = false; + let checks: ReadonlyArray = []; + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "rest" + ? { merged, merge_commit_sha: mergedOid } + : call.operationName === "CommitCheckContexts" + ? commitChecksPage(checks) + : coreResponse({ + state: merged ? "MERGED" : "OPEN", + mergedAt: merged ? "2026-07-02T00:00:00Z" : null, + }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit).toBeUndefined(); + expect(mockedExecute.mock.calls.some(([call]) => call.kind === "rest")).toBe(false); + merged = true; + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit?.checks).toEqual([]); + checks = [checkRun({ name: "Deploy", status: "IN_PROGRESS" })]; + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit?.checks?.[0]?.status).toBe( + "pending", + ); + checks = [checkRun({ name: "Deploy", conclusion: "FAILURE" })]; + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit?.checks?.[0]?.status).toBe( + "failure", + ); + }), + ); + + it.effect( + "does not use a temporary test merge SHA or invent a commit when host data is unavailable", + () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + for (const response of [ + { merged: false, merge_commit_sha: mergedOid }, + { merged: true, merge_commit_sha: null }, + {}, + ]) { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson(call.kind === "rest" ? response : coreResponse({ state: "MERGED" })), + ), + ), + ); + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit).toBeNull(); + } + expect(variablesOf("CommitCheckContexts")).toEqual([]); + }), + ); + + it.effect( + "retains the resulting commit when checks are unreadable rather than claiming no checks", + () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "rest" + ? { merged: true, merge_commit_sha: mergedOid } + : call.operationName === "CommitCheckContexts" + ? { data: { repository: { object: null } } } + : coreResponse({ state: "MERGED" }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const detail = yield* cli.getPullRequestDetail(mergeInput); + expect(detail.mergeCommit).toEqual({ + oid: mergedOid, + url: `https://github.com/acme/web/commit/${mergedOid}`, + checks: null, + }); + }), + ); + + it.effect("keeps merge checks unavailable when a nameless context could be dropped", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + for (const hidden of [ + checkRun({ name: "", conclusion: "FAILURE" }), + checkRun({ name: " ", status: "IN_PROGRESS" }), + ]) { + for (const checks of [ + [hidden], + [checkRun({ name: "Tests", conclusion: "SUCCESS" }), hidden], + ]) { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "rest" + ? { merged: true, merge_commit_sha: mergedOid } + : call.operationName === "CommitCheckContexts" + ? commitChecksPage(checks) + : coreResponse({ state: "MERGED" }), + ), + ), + ), + ); + expect((yield* cli.getPullRequestDetail(mergeInput)).mergeCommit).toEqual({ + oid: mergedOid, + url: `https://github.com/acme/web/commit/${mergedOid}`, + checks: null, + }); + } + } + }), + ); + + it.effect( + "keeps unreadable and incomplete merge checks unavailable, with the commit still linked", + () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + for (const scenario of ["rest-error", "checks-error", "incomplete"] as const) { + mockedExecute.mockImplementation((call) => { + if (call.kind === "rest") + return scenario === "rest-error" + ? Effect.fail( + new GitHubRequestPolicy.GitHubApiResponseError({ + host: call.host, + operation: call.operation, + status: 403, + }), + ) + : Effect.succeed(output(encodeJson({ merged: true, merge_commit_sha: mergedOid }))); + if (call.operationName === "CommitCheckContexts") + return scenario === "checks-error" + ? Effect.fail( + new GitHubRequestPolicy.GitHubApiResponseError({ + host: call.host, + operation: call.operation, + status: 403, + }), + ) + : Effect.succeed( + output( + encodeJson( + commitChecksPage( + [checkRun({ name: "Tests", conclusion: "SUCCESS" })], + "repeated-cursor", + ), + ), + ), + ); + return Effect.succeed(output(encodeJson(coreResponse({ state: "MERGED" })))); + }); + const detail = yield* cli.getPullRequestDetail(mergeInput); + expect(detail.state).toBe("merged"); + if (scenario === "rest-error") expect(detail.mergeCommit).toBeNull(); + else + expect(detail.mergeCommit).toEqual({ + oid: mergedOid, + url: `https://github.com/acme/web/commit/${mergedOid}`, + checks: null, + }); + } + }), + ); + + it.effect("includes failures beyond the first page of merge checks", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "rest" + ? { merged: true, merge_commit_sha: mergedOid } + : call.operationName === "CommitCheckContexts" + ? call.variables?.["after"] + ? commitChecksPage([checkRun({ name: "Deploy", conclusion: "FAILURE" })]) + : commitChecksPage([checkRun({ name: "Tests", conclusion: "SUCCESS" })], "next") + : coreResponse({ state: "MERGED" }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + expect( + (yield* cli.getPullRequestDetail(mergeInput)).mergeCommit?.checks?.map( + (check) => check.status, + ), + ).toEqual(["success", "failure"]); + expect( + variablesOf("CommitCheckContexts").map((variables) => variables["after"] ?? null), + ).toEqual([null, "next"]); + }), + ); it.effect("holds background previews once an answer reports the reserve, until it resets", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 972131d5a1295..98fb23f59a987 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -30,6 +30,7 @@ import { type PullRequestListFilters, type PullRequestListState, type PullRequestMergeMethod, + type PullRequestMergeCommit, type PullRequestOmittedFileStat, type PullRequestReaction, type PullRequestReactionContent, @@ -77,6 +78,7 @@ import { toLabelCandidates, toPullRequestActivityPage, toPullRequestCheckContextsPage, + toCommitCheckContextsPage, toPullRequestCore, toPullRequestFilesViewedPage, toPullRequestHeads, @@ -1583,11 +1585,84 @@ export const make = Effect.gen(function* () { })), ), ), + Effect.flatMap((core) => + core.state !== "merged" + ? Effect.succeed(core) + : readMergeCommit(input, allowReserve).pipe( + Effect.map((mergeCommit) => ({ ...core, mergeCommit })), + ), + ), ); }), ); }; + const decodeMergeResult = decodeJsonResult( + Schema.Struct({ + merged: Schema.Boolean, + merge_commit_sha: Schema.NullOr(TrimmedNonEmptyString), + }), + ); + const readMergeCommit = Effect.fnUntraced(function* ( + input: Parameters[0], + allowReserve: boolean, + ) { + const { owner, name } = parseRepositorySelector(input.repository); + const target = { ...input, operation: "getPullRequestDetail", allowReserve }; + // REST defines this field for merge, squash and rebase. Before merging it names a temporary + // test commit, so both the core and this response must independently confirm the merge. + const result = yield* api + .rest({ + ...target, + path: `repos/${owner}/${name}/pulls/${input.number}`, + }) + .pipe( + Effect.flatMap((response) => + response.truncated + ? Effect.succeed(null) + : decodeWith(input.cwd, target.operation, decodeMergeResult, response.body), + ), + Effect.orElseSucceed(() => null), + ); + const oid = result?.merged ? result.merge_commit_sha : null; + if (oid == null || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(oid)) return null; + const checks = yield* readPages( + GraphQLClient.pages(client.CommitCheckContexts, { + variables: { owner, name, oid }, + connection: (result) => { + const page = toCommitCheckContextsPage(result); + return page === null + ? null + : { + ...page, + pageInfo: { hasNextPage: page.nextCursor !== null, endCursor: page.nextCursor }, + }; + }, + }), + { + ...target, + maxPages: CHECK_CONTEXT_PAGES, + hasNextPage: (page) => page.nextCursor !== null, + }, + ).pipe( + graphql(target), + Effect.map(({ pages, truncated }) => { + if (truncated || pages.length === 0 || pages.some((page) => page.oid !== oid)) return null; + const contexts = pages.flatMap((page) => page.contexts); + // Dropping unnamed contexts could hide a failed or pending check from the summary. + return contexts.some((context) => !(context.name?.trim() || context.context?.trim())) + ? null + : pullRequestChecksFromContexts(contexts).checks; + }), + Effect.orElseSucceed(() => null), + ); + return { + oid, + url: `https://${input.host}/${owner}/${name}/commit/${oid}`, + checks, + } satisfies PullRequestMergeCommit; + }); + const workflowApprovalLimit = 1_000; const listHeadsByBranch = (input: { diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index bd9095909a798..1dbb5a6a0d4d8 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -16,6 +16,7 @@ import type { PullRequestFileViewedState, PullRequestLabel, PullRequestMergeCapabilities, + PullRequestMergeCommit, PullRequestMergeMethod, PullRequestMergeReadiness, PullRequestOmittedFileStat, @@ -58,7 +59,8 @@ import type { Deployment, WorkflowApprovalSuites, ActorAvatars, - CheckContext, + CommitCheckContext, + CommitCheckContexts, LabelCandidates, PullRequestActivity, PullRequestCheckContexts, @@ -540,6 +542,7 @@ export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { readonly mergedAt: string | null; readonly closedAt: string | null; readonly checks: ReadonlyArray; + readonly mergeCommit?: PullRequestMergeCommit | null; readonly deployments?: ReadonlyArray; readonly deploymentsTruncated?: boolean; /** Absent where GitHub did not report its repository-policy-aware verdict. */ @@ -1576,7 +1579,9 @@ export function toPullRequestActivityPage( /** A check context as the rollup reports it; opaque outside this module. */ export type GitHubCheckContext = CheckFields; -function toCheckContexts(nodes: ReadonlyArray): ReadonlyArray { +function toCheckContexts( + nodes: ReadonlyArray, +): ReadonlyArray { return nodes.map((check) => check.__typename === "CheckRun" ? { ...check, workflowName: check.checkSuite.workflowRun?.workflow.name ?? null } @@ -1584,6 +1589,17 @@ function toCheckContexts(nodes: ReadonlyArray): ReadonlyArray/merge. +query CommitCheckContexts($owner: String!, $name: String!, $oid: GitObjectID!, $after: String) { + repository(owner: $owner, name: $name) { + object(oid: $oid) { + ... on Commit { + oid + statusCheckRollup { + contexts(first: 100, after: $after) { + nodes { + ...CommitCheckContext + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + } +} + # The detail of a pull request with the viewer's standing on it. Pull refs let the comparison # (`$headRef` is `refs/pull//head`) share this read without first resolving a fork branch. query PullRequestCore( diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 3e1b9b2a4fd07..40de9efb3df83 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -4,7 +4,7 @@ import * as Schema from "effect/Schema" import * as Shared from "../../schema/github.graphql.ts" /** Represents an individual commit status context */ -export const StatusContext = Schema.Struct({ +export const CommitCheckContextStatusContext = Schema.Struct({ __typename: Schema.Literal("StatusContext"), /** The name of this status context. */ context: Schema.String, @@ -15,11 +15,9 @@ export const StatusContext = Schema.Struct({ /** Identifies the date and time when the object was created. */ createdAt: Shared.DateTime, /** The description for this status context. */ - description: Schema.NullOr(Schema.String), - /** Whether this is required to pass before merging for a specific pull request. */ - isRequired: Schema.optionalKey(Schema.Boolean) + description: Schema.NullOr(Schema.String) }) -export interface StatusContext extends Schema.Struct.Type {} +export interface CommitCheckContextStatusContext extends Schema.Struct.Type {} /** A workflow contains meta information about an Actions workflow file. */ export const Workflow = Schema.Struct({ @@ -43,7 +41,7 @@ export const CheckSuiteCheckSuite = Schema.Struct({ export interface CheckSuiteCheckSuite extends Schema.Struct.Type {} /** A check run. */ -export const CheckRun = Schema.Struct({ +export const CommitCheckContextCheckRun = Schema.Struct({ __typename: Schema.Literal("CheckRun"), /** The name of the check for this check run. */ name: Schema.String, @@ -57,12 +55,10 @@ export const CheckRun = Schema.Struct({ completedAt: Schema.NullOr(Shared.DateTime), /** The URL from which to find full details of the check run on the integrator's site. */ detailsUrl: Schema.NullOr(Shared.URI), - /** Whether this is required to pass before merging for a specific pull request. */ - isRequired: Schema.optionalKey(Schema.Boolean), /** The check suite that this run is a part of. */ checkSuite: CheckSuiteCheckSuite }) -export interface CheckRun extends Schema.Struct.Type {} +export interface CommitCheckContextCheckRun extends Schema.Struct.Type {} /** Types that can be inside a StatusCheckRollup context. */ export const StatusCheckRollupContextOther = Schema.Struct({ @@ -70,14 +66,66 @@ export const StatusCheckRollupContextOther = Schema.Struct({ }) export interface StatusCheckRollupContextOther extends Schema.Struct.Type {} +/** + * `fragment CommitCheckContext on StatusCheckRollupContext` + * + * Types that can be inside a StatusCheckRollup context. + */ +export const CommitCheckContext = Schema.Union([ + CommitCheckContextStatusContext, + CommitCheckContextCheckRun, + StatusCheckRollupContextOther +]) +export type CommitCheckContext = typeof CommitCheckContext.Type + +/** Represents an individual commit status context */ +export const CheckContextStatusContext = Schema.Struct({ + __typename: Schema.Literal("StatusContext"), + /** The name of this status context. */ + context: Schema.String, + /** The state of this status context. */ + state: GraphQL.enumLiterals(Shared.StatusState.literals), + /** The URL for this status context. */ + targetUrl: Schema.NullOr(Shared.URI), + /** Identifies the date and time when the object was created. */ + createdAt: Shared.DateTime, + /** The description for this status context. */ + description: Schema.NullOr(Schema.String), + /** Whether this is required to pass before merging for a specific pull request. */ + isRequired: Schema.optionalKey(Schema.Boolean) +}) +export interface CheckContextStatusContext extends Schema.Struct.Type {} + +/** A check run. */ +export const CheckContextCheckRun = Schema.Struct({ + __typename: Schema.Literal("CheckRun"), + /** The name of the check for this check run. */ + name: Schema.String, + /** The current status of the check run. */ + status: GraphQL.enumLiterals(Shared.CheckStatusState.literals), + /** The conclusion of the check run. */ + conclusion: Schema.NullOr(GraphQL.enumLiterals(Shared.CheckConclusionState.literals)), + /** Identifies the date and time when the check run was started. */ + startedAt: Schema.NullOr(Shared.DateTime), + /** Identifies the date and time when the check run was completed. */ + completedAt: Schema.NullOr(Shared.DateTime), + /** The URL from which to find full details of the check run on the integrator's site. */ + detailsUrl: Schema.NullOr(Shared.URI), + /** The check suite that this run is a part of. */ + checkSuite: CheckSuiteCheckSuite, + /** Whether this is required to pass before merging for a specific pull request. */ + isRequired: Schema.optionalKey(Schema.Boolean) +}) +export interface CheckContextCheckRun extends Schema.Struct.Type {} + /** * `fragment CheckContext on StatusCheckRollupContext` * * Types that can be inside a StatusCheckRollup context. */ export const CheckContext = Schema.Union([ - StatusContext, - CheckRun, + CheckContextStatusContext, + CheckContextCheckRun, StatusCheckRollupContextOther ]) export type CheckContext = typeof CheckContext.Type @@ -174,6 +222,89 @@ export declare namespace ActorAvatars { export type Result = typeof ActorAvatars.result.Type } +/** Information about pagination in a connection. */ +export const ContextsPageInfoPageInfo = Schema.Struct({ + /** When paginating forwards, are there more items? */ + hasNextPage: Schema.Boolean, + /** When paginating forwards, the cursor to continue. */ + endCursor: Schema.NullOr(Schema.String) +}) +export interface ContextsPageInfoPageInfo extends Schema.Struct.Type {} + +/** The connection type for StatusCheckRollupContext. */ +export const ObjectStatusCheckRollupContextsStatusCheckRollupContextConnection = Schema.Struct({ + /** A list of nodes. */ + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(CommitCheckContext))), + /** Information to aid in pagination. */ + pageInfo: ContextsPageInfoPageInfo +}) +export interface ObjectStatusCheckRollupContextsStatusCheckRollupContextConnection extends Schema.Struct.Type {} + +/** Represents the rollup for both the check runs and status for a commit. */ +export const ObjectStatusCheckRollupStatusCheckRollup = Schema.Struct({ + /** A list of status contexts and check runs for this commit. */ + contexts: ObjectStatusCheckRollupContextsStatusCheckRollupContextConnection +}) +export interface ObjectStatusCheckRollupStatusCheckRollup extends Schema.Struct.Type {} + +/** Represents a Git commit. */ +export const CommitCheckContextsRepositoryObjectCommit = Schema.Struct({ + __typename: Schema.Literal("Commit"), + /** The Git object ID */ + oid: Shared.GitObjectID, + /** Check and Status rollup information for this commit. */ + statusCheckRollup: Schema.NullOr(ObjectStatusCheckRollupStatusCheckRollup) +}) +export interface CommitCheckContextsRepositoryObjectCommit extends Schema.Struct.Type {} + +/** Represents a Git object. */ +export const GitObjectOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["Commit"]) +}) +export interface GitObjectOther extends Schema.Struct.Type {} + +/** A repository contains the content for a project. */ +export const CommitCheckContextsRepositoryRepository = Schema.Struct({ + /** A Git object in the repository */ + object: Schema.NullOr(Schema.Union([ + CommitCheckContextsRepositoryObjectCommit, + GitObjectOther + ])) +}) +export interface CommitCheckContextsRepositoryRepository extends Schema.Struct.Type {} + +/** `query CommitCheckContexts` */ +export const CommitCheckContexts = GraphQL.query("CommitCheckContexts", { + document: { + variables: [ + { name: "owner", body: "String!" }, + { name: "name", body: "String!" }, + { name: "oid", body: "GitObjectID!" }, + { name: "after", body: "String" } + ], + selections: [ + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){object(oid:$", "oid", "){__typename...on Commit{oid statusCheckRollup{contexts(first:100 after:$", "after", "){nodes{__typename...CommitCheckContext}pageInfo{hasNextPage endCursor}}}}}}"] } + ], + fragments: [ + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } + ] + }, + variables: { + owner: Schema.String, + name: Schema.String, + oid: Shared.GitObjectID, + after: Schema.optional(Schema.NullOr(Schema.String)) + }, + result: Schema.Struct({ + /** Lookup a given repository by the owner and repository name. */ + repository: Schema.NullOr(CommitCheckContextsRepositoryRepository) + }) +}) +export declare namespace CommitCheckContexts { + export type Variables = typeof CommitCheckContexts.variables.Type + export type Result = typeof CommitCheckContexts.result.Type +} + /** Represents an owner of a Repository. */ export const RepositoryOwner = Schema.Struct({ __typename: GraphQL.otherTypename()([]), @@ -521,7 +652,8 @@ export const PullRequestCore = GraphQL.query("PullRequestCore", { ], fragments: [ { name: "Deployment", body: ["on Deployment{id environment state commitOid createdAt updatedAt latestStatus{state updatedAt description environmentUrl logUrl}}"] }, - { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } + { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...CommitCheckContext...on StatusContext{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}}"] }, + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } ] }, variables: { @@ -673,7 +805,8 @@ export const PullRequestCoreWithoutDeployments = GraphQL.query("PullRequestCoreW { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission pullRequest(number:$", "number", "){isInMergeQueue@include(if:$", "required", ")mergeStateStatus viewerCanEnableAutoMerge@include(if:$", "required", ")viewerCanDisableAutoMerge@include(if:$", "required", ")number title url body state isDraft mergeable reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:100){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } ], fragments: [ - { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } + { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...CommitCheckContext...on StatusContext{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}}"] }, + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } ] }, variables: { @@ -801,7 +934,8 @@ export const PullRequestCoreEnterprise = GraphQL.query("PullRequestCoreEnterpris ], fragments: [ { name: "Deployment", body: ["on Deployment{id environment state commitOid createdAt updatedAt latestStatus{state updatedAt description environmentUrl logUrl}}"] }, - { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } + { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...CommitCheckContext...on StatusContext{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}}"] }, + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } ] }, variables: { @@ -926,7 +1060,8 @@ export const PullRequestCoreEnterpriseWithoutDeployments = GraphQL.query("PullRe { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission pullRequest(number:$", "number", "){mergeStateStatus number title url body state isDraft mergeable reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:100){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } ], fragments: [ - { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } + { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...CommitCheckContext...on StatusContext{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}}"] }, + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } ] }, variables: { @@ -946,15 +1081,6 @@ export declare namespace PullRequestCoreEnterpriseWithoutDeployments { export type Result = typeof PullRequestCoreEnterpriseWithoutDeployments.result.Type } -/** Information about pagination in a connection. */ -export const ContextsPageInfoPageInfo = Schema.Struct({ - /** When paginating forwards, are there more items? */ - hasNextPage: Schema.Boolean, - /** When paginating forwards, the cursor to continue. */ - endCursor: Schema.NullOr(Schema.String) -}) -export interface ContextsPageInfoPageInfo extends Schema.Struct.Type {} - /** The connection type for StatusCheckRollupContext. */ export const PullRequestCheckContextsRepositoryPullRequestCommitsNodesCommitStatusCheckRollupContextsStatusCheckRollupContextConnection = Schema.Struct({ /** A list of nodes. */ @@ -1022,7 +1148,8 @@ export const PullRequestCheckContexts = GraphQL.query("PullRequestCheckContexts" { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){headRefOid commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100 after:$", "after", "){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage endCursor}}}}}}}}"] } ], fragments: [ - { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } + { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...CommitCheckContext...on StatusContext{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}}"] }, + { name: "CommitCheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description}...on CheckRun{name status conclusion startedAt completedAt detailsUrl checkSuite{workflowRun{workflow{name}}}}}"] } ] }, variables: { @@ -2114,24 +2241,18 @@ export const CheckSuiteConnection = Schema.Struct({ export interface CheckSuiteConnection extends Schema.Struct.Type {} /** Represents a Git commit. */ -export const ObjectCommit = Schema.Struct({ +export const WorkflowApprovalSuitesRepositoryObjectCommit = Schema.Struct({ __typename: Schema.Literal("Commit"), /** The check suites associated with a commit. */ checkSuites: Schema.NullOr(CheckSuiteConnection) }) -export interface ObjectCommit extends Schema.Struct.Type {} - -/** Represents a Git object. */ -export const GitObjectOther = Schema.Struct({ - __typename: GraphQL.otherTypename()(["Commit"]) -}) -export interface GitObjectOther extends Schema.Struct.Type {} +export interface WorkflowApprovalSuitesRepositoryObjectCommit extends Schema.Struct.Type {} /** A repository contains the content for a project. */ export const WorkflowApprovalSuitesRepositoryRepository = Schema.Struct({ /** A Git object in the repository */ object: Schema.NullOr(Schema.Union([ - ObjectCommit, + WorkflowApprovalSuitesRepositoryObjectCommit, GitObjectOther ])) }) @@ -2168,4 +2289,4 @@ export declare namespace WorkflowApprovalSuites { } /** Every operation in pullRequestReads.graphql. */ -export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(ActorAvatars, PullRequestCore, PullRequestCoreWithoutDeployments, PullRequestCoreEnterprise, PullRequestCoreEnterpriseWithoutDeployments, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, PullRequestFilesViewed, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch, AutoMergePermissions, BranchProtectionRequiredChecks, WorkflowApprovalSuites) +export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(ActorAvatars, CommitCheckContexts, PullRequestCore, PullRequestCoreWithoutDeployments, PullRequestCoreEnterprise, PullRequestCoreEnterpriseWithoutDeployments, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, PullRequestFilesViewed, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch, AutoMergePermissions, BranchProtectionRequiredChecks, WorkflowApprovalSuites)