Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/copy-pr-slack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-prs": patch
---

Add a copy action for Slack-ready PR summaries with status, diff counts, and confirmation feedback.
2 changes: 1 addition & 1 deletion packages/prs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<img src="../../assets/prs-hover-light-v2.gif" alt="Pull request titles scrolling on hover in the OpenCode sidebar" width="640">
</picture>

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

Expand Down
7 changes: 7 additions & 0 deletions packages/prs/src/prs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PullRequest, "state" | "isDraft">): "draft" | "open" | "merged" | "closed" {
Expand All @@ -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[] {
Expand Down
39 changes: 32 additions & 7 deletions packages/prs/src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
extractCreatedPullRequests,
marquee,
pullRequestStatus,
slackPullRequest,
sortPullRequests,
uniquePullRequests,
type PullRequest,
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -141,18 +145,21 @@ function PullRequestRow(props: {
<text fg={props.link} wrapMode="none"><a href={props.pr.url}>{marquee(props.pr.title, width(), offset())}</a></text>
</box>
</box>
<text fg={props.subdued} marginLeft={2}>
#{String(props.pr.number).padStart(props.numberWidth)}
<span style={{ fg: statusColor() }}> · {pullRequestStatus(props.pr)}</span>
</text>
<box flexDirection="row" marginLeft={2}>
<text fg={props.subdued}>
#{String(props.pr.number).padStart(props.numberWidth)}
<span style={{ fg: statusColor() }}> · {pullRequestStatus(props.pr)}</span>
</text>
<text fg={props.subdued} onMouseUp={() => props.copy(slackPullRequest(props.pr))}> · ⧉</text>
</box>
</box>
)
}

async function fetchPullRequest(ref: PullRequestRef): Promise<PullRequest | undefined> {
try {
const { stdout } = await execFileAsync("gh", ["pr", "view", ref.url, "--json", "title,state,url,number,isDraft,createdAt"])
const data = JSON.parse(stdout) as Pick<PullRequest, "title" | "state" | "url" | "number" | "isDraft" | "createdAt">
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<PullRequest, "title" | "state" | "url" | "number" | "isDraft" | "createdAt" | "additions" | "deletions">
return { ...ref, ...data }
} catch {
return undefined
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -290,6 +298,7 @@ function PullRequests(props: {
draft={props.draft}
open={props.open}
merged={props.merged}
copy={props.copy}
/>
)}</For>
</Show>
Expand All @@ -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
}}
/>
),
})
Expand All @@ -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
}}
/>
},
},
Expand Down
14 changes: 12 additions & 2 deletions packages/prs/test/prs.test.js
Original file line number Diff line number Diff line change
@@ -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"), [{
Expand Down Expand Up @@ -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"),
Expand All @@ -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 <https://github.com/vvo/opencode-plugins/pull/22|*lower the cursor*> +4 -4")
})
Loading