From 609340e53b9ea0087dd34abdedfc393991d5c232 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 22 Oct 2026 17:15:47 -0300 Subject: [PATCH] feat(native): edit and delete comments from Pulls Review thread comments, comments on the pull request and a submitted review's summary get a ... menu in Pulls offering what GitHub lets the viewer do: Edit rewrites the text in place, Delete asks first. The conversation read carries each entry's database id and GitHub's viewerCanUpdate/viewerCanDelete, and the REST writes that take a body share writeBody. --- CHANGELOG.md | 1 + internal/project/prchanged_test.go | 19 +- internal/project/prreview.go | 50 +++- internal/project/prreview_test.go | 83 ++++++ internal/project/prthreads.go | 90 ++++--- internal/project/prthreads_test.go | 34 ++- native/cmd/lich-native/main.go | 1 + native/cmd/lich-native/pulls_comment_edit.go | 239 ++++++++++++++++++ native/cmd/lich-native/pulls_conversation.go | 58 +++-- .../lich-native/pulls_conversation_test.go | 53 +++- native/cmd/lich-native/pulls_submit.go | 3 + native/cmd/lich-native/pulls_view.go | 1 + native/cmd/lich-native/stale_pulls_test.go | 34 +++ native/i18n/locales/en/pulls.json | 13 +- native/i18n/locales/es/pulls.json | 13 +- native/i18n/locales/ja/pulls.json | 13 +- native/i18n/locales/ko/pulls.json | 13 +- native/i18n/locales/pt-br/pulls.json | 13 +- native/i18n/locales/ru/pulls.json | 13 +- native/i18n/locales/zh-cn/pulls.json | 13 +- native/lichclient/pulls.go | 58 ++++- 21 files changed, 726 insertions(+), 89 deletions(-) create mode 100644 native/cmd/lich-native/pulls_comment_edit.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ae9de05..a725ed693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Comments can be edited and deleted from Pulls. The … beside a review thread comment (in the Conversation tab and in the diff) or a comment on the pull request offers Edit, which rewrites it in place on GitHub, and Delete, which asks first. A submitted review's summary can be edited too; its verdict stays as it is. The … shows only on what GitHub lets your account change. - Choosing a monospace font for the interface in Settings › Appearance asks whether the terminal and the editor should use it too. Use everywhere sets both; Interface only leaves them as they were. A proportional font or System asks nothing. - A comment waiting in the review batch can be reworded before it is sent: its pencil opens it in a dialog, saved with Save or Ctrl+Enter. A line comment in a pull request's pending review has an Edit button beside Discard that opens it in a box, saved with Save or Ctrl+Enter. diff --git a/internal/project/prchanged_test.go b/internal/project/prchanged_test.go index 0b9506de2..8aeef4e8e 100644 --- a/internal/project/prchanged_test.go +++ b/internal/project/prchanged_test.go @@ -18,13 +18,18 @@ func (l *changeLog) emit(name string, data any) { func TestPullRequestWritesAnnounceTheChange(t *testing.T) { t.Parallel() writes := map[string]func(s *Service) error{ - "merge": func(s *Service) error { return s.MergePullRequest("/repo", 7, "squash", "", "", false) }, - "submit review": func(s *Service) error { return s.SubmitReview("/repo", 7, "approve", "", "", nil) }, - "reply": func(s *Service) error { return s.ReplyToReviewThread("/repo", 7, 1, "ok") }, - "resolve thread": func(s *Service) error { return s.ResolveReviewThread("/repo", "T_1", true) }, - "comment": func(s *Service) error { return s.CommentOnPullRequest("/repo", 7, "hi") }, - "edit title": func(s *Service) error { return s.EditPullRequestTitle("/repo", 7, "title") }, - "edit body": func(s *Service) error { return s.EditPullRequestBody("/repo", 7, "") }, + "merge": func(s *Service) error { return s.MergePullRequest("/repo", 7, "squash", "", "", false) }, + "submit review": func(s *Service) error { return s.SubmitReview("/repo", 7, "approve", "", "", nil) }, + "reply": func(s *Service) error { return s.ReplyToReviewThread("/repo", 7, 1, "ok") }, + "edit comment": func(s *Service) error { return s.EditReviewComment("/repo", 7, 1, "ok") }, + "delete comment": func(s *Service) error { return s.DeleteReviewComment("/repo", 7, 1) }, + "edit pr comment": func(s *Service) error { return s.EditPullRequestComment("/repo", 7, 1, "ok") }, + "delete pr comment": func(s *Service) error { return s.DeletePullRequestComment("/repo", 7, 1) }, + "edit review body": func(s *Service) error { return s.EditReviewBody("/repo", 7, 1, "ok") }, + "resolve thread": func(s *Service) error { return s.ResolveReviewThread("/repo", "T_1", true) }, + "comment": func(s *Service) error { return s.CommentOnPullRequest("/repo", 7, "hi") }, + "edit title": func(s *Service) error { return s.EditPullRequestTitle("/repo", 7, "title") }, + "edit body": func(s *Service) error { return s.EditPullRequestBody("/repo", 7, "") }, } for name, write := range writes { t.Run(name+" emits once it landed", func(t *testing.T) { diff --git a/internal/project/prreview.go b/internal/project/prreview.go index db41fe943..0d44ce1a0 100644 --- a/internal/project/prreview.go +++ b/internal/project/prreview.go @@ -2,6 +2,7 @@ package project import ( "encoding/json" + "errors" "fmt" "os" "strconv" @@ -165,11 +166,40 @@ func writeTempJSON(payload any) (string, error) { // it is an answer to something already said, which is the same split GitHub // draws between "add single comment" and "start a review". func (s *Service) ReplyToReviewThread(path string, number int, commentID int64, body string) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments/%d/replies", number, commentID) + return s.writeBody(path, number, "POST", endpoint, body, "a reply needs something written in it") +} + +// writeBody sends body to a REST endpoint that takes a text and nothing else, +// refusing an empty one with emptyErr before it reaches gh: GitHub would +// refuse it too, a round-trip later. +func (s *Service) writeBody(path string, number int, method, endpoint, body, emptyErr string) error { if body == "" { - return fmt.Errorf("a reply needs something written in it") + return errors.New(emptyErr) } - endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/comments/%d/replies", number, commentID) - return s.writePR(prReviewTimeout, path, number, "api", "--method", "POST", endpoint, "-f", "body="+body) + return s.writePR(prReviewTimeout, path, number, "api", "--method", method, endpoint, "-f", "body="+body) +} + +// EditReviewComment rewrites one comment of a review thread. commentID is its +// database id, the same address a reply takes; GitHub refuses the edit unless +// the account gh runs as may update that comment. +func (s *Service) EditReviewComment(path string, number int, commentID int64, body string) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", commentID) + return s.writeBody(path, number, "PATCH", endpoint, body, "a review comment needs something written in it") +} + +// DeleteReviewComment removes one comment of a review thread; the last one +// gone takes the thread with it. There is no undo on GitHub's side. +func (s *Service) DeleteReviewComment(path string, number int, commentID int64) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/comments/%d", commentID) + return s.writePR(prReviewTimeout, path, number, "api", "--method", "DELETE", endpoint) +} + +// EditReviewBody rewrites the summary of a submitted review. reviewID is its +// database id; the verdict stays what it was, GitHub has no way to change it. +func (s *Service) EditReviewBody(path string, number int, reviewID int64, body string) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/pulls/%d/reviews/%d", number, reviewID) + return s.writeBody(path, number, "PUT", endpoint, body, "a review summary needs something written in it") } // ResolveReviewThread marks a thread resolved, or reopens it. threadID is the @@ -195,6 +225,20 @@ func (s *Service) CommentOnPullRequest(path string, number int, body string) err return s.writePR(prReviewTimeout, path, number, prArgs("comment", number, "--body", body)...) } +// EditPullRequestComment rewrites a comment on the pull request itself. +// GitHub files those as issue comments, which is the endpoint they live at. +func (s *Service) EditPullRequestComment(path string, number int, commentID int64, body string) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", commentID) + return s.writeBody(path, number, "PATCH", endpoint, body, "a comment needs something written in it") +} + +// DeletePullRequestComment removes a comment on the pull request itself, for +// good. +func (s *Service) DeletePullRequestComment(path string, number int, commentID int64) error { + endpoint := fmt.Sprintf("repos/{owner}/{repo}/issues/comments/%d", commentID) + return s.writePR(prReviewTimeout, path, number, "api", "--method", "DELETE", endpoint) +} + // EditPullRequestTitle renames the pull request on GitHub. GitHub refuses a pull // request without a title, so an empty one never reaches gh. func (s *Service) EditPullRequestTitle(path string, number int, title string) error { diff --git a/internal/project/prreview_test.go b/internal/project/prreview_test.go index b65c2b521..d44e69ebb 100644 --- a/internal/project/prreview_test.go +++ b/internal/project/prreview_test.go @@ -180,6 +180,89 @@ func TestReplyToReviewThreadFlow(t *testing.T) { }) } +func TestEditReviewCommentFlow(t *testing.T) { + t.Parallel() + t.Run("an edit patches the comment by its database id", func(t *testing.T) { + gh := &fakeGH{} + if err := withGH(gh).EditReviewComment("/repo", 42, 900001, "reworded"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{ + "api", "--method", "PATCH", + "repos/{owner}/{repo}/pulls/comments/900001", + "-f", "body=reworded", + } + if !slices.Equal(gh.args, want) { + t.Errorf("args = %v, want %v", gh.args, want) + } + }) + + t.Run("an empty edit never reaches gh", func(t *testing.T) { + gh := &fakeGH{} + if err := withGH(gh).EditReviewComment("/repo", 42, 1, ""); err == nil { + t.Error("expected an error for an empty comment") + } + if gh.calls != 0 { + t.Errorf("gh was called %d times, want 0", gh.calls) + } + }) +} + +func TestConversationWritesAddressGitHub(t *testing.T) { + t.Parallel() + cases := map[string]struct { + write func(s *Service) error + want []string + }{ + "deleting a thread comment": { + func(s *Service) error { return s.DeleteReviewComment("/repo", 42, 900001) }, + []string{"api", "--method", "DELETE", "repos/{owner}/{repo}/pulls/comments/900001"}, + }, + "editing a pull request comment": { + func(s *Service) error { return s.EditPullRequestComment("/repo", 42, 800001, "reworded") }, + []string{"api", "--method", "PATCH", "repos/{owner}/{repo}/issues/comments/800001", "-f", "body=reworded"}, + }, + "deleting a pull request comment": { + func(s *Service) error { return s.DeletePullRequestComment("/repo", 42, 800001) }, + []string{"api", "--method", "DELETE", "repos/{owner}/{repo}/issues/comments/800001"}, + }, + "editing a review's summary": { + func(s *Service) error { return s.EditReviewBody("/repo", 42, 700001, "reworded") }, + []string{"api", "--method", "PUT", "repos/{owner}/{repo}/pulls/42/reviews/700001", "-f", "body=reworded"}, + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + gh := &fakeGH{} + if err := c.write(withGH(gh)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(gh.args, c.want) { + t.Errorf("args = %v, want %v", gh.args, c.want) + } + }) + } +} + +func TestConversationEditsRefuseAnEmptyBody(t *testing.T) { + t.Parallel() + edits := map[string]func(s *Service) error{ + "pull request comment": func(s *Service) error { return s.EditPullRequestComment("/repo", 42, 1, "") }, + "review summary": func(s *Service) error { return s.EditReviewBody("/repo", 42, 1, "") }, + } + for name, edit := range edits { + t.Run(name, func(t *testing.T) { + gh := &fakeGH{} + if err := edit(withGH(gh)); err == nil { + t.Error("expected an error for an empty body") + } + if gh.calls != 0 { + t.Errorf("gh was called %d times, want 0", gh.calls) + } + }) + } +} + func TestResolveReviewThreadFlow(t *testing.T) { t.Parallel() t.Run("resolving and reopening are two mutations, one node id", func(t *testing.T) { diff --git a/internal/project/prthreads.go b/internal/project/prthreads.go index f6c025a50..4ee9a0cca 100644 --- a/internal/project/prthreads.go +++ b/internal/project/prthreads.go @@ -23,11 +23,11 @@ const prConversationQuery = `query($owner:String!,$repo:String!,$number:Int!){ repository(owner:$owner,name:$repo){ pullRequest(number:$number){ headRefOid - reviews(last:50){nodes{id author{login} state body submittedAt}} - comments(last:50){nodes{author{login} body createdAt}} + reviews(last:50){nodes{id databaseId author{login} state body submittedAt viewerCanUpdate}} + comments(last:50){nodes{databaseId author{login} body createdAt viewerCanUpdate viewerCanDelete}} reviewThreads(last:100){nodes{ id isResolved isOutdated path line originalLine startLine originalStartLine diffSide - comments(last:50){nodes{databaseId author{login} body createdAt diffHunk}} + comments(last:50){nodes{databaseId author{login} body createdAt diffHunk viewerCanUpdate viewerCanDelete}} }} } } @@ -53,19 +53,26 @@ type PRConversation struct { // summary box above the line comments. type PRReview struct { // ID is the GraphQL node id, which is what dismissing the review takes. - ID string `json:"id"` - Author string `json:"author"` + ID string `json:"id"` + // DatabaseID is the REST id, the address its summary is rewritten at. + DatabaseID int64 `json:"databaseId"` + Author string `json:"author"` // State is gh's APPROVED | CHANGES_REQUESTED | COMMENTED | DISMISSED. - State string `json:"state"` - Body string `json:"body"` - Date string `json:"date"` + State string `json:"state"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` } // PRComment is one comment on the pull request itself. type PRComment struct { - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` + // ID is the REST database id, the address an edit or a delete takes. + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` } // PRReviewThread is one conversation anchored to a line of the diff. @@ -100,6 +107,10 @@ type PRThreadComment struct { // diff already has them where the thread is anchored; this is what an // outdated thread has left. DiffHunk string `json:"diffHunk"` + // CanEdit is GitHub's own answer to whether the account reading may + // rewrite the comment, which is what the Edit button stands on. + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` } // ghConversation mirrors the GraphQL payload; nothing below leaves this package @@ -130,17 +141,22 @@ type ghActor struct { } type ghReview struct { - ID string `json:"id"` - Author ghActor `json:"author"` - State string `json:"state"` - Body string `json:"body"` - SubmittedAt string `json:"submittedAt"` + ID string `json:"id"` + DatabaseID int64 `json:"databaseId"` + Author ghActor `json:"author"` + State string `json:"state"` + Body string `json:"body"` + SubmittedAt string `json:"submittedAt"` + ViewerCanUpdate bool `json:"viewerCanUpdate"` } type ghIssueComment struct { - Author ghActor `json:"author"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` + DatabaseID int64 `json:"databaseId"` + Author ghActor `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + ViewerCanUpdate bool `json:"viewerCanUpdate"` + ViewerCanDelete bool `json:"viewerCanDelete"` } type ghReviewThread struct { @@ -164,6 +180,9 @@ type ghThreadComment struct { Body string `json:"body"` CreatedAt string `json:"createdAt"` DiffHunk string `json:"diffHunk"` + // ViewerCanUpdate answers for the account gh ran as. + ViewerCanUpdate bool `json:"viewerCanUpdate"` + ViewerCanDelete bool `json:"viewerCanDelete"` } // PullRequestConversation reads one pull request's reviews, comments and review @@ -211,11 +230,13 @@ func toReviews(nodes []ghReview) []PRReview { continue } out = append(out, PRReview{ - ID: r.ID, - Author: r.Author.Login, - State: r.State, - Body: r.Body, - Date: r.SubmittedAt, + ID: r.ID, + DatabaseID: r.DatabaseID, + Author: r.Author.Login, + State: r.State, + Body: r.Body, + Date: r.SubmittedAt, + CanEdit: r.ViewerCanUpdate, }) } if len(out) == 0 { @@ -230,7 +251,14 @@ func toComments(nodes []ghIssueComment) []PRComment { } out := make([]PRComment, 0, len(nodes)) for _, c := range nodes { - out = append(out, PRComment{Author: c.Author.Login, Body: c.Body, Date: c.CreatedAt}) + out = append(out, PRComment{ + ID: c.DatabaseID, + Author: c.Author.Login, + Body: c.Body, + Date: c.CreatedAt, + CanEdit: c.ViewerCanUpdate, + CanDelete: c.ViewerCanDelete, + }) } return out } @@ -289,11 +317,13 @@ func toThreadComments(nodes []ghThreadComment) []PRThreadComment { out := make([]PRThreadComment, 0, len(nodes)) for _, c := range nodes { out = append(out, PRThreadComment{ - ID: c.DatabaseID, - Author: c.Author.Login, - Body: c.Body, - Date: c.CreatedAt, - DiffHunk: c.DiffHunk, + ID: c.DatabaseID, + Author: c.Author.Login, + Body: c.Body, + Date: c.CreatedAt, + DiffHunk: c.DiffHunk, + CanEdit: c.ViewerCanUpdate, + CanDelete: c.ViewerCanDelete, }) } return out diff --git a/internal/project/prthreads_test.go b/internal/project/prthreads_test.go index 4c64341c9..a79c60074 100644 --- a/internal/project/prthreads_test.go +++ b/internal/project/prthreads_test.go @@ -9,20 +9,20 @@ import ( const conversationPayload = `{"data":{"repository":{"pullRequest":{ "headRefOid": "21e6cdc4336e8f5db46724d82fecf0423bc41a0a", "reviews": {"nodes": [ - {"id":"PRR_kw1","author":{"login":"omartelo"},"state":"CHANGES_REQUESTED","body":"two things first","submittedAt":"2026-07-30T10:00:00Z"}, + {"id":"PRR_kw1","databaseId":700001,"author":{"login":"omartelo"},"state":"CHANGES_REQUESTED","body":"two things first","submittedAt":"2026-07-30T10:00:00Z","viewerCanUpdate":true}, {"author":{"login":"bot"},"state":"COMMENTED","body":"","submittedAt":"2026-07-30T10:05:00Z"}, {"author":{"login":"drafting"},"state":"PENDING","body":"not submitted","submittedAt":null}, {"id":"PRR_kw2","author":{"login":"reviewer"},"state":"APPROVED","body":"","submittedAt":"2026-07-30T11:00:00Z"} ]}, "comments": {"nodes": [ - {"author":{"login":"omartelo"},"body":"rebased on main","createdAt":"2026-07-30T09:00:00Z"} + {"databaseId":800001,"author":{"login":"omartelo"},"body":"rebased on main","createdAt":"2026-07-30T09:00:00Z","viewerCanUpdate":true,"viewerCanDelete":true} ]}, "reviewThreads": {"nodes": [ {"id":"PRRT_kw1","isResolved":false,"isOutdated":false,"path":"internal/project/prthreads.go", "line":61,"originalLine":58,"startLine":59,"diffSide":"RIGHT", "comments":{"nodes":[ - {"databaseId":900001,"author":{"login":"omartelo"},"body":"this buffers every hunk","createdAt":"2026-07-30T10:01:00Z","diffHunk":"@@ -57,6 +57,10 @@"}, - {"databaseId":900002,"author":{"login":"claude"},"body":"dropped it","createdAt":"2026-07-30T10:30:00Z","diffHunk":"@@ -57,6 +57,10 @@"} + {"databaseId":900001,"author":{"login":"omartelo"},"body":"this buffers every hunk","createdAt":"2026-07-30T10:01:00Z","diffHunk":"@@ -57,6 +57,10 @@","viewerCanUpdate":true,"viewerCanDelete":true}, + {"databaseId":900002,"author":{"login":"claude"},"body":"dropped it","createdAt":"2026-07-30T10:30:00Z","diffHunk":"@@ -57,6 +57,10 @@","viewerCanUpdate":false} ]}}, {"id":"PRRT_kw2","isResolved":true,"isOutdated":true,"path":"frontend/src/lib/pulls/hook.ts", "line":null,"originalLine":18,"startLine":null,"diffSide":"RIGHT", @@ -98,6 +98,32 @@ func TestParseConversation(t *testing.T) { } }) + t.Run("only a comment GitHub lets the viewer update is editable", func(t *testing.T) { + comments := convo.Threads[0].Comments + if !comments[0].CanEdit || comments[1].CanEdit { + t.Errorf("canEdit = %v, %v, want true, false", comments[0].CanEdit, comments[1].CanEdit) + } + if !comments[0].CanDelete || comments[1].CanDelete { + t.Errorf("canDelete = %v, %v, want true, false", comments[0].CanDelete, comments[1].CanDelete) + } + }) + + t.Run("a pull request comment carries its database id and what the viewer may do", func(t *testing.T) { + c := convo.Comments[0] + if c.ID != 800001 || !c.CanEdit || !c.CanDelete { + t.Errorf("comment = %+v, want id 800001, editable and deletable", c) + } + }) + + t.Run("a review carries the database id its body is rewritten by", func(t *testing.T) { + if r := convo.Reviews[0]; r.DatabaseID != 700001 || !r.CanEdit { + t.Errorf("review = %+v, want database id 700001 and editable", r) + } + if convo.Reviews[1].CanEdit { + t.Error("a review GitHub did not mark updatable came through editable") + } + }) + t.Run("an outdated thread falls back to its original line", func(t *testing.T) { outdated := convo.Threads[1] if !outdated.IsOutdated || !outdated.IsResolved { diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 86dc59643..13595577b 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -492,6 +492,7 @@ func (u *window) layout(gtx C) error { u.layoutCloseWorktreeConfirm(gtx, u.theme) u.layoutSubmitDialog(gtx, u.theme) u.layoutMergeDialog(gtx, u.theme) + u.layoutDeleteCommentDialog(gtx, u.theme) u.review.layoutDialogs(gtx, u.theme) u.commentEdit.layout(gtx, u.theme, &u.comments) u.settings.theme.layoutDialogs(gtx, u.theme) diff --git a/native/cmd/lich-native/pulls_comment_edit.go b/native/cmd/lich-native/pulls_comment_edit.go new file mode 100644 index 000000000..400dc5932 --- /dev/null +++ b/native/cmd/lich-native/pulls_comment_edit.go @@ -0,0 +1,239 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "gioui.org/layout" + + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/ui" + "github.com/lichdotdev/lich/native/ui/icons" +) + +var icPullMore = icons.Lucide("ellipsis") + +// commentKind is which of GitHub's three addresses a conversation entry's +// writes go to. +type commentKind uint8 + +const ( + threadComment commentKind = iota + prComment + reviewSummary +) + +// commentTarget is what an entry's ⋯ acts on, with what GitHub lets the viewer +// do to it. lastInThread marks the thread comment whose delete takes the +// thread with it. +type commentTarget struct { + kind commentKind + id int64 + body string + canEdit bool + canDelete bool + lastInThread bool +} + +// key addresses the target's widgets in the maps a thread's live in; a thread +// id never takes this shape. +func (t commentTarget) key() string { return fmt.Sprintf("%d:%d", t.kind, t.id) } + +// commentEdit is an edit's write, carried to the window. +type commentEdit struct { + target commentTarget + body string +} + +func threadTarget(t lichclient.PRReviewThread, c lichclient.PRThreadComment) commentTarget { + return commentTarget{ + kind: threadComment, id: c.ID, body: c.Body, + canEdit: c.CanEdit, canDelete: c.CanDelete, lastInThread: len(t.Comments) == 1, + } +} + +// commentEntries is the ⋯ menu: Edit and Delete as far as GitHub allows them. +func (s *talkState) commentEntries(t commentTarget) []ui.MenuEntry { + var entries []ui.MenuEntry + if t.canEdit { + entries = append(entries, ui.MenuEntry{Label: i18n.T("pulls.reviewThread.edit"), OnClick: func() { s.startEdit(t) }}) + } + if t.canDelete { + entries = append(entries, ui.MenuEntry{Label: i18n.T("pulls.reviewThread.delete"), Variant: ui.VariantDestructive, OnClick: func() { + target := t + s.deleting = &target + }}) + } + return entries +} + +// isEditing takes the entry's Cancel and answers whether it is still being +// edited; the click is taken before anything reads the state, so the frame +// that took it draws the result. +func (s *talkState) isEditing(gtx C, t commentTarget) bool { + if clickableOf(s.cancelClicks, t.key()).Clicked(gtx) { + s.editing[t.key()] = false + } + return s.editing[t.key()] +} + +func (s *talkState) startEdit(t commentTarget) { + s.editing[t.key()] = true + s.editorOf(t.key()).SetText(t.body) + s.focusEdit = t.key() +} + +// commentHead is an entry's byline with the ⋯ beside it, which is gone while +// the entry is being edited. +func (v *pullView) commentHead(gtx C, th *ui.Theme, p *pullsScreen, t commentTarget, byline layout.Widget) D { + s := &v.talk + entries := s.commentEntries(t) + if len(entries) == 0 || s.editing[t.key()] { + return byline(gtx) + } + more := func(gtx C) D { + b := th.Button(clickableOf(s.moreClicks, t.key()), "") + b.Icon, b.Variant, b.Size = icPullMore, ui.VariantGhost, ui.SizeIconXS + return v.withTip(gtx, th, p, "more:"+t.key(), i18n.T("pulls.reviewThread.more"), b.Layout) + } + menu, ok := s.menus[t.key()] + if !ok { + menu = new(ui.MenuState) + s.menus[t.key()] = menu + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, byline), + layout.Rigid(func(gtx C) D { return th.DropdownMenu(p.root, menu, entries).Layout(gtx, more) }), + ) +} + +// threadComment is one comment of a thread: its byline over its body, or over +// the box editing it. +func (v *pullView) threadComment(gtx C, th *ui.Theme, p *pullsScreen, t commentTarget, c lichclient.PRThreadComment, act *pullsAction, now time.Time) D { + editing := v.talk.editing[t.key()] + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return v.commentHead(gtx, th, p, t, func(gtx C) D { return byline(gtx, th, c.Author, "", c.Date, now) }) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: ui.Space(0.5)}.Layout(gtx, func(gtx C) D { + if editing { + return v.editBox(gtx, th, t, act) + } + return wrapped(gtx, th.Text(ui.TextSM, c.Body)) + }) + }), + ) +} + +// editBox rewrites an entry in place; Save sends it as it stands. A review's +// verdict is not part of what GitHub lets an edit change, and the box says so. +func (v *pullView) editBox(gtx C, th *ui.Theme, t commentTarget, act *pullsAction) D { + s := &v.talk + key := t.key() + ed := s.editorOf(key) + if s.focusEdit == key { + gtx.Execute(keyFocus(ed)) + s.focusEdit = "" + } + save, cancel := clickableOf(s.sendClicks, key), clickableOf(s.cancelClicks, key) + body := strings.TrimSpace(ed.Text()) + if (save.Clicked(gtx) || ctrlEnter(gtx, ed)) && body != "" { + act.edit = &commentEdit{target: t, body: body} + } + busy := v.busyNow() == "edit:"+key + label := i18n.T("pulls.reviewThread.save") + if busy { + label = i18n.T("pulls.reviewThread.saving") + } + saveB := th.Button(save, label) + saveB.Size, saveB.Disabled = ui.SizeXS, body == "" || busy + cancelB := th.Button(cancel, i18n.T("common.action.cancel")) + cancelB.Size, cancelB.Variant = ui.SizeXS, ui.VariantGhost + buttons := []layout.FlexChild{layout.Rigid(saveB.Layout), ui.Gap(2), layout.Rigid(cancelB.Layout)} + if t.kind == reviewSummary { + buttons = append(buttons, ui.Gap(2), layout.Rigid(th.Text(ui.TextXS, i18n.T("pulls.reviewThread.verdictStays")).Muted().Layout)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + ta := th.Textarea(ed, "") + ta.Resize = &s.fieldOf(key).resize + return ta.Layout(gtx) + }), + ui.VGap(2), + layout.Rigid(func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, buttons...) }), + ) +} + +// editComment sends an edit to the address its kind lives at; the re-read +// comes back as PRChangedEvent. +func (u *window) editComment(path string, number int, e commentEdit) { + v := &u.pulls.view + key := e.target.key() + u.prWrite("edit:"+key, func(err error) string { + return i18n.T("pulls.reviewThread.editFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + }, func() error { + var err error + switch e.target.kind { + case threadComment: + err = u.client.EditReviewComment(u.ctx, path, number, e.target.id, e.body) + case prComment: + err = u.client.EditPullRequestComment(u.ctx, path, number, e.target.id, e.body) + case reviewSummary: + err = u.client.EditReviewBody(u.ctx, path, number, e.target.id, e.body) + } + if err != nil { + return err + } + v.onFrame(func() { v.talk.editing[key] = false }) + return nil + }) +} + +// deleteComment removes a thread comment or a pull request comment; a review +// has no delete. +func (u *window) deleteComment(path string, number int, t commentTarget) { + u.prWrite("delete:"+t.key(), func(err error) string { + return i18n.T("pulls.reviewThread.deleteFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + }, func() error { + if t.kind == prComment { + return u.client.DeletePullRequestComment(u.ctx, path, number, t.id) + } + return u.client.DeleteReviewComment(u.ctx, path, number, t.id) + }) +} + +// layoutDeleteCommentDialog is the delete confirmation, laid out by the window +// ahead of its content so it takes Escape first. +func (u *window) layoutDeleteCommentDialog(gtx C, th *ui.Theme) { + p := u.pulls + s := &p.view.talk + if s.deleting != nil { + s.deleteShown = *s.deleting + } + t := s.deleteShown + switch { + case s.deleteYes.Clicked(gtx) && s.deleting != nil && p.selected != 0: + s.deleting = nil + go u.deleteComment(p.project.Path, p.selected, t) + case s.deleteNo.Clicked(gtx): + s.deleting = nil + } + description := i18n.T("pulls.reviewThread.deleteGone") + if t.lastInThread { + description = i18n.T("pulls.reviewThread.deleteLast") + } + pop := th.On(th.Popover) + d := th.Dialog(&s.deleteDialog, s.deleting != nil, i18n.T("pulls.reviewThread.deleteTitle"), description) + confirm := pop.Button(&s.deleteYes, i18n.T("pulls.reviewThread.delete")) + confirm.Variant = ui.VariantDestructive + back := pop.Button(&s.deleteNo, i18n.T("common.action.cancel")) + back.Variant = ui.VariantOutline + d.Actions = []layout.Widget{back.Layout, confirm.Layout} + d.Root = &u.root + if d.Layout(gtx) { + s.deleting = nil + } +} diff --git a/native/cmd/lich-native/pulls_conversation.go b/native/cmd/lich-native/pulls_conversation.go index 1828cb91d..6b929cb09 100644 --- a/native/cmd/lich-native/pulls_conversation.go +++ b/native/cmd/lich-native/pulls_conversation.go @@ -25,6 +25,15 @@ type talkState struct { sendClicks map[string]*widget.Clickable cancelClicks map[string]*widget.Clickable resolveClick map[string]*widget.Clickable + editing map[string]bool + focusEdit string + menus map[string]*ui.MenuState + moreClicks map[string]*widget.Clickable + deleting *commentTarget + deleteShown commentTarget + deleteDialog ui.DialogState + deleteYes widget.Clickable + deleteNo widget.Clickable showResolved widget.Bool resolvedTap widget.Clickable comment widget.Editor @@ -50,6 +59,7 @@ func (s *talkState) init() { s.replying, s.replyFields = map[string]bool{}, map[string]*replyField{} s.replyClicks, s.sendClicks = map[string]*widget.Clickable{}, map[string]*widget.Clickable{} s.cancelClicks, s.resolveClick = map[string]*widget.Clickable{}, map[string]*widget.Clickable{} + s.editing, s.menus, s.moreClicks = map[string]bool{}, map[string]*ui.MenuState{}, map[string]*widget.Clickable{} s.discards, s.edits = map[int]*widget.Clickable{}, map[int]*widget.Clickable{} } @@ -128,9 +138,13 @@ func (v *pullView) conversationRows(gtx C, th *ui.Theme, p *pullsScreen, d lichc for _, it := range items { switch { case it.review != nil: - rows = append(rows, v.talkEntry(th, "review:"+it.review.ID, it.review.Author, verdictLine(*it.review), it.review.Body, it.review.Date, now, act)) + r := *it.review + target := commentTarget{kind: reviewSummary, id: r.DatabaseID, body: r.Body, canEdit: r.CanEdit} + rows = append(rows, v.talkEntry(th, p, "review:"+r.ID, r.Author, verdictLine(r), target, r.Date, now, act)) case it.comment != nil: - rows = append(rows, v.talkEntry(th, "comment:"+it.comment.Date+it.comment.Author, it.comment.Author, "", it.comment.Body, it.comment.Date, now, act)) + c := *it.comment + target := commentTarget{kind: prComment, id: c.ID, body: c.Body, canEdit: c.CanEdit, canDelete: c.CanDelete} + rows = append(rows, v.talkEntry(th, p, "comment:"+c.Date+c.Author, c.Author, "", target, c.Date, now, act)) default: t := *it.thread rows = append(rows, func(gtx C) D { return v.threadCard(gtx, th, p, d, t, act, now) }) @@ -155,18 +169,26 @@ func verdictLine(r lichclient.PRReview) string { } // talkEntry is a review or a comment: who, what they decided, when, and what -// they wrote. -func (v *pullView) talkEntry(th *ui.Theme, key, author, verdict, body, date string, now time.Time, act *pullsAction) layout.Widget { - body = strings.TrimSpace(body) +// they wrote, with the ⋯ of what the viewer may do to it. +func (v *pullView) talkEntry(th *ui.Theme, p *pullsScreen, key, author, verdict string, target commentTarget, date string, now time.Time, act *pullsAction) layout.Widget { + body := strings.TrimSpace(target.body) return func(gtx C) D { + editing := v.talk.isEditing(gtx, target) return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(3), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { return byline(gtx, th, author, verdict, date, now) }), layout.Rigid(func(gtx C) D { - if body == "" { + return v.commentHead(gtx, th, p, target, func(gtx C) D { return byline(gtx, th, author, verdict, date, now) }) + }), + layout.Rigid(func(gtx C) D { + if !editing && body == "" { return D{} } - return layout.Inset{Top: ui.Space(1)}.Layout(gtx, func(gtx C) D { return v.md.layout(gtx, th, key, body, act.open) }) + return layout.Inset{Top: ui.Space(1)}.Layout(gtx, func(gtx C) D { + if editing { + return v.editBox(gtx, th, target, act) + } + return v.md.layout(gtx, th, key, body, act.open) + }) }), ) }) @@ -264,21 +286,19 @@ func (v *pullView) threadBody(gtx C, th *ui.Theme, p *pullsScreen, d lichclient. return layout.Inset{Top: ui.Space(2)}.Layout(gtx, func(gtx C) D { return hunkTail(gtx, th, t.Comments[0].DiffHunk) }) })) } + editing := false for _, c := range t.Comments { + target := threadTarget(t, c) + editing = v.talk.isEditing(gtx, target) || editing rows = append(rows, layout.Rigid(func(gtx C) D { - return layout.Inset{Top: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { return byline(gtx, th, c.Author, "", c.Date, now) }), - layout.Rigid(func(gtx C) D { - return layout.Inset{Top: ui.Space(0.5)}.Layout(gtx, func(gtx C) D { return wrapped(gtx, th.Text(ui.TextSM, c.Body)) }) - }), - ) - }) + return layout.Inset{Top: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { return v.threadComment(gtx, th, p, target, c, act, now) }) + })) + } + if !editing { + rows = append(rows, layout.Rigid(func(gtx C) D { + return layout.Inset{Top: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { return v.threadActions(gtx, th, d, t, act) }) })) } - rows = append(rows, layout.Rigid(func(gtx C) D { - return layout.Inset{Top: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { return v.threadActions(gtx, th, d, t, act) }) - })) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) } diff --git a/native/cmd/lich-native/pulls_conversation_test.go b/native/cmd/lich-native/pulls_conversation_test.go index 07cc6a164..5e0557c24 100644 --- a/native/cmd/lich-native/pulls_conversation_test.go +++ b/native/cmd/lich-native/pulls_conversation_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "image" "net/http" + "slices" "testing" "time" @@ -13,31 +14,36 @@ import ( "gioui.org/op" "gioui.org/text" + "github.com/lichdotdev/lich/native/i18n" "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) func talkFixture() *lichclient.PRConversation { return &lichclient.PRConversation{ - Reviews: []lichclient.PRReview{{Author: "rev", State: "CHANGES_REQUESTED", Body: "A couple of things.", Date: "2026-01-02T00:00:00Z"}}, - Comments: []lichclient.PRComment{{Author: "octo", Body: "Thanks!", Date: "2026-01-01T00:00:00Z"}}, + Reviews: []lichclient.PRReview{{DatabaseID: 30, Author: "rev", State: "CHANGES_REQUESTED", Body: "A couple of things.", Date: "2026-01-02T00:00:00Z", CanEdit: true}}, + Comments: []lichclient.PRComment{{ID: 20, Author: "octo", Body: "Thanks!", Date: "2026-01-01T00:00:00Z", CanEdit: true, CanDelete: true}}, Threads: []lichclient.PRReviewThread{ {ID: "T1", Path: "internal/project/pr.go", Line: 61, StartLine: 58, IsOutdated: true, Comments: []lichclient.PRThreadComment{ - {ID: 1, Author: "rev", Body: "Why not cap this?", Date: "2026-01-02T00:00:00Z", DiffHunk: "@@ -1,3 +1,4 @@\n ctx\n-old\n+new"}, + {ID: 1, Author: "rev", Body: "Why not cap this?", Date: "2026-01-02T00:00:00Z", DiffHunk: "@@ -1,3 +1,4 @@\n ctx\n-old\n+new", CanEdit: true, CanDelete: true}, }}, {ID: "T2", Path: "a.go", Line: 3, IsResolved: true, Comments: []lichclient.PRThreadComment{{ID: 2, Author: "rev", Body: "ok", Date: "2026-01-01T00:00:00Z"}}}, }, } } -// layoutTalk lays the Conversation tab out once and answers the frame. -func layoutTalk(t *testing.T, conv *lichclient.PRConversation) (*op.Ops, image.Point) { +// layoutTalk lays the Conversation tab out once and answers the frame; +// prepare, when set, puts the tab's widgets in a state first. +func layoutTalk(t *testing.T, conv *lichclient.PRConversation, prepare func(*talkState)) (*op.Ops, image.Point) { t.Helper() stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { return conv, http.StatusOK }} p := newPullsScreen(newPullsStore(context.Background(), stub.client(t), func() {}), new(ui.Root)) p.project, p.selected = lichclient.Project{Path: "/repo"}, 7 d := lichclient.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7"} p.view.pendingOf(d.URL).add("a.go", "3-5", "Rename this.") + if prepare != nil { + prepare(&p.view.talk) + } p.store.conversation("/repo", 7) waitFor(t, "the conversation", func() bool { return p.store.conversation("/repo", 7).val != nil }) th := &ui.Theme{Palette: ui.Dark, Shaper: text.NewShaper(text.WithCollection(gofont.Collection())), Surface: ui.Dark.Background} @@ -61,5 +67,40 @@ func layoutTalk(t *testing.T, conv *lichclient.PRConversation) (*op.Ops, image.P } func TestConversationTabLaysOutEveryKindOfEntry(t *testing.T) { - layoutTalk(t, talkFixture()) + layoutTalk(t, talkFixture(), nil) +} + +func TestCommentMenuOffersWhatGitHubAllows(t *testing.T) { + var s talkState + s.init() + labels := func(entries []ui.MenuEntry) []string { + var out []string + for _, e := range entries { + out = append(out, e.Label) + } + return out + } + edit, del := i18n.T("pulls.reviewThread.edit"), i18n.T("pulls.reviewThread.delete") + if got := labels(s.commentEntries(commentTarget{kind: reviewSummary, canEdit: true})); !slices.Equal(got, []string{edit}) { + t.Errorf("review summary menu = %v, want only Edit", got) + } + both := s.commentEntries(commentTarget{kind: prComment, canEdit: true, canDelete: true}) + if got := labels(both); !slices.Equal(got, []string{edit, del}) { + t.Errorf("comment menu = %v, want Edit and Delete", got) + } + if both[1].Variant != ui.VariantDestructive { + t.Error("Delete is not drawn as destructive") + } + if got := s.commentEntries(commentTarget{kind: threadComment}); got != nil { + t.Errorf("menu for a comment the viewer may not touch = %v, want none", labels(got)) + } +} + +func TestEditingAConversationEntryLaysOutEveryKind(t *testing.T) { + conv := talkFixture() + layoutTalk(t, conv, func(s *talkState) { + s.startEdit(threadTarget(conv.Threads[0], conv.Threads[0].Comments[0])) + s.startEdit(commentTarget{kind: prComment, id: 20, body: "Thanks!", canEdit: true}) + s.startEdit(commentTarget{kind: reviewSummary, id: 30, body: "A couple of things.", canEdit: true}) + }) } diff --git a/native/cmd/lich-native/pulls_submit.go b/native/cmd/lich-native/pulls_submit.go index cf60052fb..5859f602f 100644 --- a/native/cmd/lich-native/pulls_submit.go +++ b/native/cmd/lich-native/pulls_submit.go @@ -190,6 +190,9 @@ func (u *window) applyTalk(path string, number int, act pullsAction) { return nil }) } + if e := act.edit; e != nil { + go u.editComment(path, number, *e) + } if r := act.resolve; r != nil { resolve := *r go u.prWrite("resolve:"+resolve.id, func(err error) string { diff --git a/native/cmd/lich-native/pulls_view.go b/native/cmd/lich-native/pulls_view.go index 29790047c..e670a3bd4 100644 --- a/native/cmd/lich-native/pulls_view.go +++ b/native/cmd/lich-native/pulls_view.go @@ -45,6 +45,7 @@ type pullsAction struct { comment string // a comment on the pull request itself reply *threadReply resolve *threadResolve + edit *commentEdit title string // a new title for the pull request body *string // a new description; empty clears it } diff --git a/native/cmd/lich-native/stale_pulls_test.go b/native/cmd/lich-native/stale_pulls_test.go index 4127d38bc..57414cade 100644 --- a/native/cmd/lich-native/stale_pulls_test.go +++ b/native/cmd/lich-native/stale_pulls_test.go @@ -55,6 +55,7 @@ func pullsFrames(t *testing.T) (*window, *frameRig) { u.root.Layout(gtx, func(gtx C) D { u.layoutSubmitDialog(gtx, th) u.layoutMergeDialog(gtx, th) + u.layoutDeleteCommentDialog(gtx, th) u.applyPulls(u.pulls.layout(gtx, th)) return D{Size: gtx.Constraints.Max} }) @@ -136,6 +137,39 @@ func TestPullsReplyCancelClosesTheBoxInTheFrameThatTookTheClick(t *testing.T) { } } +func TestPullsCommentEditTakesTheThreadsButtonsUntilCancel(t *testing.T) { + u, r := pullsDetailShown(t) + reply := i18n.T("pulls.reviewThread.reply") + openPullsTab(t, r, tabConversation.label(), reply) + conv := talkFixture() + target := threadTarget(conv.Threads[0], conv.Threads[0].Comments[0]) + u.pulls.view.talk.startEdit(target) + r.waitFor(t, i18n.T("pulls.reviewThread.save")) + if got := u.pulls.view.talk.editorOf(target.key()).Text(); got != "Why not cap this?" { + t.Errorf("edit box = %q, want the comment as it stands", got) + } + if r.shows(reply) { + t.Error("Reply stayed under the comment being edited") + } + r.clickLabel(t, i18n.T("common.action.cancel")) + if u.pulls.view.talk.editing[target.key()] { + t.Fatal("the click missed Cancel") + } + r.waitFor(t, reply) +} + +func TestPullsDeleteDialogWarnsTheThreadGoesAndCancelCloses(t *testing.T) { + u, r := pullsDetailShown(t) + conv := talkFixture() + target := threadTarget(conv.Threads[0], conv.Threads[0].Comments[0]) + u.pulls.view.talk.deleting = &target + r.waitFor(t, i18n.T("pulls.reviewThread.deleteLast")) + r.clickLabel(t, i18n.T("common.action.cancel")) + if u.pulls.view.talk.deleting != nil { + t.Fatal("the click missed Cancel") + } +} + func TestPullsDescriptionCancelInTheFrameThatTookTheClick(t *testing.T) { u, r := pullsDetailShown(t) r.clickLabel(t, i18n.T("pulls.pullsOverview.editDescription")) diff --git a/native/i18n/locales/en/pulls.json b/native/i18n/locales/en/pulls.json index 1764df10c..44594f614 100644 --- a/native/i18n/locales/en/pulls.json +++ b/native/i18n/locales/en/pulls.json @@ -182,7 +182,18 @@ "reopen": "Reopen", "resolve": "Resolve", "reply": "Reply", - "replyPlaceholder": "Reply to this thread" + "replyPlaceholder": "Reply to this thread", + "edit": "Edit", + "save": "Save", + "saving": "Saving…", + "editFailed": "Couldn't update the comment: {error}", + "delete": "Delete", + "deleteFailed": "Couldn't delete the comment: {error}", + "deleteTitle": "Delete this comment?", + "deleteGone": "GitHub can't bring it back.", + "deleteLast": "It's the only comment in this thread, so the thread goes too. GitHub can't bring it back.", + "verdictStays": "The verdict stays as it is.", + "more": "Comment options" }, "pulls": { "alreadyRemoved": "{name} was already removed", diff --git a/native/i18n/locales/es/pulls.json b/native/i18n/locales/es/pulls.json index 933519aee..6fe309778 100644 --- a/native/i18n/locales/es/pulls.json +++ b/native/i18n/locales/es/pulls.json @@ -182,7 +182,18 @@ "reopen": "Reabrir", "resolve": "Resolver", "reply": "Responder", - "replyPlaceholder": "Responde a este hilo" + "replyPlaceholder": "Responde a este hilo", + "edit": "Editar", + "save": "Guardar", + "saving": "Guardando…", + "editFailed": "No se pudo actualizar el comentario: {error}", + "delete": "Eliminar", + "deleteFailed": "No se pudo eliminar el comentario: {error}", + "deleteTitle": "¿Eliminar este comentario?", + "deleteGone": "GitHub no puede recuperarlo.", + "deleteLast": "Es el único comentario de este hilo, así que el hilo también desaparece. GitHub no puede recuperarlo.", + "verdictStays": "El veredicto no cambia.", + "more": "Opciones del comentario" }, "pulls": { "alreadyRemoved": "{name} ya se había eliminado", diff --git a/native/i18n/locales/ja/pulls.json b/native/i18n/locales/ja/pulls.json index 08d1b4eea..0b5bc7157 100644 --- a/native/i18n/locales/ja/pulls.json +++ b/native/i18n/locales/ja/pulls.json @@ -181,7 +181,18 @@ "reopen": "再オープン", "resolve": "解決", "reply": "返信", - "replyPlaceholder": "このスレッドに返信" + "replyPlaceholder": "このスレッドに返信", + "edit": "編集", + "save": "保存", + "saving": "保存中…", + "editFailed": "コメントを更新できませんでした: {error}", + "delete": "削除", + "deleteFailed": "コメントを削除できませんでした: {error}", + "deleteTitle": "このコメントを削除しますか?", + "deleteGone": "GitHub では元に戻せません。", + "deleteLast": "このスレッドの唯一のコメントなので、スレッドも削除されます。GitHub では元に戻せません。", + "verdictStays": "判定は変わりません。", + "more": "コメントのオプション" }, "pulls": { "alreadyRemoved": "{name} はすでに削除されています", diff --git a/native/i18n/locales/ko/pulls.json b/native/i18n/locales/ko/pulls.json index a09f58958..8546d8f10 100644 --- a/native/i18n/locales/ko/pulls.json +++ b/native/i18n/locales/ko/pulls.json @@ -177,7 +177,18 @@ "reopen": "다시 열기", "resolve": "해결", "reply": "답글", - "replyPlaceholder": "이 스레드에 답글" + "replyPlaceholder": "이 스레드에 답글", + "edit": "편집", + "save": "저장", + "saving": "저장 중…", + "editFailed": "댓글을 업데이트하지 못했습니다: {error}", + "delete": "삭제", + "deleteFailed": "댓글을 삭제하지 못했습니다: {error}", + "deleteTitle": "이 댓글을 삭제할까요?", + "deleteGone": "GitHub에서 되돌릴 수 없습니다.", + "deleteLast": "이 스레드의 유일한 댓글이라 스레드도 함께 사라집니다. GitHub에서 되돌릴 수 없습니다.", + "verdictStays": "판정은 그대로 유지됩니다.", + "more": "댓글 옵션" }, "pulls": { "alreadyRemoved": "{name}은(는) 이미 삭제되었습니다", diff --git a/native/i18n/locales/pt-br/pulls.json b/native/i18n/locales/pt-br/pulls.json index 60705f90f..4b1df8283 100644 --- a/native/i18n/locales/pt-br/pulls.json +++ b/native/i18n/locales/pt-br/pulls.json @@ -182,7 +182,18 @@ "reopen": "Reabrir", "resolve": "Resolver", "reply": "Responder", - "replyPlaceholder": "Responder a esta thread" + "replyPlaceholder": "Responder a esta thread", + "edit": "Editar", + "save": "Salvar", + "saving": "Salvando…", + "editFailed": "Não foi possível atualizar o comentário: {error}", + "delete": "Excluir", + "deleteFailed": "Não foi possível excluir o comentário: {error}", + "deleteTitle": "Excluir este comentário?", + "deleteGone": "O GitHub não tem como recuperá-lo.", + "deleteLast": "É o único comentário desta thread, então a thread some junto. O GitHub não tem como recuperá-lo.", + "verdictStays": "O veredito continua o mesmo.", + "more": "Opções do comentário" }, "pulls": { "alreadyRemoved": "{name} já foi removido", diff --git a/native/i18n/locales/ru/pulls.json b/native/i18n/locales/ru/pulls.json index b9adadc9e..5c715a46e 100644 --- a/native/i18n/locales/ru/pulls.json +++ b/native/i18n/locales/ru/pulls.json @@ -192,7 +192,18 @@ "reopen": "Открыть снова", "resolve": "Решено", "reply": "Ответить", - "replyPlaceholder": "Ответьте в этом обсуждении" + "replyPlaceholder": "Ответьте в этом обсуждении", + "edit": "Изменить", + "save": "Сохранить", + "saving": "Сохранение…", + "editFailed": "Не удалось обновить комментарий: {error}", + "delete": "Удалить", + "deleteFailed": "Не удалось удалить комментарий: {error}", + "deleteTitle": "Удалить этот комментарий?", + "deleteGone": "GitHub не сможет его вернуть.", + "deleteLast": "Это единственный комментарий в обсуждении, поэтому обсуждение тоже исчезнет. GitHub не сможет его вернуть.", + "verdictStays": "Вердикт останется прежним.", + "more": "Действия с комментарием" }, "pulls": { "alreadyRemoved": "{name} уже удалён", diff --git a/native/i18n/locales/zh-cn/pulls.json b/native/i18n/locales/zh-cn/pulls.json index e4c0b3c37..44a2ea49e 100644 --- a/native/i18n/locales/zh-cn/pulls.json +++ b/native/i18n/locales/zh-cn/pulls.json @@ -181,7 +181,18 @@ "reopen": "重新打开", "resolve": "标记为已解决", "reply": "回复", - "replyPlaceholder": "回复此讨论串" + "replyPlaceholder": "回复此讨论串", + "edit": "编辑", + "save": "保存", + "saving": "正在保存…", + "editFailed": "无法更新评论:{error}", + "delete": "删除", + "deleteFailed": "无法删除评论:{error}", + "deleteTitle": "删除这条评论?", + "deleteGone": "GitHub 无法恢复它。", + "deleteLast": "这是该讨论串中唯一的评论,因此讨论串也会一并删除。GitHub 无法恢复它。", + "verdictStays": "结论保持不变。", + "more": "评论选项" }, "pulls": { "alreadyRemoved": "{name} 已被移除", diff --git a/native/lichclient/pulls.go b/native/lichclient/pulls.go index 22ce97282..00437059c 100644 --- a/native/lichclient/pulls.go +++ b/native/lichclient/pulls.go @@ -231,18 +231,23 @@ type PRConversation struct { // PRReview mirrors project.PRReview. type PRReview struct { - ID string `json:"id"` - Author string `json:"author"` - State string `json:"state"` - Body string `json:"body"` - Date string `json:"date"` + ID string `json:"id"` + DatabaseID int64 `json:"databaseId"` + Author string `json:"author"` + State string `json:"state"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` } // PRComment mirrors project.PRComment. type PRComment struct { - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` } // PRReviewThread mirrors project.PRReviewThread. @@ -259,11 +264,13 @@ type PRReviewThread struct { // PRThreadComment mirrors project.PRThreadComment. type PRThreadComment struct { - ID int64 `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` - DiffHunk string `json:"diffHunk"` + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Date string `json:"date"` + DiffHunk string `json:"diffHunk"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` } func (c *Client) PullRequestConversation(ctx context.Context, path string, number int) (*PRConversation, error) { @@ -276,6 +283,21 @@ func (c *Client) ReplyToReviewThread(ctx context.Context, path string, number in return c.Call(ctx, "project.ReplyToReviewThread", nil, path, number, commentID, body) } +// EditReviewComment rewrites thread comment commentID. +func (c *Client) EditReviewComment(ctx context.Context, path string, number int, commentID int64, body string) error { + return c.Call(ctx, "project.EditReviewComment", nil, path, number, commentID, body) +} + +// DeleteReviewComment removes thread comment commentID. +func (c *Client) DeleteReviewComment(ctx context.Context, path string, number int, commentID int64) error { + return c.Call(ctx, "project.DeleteReviewComment", nil, path, number, commentID) +} + +// EditReviewBody rewrites the summary of review reviewID (its database id). +func (c *Client) EditReviewBody(ctx context.Context, path string, number int, reviewID int64, body string) error { + return c.Call(ctx, "project.EditReviewBody", nil, path, number, reviewID, body) +} + // ResolveReviewThread resolves thread threadID, or reopens it. func (c *Client) ResolveReviewThread(ctx context.Context, path, threadID string, resolved bool) error { return c.Call(ctx, "project.ResolveReviewThread", nil, path, threadID, resolved) @@ -286,6 +308,16 @@ func (c *Client) CommentOnPullRequest(ctx context.Context, path string, number i return c.Call(ctx, "project.CommentOnPullRequest", nil, path, number, body) } +// EditPullRequestComment rewrites pull request comment commentID. +func (c *Client) EditPullRequestComment(ctx context.Context, path string, number int, commentID int64, body string) error { + return c.Call(ctx, "project.EditPullRequestComment", nil, path, number, commentID, body) +} + +// DeletePullRequestComment removes pull request comment commentID. +func (c *Client) DeletePullRequestComment(ctx context.Context, path string, number int, commentID int64) error { + return c.Call(ctx, "project.DeletePullRequestComment", nil, path, number, commentID) +} + // EditPullRequestTitle renames the pull request. func (c *Client) EditPullRequestTitle(ctx context.Context, path string, number int, title string) error { return c.Call(ctx, "project.EditPullRequestTitle", nil, path, number, title)