From 6074f39a32045d7d09f2a1a934f0b7d85f095d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 31 Aug 2026 11:25:58 +0000 Subject: [PATCH] feat: add pull request link copy actions (#3179) (#3182) --- .../backend/internal/github/client_helpers.go | 3 + .../internal/github/client_helpers_test.go | 42 +++-- apps/backend/internal/github/gh_client.go | 1 + .../github/gh_client_commands_test.go | 6 +- apps/backend/internal/github/models.go | 1 + .../internal/github/pat_client_writes_test.go | 8 +- .../github/pr-detail-panel-mapping.test.ts | 100 ++++++++++ .../web/components/github/pr-detail-panel.tsx | 26 +-- .../change-request-detail-comments.tsx | 4 + .../change-request-detail-copy-button.tsx | 70 +++++++ .../change-request-detail-feedback.tsx | 35 +++- .../change-request-detail-header.tsx | 6 + .../change-request-detail.test.tsx | 97 +++++++++- .../integrations/change-request-detail.tsx | 1 + apps/web/e2e/helpers/api-client.ts | 2 + .../pr/mobile-pr-detail-copy-links.spec.ts | 123 +++++++++++++ .../e2e/tests/pr/pr-detail-copy-links.spec.ts | 117 ++++++++++++ apps/web/lib/types/github.ts | 1 + apps/web/src/locales/en/integrations.json | 4 + apps/web/src/locales/pseudo/integrations.json | 4 + apps/web/src/locales/pt-pt/integrations.json | 4 + apps/web/src/locales/zh-cn/integrations.json | 4 + apps/web/src/locales/zh-hk/integrations.json | 4 + apps/web/src/locales/zh-tw/integrations.json | 4 + docs/plans/pr-link-copy-actions/plan.md | 114 ++++++++++++ .../task-01-provider-urls-and-copy-actions.md | 121 +++++++++++++ docs/specs/integrations/README.md | 2 + .../requirements/pr-link-copy-actions.md | 74 ++++++++ .../system-design/pr-link-copy-actions.md | 171 ++++++++++++++++++ 29 files changed, 1108 insertions(+), 41 deletions(-) create mode 100644 apps/web/components/github/pr-detail-panel-mapping.test.ts create mode 100644 apps/web/components/integrations/change-request-detail-copy-button.tsx create mode 100644 apps/web/e2e/tests/pr/mobile-pr-detail-copy-links.spec.ts create mode 100644 apps/web/e2e/tests/pr/pr-detail-copy-links.spec.ts create mode 100644 docs/plans/pr-link-copy-actions/plan.md create mode 100644 docs/plans/pr-link-copy-actions/task-01-provider-urls-and-copy-actions.md create mode 100644 docs/specs/integrations/requirements/pr-link-copy-actions.md create mode 100644 docs/specs/integrations/system-design/pr-link-copy-actions.md diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 01377f7883..bcdabc3097 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -314,6 +314,7 @@ func convertRawComments(raw []ghComment) []PRComment { for i, c := range raw { comments[i] = PRComment{ ID: c.ID, + HTMLURL: c.HTMLURL, Author: c.User.Login, AuthorAvatar: c.User.AvatarURL, AuthorIsBot: isGitHubBot(c.User.Type), @@ -333,6 +334,7 @@ func convertRawComments(raw []ghComment) []PRComment { // ghIssueComment is the JSON shape for issue comments from the GitHub API. type ghIssueComment struct { ID int64 `json:"id"` + HTMLURL string `json:"html_url"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -349,6 +351,7 @@ func convertRawIssueComments(raw []ghIssueComment) []PRComment { for i, c := range raw { comments[i] = PRComment{ ID: c.ID, + HTMLURL: c.HTMLURL, Author: c.User.Login, AuthorAvatar: c.User.AvatarURL, AuthorIsBot: isGitHubBot(c.User.Type), diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index b4f13af7ce..66b2467045 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -2,11 +2,29 @@ package github import ( "context" + "encoding/json" "errors" "testing" "time" ) +func assertPRCommentHTMLURL(t *testing.T, comment PRComment, want string) { + t.Helper() + encoded, err := json.Marshal(comment) + if err != nil { + t.Fatalf("marshal PR comment: %v", err) + } + var payload struct { + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("unmarshal PR comment: %v", err) + } + if payload.HTMLURL != want { + t.Errorf("html_url = %q, want %q", payload.HTMLURL, want) + } +} + // TestNewPRStatus_MarksOutcomeFieldsPopulatedNotClosureAttribution covers // AC-10: newPRStatus (the single convergence point for REST and gh CLI // single-PR paths) always marks the outcome-field group populated, since it @@ -374,23 +392,14 @@ func TestConvertRawCheckRunsEmpty(t *testing.T) { } func TestConvertRawComments(t *testing.T) { - now := time.Now() - raw := []ghComment{ - { - ID: 1, - Path: "main.go", - Line: 42, - Side: "RIGHT", - Body: "Looks good", - CreatedAt: now, - UpdatedAt: now, - User: struct { - Login string `json:"login"` - AvatarURL string `json:"avatar_url"` - Type string `json:"type"` - }{Login: "alice", AvatarURL: "https://avatar.example.com/alice", Type: "User"}, - }, + var rawComment ghComment + if err := json.Unmarshal([]byte(`{"id":1,"path":"main.go","line":42,"side":"RIGHT","body":"Looks good", + "created_at":"2026-01-05T12:00:00Z","updated_at":"2026-01-05T12:00:00Z", + "html_url":"https://github.com/acme/widget/pull/42#discussion_r1", + "user":{"login":"alice","avatar_url":"https://avatar.example.com/alice","type":"User"}}`), &rawComment); err != nil { + t.Fatalf("decode raw comment: %v", err) } + raw := []ghComment{rawComment} comments := convertRawComments(raw) @@ -412,6 +421,7 @@ func TestConvertRawComments(t *testing.T) { if comments[0].AuthorIsBot { t.Error("expected non-bot comment") } + assertPRCommentHTMLURL(t, comments[0], "https://github.com/acme/widget/pull/42#discussion_r1") } func TestConvertRawIssueComments(t *testing.T) { diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index c46f253528..1408f778e9 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -694,6 +694,7 @@ func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number // ghComment is the JSON shape for review comments from the GitHub API. type ghComment struct { ID int64 `json:"id"` + HTMLURL string `json:"html_url"` Path string `json:"path"` Line int `json:"line"` Side string `json:"side"` diff --git a/apps/backend/internal/github/gh_client_commands_test.go b/apps/backend/internal/github/gh_client_commands_test.go index 8fa61e6049..1cdd257f8b 100644 --- a/apps/backend/internal/github/gh_client_commands_test.go +++ b/apps/backend/internal/github/gh_client_commands_test.go @@ -214,12 +214,14 @@ func TestGHClient_ListPRComments_MergesBothEndpointsInTimeOrder(t *testing.T) { Prefix: "api repos/acme/widget/pulls/42/comments", Stdout: `[{"id":20,"path":"main.go","line":7,"side":"RIGHT","body":"inline note", "created_at":"2026-01-05T12:00:00Z","updated_at":"2026-01-05T12:30:00Z", - "in_reply_to_id":19,"user":{"login":"alice","avatar_url":"https://a","type":"User"}}]`, + "in_reply_to_id":19,"html_url":"https://github.com/acme/widget/pull/42#discussion_r20", + "user":{"login":"alice","avatar_url":"https://a","type":"User"}}]`, }, ghResponse{ Prefix: "api repos/acme/widget/issues/42/comments", Stdout: `[{"id":10,"body":"conversation note","created_at":"2026-01-05T09:00:00Z", "updated_at":"2026-01-05T09:00:00Z", + "html_url":"https://github.com/acme/widget/pull/42#issuecomment-10", "user":{"login":"dependabot","avatar_url":"https://d","type":"Bot"}}]`, }, ) @@ -237,6 +239,7 @@ func TestGHClient_ListPRComments_MergesBothEndpointsInTimeOrder(t *testing.T) { if comments[0].ID != 10 || comments[0].CommentType != commentTypeIssue || !comments[0].AuthorIsBot { t.Errorf("comments[0] = %#v, want the older bot-authored issue comment first", comments[0]) } + assertPRCommentHTMLURL(t, comments[0], "https://github.com/acme/widget/pull/42#issuecomment-10") second := comments[1] if second.ID != 20 || second.CommentType != commentTypeReview || second.AuthorIsBot { t.Errorf("comments[1] = %#v", second) @@ -247,6 +250,7 @@ func TestGHClient_ListPRComments_MergesBothEndpointsInTimeOrder(t *testing.T) { if second.InReplyTo == nil || *second.InReplyTo != 19 { t.Errorf("in_reply_to = %v, want 19", second.InReplyTo) } + assertPRCommentHTMLURL(t, second, "https://github.com/acme/widget/pull/42#discussion_r20") } func TestGHClient_ListPRComments_AppendsSinceToBothEndpoints(t *testing.T) { diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index f05d717b8a..75aa446970 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -340,6 +340,7 @@ type PRReview struct { // PRComment represents a review comment on specific code. type PRComment struct { ID int64 `json:"id"` + HTMLURL string `json:"html_url,omitempty"` Author string `json:"author"` AuthorAvatar string `json:"author_avatar"` AuthorIsBot bool `json:"author_is_bot"` diff --git a/apps/backend/internal/github/pat_client_writes_test.go b/apps/backend/internal/github/pat_client_writes_test.go index b9e73f79ec..46855f2fc5 100644 --- a/apps/backend/internal/github/pat_client_writes_test.go +++ b/apps/backend/internal/github/pat_client_writes_test.go @@ -344,11 +344,13 @@ func TestPATClient_ListPRComments_MergesReviewAndIssueCommentsInTimeOrder(t *tes "/repos/acme/widget/pulls/42/comments": `[ {"id":20,"path":"main.go","line":7,"side":"RIGHT","body":"inline note", "created_at":"2026-01-05T12:00:00Z","updated_at":"2026-01-05T12:30:00Z", - "in_reply_to_id":19,"user":{"login":"alice","avatar_url":"https://a","type":"User"}} + "in_reply_to_id":19,"html_url":"https://github.com/acme/widget/pull/42#discussion_r20", + "user":{"login":"alice","avatar_url":"https://a","type":"User"}} ]`, "/repos/acme/widget/issues/42/comments": `[ {"id":10,"body":"conversation note","created_at":"2026-01-05T09:00:00Z", - "updated_at":"2026-01-05T09:00:00Z","user":{"login":"dependabot","avatar_url":"https://d","type":"Bot"}} + "updated_at":"2026-01-05T09:00:00Z","html_url":"https://github.com/acme/widget/pull/42#issuecomment-10", + "user":{"login":"dependabot","avatar_url":"https://d","type":"Bot"}} ]`, }) @@ -364,6 +366,7 @@ func TestPATClient_ListPRComments_MergesReviewAndIssueCommentsInTimeOrder(t *tes if first.ID != 10 || first.CommentType != commentTypeIssue { t.Errorf("comments[0] = %#v, want the older issue comment", first) } + assertPRCommentHTMLURL(t, first, "https://github.com/acme/widget/pull/42#issuecomment-10") if !first.AuthorIsBot { t.Error("a Bot-typed user must set AuthorIsBot") } @@ -376,6 +379,7 @@ func TestPATClient_ListPRComments_MergesReviewAndIssueCommentsInTimeOrder(t *tes if second.InReplyTo == nil || *second.InReplyTo != 19 { t.Errorf("in_reply_to = %v, want 19", second.InReplyTo) } + assertPRCommentHTMLURL(t, second, "https://github.com/acme/widget/pull/42#discussion_r20") if second.AuthorIsBot { t.Error("a User-typed author must not set AuthorIsBot") } diff --git a/apps/web/components/github/pr-detail-panel-mapping.test.ts b/apps/web/components/github/pr-detail-panel-mapping.test.ts new file mode 100644 index 0000000000..367e155416 --- /dev/null +++ b/apps/web/components/github/pr-detail-panel-mapping.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { PRComment, PRFeedback } from "@/lib/types/github"; +import { mapGitHubComments } from "./pr-detail-panel"; + +const REVIEW_TIMESTAMP = "2026-01-05T12:00:00Z"; +const ISSUE_TIMESTAMP = "2026-01-05T09:00:00Z"; +const REVIEW_COMMENT_URL = "https://github.com/acme/widget/pull/42#discussion_r20"; +const CONVERSATION_COMMENT_URL = "https://github.com/acme/widget/pull/42#issuecomment-10"; + +describe("mapGitHubComments", () => { + it("preserves the exact GitHub permalink for every comment type", () => { + const comments: PRComment[] = [ + { + id: 20, + html_url: REVIEW_COMMENT_URL, + author: "alice", + author_avatar: "", + author_is_bot: false, + body: "Inline note", + path: "main.go", + line: 7, + side: "RIGHT", + comment_type: "review", + created_at: REVIEW_TIMESTAMP, + updated_at: REVIEW_TIMESTAMP, + in_reply_to: null, + }, + { + id: 10, + html_url: CONVERSATION_COMMENT_URL, + author: "dependabot", + author_avatar: "", + author_is_bot: true, + body: "Conversation note", + path: "", + line: 0, + side: "", + comment_type: "issue", + created_at: ISSUE_TIMESTAMP, + updated_at: ISSUE_TIMESTAMP, + in_reply_to: null, + }, + ]; + + expect(mapGitHubComments({ comments } as unknown as PRFeedback)).toMatchObject([ + { + id: "20", + url: REVIEW_COMMENT_URL, + }, + { + id: "10", + url: CONVERSATION_COMMENT_URL, + }, + ]); + }); + + it("does not invent a URL when GitHub omits one", () => { + const comment = { + id: 20, + html_url: "", + author: "alice", + author_avatar: "", + author_is_bot: false, + body: "Inline note", + path: "main.go", + line: 7, + side: "RIGHT", + comment_type: "review", + created_at: REVIEW_TIMESTAMP, + updated_at: REVIEW_TIMESTAMP, + in_reply_to: null, + } satisfies PRComment; + + expect( + mapGitHubComments({ comments: [comment] } as unknown as PRFeedback)[0], + ).not.toHaveProperty("url"); + }); + + it("normalizes whitespace around a provider URL", () => { + const comment = { + id: 20, + html_url: ` ${REVIEW_COMMENT_URL} `, + author: "alice", + author_avatar: "", + author_is_bot: false, + body: "Inline note", + path: "main.go", + line: 7, + side: "RIGHT", + comment_type: "review", + created_at: REVIEW_TIMESTAMP, + updated_at: REVIEW_TIMESTAMP, + in_reply_to: null, + } satisfies PRComment; + + expect(mapGitHubComments({ comments: [comment] } as unknown as PRFeedback)[0]).toMatchObject({ + url: REVIEW_COMMENT_URL, + }); + }); +}); diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index da1b822e5c..0ed73a4a30 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -254,7 +254,7 @@ function mapGitHubIdentity(taskPR: TaskPR, feedback: PRFeedback | null) { const live = feedback?.pr; return { title: live?.title ?? taskPR.pr_title, - url: live?.html_url || live?.url || taskPR.pr_url, + url: live?.html_url || taskPR.pr_url, state: live?.state ?? taskPR.state, draft: live?.draft, author: githubPerson(live?.author_login ?? taskPR.author_login), @@ -323,16 +323,20 @@ function mapGitHubChecks(feedback: PRFeedback | null) { })); } -function mapGitHubComments(feedback: PRFeedback | null) { - return (feedback?.comments ?? []).map((comment) => ({ - id: String(comment.id), - parentId: comment.in_reply_to ? String(comment.in_reply_to) : undefined, - author: githubPerson(comment.author, comment.author_avatar, comment.author_is_bot), - body: comment.body, - createdAt: comment.created_at, - path: comment.path || undefined, - line: comment.line || undefined, - })); +export function mapGitHubComments(feedback: PRFeedback | null) { + return (feedback?.comments ?? []).map((comment) => { + const url = comment.html_url?.trim(); + return { + id: String(comment.id), + parentId: comment.in_reply_to ? String(comment.in_reply_to) : undefined, + author: githubPerson(comment.author, comment.author_avatar, comment.author_is_bot), + body: comment.body, + createdAt: comment.created_at, + ...(url ? { url } : {}), + path: comment.path || undefined, + line: comment.line || undefined, + }; + }); } function mapGitHubDetail( diff --git a/apps/web/components/integrations/change-request-detail-comments.tsx b/apps/web/components/integrations/change-request-detail-comments.tsx index f6640bb64b..41af03f7ae 100644 --- a/apps/web/components/integrations/change-request-detail-comments.tsx +++ b/apps/web/components/integrations/change-request-detail-comments.tsx @@ -136,6 +136,8 @@ function CommentThreadBlock({ body={thread.root.body} createdAt={thread.root.createdAt} metadata={commentMetadata(thread.root)} + copyUrl={thread.root.url} + copyTestId={`change-request-comment-copy-${thread.root.id}`} onAdd={ props.onAddContext ? () => @@ -162,6 +164,8 @@ function CommentThreadBlock({ body={reply.body} createdAt={reply.createdAt} metadata={commentMetadata(reply, true)} + copyUrl={reply.url} + copyTestId={`change-request-comment-copy-${reply.id}`} onAdd={ props.onAddContext ? () => props.onAddContext?.("comment", buildCommentContext(reply, detail.url)) diff --git a/apps/web/components/integrations/change-request-detail-copy-button.tsx b/apps/web/components/integrations/change-request-detail-copy-button.tsx new file mode 100644 index 0000000000..6d682a11ac --- /dev/null +++ b/apps/web/components/integrations/change-request-detail-copy-button.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; + +const COPIED_FEEDBACK_DURATION_MS = 1500; + +type ChangeRequestCopyKind = "changeRequest" | "comment"; + +const labels: Record = { + changeRequest: { + copy: "integrations:copyChangeRequestUrl", + copied: "integrations:changeRequestUrlCopied", + }, + comment: { + copy: "integrations:copyCommentUrl", + copied: "integrations:commentUrlCopied", + }, +}; + +export function ChangeRequestDetailCopyButton({ + url, + kind, + testId, +}: { + url?: string; + kind: ChangeRequestCopyKind; + testId: string; +}) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timeout = window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_DURATION_MS); + return () => window.clearTimeout(timeout); + }, [copied]); + + if (!url?.trim()) return null; + + const label = t(copied ? labels[kind].copied : labels[kind].copy); + return ( + + + + + {label} + + ); +} diff --git a/apps/web/components/integrations/change-request-detail-feedback.tsx b/apps/web/components/integrations/change-request-detail-feedback.tsx index c320088b26..623c1aaaa2 100644 --- a/apps/web/components/integrations/change-request-detail-feedback.tsx +++ b/apps/web/components/integrations/change-request-detail-feedback.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { AddToContextButton, ExpandableBody, formatTimeAgo } from "@/components/github/pr-shared"; import { ChangeRequestPersonLink } from "./change-request-detail-header"; +import { ChangeRequestDetailCopyButton } from "./change-request-detail-copy-button"; import type { ChangeRequestDetailPerson } from "./change-request-detail"; function PersonAvatar({ person }: { person: ChangeRequestDetailPerson }) { @@ -20,6 +21,8 @@ export function ChangeRequestFeedbackRow({ createdAt, metadata, onAdd, + copyUrl, + copyTestId, reply, }: { person: ChangeRequestDetailPerson; @@ -27,23 +30,37 @@ export function ChangeRequestFeedbackRow({ createdAt?: string; metadata?: ReactNode; onAdd?: () => void; + copyUrl?: string; + copyTestId?: string; reply?: boolean; }) { return (
-
+
{metadata} - {createdAt ? ( - - {formatTimeAgo(createdAt)} - - ) : null} - {onAdd ? ( -
- + {createdAt || onAdd || (copyUrl && copyTestId) ? ( +
+ {createdAt ? ( + + {formatTimeAgo(createdAt)} + + ) : null} + {onAdd ? ( +
+ +
+ ) : null} + {copyUrl && copyTestId ? ( +
+ +
+ ) : null}
) : null}
diff --git a/apps/web/components/integrations/change-request-detail-header.tsx b/apps/web/components/integrations/change-request-detail-header.tsx index d4b09c2efa..b5bd570799 100644 --- a/apps/web/components/integrations/change-request-detail-header.tsx +++ b/apps/web/components/integrations/change-request-detail-header.tsx @@ -4,6 +4,7 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { formatTimeAgo, getTimeAgoColor } from "@/components/github/pr-shared"; import { t } from "@/lib/i18n"; +import { ChangeRequestDetailCopyButton } from "./change-request-detail-copy-button"; import type { ChangeRequestDetailAction, ChangeRequestDetailModel, @@ -209,6 +210,11 @@ export function ChangeRequestDetailHeader({
#{detail.number} + {detail.sourceBranch} diff --git a/apps/web/components/integrations/change-request-detail.test.tsx b/apps/web/components/integrations/change-request-detail.test.tsx index 00c2ab9dc0..9236a3e080 100644 --- a/apps/web/components/integrations/change-request-detail.test.tsx +++ b/apps/web/components/integrations/change-request-detail.test.tsx @@ -1,12 +1,23 @@ -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@/lib/i18n"; import { ChangeRequestDetail, type ChangeRequestDetailModel, type ChangeRequestDetailProps, } from "./change-request-detail"; +const clipboardMocks = vi.hoisted(() => ({ copyToClipboard: vi.fn() })); + +vi.mock("@/lib/utils/copy-to-clipboard", () => clipboardMocks); + +const REQUEST_COPY_TEST_ID = "change-request-copy-url"; +const ARIA_LABEL_ATTRIBUTE = "aria-label"; +const REQUEST_COPY_LABEL = "Copy pull request URL"; +const REQUEST_COPIED_LABEL = "Pull request URL copied"; +const COMMENT_COPIED_LABEL = "Comment URL copied"; + const detail: ChangeRequestDetailModel = { providerId: "bitbucket", reviewKey: "workspace/repo#42", @@ -73,9 +84,13 @@ function renderDetail(props: Partial = {}) { ); } -afterEach(() => { +afterEach(async () => { cleanup(); vi.useRealTimers(); + clipboardMocks.copyToClipboard.mockReset(); + await act(async () => { + await i18n.changeLanguage("en"); + }); }); describe("ChangeRequestDetail", () => { @@ -164,6 +179,84 @@ describe("ChangeRequestDetail interactions", () => { }); }); +describe("ChangeRequestDetail copy actions", () => { + it("copies the request and comment permalinks with transient confirmation", async () => { + clipboardMocks.copyToClipboard.mockResolvedValue(true); + const detailWithUrls = { + ...detail, + state: "merged", + comments: detail.comments.map((comment) => ({ + ...comment, + url: `https://bitbucket.org/workspace/repo/pull-requests/42#${comment.id}`, + })), + } as unknown as ChangeRequestDetailModel; + + renderDetail({ detail: detailWithUrls, presentation: "mobile" }); + + const requestCopy = screen.getByTestId(REQUEST_COPY_TEST_ID); + const rootCopy = screen.getByTestId("change-request-comment-copy-comment-1"); + const replyCopy = screen.getByTestId("change-request-comment-copy-comment-2"); + + fireEvent.click(requestCopy); + fireEvent.click(rootCopy); + fireEvent.click(replyCopy); + + await waitFor(() => { + expect(clipboardMocks.copyToClipboard).toHaveBeenNthCalledWith(1, detailWithUrls.url); + expect(clipboardMocks.copyToClipboard).toHaveBeenNthCalledWith( + 2, + "https://bitbucket.org/workspace/repo/pull-requests/42#comment-1", + ); + expect(clipboardMocks.copyToClipboard).toHaveBeenNthCalledWith( + 3, + "https://bitbucket.org/workspace/repo/pull-requests/42#comment-2", + ); + }); + expect(requestCopy.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(REQUEST_COPIED_LABEL); + expect(rootCopy.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(COMMENT_COPIED_LABEL); + }); + + it("omits copy actions when a request or comment URL is unavailable", () => { + const detailWithoutUrls = { + ...detail, + url: "", + comments: detail.comments.map(({ ...comment }) => { + delete (comment as { url?: string }).url; + return comment; + }), + } as unknown as ChangeRequestDetailModel; + + renderDetail({ detail: detailWithoutUrls }); + + expect(screen.queryByTestId(REQUEST_COPY_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("change-request-comment-copy-comment-1")).toBeNull(); + expect(screen.queryByTestId("change-request-comment-copy-comment-2")).toBeNull(); + }); + + it("does not show copied confirmation when the clipboard write fails", async () => { + clipboardMocks.copyToClipboard.mockResolvedValue(false); + renderDetail(); + + const requestCopy = screen.getByTestId(REQUEST_COPY_TEST_ID); + fireEvent.click(requestCopy); + + await waitFor(() => expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith(detail.url)); + expect(requestCopy.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(REQUEST_COPY_LABEL); + }); + + it("updates copy labels when the locale changes", async () => { + renderDetail(); + const requestCopy = screen.getByTestId(REQUEST_COPY_TEST_ID); + expect(requestCopy.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(REQUEST_COPY_LABEL); + + await act(async () => { + await i18n.changeLanguage("pseudo"); + }); + + expect(requestCopy.getAttribute(ARIA_LABEL_ATTRIBUTE)).toContain("ƥũĺĺ"); + }); +}); + describe("ChangeRequestDetail actions and states", () => { it("dispatches only advertised provider actions with mobile-safe targets", () => { const onAction = vi.fn(); diff --git a/apps/web/components/integrations/change-request-detail.tsx b/apps/web/components/integrations/change-request-detail.tsx index 00c9440f78..0279538f3b 100644 --- a/apps/web/components/integrations/change-request-detail.tsx +++ b/apps/web/components/integrations/change-request-detail.tsx @@ -59,6 +59,7 @@ export type ChangeRequestDetailComment = { author: ChangeRequestDetailPerson; body: string; createdAt?: string; + url?: string; path?: string; line?: number; resolved?: boolean; diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index a222499b6b..1cb4ad8ef7 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1912,6 +1912,8 @@ export class ApiClient { line?: number; side?: string; comment_type?: string; + html_url?: string; + in_reply_to?: number | null; created_at?: string; updated_at?: string; }>; diff --git a/apps/web/e2e/tests/pr/mobile-pr-detail-copy-links.spec.ts b/apps/web/e2e/tests/pr/mobile-pr-detail-copy-links.spec.ts new file mode 100644 index 0000000000..30eecd6eb3 --- /dev/null +++ b/apps/web/e2e/tests/pr/mobile-pr-detail-copy-links.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; +import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions"; + +const OWNER = "testorg"; +const REPO = "testrepo"; +const PR_NUMBER = 720; +const PR_URL = `https://github.com/${OWNER}/${REPO}/pull/${PR_NUMBER}`; +const COMMENT_URLS = new Map([ + [201, `${PR_URL}#discussion_r201`], + [202, `${PR_URL}#discussion_r202`], + [203, `${PR_URL}#issuecomment-203`], +]); + +async function readClipboard(page: Page) { + return page.evaluate(() => navigator.clipboard.readText()); +} + +test.describe("mobile PR detail copy links", () => { + test("copies PR and comment links with touch-sized controls", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + test.setTimeout(120_000); + await testPage.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Copy links from mobile PR details", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + pr_url: PR_URL, + pr_title: "Copy links from mobile review details", + head_branch: "feat/mobile-copy-pr-links", + base_branch: "main", + author_login: "test-user", + state: "closed", + }); + await apiClient.mockGitHubSeedPRFeedback({ + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + comments: [ + { + id: 201, + author: "reviewer", + body: "Please update this line.", + path: "main.go", + line: 7, + side: "RIGHT", + comment_type: "review", + html_url: COMMENT_URLS.get(201), + created_at: "2026-08-01T09:00:00Z", + }, + { + id: 202, + author: "test-user", + body: "I will update it.", + comment_type: "review", + html_url: COMMENT_URLS.get(202), + in_reply_to: 201, + created_at: "2026-08-01T09:01:00Z", + }, + { + id: 203, + author: "maintainer", + body: "Thanks for the follow-up.", + comment_type: "issue", + html_url: COMMENT_URLS.get(203), + created_at: "2026-08-01T09:02:00Z", + }, + ], + }); + + await testPage.goto(`/t/${task.id}`); + await testPage.getByRole("button", { name: "Review", exact: true }).tap(); + + const panel = testPage.getByTestId("mobile-review-panel"); + await expect(panel).toBeVisible({ timeout: 15_000 }); + const detail = panel.getByTestId("change-request-detail"); + await expect(detail).toBeVisible(); + await prCapture.screenshot("mobile-pr-detail-copy-links", { + caption: "Mobile pull request details with touch-sized copy actions", + }); + + const requestCopy = detail.getByTestId("change-request-copy-url"); + await expect(requestCopy).toBeVisible(); + expect((await requireBox(requestCopy, "mobile PR copy action")).height).toBeGreaterThanOrEqual( + 44, + ); + await requestCopy.tap(); + await expect.poll(() => readClipboard(testPage)).toBe(PR_URL); + await expect(requestCopy).toHaveAttribute("aria-label", "Pull request URL copied"); + + for (const id of [201, 202, 203]) { + const copy = detail.getByTestId(`change-request-comment-copy-${id}`); + await expect(copy).toBeVisible(); + expect( + (await requireBox(copy, `mobile comment ${id} copy action`)).height, + ).toBeGreaterThanOrEqual(44); + await copy.tap(); + await expect.poll(() => readClipboard(testPage)).toBe(COMMENT_URLS.get(id)); + await expect(copy).toHaveAttribute("aria-label", "Comment URL copied"); + } + + await assertNoDocumentHorizontalOverflow(testPage, "mobile PR detail copy links"); + }); +}); diff --git a/apps/web/e2e/tests/pr/pr-detail-copy-links.spec.ts b/apps/web/e2e/tests/pr/pr-detail-copy-links.spec.ts new file mode 100644 index 0000000000..7f455caacd --- /dev/null +++ b/apps/web/e2e/tests/pr/pr-detail-copy-links.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; +import { SessionPage } from "../../pages/session-page"; + +const OWNER = "testorg"; +const REPO = "testrepo"; +const PR_NUMBER = 719; +const PR_URL = `https://github.com/${OWNER}/${REPO}/pull/${PR_NUMBER}`; +const COMMENT_URLS = new Map([ + [101, `${PR_URL}#discussion_r101`], + [102, `${PR_URL}#discussion_r102`], + [103, `${PR_URL}#issuecomment-103`], +]); + +async function readClipboard(page: Page) { + return page.evaluate(() => navigator.clipboard.readText()); +} + +test.describe("PR detail copy links", () => { + test("copies the PR URL and every visible comment permalink", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + test.setTimeout(120_000); + await testPage.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Copy links from PR details", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + pr_url: PR_URL, + pr_title: "Copy links from review details", + head_branch: "feat/copy-pr-links", + base_branch: "main", + author_login: "test-user", + state: "open", + }); + await apiClient.mockGitHubSeedPRFeedback({ + owner: OWNER, + repo: REPO, + pr_number: PR_NUMBER, + comments: [ + { + id: 101, + author: "reviewer", + body: "Please update this line.", + path: "main.go", + line: 7, + side: "RIGHT", + comment_type: "review", + html_url: COMMENT_URLS.get(101), + created_at: "2026-08-01T09:00:00Z", + }, + { + id: 102, + author: "test-user", + body: "I will update it.", + comment_type: "review", + html_url: COMMENT_URLS.get(102), + in_reply_to: 101, + created_at: "2026-08-01T09:01:00Z", + }, + { + id: 103, + author: "maintainer", + body: "Thanks for the follow-up.", + comment_type: "issue", + html_url: COMMENT_URLS.get(103), + created_at: "2026-08-01T09:02:00Z", + }, + ], + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); + await session.prTopbarButton().click(); + + const detail = session.prDetailPanel().getByTestId("change-request-detail"); + await expect(detail).toBeVisible(); + const requestCopy = detail.getByTestId("change-request-copy-url"); + await expect(requestCopy).toBeVisible(); + await detail.getByTestId("change-request-comment-copy-101-row").hover(); + await prCapture.screenshot("desktop-pr-detail-copy-links", { + caption: "Desktop pull request details with copy actions for the PR and comments", + }); + await requestCopy.click(); + await expect.poll(() => readClipboard(testPage)).toBe(PR_URL); + await expect(requestCopy).toHaveAttribute("aria-label", "Pull request URL copied"); + + for (const id of [101, 102, 103]) { + const copy = detail.getByTestId(`change-request-comment-copy-${id}`); + await expect(copy).toBeVisible(); + await detail.getByTestId(`change-request-comment-copy-${id}-row`).hover(); + await copy.click(); + await expect.poll(() => readClipboard(testPage)).toBe(COMMENT_URLS.get(id)); + await expect(copy).toHaveAttribute("aria-label", "Comment URL copied"); + } + }); +}); diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index ae6d80eb06..602fe96e7b 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -155,6 +155,7 @@ export type PRReview = { export type PRComment = { id: number; + html_url?: string; author: string; author_avatar: string; author_is_bot: boolean; diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index ebe55be70e..ea44289034 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -35,6 +35,7 @@ "comments": "Comments", "commentCount_one": "{{count}} comment", "commentCount_other": "{{count}} comments", + "commentUrlCopied": "Comment URL copied", "commit": "Commit", "commitChangedFiles_one": "Commit {{count}} changed file", "commitChangedFiles_other": "Commit {{count}} changed files", @@ -50,6 +51,8 @@ "copyConfigToAnotherWorkspace": "Copy config to another workspace", "copyConfiguration": "Copy {{label}} configuration", "copyConfigWithCredentials": "Copy the {{label}} settings and credentials from <4>{{sourceName}} into another workspace. This overwrites the target workspace's current {{label}} configuration. Watchers and automations are not copied.", + "copyChangeRequestUrl": "Copy pull request URL", + "copyCommentUrl": "Copy comment URL", "copyingConfigTo": "Copying {{label}} config to {{targetName}}…", "createAsDraft": "Create as draft", "createdSuccessfully": "{{longName}} created successfully", @@ -66,6 +69,7 @@ "creatingEllipsis": "Creating...", "creatingFrom": "Creating {{shortName}} from <4>{{displayBranch}}", "creatingFromInto": "Creating {{shortName}} from <4>{{displayBranch}} → <6>{{baseBranch}}", + "changeRequestUrlCopied": "Pull request URL copied", "deleteSavedQueryNamed": "Delete {{label}} saved query", "describeYourChanges": "Describe your changes...", "description": "Description", diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index 9778c9ddd1..9a6e7d55bc 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -35,6 +35,7 @@ "comments": "Ćōḿḿēńţś", "commentCount_one": "{{count}} ćōḿḿēńţ", "commentCount_other": "{{count}} ćōḿḿēńţś", + "commentUrlCopied": "Ćōḿḿēńţ ŨŔĹ ćōƥĩēď", "commit": "Ćōḿḿĩţ", "commitChangedFiles_one": "Ćōḿḿĩţ {{count}} ćĥàńĝēď ƒĩĺē", "commitChangedFiles_other": "Ćōḿḿĩţ {{count}} ćĥàńĝēď ƒĩĺēś", @@ -50,6 +51,8 @@ "copyConfigToAnotherWorkspace": "Ćōƥŷ ćōńƒĩĝ ţō àńōţĥēŕ ŵōŕķśƥàćē", "copyConfiguration": "Ćōƥŷ {{label}} ćōńƒĩĝũŕàţĩōń", "copyConfigWithCredentials": "Ćōƥŷ ţĥē {{label}} śēţţĩńĝś àńď ćŕēďēńţĩàĺś ƒŕōḿ <4>{{sourceName}} ĩńţō àńōţĥēŕ ŵōŕķśƥàćē. Ţĥĩś ōvēŕŵŕĩţēś ţĥē ţàŕĝēţ ŵōŕķśƥàćē'ś ćũŕŕēńţ {{label}} ćōńƒĩĝũŕàţĩōń. Ŵàţćĥēŕś àńď àũţōḿàţĩōńś àŕē ńōţ ćōƥĩēď.", + "copyChangeRequestUrl": "Ćōƥŷ ƥũĺĺ ŕēqũēśţ ŨŔĹ", + "copyCommentUrl": "Ćōƥŷ ćōḿḿēńţ ŨŔĹ", "copyingConfigTo": "Ćōƥŷĩńĝ {{label}} ćōńƒĩĝ ţō {{targetName}}…", "createAsDraft": "Ćŕēàţē àś ďŕàƒţ", "createdSuccessfully": "{{longName}} ćŕēàţēď śũććēśśƒũĺĺŷ", @@ -66,6 +69,7 @@ "creatingEllipsis": "Ćŕēàţĩńĝ...", "creatingFrom": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}}", "creatingFromInto": "Ćŕēàţĩńĝ {{shortName}} ƒŕōḿ <4>{{displayBranch}} → <6>{{baseBranch}}", + "changeRequestUrlCopied": "Ƥũĺĺ ŕēqũēśţ ŨŔĹ ćōƥĩēď", "deleteSavedQueryNamed": "Ďēĺēţē {{label}} śàvēď qũēŕŷ", "describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...", "description": "Ďēśćŕĩƥţĩōń", diff --git a/apps/web/src/locales/pt-pt/integrations.json b/apps/web/src/locales/pt-pt/integrations.json index 574c3f9b28..68f11759e3 100644 --- a/apps/web/src/locales/pt-pt/integrations.json +++ b/apps/web/src/locales/pt-pt/integrations.json @@ -20,12 +20,15 @@ "configureAUtilityAgentInSettings": "Configure um agente utilitário nas definições para ativar a geração por IA", "copiedConfigTo": "Configuração do {{label}} copiada para {{targetName}}.", "copyConfig": "Copiar configuração", + "copyChangeRequestUrl": "Copiar URL do pull request", + "copyCommentUrl": "Copiar URL do comentário", "copyConfigSettingsOnly": "Copie as definições do {{label}} de <4>{{sourceName}} para outra área de trabalho. Isto substitui a configuração atual do {{label}} na área de trabalho de destino. As vigilâncias e as automatizações não são copiadas.", "copyConfigToAnotherWorkspace": "Copiar a configuração para outra área de trabalho", "copyConfiguration": "Copiar a configuração do {{label}}", "copyConfigWithCredentials": "Copie as definições e as credenciais do {{label}} de <4>{{sourceName}} para outra área de trabalho. Isto substitui a configuração atual do {{label}} na área de trabalho de destino. As vigilâncias e as automatizações não são copiadas.", "copyingConfigTo": "A copiar a configuração do {{label}} para {{targetName}}…", "createAsDraft": "Criar como rascunho", + "changeRequestUrlCopied": "URL do pull request copiado", "createdSuccessfully": "{{longName}} criado com êxito", "createdLinkNeedsAttention": "{{shortName}} criado; a ligação à tarefa requer atenção", "associationRetryViaTaskMenu": "{{message}}. Utilize Ligação no menu da tarefa para tentar novamente.", @@ -133,6 +136,7 @@ "closePullRequestStatus": "Fechar estado do PR", "commentCount_one": "{{count}} comentário", "commentCount_other": "{{count}} comentários", + "commentUrlCopied": "URL do comentário copiado", "commented": "Comentou", "comments": "Comentários", "dismissed": "Descartado", diff --git a/apps/web/src/locales/zh-cn/integrations.json b/apps/web/src/locales/zh-cn/integrations.json index 081119987c..b00c4f0ecb 100644 --- a/apps/web/src/locales/zh-cn/integrations.json +++ b/apps/web/src/locales/zh-cn/integrations.json @@ -20,12 +20,15 @@ "configureAUtilityAgentInSettings": "在设置中配置辅助智能体以启用 AI 生成", "copiedConfigTo": "已将 {{label}} 配置复制到 {{targetName}}。", "copyConfig": "复制配置", + "copyChangeRequestUrl": "复制拉取请求 URL", + "copyCommentUrl": "复制评论 URL", "copyConfigSettingsOnly": "将 <4>{{sourceName}} 的 {{label}} 设置复制到另一个工作区。这将覆盖目标工作区当前的 {{label}} 配置。不会复制监控与自动化。", "copyConfigToAnotherWorkspace": "复制配置到另一个工作区", "copyConfiguration": "复制 {{label}} 配置", "copyConfigWithCredentials": "将 <4>{{sourceName}} 的 {{label}} 设置与凭据复制到另一个工作区。这将覆盖目标工作区当前的 {{label}} 配置。不会复制监控与自动化。", "copyingConfigTo": "正在将 {{label}} 配置复制到 {{targetName}}…", "createAsDraft": "创建为草稿", + "changeRequestUrlCopied": "拉取请求 URL 已复制", "createdSuccessfully": "{{longName}} 创建成功", "createdLinkNeedsAttention": "{{shortName}} 已创建;任务链接需要处理", "associationRetryViaTaskMenu": "{{message}}。请在任务菜单中使用“链接”重试。", @@ -133,6 +136,7 @@ "closePullRequestStatus": "关闭 PR 状态", "commentCount_one": "{{count}} 条评论", "commentCount_other": "{{count}} 条评论", + "commentUrlCopied": "评论 URL 已复制", "commented": "已评论", "comments": "评论", "dismissed": "已忽略", diff --git a/apps/web/src/locales/zh-hk/integrations.json b/apps/web/src/locales/zh-hk/integrations.json index a2c099cf66..5e6aac5af8 100644 --- a/apps/web/src/locales/zh-hk/integrations.json +++ b/apps/web/src/locales/zh-hk/integrations.json @@ -20,12 +20,15 @@ "configureAUtilityAgentInSettings": "在設定中設定輔助代理程式以啓用 AI 產生", "copiedConfigTo": "已將 {{label}} 設定複製到 {{targetName}}。", "copyConfig": "複製設定", + "copyChangeRequestUrl": "複製拉取要求 URL", + "copyCommentUrl": "複製評論 URL", "copyConfigSettingsOnly": "將 <4>{{sourceName}} 的 {{label}} 設定複製到另一個工作區。這將覆蓋目標工作區目前的 {{label}} 設定。不會複製監控與自動化。", "copyConfigToAnotherWorkspace": "複製設定到另一個工作區", "copyConfiguration": "複製 {{label}} 設定", "copyConfigWithCredentials": "將 <4>{{sourceName}} 的 {{label}} 設定與認證資料複製到另一個工作區。這將覆蓋目標工作區目前的 {{label}} 設定。不會複製監控與自動化。", "copyingConfigTo": "正在將 {{label}} 設定複製到 {{targetName}}…", "createAsDraft": "建立為草稿", + "changeRequestUrlCopied": "拉取要求 URL 已複製", "createdSuccessfully": "{{longName}} 建立成功", "createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理", "associationRetryViaTaskMenu": "{{message}}。請在任務菜單中使用「連結」重試。", @@ -133,6 +136,7 @@ "closePullRequestStatus": "關閉 PR 狀態", "commentCount_one": "{{count}} 條評論", "commentCount_other": "{{count}} 條評論", + "commentUrlCopied": "評論 URL 已複製", "commented": "已評論", "comments": "評論", "dismissed": "已忽略", diff --git a/apps/web/src/locales/zh-tw/integrations.json b/apps/web/src/locales/zh-tw/integrations.json index 80dd35cff6..b9d87cd6b7 100644 --- a/apps/web/src/locales/zh-tw/integrations.json +++ b/apps/web/src/locales/zh-tw/integrations.json @@ -20,12 +20,15 @@ "configureAUtilityAgentInSettings": "在設定中設定輔助代理程式以啟用 AI 產生", "copiedConfigTo": "已將 {{label}} 設定複製到 {{targetName}}。", "copyConfig": "複製設定", + "copyChangeRequestUrl": "複製提取要求 URL", + "copyCommentUrl": "複製評論 URL", "copyConfigSettingsOnly": "將 <4>{{sourceName}} 的 {{label}} 設定複製到另一個工作區。這將覆蓋目標工作區目前的 {{label}} 設定。不會複製監控與自動化。", "copyConfigToAnotherWorkspace": "複製設定到另一個工作區", "copyConfiguration": "複製 {{label}} 設定", "copyConfigWithCredentials": "將 <4>{{sourceName}} 的 {{label}} 設定與認證資訊複製到另一個工作區。這將覆蓋目標工作區目前的 {{label}} 設定。不會複製監控與自動化。", "copyingConfigTo": "正在將 {{label}} 設定複製到 {{targetName}}…", "createAsDraft": "建立為草稿", + "changeRequestUrlCopied": "提取要求 URL 已複製", "createdSuccessfully": "{{longName}} 建立成功", "createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理", "associationRetryViaTaskMenu": "{{message}}。請在任務選單中使用「連結」重試。", @@ -133,6 +136,7 @@ "closePullRequestStatus": "關閉 PR 狀態", "commentCount_one": "{{count}} 條評論", "commentCount_other": "{{count}} 條評論", + "commentUrlCopied": "評論 URL 已複製", "commented": "已評論", "comments": "評論", "dismissed": "已忽略", diff --git a/docs/plans/pr-link-copy-actions/plan.md b/docs/plans/pr-link-copy-actions/plan.md new file mode 100644 index 0000000000..4f4dddaeda --- /dev/null +++ b/docs/plans/pr-link-copy-actions/plan.md @@ -0,0 +1,114 @@ +--- +created: 2026-08-31 +status: implemented +requirements: + - ../../specs/integrations/requirements/pr-link-copy-actions.md +system_design: + - ../../specs/integrations/system-design/pr-link-copy-actions.md +legacy_specs: [] +--- + +# Implementation Plan: Pull request link copy actions + +## Overview + +Implement issue #3179 through the existing GitHub feedback and shared +change-request detail paths. Preserve provider comment permalinks at the +backend boundary, map them into the provider-neutral detail model, and add +localized copy actions that work on desktop and on the phone Review surface. + +The change is read-only from the provider's perspective. It needs no new API, +database migration, polling, or task-state mutation. + +## Confirmed diagnosis + +The linked PR URL is already available to `PRDetailContent` through the live PR +model and the persisted `TaskPR.pr_url`. The shared detail header currently +renders the PR title, state, number, and provider actions but has no copy +control. + +GitHub review and conversation comments are already fetched together. The +backend raw comment types and the normalized `PRComment` omit `html_url`, so +the frontend cannot produce a canonical comment permalink. The shared comment +rows also have no link action. Both desktop/tablet and phone use the same +`ChangeRequestDetail`, so one shared implementation covers the surfaces. + +## Delivery sequence + +1. Extend the GitHub raw, backend, mock, and frontend feedback types with the + provider comment URL. Add converter and transport regressions for review and + conversation comments. +2. Extend the provider-neutral comment model and add a shared copy affordance + using `copyToClipboard`, localized tooltip/accessibility text, transient + success state, and existing responsive row geometry. +3. Map GitHub comment URLs into the shared detail and add component tests for + exact values, omitted URLs, and closed or merged PRs. +4. Add desktop and phone Playwright coverage using the existing mock feedback + seed and Review navigation. Assert clipboard contents, confirmation, + touch geometry, and no document horizontal overflow. + +## Implementation Waves + +Wave 1: + +- [x] [Task 01: Provider URLs and shared copy actions](task-01-provider-urls-and-copy-actions.md) + +Execution is sequential in the primary conversation. No subagent delegation is +planned or authorized. + +## Verification + +Run focused backend and frontend tests first: + +```bash +cd apps/backend +go test ./internal/github + +cd ../ +pnpm --filter @kandev/web test -- --run \ + components/github/pr-detail-panel-mapping.test.ts \ + components/integrations/change-request-detail.test.tsx \ + components/github/pr-detail-panel.test.ts +``` + +Then run the focused desktop and mobile browser scenarios: + +```bash +cd apps/web +pnpm e2e:run tests/pr/pr-detail-copy-links.spec.ts +pnpm e2e:run --project=mobile-chrome tests/pr/mobile-pr-detail-copy-links.spec.ts +``` + +The implementation task must also run the repository's applicable formatting, +lint, typecheck, and i18n checks after the focused tests pass. + +## Verification results + +- Backend unit and race tests pass: `go test ./internal/github -count=1` and + `go test -race ./internal/github -count=1`. +- Frontend focused tests pass: 43 tests across the shared detail, GitHub panel, + and GitHub mapping test files. +- Desktop and Pixel 5 E2E copy-link scenarios pass, including exact clipboard + values, closed-PR availability, touch targets, and horizontal-overflow checks. +- `make -C apps/backend lint` reports 0 issues. Web typecheck, lint, i18n + checks, specification lint, and diff checks pass. + +## Risks + +- The copy action must preserve the provider's complete anchor. Composing a URL + from an ID could silently break review-comment navigation. +- A hover-only implementation would fail on phone and keyboard paths. The + coarse-pointer path must keep actions visible, and focus must reveal them on + fine-pointer paths. +- Adding a header control can affect the already-sensitive PR header wrapping. + It must stay in the identity row and retain the existing mobile minimum hit + size. +- GitHub feedback fixtures currently do not type or seed comment URLs. The + mock helper and raw payload tests must be updated before E2E can prove the + clipboard contract. + +## Documentation impact + +No public CLI, configuration, API, executor, or workflow documentation changes +are required. The feature is discoverable through its localized tooltip and +copied confirmation. diff --git a/docs/plans/pr-link-copy-actions/task-01-provider-urls-and-copy-actions.md b/docs/plans/pr-link-copy-actions/task-01-provider-urls-and-copy-actions.md new file mode 100644 index 0000000000..32825791eb --- /dev/null +++ b/docs/plans/pr-link-copy-actions/task-01-provider-urls-and-copy-actions.md @@ -0,0 +1,121 @@ +--- +id: "01-provider-urls-and-copy-actions" +title: "Provider URLs and shared copy actions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001 +acceptance_criteria: + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.1 + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.2 + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.3 + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.4 + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.5 + - AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.6 +system_design: "../../specs/integrations/system-design/pr-link-copy-actions.md" +--- + +# Task 01: Provider URLs and shared copy actions + +## Outcome + +Users can copy the exact linked GitHub pull-request URL from the shared detail +header and the exact permalink from every URL-bearing conversation or review +comment row on desktop, tablet, and phone. + +## In scope + +- Preserve GitHub `html_url` for review and conversation comments through gh, + PAT, mock, backend, and frontend feedback mappings. +- Add the optional provider-neutral comment URL field and shared localized copy + action to the header and feedback rows. +- Keep the existing PR URL fallback, closed/merged behavior, single-scroll + detail geometry, keyboard access, and phone touch targets. +- Add unit/component, backend transport/converter, and desktop/mobile Playwright + regression coverage. + +## Explicit exclusions + +- Markdown link formatting. +- New persistence, endpoints, polling, or provider mutations. +- GitLab or Azure DevOps adapter changes. +- A parallel mobile or provider-specific detail component. + +## Acceptance conditions + +- The existing feedback response exposes both GitHub comment URL forms and the + shared model copies those exact strings, with no action for an omitted URL. +- Header and comment-row copy actions use the repository clipboard utility, + expose localized tooltip and accessible labels, show transient success only + after a successful copy, and preserve retry on failure. +- Desktop and mobile tests prove PR, review-comment, and conversation-comment + clipboard values, closed/merged availability, phone visibility without hover, + 44-pixel targets, and no document horizontal overflow. + +## Verification + +```bash +cd apps/backend +go test ./internal/github + +cd ../ +pnpm --filter @kandev/web test -- --run \ + components/github/pr-detail-panel-mapping.test.ts \ + components/integrations/change-request-detail.test.tsx \ + components/github/pr-detail-panel.test.ts + +cd web +pnpm e2e:run tests/pr/pr-detail-copy-links.spec.ts +pnpm e2e:run --project=mobile-chrome tests/pr/mobile-pr-detail-copy-links.spec.ts +``` + +Also run the applicable frontend i18n, typecheck, lint, formatting, and +repository validation commands. Use TDD: add a failing regression before each +production change, then refactor only after the focused tests pass. + +## Likely files + +- `apps/backend/internal/github/gh_client.go` +- `apps/backend/internal/github/pat_client.go` +- `apps/backend/internal/github/client_helpers.go` +- `apps/backend/internal/github/models.go` +- `apps/backend/internal/github/mock_controller.go` +- `apps/backend/internal/github/*_test.go` for comment conversion and transport + regressions +- `apps/web/lib/types/github.ts` +- `apps/web/components/github/pr-detail-panel.tsx` +- `apps/web/components/integrations/change-request-detail.tsx` +- `apps/web/components/integrations/change-request-detail-header.tsx` +- `apps/web/components/integrations/change-request-detail-feedback.tsx` +- `apps/web/components/integrations/change-request-detail-comments.tsx` +- `apps/web/components/integrations/change-request-detail.test.tsx` +- `apps/web/components/github/pr-detail-panel-mapping.test.ts` +- `apps/web/components/github/pr-detail-panel.test.ts` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/integrations.json` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/tests/pr/pr-detail-copy-links.spec.ts` +- `apps/web/e2e/tests/pr/mobile-pr-detail-copy-links.spec.ts` + +## Dependencies + +None. The existing GitHub PR feedback endpoint and shared mobile Review +composition provide the required data and entry points. + +## Results + +- RED: backend gh/PAT/converter tests failed because `html_url` was empty, and + the shared copy-action test failed because no copy controls existed. +- GREEN: GitHub raw and domain comment URLs now survive gh, PAT, and mock + feedback responses. The shared detail maps them into localized copy buttons + with exact clipboard values, transient success only after a successful + write, and no action for missing URLs. +- REFACTOR: the copy control is shared by the header and comment rows; labels + follow locale changes, comment actions reveal on fine-pointer hover/focus, + and they remain visible at coarse pointers with 44px targets. The public PR + URL fallback avoids API URLs, and whitespace around provider URLs is + normalized before rendering. +- Verification: backend unit and race tests, `make -C apps/backend lint`, + 43 focused frontend tests, desktop and Pixel 5 E2E scenarios, web typecheck, + lint, i18n checks, specification lint, and diff checks all pass. diff --git a/docs/specs/integrations/README.md b/docs/specs/integrations/README.md index 7e2b50f30b..c26d04e297 100644 --- a/docs/specs/integrations/README.md +++ b/docs/specs/integrations/README.md @@ -54,6 +54,7 @@ outcomes that expose those contracts. - [Jira Ticket Status Filter](requirements/jira-status-filter.md) - [MCP Tool Argument Validation](requirements/mcp-tool-argument-validation.md) - [Pull request outcome attribution](requirements/pr-outcome-attribution.md) +- [Pull request link copy actions](requirements/pr-link-copy-actions.md) - [Provider-Aware Review Automation Runtime](requirements/provider-aware-review-automation.md) - [Slack Integration](requirements/slack.md) @@ -76,6 +77,7 @@ outcomes that expose those contracts. - [GitHub PR Merge Queue](system-design/github-pr-merge-queue.md) - [GitHub PR Merge Queue Recovery](system-design/github-pr-merge-queue-recovery.md) - [GitHub task pull request sync coordination](system-design/github-task-pr-sync-coordination.md) +- [Pull request link copy actions](system-design/pr-link-copy-actions.md) - [GitLab Integration System Design Part 1](system-design/gitlab-integration-01.md) - [GitLab Integration System Design Part 2](system-design/gitlab-integration-02.md) - [GitLab MR Status Chip System Design Part 1](system-design/gitlab-mr-status-chip-01.md) diff --git a/docs/specs/integrations/requirements/pr-link-copy-actions.md b/docs/specs/integrations/requirements/pr-link-copy-actions.md new file mode 100644 index 0000000000..e10be79df2 --- /dev/null +++ b/docs/specs/integrations/requirements/pr-link-copy-actions.md @@ -0,0 +1,74 @@ +--- +status: draft +system: integrations +created: 2026-08-31 +owners: + - Kandev +--- + +# Pull request link copy actions Requirements + +## Overview + +The integration system owns the canonical links returned by external code-host +providers and the normalized feedback displayed in a change-request detail +surface. Users need to copy a pull request link or a link to one specific +comment without leaving Kandev. The shared UI system supplies the reusable +presentation, focus, and responsive interaction contract. + +## Terminology + +- **Change request:** A provider pull request, merge request, or equivalent + reviewable code change. +- **Comment permalink:** The provider-supplied URL that opens the change + request at one conversation or review comment. + +## Requirements + +### REQ-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001: Pull request link copy actions + +**Intent:** Users can share a linked pull request or a specific conversation or +review comment directly from its Kandev detail surface. + +**User story:** As a reviewer, I want to copy the pull request or comment link +from Kandev, so that I can share the exact review context without searching for +it again on the provider site. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.1:** When a GitHub pull-request + detail surface has a non-blank canonical pull-request URL, its header shall + expose an icon-only copy action near the pull-request identity, and activating + it shall place that exact URL on the clipboard. +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.2:** When a rendered GitHub + conversation or review comment, including a reply, has a non-blank canonical + comment permalink, its row shall expose a copy action, and activating it shall + place that exact permalink on the clipboard. +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.3:** After a successful copy, the + activated action shall provide a localized tooltip and transient copied + confirmation. A failed copy shall not report success, and the comment or + pull-request content shall remain usable. +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.4:** Copy actions shall remain + available when the pull request is closed or merged; their availability shall + not depend on an open-state action such as merge or approval. +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.5:** On fine-pointer desktop + surfaces, comment-row copy actions may be revealed on row hover but shall also + be reachable through keyboard focus. On phone-sized surfaces, the actions + shall not require hover and shall retain a touch target of at least 44 pixels + in both dimensions. The shared detail surface shall keep its existing single + scroll owner and shall not introduce horizontal page overflow. +- **AC-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001.6:** When a provider feedback + payload omits a comment permalink, Kandev shall render the existing comment + content without offering a copy action for an empty or invented URL. + +## Out of scope + +- Copying a link as Markdown or copying a formatted title and URL pair. +- Adding a new persistence column or endpoint solely for comment permalinks. +- Delivering GitLab, Azure DevOps, or other provider-specific permalink mapping + in this issue. The shared presentation can consume those providers later + when their adapters expose the same normalized URL field. + +## Implementation plan + +[Implementation plan](../../../plans/pr-link-copy-actions/plan.md) diff --git a/docs/specs/integrations/system-design/pr-link-copy-actions.md b/docs/specs/integrations/system-design/pr-link-copy-actions.md new file mode 100644 index 0000000000..79c040d074 --- /dev/null +++ b/docs/specs/integrations/system-design/pr-link-copy-actions.md @@ -0,0 +1,171 @@ +--- +status: draft +system: integrations +requirements: + - REQ-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001 +created: 2026-08-31 +owners: + - Kandev +--- + +# Pull request link copy actions System Design + +## Purpose and boundaries + +The integration system owns the provider URL data that crosses the GitHub +feedback boundary and the vertical behavior that exposes those URLs from a +linked change-request detail surface. The UI system owns reusable layout and +responsive interaction primitives, while the integration design decides which +normalized provider values are safe to expose. This design does not add a +provider-specific detail panel or a second mobile review surface. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-INTEGRATIONS-PR-LINK-COPY-ACTIONS-001` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow), [Presentation and responsive behavior](#presentation-and-responsive-behavior), [Failure and recovery](#failure-and-recovery), [Tests](#tests) | + +## Components and responsibilities + +### GitHub provider and feedback boundary + +- `apps/backend/internal/github/gh_client.go` and `pat_client.go` decode the + existing review-comment and issue-comment API responses. +- `apps/backend/internal/github/client_helpers.go` converts both raw comment + shapes into the shared `PRComment` model. +- `apps/backend/internal/github/models.go` carries the canonical comment URL in + the feedback payload returned by the existing PR feedback endpoint. +- `apps/backend/internal/github/mock_client.go` and `mock_controller.go` keep + the field available to deterministic browser fixtures. + +### Shared change-request presentation + +- `apps/web/lib/types/github.ts` preserves the GitHub comment URL in the + frontend feedback type. +- `apps/web/components/github/pr-detail-panel.tsx` maps the GitHub URL into the + provider-neutral `ChangeRequestDetailModel`. +- `apps/web/components/integrations/change-request-detail-header.tsx` renders + the header copy action from the normalized change-request URL. +- `apps/web/components/integrations/change-request-detail-feedback.tsx` and + `change-request-detail-comments.tsx` render the per-comment action for rows + that have a URL. The same components serve desktop, tablet, and phone Review + surfaces. +- `apps/web/lib/utils/copy-to-clipboard.ts` remains the browser clipboard + boundary. The detail surface does not call the Clipboard API directly. + +## Data and contracts + +The existing GitHub PR feedback response remains the transport boundary. No +new endpoint, store slice, database column, WebSocket event, or recurring +request is needed. + +The normalized values are: + +| Boundary | Field | Rule | +| --- | --- | --- | +| GitHub raw review comment | `html_url` | Preserve the provider value. | +| GitHub raw conversation comment | `html_url` | Preserve the provider value. | +| Backend `PRComment` | `html_url` | Serialize the preserved canonical URL. | +| Frontend `PRComment` | `html_url` | Type the existing payload field. | +| `ChangeRequestDetailComment` | `url?: string` | Map a non-blank provider URL; omit it when absent. | +| `ChangeRequestDetailModel` | `url: string` | Use the live PR `html_url` when available, then the persisted task PR URL. | + +GitHub review-comment URLs already contain the provider's +`#discussion_r` anchor, while conversation-comment URLs contain the +`#issuecomment-` anchor. The host copies the returned string exactly. It +does not reconstruct provider URLs from owner, repository, number, or comment +type, because the provider remains authoritative for permalink format and +future URL changes. + +The persisted `github_task_prs.pr_url` continues to supply the existing PR URL +fallback. Comment URLs remain transient feedback data and are not persisted. + +## Control flow + +1. The GitHub client fetches review comments and conversation comments through + the existing feedback read path. +2. Each raw comment converter preserves its `html_url` in `PRComment`; the + response carries the field through the existing controller response. +3. `PRDetailContent` maps the live PR URL to the detail model and maps each + non-blank comment `html_url` to `ChangeRequestDetailComment.url`. +4. The shared header renders one copy action when the detail URL is non-blank. + Each comment or reply row renders its own action when its URL is non-blank. +5. The action calls `copyToClipboard` with the exact normalized URL. On success + it switches to a transient copied state; on failure it keeps the normal + action state and does not show a success confirmation. + +Copying is client-only. It does not refresh feedback, mutate GitHub, update the +task store, or alter the selected Review item. + +## Presentation and responsive behavior + +The header copy action sits with the existing state and number identity row so +it remains close to the title without competing with provider mutation +actions. It is icon-only, has a localized accessible name, and uses the +existing compact desktop sizing with a minimum 44 by 44 pixel phone target. + +Comment rows use the existing feedback-row action area. On fine pointers the +copy action can use the established hover-reveal treatment, with focus-within +keeping it discoverable to keyboard users. On coarse pointers it remains +visible without hover. Tooltip text and the copied state are localized. + +The phone path remains `SessionMobileLayout` to `MobileReviewPanel` to the +shared change-request detail. The header stays above +`change-request-detail-scroll`, which remains the single vertical scroll owner; +safe-area padding and `h-dvh` layout are unchanged. No provider-specific phone +branch is introduced. + +## Failure and recovery + +- An absent or blank provider URL suppresses only the corresponding copy action + and leaves all existing detail content rendered. +- A clipboard rejection leaves the action enabled for retry and does not show a + false copied state. +- Clipboard fallback behavior remains owned by `copyToClipboard`, including + browsers without the modern Clipboard API. +- Closed and merged PRs use the same URL fields and copy behavior as open PRs; + no lifecycle refresh or mutation permission is required. + +## Persistence + +No persistence changes or migrations are required. The PR URL continues to use +the existing task association field, and comment URLs are carried only in the +live feedback response. + +## Security + +The provider supplies the URL and remains the authority for its external +destination. Kandev does not construct a destination from untrusted path +fragments or send the URL to a new server endpoint. The action copies plain +text only and does not grant or change provider permissions. + +## Observability + +No new backend metrics, logs, or polling are required. The feature is a local +clipboard interaction. Existing feedback request errors remain visible through +the existing detail loading and error states. + +## Tests + +- Backend converter and transport tests cover `html_url` preservation for both + GitHub review comments and conversation comments through the gh and PAT + clients. +- Shared detail component tests cover the header action, per-row actions, + exact clipboard values, transient success state, missing URLs, and terminal + PR states. +- GitHub panel tests cover mapping the feedback URL into the normalized detail + model without changing the existing PR URL fallback. +- Desktop Playwright coverage seeds one review comment and one conversation + comment, copies the PR URL and both comment URLs, and checks the visible + confirmation. +- Mobile Playwright coverage enters through the existing Review destination, + copies a comment URL without hover, and checks the 44-pixel target and zero + document-level horizontal overflow. + +## Related systems + +- [UI system](../../ui/README.md): owns the reusable detail presentation and + responsive interaction primitives. +- [GitHub task pull request synchronization](github-task-pr-sync-coordination.md): + continues to own task-association synchronization and freshness, which this + read-only action does not change.