From b449d106746888586a25c44f3329d1faa9db25da Mon Sep 17 00:00:00 2001 From: Vincent Voyer Date: Fri, 4 Sep 2026 23:52:59 +0200 Subject: [PATCH] [opencode-prs] copy Slack summary --- .changeset/copy-pr-slack.md | 5 +++++ packages/prs/README.md | 2 +- packages/prs/src/prs.ts | 7 +++++++ packages/prs/src/tui.tsx | 39 ++++++++++++++++++++++++++++------- packages/prs/test/prs.test.js | 14 +++++++++++-- 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 .changeset/copy-pr-slack.md diff --git a/.changeset/copy-pr-slack.md b/.changeset/copy-pr-slack.md new file mode 100644 index 0000000..79168a3 --- /dev/null +++ b/.changeset/copy-pr-slack.md @@ -0,0 +1,5 @@ +--- +"opencode-prs": patch +--- + +Add a copy action for Slack-ready PR summaries with status, diff counts, and confirmation feedback. diff --git a/packages/prs/README.md b/packages/prs/README.md index 4094d98..8493bf4 100644 --- a/packages/prs/README.md +++ b/packages/prs/README.md @@ -7,7 +7,7 @@ Pull request titles scrolling on hover in the OpenCode sidebar -The sidebar section is collapsible. Each pull request uses a full-width linked title with its muted number and colored status below. Hover a clipped title to scroll through its full text once. +The sidebar section is collapsible. Each pull request uses a full-width linked title with its muted number and colored status below. Hover a clipped title to scroll through its full text once. Select `⧉` to copy a Slack-ready PR summary and confirm it with a toast. ## Install diff --git a/packages/prs/src/prs.ts b/packages/prs/src/prs.ts index 2cdbd74..0d15aac 100644 --- a/packages/prs/src/prs.ts +++ b/packages/prs/src/prs.ts @@ -4,6 +4,8 @@ export type PullRequest = PullRequestRef & { state: "OPEN" | "CLOSED" | "MERGED" isDraft: boolean createdAt: string + additions: number + deletions: number } export function pullRequestStatus(pr: Pick): "draft" | "open" | "merged" | "closed" { @@ -21,6 +23,11 @@ export function sortPullRequests(prs: PullRequest[]): PullRequest[] { }) } +export function slackPullRequest(pr: PullRequest): string { + const status = pullRequestStatus(pr) + return `:pr-${status}: ${pr.owner}/${pr.repo} <${pr.url}|*${pr.title}*> +${pr.additions} -${pr.deletions}` +} + const GITHUB_PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:\b|\/)/g export function extractPullRequests(text: string): PullRequestRef[] { diff --git a/packages/prs/src/tui.tsx b/packages/prs/src/tui.tsx index b2443e2..42ecd31 100644 --- a/packages/prs/src/tui.tsx +++ b/packages/prs/src/tui.tsx @@ -9,6 +9,7 @@ import { extractCreatedPullRequests, marquee, pullRequestStatus, + slackPullRequest, sortPullRequests, uniquePullRequests, type PullRequest, @@ -60,7 +61,9 @@ function samePullRequest(left: PullRequest, right: PullRequest): boolean { left.title === right.title && left.state === right.state && left.isDraft === right.isDraft && - left.createdAt === right.createdAt + left.createdAt === right.createdAt && + left.additions === right.additions && + left.deletions === right.deletions ) } @@ -91,6 +94,7 @@ function PullRequestRow(props: { draft: string | RGBA open: string | RGBA merged: string | RGBA + copy: (text: string) => boolean }) { const [hovered, setHovered] = createSignal(false) const [width, setWidth] = createSignal(1) @@ -141,18 +145,21 @@ function PullRequestRow(props: { {marquee(props.pr.title, width(), offset())} - - #{String(props.pr.number).padStart(props.numberWidth)} - · {pullRequestStatus(props.pr)} - + + + #{String(props.pr.number).padStart(props.numberWidth)} + · {pullRequestStatus(props.pr)} + + props.copy(slackPullRequest(props.pr))}> · ⧉ + ) } async function fetchPullRequest(ref: PullRequestRef): Promise { try { - const { stdout } = await execFileAsync("gh", ["pr", "view", ref.url, "--json", "title,state,url,number,isDraft,createdAt"]) - const data = JSON.parse(stdout) as Pick + const { stdout } = await execFileAsync("gh", ["pr", "view", ref.url, "--json", "title,state,url,number,isDraft,createdAt,additions,deletions"]) + const data = JSON.parse(stdout) as Pick return { ...ref, ...data } } catch { return undefined @@ -220,6 +227,7 @@ function PullRequests(props: { draft: string | RGBA open: string | RGBA merged: string | RGBA + copy: (text: string) => boolean }) { const cache = getSessionCache(props.sessionID) const [open, setOpen] = createSignal(true) @@ -290,6 +298,7 @@ function PullRequests(props: { draft={props.draft} open={props.open} merged={props.merged} + copy={props.copy} /> )} @@ -312,6 +321,14 @@ function setup(context: Context) { draft={context.theme.text.feedback.warning.default} open={context.theme.text.feedback.info.default} merged={context.theme.text.feedback.success.default} + copy={(text) => { + const copied = context.renderer.copyToClipboardOSC52(text) + context.ui.toast.show({ + message: copied ? "Copied to clipboard" : "Could not copy to clipboard", + variant: copied ? "success" : "error", + }) + return copied + }} /> ), }) @@ -333,6 +350,14 @@ const tui: TuiPlugin = async (api) => { draft={api.theme.current.warning} open={api.theme.current.info} merged={api.theme.current.success} + copy={(text) => { + const copied = api.renderer.copyToClipboardOSC52(text) + api.ui.toast({ + message: copied ? "Copied to clipboard" : "Could not copy to clipboard", + variant: copied ? "success" : "error", + }) + return copied + }} /> }, }, diff --git a/packages/prs/test/prs.test.js b/packages/prs/test/prs.test.js index deedadf..34080ee 100644 --- a/packages/prs/test/prs.test.js +++ b/packages/prs/test/prs.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { extractCreatedPullRequests, extractPullRequests, marquee, pullRequestStatus, sortPullRequests, truncate, uniquePullRequests } from "../dist/prs.js" +import { extractCreatedPullRequests, extractPullRequests, marquee, pullRequestStatus, slackPullRequest, sortPullRequests, truncate, uniquePullRequests } from "../dist/prs.js" test("extracts and normalizes GitHub pull request links", () => { assert.deepEqual(extractPullRequests("See https://github.com/vvo/opencode-plugins/pull/12/files"), [{ @@ -37,7 +37,7 @@ test("scrolls long titles", () => { test("sorts open, draft, and merged PRs by recency", () => { const pr = (number, state, isDraft, createdAt) => ({ owner: "vvo", repo: "repo", number, url: `https://github.com/vvo/repo/pull/${number}`, - title: String(number), state, isDraft, createdAt, + title: String(number), state, isDraft, createdAt, additions: 4, deletions: 2, }) const sorted = sortPullRequests([ pr(1, "MERGED", false, "2026-09-04T10:00:00Z"), @@ -47,3 +47,13 @@ test("sorts open, draft, and merged PRs by recency", () => { ]) assert.deepEqual(sorted.map(({ number }) => number), [4, 3, 2, 1]) }) + +test("formats a PR for Slack", () => { + const pr = { + owner: "vvo", repo: "opencode-plugins", number: 22, + url: "https://github.com/vvo/opencode-plugins/pull/22", + title: "lower the cursor", state: "MERGED", isDraft: false, + createdAt: "2026-09-04T10:00:00Z", additions: 4, deletions: 4, + } + assert.equal(slackPullRequest(pr), ":pr-merged: vvo/opencode-plugins +4 -4") +})