diff --git a/src/bundled/github/GitHub.module.css b/src/bundled/github/GitHub.module.css index 4cbc07fd0d..5671effbc8 100644 --- a/src/bundled/github/GitHub.module.css +++ b/src/bundled/github/GitHub.module.css @@ -386,6 +386,18 @@ .description { margin-top: var(--space-4); } +.views { + margin-top: var(--space-4); +} +.tabCounts { + margin-inline-start: var(--space-1); + font-variant-numeric: tabular-nums; +} +.files { + display: grid; + gap: var(--space-3); + margin-top: var(--space-4); +} /* A Solo chat's linked pull request, in the sidebar's chat icon slot. */ .soloPull { display: inline-flex; diff --git a/src/bundled/github/GitHubPanel.test.tsx b/src/bundled/github/GitHubPanel.test.tsx index 5a9d1245f1..a4dc765547 100644 --- a/src/bundled/github/GitHubPanel.test.tsx +++ b/src/bundled/github/GitHubPanel.test.tsx @@ -11,7 +11,12 @@ import { import { StrictMode } from "react"; import { afterEach, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import { GhUnavailable, GitHubError, type GitHubCli } from "./gh"; +import { + GhUnavailable, + GitHubError, + OutputTooLarge, + type GitHubCli, +} from "./gh"; import { GitHubPanel, REFRESH_MS } from "./index"; import { createPullStates } from "./pull-states"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; @@ -1104,6 +1109,23 @@ it("places each resolution action in its matching checklist row", async () => { const reviewMutation = "mutation($input:AddPullRequestReviewInput!){addPullRequestReview(input:$input){clientMutationId}}"; +const patch = `diff --git a/src/app.ts b/src/app.ts +index 1111111..2222222 100644 +--- a/src/app.ts ++++ b/src/app.ts +@@ -1,2 +1,2 @@ + const a = 1; +-const b = 2; ++const b = 3; +diff --git a/README.md b/README.md +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/README.md +@@ -0,0 +1 @@ ++Hello +`; + it("approves someone else's pull request without a comment, at the head it showed", async () => { const user = userEvent.setup(); let approved = false; @@ -1163,6 +1185,140 @@ it("approves someone else's pull request without a comment, at the head it showe ]); }); +it("shows a pull request's changes under Changes, with its line counts", async () => { + const user = userEvent.setup(); + const gh = vi.fn(async () => pullData()); + const diff = vi.fn(async () => patch); + render( + {}} + gh={gh as unknown as GitHubCli} + diff={diff} + />, + { wrapper: ToastProvider }, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + expect(screen.getByRole("tab", { name: "Summary" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(diff).not.toHaveBeenCalled(); + const changes = screen.getByRole("tab", { name: /^Changes/ }); + expect(changes).toHaveTextContent("+3 −1"); + await user.click(changes); + expect( + await screen.findByRole("region", { name: "src/app.ts" }), + ).toHaveTextContent("+1−1"); + expect(screen.getByRole("region", { name: "README.md" })).toHaveTextContent( + "New file", + ); + expect( + screen.queryByRole("heading", { name: "Private change #9" }), + ).not.toBeInTheDocument(); + // Reviewing stays a click away while reading the changes. + expect( + screen.getByRole("button", { name: "Submit review" }), + ).toBeInTheDocument(); + expect(diff).toHaveBeenCalledWith( + { + repository: "block/private", + base: "main", + head: "abc123", + merged: undefined, + }, + expect.any(AbortSignal), + ); + await user.click(screen.getByRole("tab", { name: "Summary" })); + expect( + screen.getByRole("heading", { name: "Private change #9" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "src/app.ts" }), + ).not.toBeInTheDocument(); + await user.click(changes); + expect(screen.getByRole("region", { name: "src/app.ts" })).toBeVisible(); + expect(diff).toHaveBeenCalledTimes(1); +}); + +it("reads a merged pull request's changes by its number", async () => { + const user = userEvent.setup(); + const gh = vi.fn(async () => pullData({ state: "MERGED" })); + const diff = vi.fn(async () => patch); + render( + {}} + gh={gh as unknown as GitHubCli} + diff={diff} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + await user.click(screen.getByRole("tab", { name: /^Changes/ })); + expect( + await screen.findByRole("region", { name: "src/app.ts" }), + ).toBeInTheDocument(); + expect(diff).toHaveBeenCalledWith( + { repository: "block/private", base: "main", head: "abc123", merged: 9 }, + expect.any(AbortSignal), + ); +}); + +it("points to GitHub when the changes cannot be shown", async () => { + const user = userEvent.setup(); + const gh = vi.fn(async () => pullData()); + const diff = vi.fn(async () => { + throw new OutputTooLarge(); + }); + render( + {}} + gh={gh as unknown as GitHubCli} + diff={diff} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + await user.click(screen.getByRole("tab", { name: /^Changes/ })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "This diff is too large to show here.", + ); + expect(screen.getByRole("link", { name: "Open on GitHub" })).toHaveAttribute( + "href", + `${pull}/files`, + ); +}); + +it("shows a file the parser cannot render as raw text beside the rest", async () => { + const user = userEvent.setup(); + const gh = vi.fn(async () => pullData({ viewerDidAuthor: true })); + const binary = `diff --git a/logo.png b/logo.png +GIT binary patch +literal 1 +Ic${"$"}@ +`; + render( + {}} + gh={gh as unknown as GitHubCli} + diff={async () => patch + binary} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + // The viewer's own pull request takes no review, but its changes show. + expect( + screen.queryByRole("button", { name: "Submit review" }), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("tab", { name: /^Changes/ })); + expect( + await screen.findByRole("region", { name: "src/app.ts" }), + ).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Raw diff" })).toHaveTextContent( + "GIT binary patch", + ); +}); + it("keeps a refused review's draft with GitHub's reason, and clears it once it is in", async () => { const user = userEvent.setup(); let refuse = true; diff --git a/src/bundled/github/PullFiles.tsx b/src/bundled/github/PullFiles.tsx new file mode 100644 index 0000000000..65aa6319c8 --- /dev/null +++ b/src/bundled/github/PullFiles.tsx @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useState } from "react"; +import { ArrowSquareOutIcon } from "../../shared/design-system/icons/index"; +import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion"; +import { DiffViewer } from "../diffs/DiffViewer"; +import { parsePatch } from "../diffs/parse"; +import type { GitHubDiff } from "./gh"; +import { readable } from "./object"; +import styles from "./GitHub.module.css"; + +/** One patch per file, so a file the parser cannot render falls back to raw + * text without taking the rest of the diff with it. */ +export function splitPatch(content: string): string[] { + return content.split(/^(?=diff --git )/m).filter((part) => part.trim()); +} + +/** A pull request's Files changed, read for the base and head it names. */ +export function PullFiles({ + url, + pull, + diff, +}: { + url: string; + pull: Parameters[0]; + diff: GitHubDiff; +}) { + const { repository, base, head, merged } = pull; + const [result, setResult] = useState(); + useEffect(() => { + const controller = new AbortController(); + diff({ repository, base, head, merged }, controller.signal).then( + (content) => { + if (!controller.signal.aborted) setResult(content); + }, + (failure) => { + if (!controller.signal.aborted) setResult(readable(failure)); + }, + ); + return () => controller.abort(); + }, [diff, repository, base, head, merged]); + const parts = useMemo( + () => + typeof result === "string" + ? splitPatch(result).map((content) => ({ + content, + files: parsePatch(content), + })) + : undefined, + [result], + ); + return ( + +

{result.message}

+ + Open on GitHub + + + ) : undefined + } + > + {parts && + (parts.length ? ( +
+ {parts.map((part, index) => ( + + ))} +
+ ) : ( +

No file changes.

+ ))} +
+ ); +} diff --git a/src/bundled/github/README.md b/src/bundled/github/README.md index c63c058da8..9bb769e4d2 100644 --- a/src/bundled/github/README.md +++ b/src/bundled/github/README.md @@ -8,4 +8,5 @@ Preview GitHub repositories, pull requests, issues, and commits without leaving icon, kept current while shown. Click any github.com link to open it in a side panel. 2. On a pull request, switch between **Discussion** and **Checks**. -3. Choose **Open on GitHub** to see the full page. +3. Switch a pull request from **Summary** to **Changes** to read its diff. +4. Choose **Open on GitHub** to see the full page. diff --git a/src/bundled/github/gh.test.ts b/src/bundled/github/gh.test.ts index c9e9558d1b..1ba2a26538 100644 --- a/src/bundled/github/gh.test.ts +++ b/src/bundled/github/gh.test.ts @@ -1,6 +1,14 @@ import { expect, it, vi } from "vitest"; import type { Host, HostProcessOptions } from "../../features/host/service"; -import { GhUnavailable, GitHubError, gitHubCli, parseAnswer } from "./gh"; +import { + GhUnavailable, + GitHubError, + gitHubCli, + gitHubDiff, + MAX_DIFF_CHARS, + OutputTooLarge, + parseAnswer, +} from "./gh"; type HostSpawn = NonNullable; @@ -120,3 +128,102 @@ it("treats a gh that cannot start as unavailable", async () => { }); await expect(gh?.("query{}", {})).rejects.toThrow(GhUnavailable); }); + +function diffSpawn(stdout: readonly string[], code: number, stderr = "") { + const kill = vi.fn(async () => {}); + const spawn = vi.fn(async (_id: string, options?: HostProcessOptions) => { + let exit!: (code: number | null) => void; + const exited = new Promise((resolve) => { + exit = resolve; + }); + kill.mockImplementation(async () => exit(null)); + return { + write: async () => {}, + end: async () => { + for (const piece of stdout) options?.onStdout?.(piece); + if (stderr) options?.onStderr?.(stderr); + exit(code); + }, + kill, + exited, + }; + }); + return { spawn, kill }; +} + +it("reads the diff from the base branch to the head a review names", async () => { + expect(gitHubDiff({})).toBeUndefined(); + const { spawn } = diffSpawn(["diff --git a/x b/x\n", "@@ -1 +1 @@\n"], 0); + const diff = gitHubDiff({ spawn }); + expect( + await diff?.({ + repository: "block/buzz", + base: "release/1.0", + head: "abc123", + }), + ).toBe("diff --git a/x b/x\n@@ -1 +1 @@\n"); + expect(spawn).toHaveBeenCalledWith("diff", { + args: ["repos/block/buzz/compare/release/1.0...abc123"], + onStdout: expect.any(Function), + onStderr: expect.any(Function), + }); + await expect( + diff?.({ repository: "block/buzz", base: "main", head: "HEAD;rm" }), + ).rejects.toThrow(GitHubError); +}); + +it("reads a merged pull request's own diff, since its head is in the base by then", async () => { + const { spawn } = diffSpawn(["diff --git a/x b/x\n"], 0); + const diff = gitHubDiff({ spawn }); + await diff?.({ + repository: "block/buzz", + base: "main", + head: "abc123", + merged: 42, + }); + expect(spawn).toHaveBeenCalledWith("diff", { + args: ["repos/block/buzz/pulls/42"], + onStdout: expect.any(Function), + onStderr: expect.any(Function), + }); + await expect( + diff?.({ + repository: "block/buzz", + base: "main", + head: "abc123", + merged: 0, + }), + ).rejects.toThrow(GitHubError); +}); + +it("gives GitHub's reason a diff was refused, and stops gh past the size the panel shows", async () => { + const refused = gitHubDiff( + diffSpawn(['{"message":"Not Found"}'], 1, "gh: Not Found (HTTP 404)"), + ); + await expect( + refused?.({ repository: "block/buzz", base: "main", head: "abc123" }), + ).rejects.toThrow(new GitHubError("Not Found (HTTP 404)")); + const escaped = gitHubDiff( + diffSpawn( + [], + 1, + "the response contains terminal escape sequences; pass --allow-escape-sequences to output it anyway", + ), + ); + await expect( + escaped?.({ repository: "block/buzz", base: "main", head: "abc123" }), + ).rejects.toThrow(GitHubError); + const signedOut = gitHubDiff(diffSpawn([], 4, "gh auth login")); + await expect( + signedOut?.({ repository: "block/buzz", base: "main", head: "abc123" }), + ).rejects.toThrow(GhUnavailable); + const { spawn, kill } = diffSpawn(["x".repeat(MAX_DIFF_CHARS), "y"], 0); + await expect( + gitHubDiff({ spawn })?.({ + repository: "block/buzz", + base: "main", + head: "abc123", + }), + ).rejects.toThrow(OutputTooLarge); + expect(kill).toHaveBeenCalled(); +}); diff --git a/src/bundled/github/gh.ts b/src/bundled/github/gh.ts index c7e8700149..0f14de2dd2 100644 --- a/src/bundled/github/gh.ts +++ b/src/bundled/github/gh.ts @@ -29,6 +29,68 @@ export type GitHubCli = ( const DEADLINE_MS = 20_000; +type Spawn = NonNullable; +type Output = { stdout: string; stderr: string; code: number | null }; + +/** Runs one of the manifest's `gh` processes to its end. Past `maxOutput` + * characters it stops `gh` and rejects with {@link OutputTooLarge}. */ +async function runGh( + spawn: Spawn, + id: string, + { + args, + input, + signal, + maxOutput = Number.POSITIVE_INFINITY, + }: { + args?: readonly string[]; + input?: string; + signal?: AbortSignal | undefined; + maxOutput?: number; + }, +): Promise { + signal?.throwIfAborted(); + let stdout = ""; + let stderr = ""; + const overflow = new AbortController(); + let process: Awaited>; + try { + process = await spawn(id, { + ...(args && { args }), + onStdout: (data) => { + stdout += data; + if (stdout.length > maxOutput) overflow.abort(new OutputTooLarge()); + }, + onStderr: (data) => { + stderr += data; + }, + }); + } catch (error) { + throw new GhUnavailable( + error instanceof Error ? error.message : String(error), + ); + } + const limit = AbortSignal.timeout(DEADLINE_MS); + const stop = () => void process.kill(); + const abort = AbortSignal.any( + signal ? [signal, limit, overflow.signal] : [limit, overflow.signal], + ); + abort.addEventListener("abort", stop, { once: true }); + try { + // The caller may have given up while gh was starting. + if (abort.aborted) { + stop(); + abort.throwIfAborted(); + } + await process.end(input); + const code = await process.exited; + abort.throwIfAborted(); + return { stdout, stderr, code }; + } finally { + abort.removeEventListener("abort", stop); + } +} + /** A GraphQL client for `host`, or undefined where processes cannot run. */ export function gitHubCli(host: Pick): GitHubCli | undefined { const spawn = host.spawn?.bind(host); @@ -38,41 +100,71 @@ export function gitHubCli(host: Pick): GitHubCli | undefined { variables: Record, signal?: AbortSignal, ) => { - signal?.throwIfAborted(); - let stdout = ""; - let stderr = ""; - let process: Awaited>; - try { - process = await spawn("graphql", { - onStdout: (data) => { - stdout += data; - }, - onStderr: (data) => { - stderr += data; - }, - }); - } catch (error) { - throw new GhUnavailable( - error instanceof Error ? error.message : String(error), + const { stdout, stderr, code } = await runGh(spawn, "graphql", { + input: JSON.stringify({ query, variables }), + signal, + }); + return parseAnswer(stdout, stderr, code); + }; +} + +/** The diff is more than the panel shows. */ +export class OutputTooLarge extends Error { + constructor() { + super("This diff is too large to show here."); + } +} + +/** A pull request's unified diff, as GitHub's Files changed tab shows it, + * for exactly `head`. */ +export type GitHubDiff = ( + pull: Readonly<{ + repository: string; + base: string; + head: string; + /** A merged pull request's number. Its head is in the base by then, so + * comparing the two would show nothing. */ + merged?: number | undefined; + }>, + signal?: AbortSignal, +) => Promise; + +/** About 20,000 lines: as far as the panel renders a diff. */ +export const MAX_DIFF_CHARS = 1_000_000; + +/** Reads diffs through the manifest's `diff` process, or undefined where + * processes cannot run. Comparing the base branch with the head commit, not + * the pull request, pins the diff to the head a review will name. */ +export function gitHubDiff(host: Pick): GitHubDiff | undefined { + const spawn = host.spawn?.bind(host); + if (!spawn) return undefined; + return async ({ repository, base, head, merged }, signal) => { + if ( + !/^[\w.-]+\/[\w.-]+$/.test(repository) || + !/^[0-9a-f]{1,64}$/i.test(head) || + (merged !== undefined && !(Number.isSafeInteger(merged) && merged > 0)) + ) + throw new GitHubError("Unsupported pull request"); + const range = `${base.split("/").map(encodeURIComponent).join("/")}...${head}`; + const { stdout, stderr, code } = await runGh(spawn, "diff", { + args: [ + merged === undefined + ? `repos/${repository}/compare/${range}` + : `repos/${repository}/pulls/${merged}`, + ], + signal, + maxOutput: MAX_DIFF_CHARS, + }); + if (code === 0) return stdout; + const reason = stderr.trim().replace(/^gh: /, ""); + // gh refuses to print a response with a raw ESC byte. Its flag to allow + // one is too new to rely on every installed gh having. + if (/escape sequences/.test(reason)) + throw new GitHubError( + "This diff contains terminal control characters, so it can’t be shown here.", ); - } - const limit = AbortSignal.timeout(DEADLINE_MS); - const stop = () => void process.kill(); - const abort = signal ? AbortSignal.any([signal, limit]) : limit; - abort.addEventListener("abort", stop, { once: true }); - try { - // The caller may have given up while gh was starting. - if (abort.aborted) { - stop(); - abort.throwIfAborted(); - } - await process.end(JSON.stringify({ query, variables })); - const code = await process.exited; - abort.throwIfAborted(); - return parseAnswer(stdout, stderr, code); - } finally { - abort.removeEventListener("abort", stop); - } + if (!stdout.trim()) throw new GhUnavailable(reason || "gh did not answer"); + throw new GitHubError(reason || "GitHub did not answer"); }; } diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index c4018df1e3..bddd5340af 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -1,6 +1,7 @@ import { ResolvePull } from "./ResolvePull"; import type { PullChecklist as Checklist } from "./pull"; import { Button } from "../../shared/design-system/ui/Button"; +import { Tabs } from "../../shared/design-system/ui/Tabs"; import { useCallback, useEffect, @@ -21,7 +22,13 @@ import { import type { PluginModule } from "../../plugins/api"; import type { PanelProps } from "../../features/panels/service"; import { parseGitHubReference, type GitHubReference } from "./references"; -import { gitHubCli, GitHubError, type GitHubCli } from "./gh"; +import { + gitHubCli, + gitHubDiff, + GitHubError, + type GitHubCli, + type GitHubDiff, +} from "./gh"; import { loadGitHubObject, readable, type GitHubObject } from "./object"; import { PublicChecklist, PullChecklist } from "./PullChecklist"; import styles from "./GitHub.module.css"; @@ -50,6 +57,7 @@ import { import { watchPulls } from "./watcher"; import { presentPull } from "./attention"; import { SubmitReview } from "./SubmitReview"; +import { PullFiles } from "./PullFiles"; import { AuthorSettings } from "./AuthorSettings"; /** How often an open pull request panel reads GitHub again. */ @@ -58,6 +66,7 @@ export const REFRESH_MS = PANEL_REFRESH_MS; export const inject = ["panels", "host", "conversation", "relay", "agents"]; export const apply: PluginModule["apply"] = (ctx) => { const gh = gitHubCli(ctx.host); + const diff = gitHubDiff(ctx.host); // A pull request's state is nobody's community's: one store serves every // session's sidebar, every link in messages, the panel and agents, so one // shown in several places costs one read. @@ -85,6 +94,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ReactNode) | undefined; resolveActions: @@ -370,6 +385,10 @@ function ObjectPanel({ }) { const isPull = reference.kind === "pull"; const pull = usePullPanel(states, reference.url, isPull); + const [view, setView] = useState<"summary" | "changes">("summary"); + // Files changed stays mounted once shown, so going back to it doesn't read + // the diff again. + const [changesShown, setChangesShown] = useState(false); const other = useGitHubObject(reference, gh, !isPull); const loaded = isPull ? pull.loaded : other.loaded; const load = isPull ? pull.load : other.load; @@ -382,6 +401,29 @@ function ObjectPanel({ ? stateIcons[state as keyof typeof stateIcons] : undefined; const [owner, repositoryName] = reference.repository.split("/"); + const files = + diff && gh && typeof loaded === "object" && loaded.checklist + ? { + repository: reference.repository, + base: loaded.checklist.base, + head: loaded.checklist.head, + merged: + loaded.checklist.state === "merged" + ? Number(reference.label.slice(1)) + : undefined, + diff, + } + : undefined; + const counts = + typeof result === "object" + ? result.facts + .map(([, value]) => value) + .find( + (value): value is { additions: number; deletions: number } => + typeof value === "object" && "additions" in value, + ) + : undefined; + const showChanges = !!files && view === "changes"; const title = (

{reference.kind === "pull" ? ( @@ -448,6 +490,47 @@ function ObjectPanel({ )} + {files && ( +
+ + + +{counts.additions} + {" "} + + −{counts.deletions} + + + ), + }, + ]} + onValueChange={(next) => { + setView(next); + if (next === "changes") setChangesShown(true); + }} + /> +
+ )} + {files && changesShown && ( + + )} {reference.kind !== "pull" && ( )} - {reference.kind === "pull" && typeof result !== "object" && title} - {result === undefined ? ( -

Loading from GitHub…

- ) : typeof result === "string" ? ( -
-

{result}

-
- - -
-
- ) : ( + {!showChanges && ( <> - {reference.kind === "pull" && title} -
- {result.state && ( - - {StateIcon && - )} - {result.author && ( - - by{" "} - {result.authorUrl ? ( - Loading from GitHub…

+ ) : typeof result === "string" ? ( +
+

{result}

+
+ + +
+
+ ) : ( + <> + {reference.kind === "pull" && title} +
+ {result.state && ( + - {result.author} - - ) : ( - result.author + {StateIcon &&
- {reference.kind !== "pull" && title} - {(!!result.facts.length || reference.kind === "pull") && ( -
- {result.facts.map(([label, value]) => ( -
-
{label}
-
- {typeof value !== "object" ? ( - value - ) : "head" in value ? ( - <> - {value.head.url ? ( - - {value.head.label} - + {result.author && ( + + by{" "} + {result.authorUrl ? ( + + {result.author} + + ) : ( + result.author + )} + + )} +
+ {reference.kind !== "pull" && title} + {(!!result.facts.length || reference.kind === "pull") && ( +
+ {result.facts.map(([label, value]) => ( +
+
{label}
+
+ {typeof value !== "object" ? ( + value + ) : "head" in value ? ( + <> + {value.head.url ? ( + + {value.head.label} + + ) : ( + + {value.head.label} + + )} + {" → "} + {value.base.url ? ( + + {value.base.label} + + ) : ( + + {value.base.label} + + )} + ) : ( - - {value.head.label} - + <> + + +{value.additions} + + {" / "} + + −{value.deletions} + + )} - {" → "} - {value.base.url ? ( - +
+ ))} + {reference.kind === "pull" && ( +
+
Last updated
+
+ {result.updatedAt ? ( + + {relativeTimestamp( + Date.parse(result.updatedAt) / 1000, + )} + ) : ( - - {value.base.label} - + "Unavailable" )} - - ) : ( - <> - - +{value.additions} - - {" / "} - - −{value.deletions} - - - )} -
-
- ))} - {reference.kind === "pull" && ( -
-
Last updated
-
- {result.updatedAt ? ( - - ) : ( - "Unavailable" - )} -
-
+ +
+ )} + )} - - )} - {reference.kind === "pull" ? ( - <> - {typeof loaded === "object" && loaded.checklist && gh ? ( - - loaded.checklist - ? resolveActions?.( - reference.url, - loaded.checklist, - action, - ) - : undefined - } - /> + {reference.kind === "pull" ? ( + <> + {typeof loaded === "object" && loaded.checklist && gh ? ( + + loaded.checklist + ? resolveActions?.( + reference.url, + loaded.checklist, + action, + ) + : undefined + } + /> + ) : ( + + )} + {chatLink?.(reference.url)} + {result.body && ( +
+ +
+ )} + ) : ( - - )} - {chatLink?.(reference.url)} - {result.body && ( -
+ result.body && ( -
+ ) )} - ) : ( - result.body && ( - - ) )} )} diff --git a/src/bundled/github/manifest.json b/src/bundled/github/manifest.json index fd9219b907..55512d01df 100644 --- a/src/bundled/github/manifest.json +++ b/src/bundled/github/manifest.json @@ -13,6 +13,19 @@ "id": "graphql", "program": "gh", "args": ["api", "graphql", "--hostname", "github.com", "--input", "-"] + }, + { + "id": "diff", + "program": "gh", + "args": [ + "api", + "--hostname", + "github.com", + "--method", + "GET", + "--header", + "Accept: application/vnd.github.diff" + ] } ] }