Skip to content

Commit 1b1cfc2

Browse files
committed
feat(web): enhance PR comment review UI
1 parent 53cb47a commit 1b1cfc2

9 files changed

Lines changed: 627 additions & 156 deletions

File tree

internal/server/github_comments.go

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,52 @@ type ghAuthor struct {
6060
}
6161

6262
type githubPullResponse struct {
63-
Head struct {
64-
SHA string `json:"sha"`
63+
Title string `json:"title"`
64+
State string `json:"state"`
65+
Draft bool `json:"draft"`
66+
Merged bool `json:"merged"`
67+
CreatedAt time.Time `json:"created_at"`
68+
UpdatedAt time.Time `json:"updated_at"`
69+
Additions int `json:"additions"`
70+
Deletions int `json:"deletions"`
71+
Changed int `json:"changed_files"`
72+
Commits int `json:"commits"`
73+
User *ghAuthor `json:"user"`
74+
Head struct {
75+
SHA string `json:"sha"`
76+
Ref string `json:"ref"`
77+
Label string `json:"label"`
78+
Repo struct {
79+
FullName string `json:"full_name"`
80+
} `json:"repo"`
6581
} `json:"head"`
82+
Base struct {
83+
Ref string `json:"ref"`
84+
Label string `json:"label"`
85+
Repo struct {
86+
FullName string `json:"full_name"`
87+
} `json:"repo"`
88+
} `json:"base"`
89+
}
90+
91+
type pullRequestInfoResponse struct {
92+
Title string `json:"title"`
93+
State string `json:"state"`
94+
Draft bool `json:"draft"`
95+
Merged bool `json:"merged"`
96+
Author string `json:"author"`
97+
CreatedAt time.Time `json:"createdAt"`
98+
UpdatedAt time.Time `json:"updatedAt"`
99+
Additions int `json:"additions"`
100+
Deletions int `json:"deletions"`
101+
ChangedFiles int `json:"changedFiles"`
102+
Commits int `json:"commits"`
103+
HeadRef string `json:"headRef"`
104+
HeadLabel string `json:"headLabel"`
105+
HeadRepo string `json:"headRepo"`
106+
BaseRef string `json:"baseRef"`
107+
BaseLabel string `json:"baseLabel"`
108+
BaseRepo string `json:"baseRepo"`
66109
}
67110

68111
type githubCreatedComment struct {
@@ -243,23 +286,57 @@ func (s *Server) findPullRequestThread(ctx context.Context, org, repo, number st
243286
}
244287

245288
func (s *Server) pullRequestHeadSHA(ctx context.Context, org, repo, number string) (string, error) {
289+
response, err := s.pullRequest(ctx, org, repo, number)
290+
if err != nil {
291+
return "", err
292+
}
293+
if response.Head.SHA == "" {
294+
return "", errors.New("pull request head sha is missing")
295+
}
296+
return response.Head.SHA, nil
297+
}
298+
299+
func (s *Server) pullRequestInfo(ctx context.Context, org, repo, number string) (pullRequestInfoResponse, error) {
300+
response, err := s.pullRequest(ctx, org, repo, number)
301+
if err != nil {
302+
return pullRequestInfoResponse{}, err
303+
}
304+
return pullRequestInfoResponse{
305+
Title: response.Title,
306+
State: response.State,
307+
Draft: response.Draft,
308+
Merged: response.Merged,
309+
Author: commentAuthor(githubReviewComment{Author: response.User}),
310+
CreatedAt: response.CreatedAt,
311+
UpdatedAt: response.UpdatedAt,
312+
Additions: response.Additions,
313+
Deletions: response.Deletions,
314+
ChangedFiles: response.Changed,
315+
Commits: response.Commits,
316+
HeadRef: response.Head.Ref,
317+
HeadLabel: response.Head.Label,
318+
HeadRepo: response.Head.Repo.FullName,
319+
BaseRef: response.Base.Ref,
320+
BaseLabel: response.Base.Label,
321+
BaseRepo: response.Base.Repo.FullName,
322+
}, nil
323+
}
324+
325+
func (s *Server) pullRequest(ctx context.Context, org, repo, number string) (githubPullResponse, error) {
246326
out, err := s.ghOutput(ctx, "gh api pull request",
247327
"api",
248328
fmt.Sprintf("repos/%s/%s/pulls/%s", org, repo, number),
249329
"--hostname",
250330
s.githubHost,
251331
)
252332
if err != nil {
253-
return "", err
333+
return githubPullResponse{}, err
254334
}
255335
var response githubPullResponse
256336
if err := json.Unmarshal([]byte(out), &response); err != nil {
257-
return "", err
337+
return githubPullResponse{}, err
258338
}
259-
if response.Head.SHA == "" {
260-
return "", errors.New("pull request head sha is missing")
261-
}
262-
return response.Head.SHA, nil
339+
return response, nil
263340
}
264341

265342
func convertGitHubThread(thread githubReviewThread) (comments.Thread, bool) {

internal/server/server.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func New(cfg Config) (http.Handler, error) {
135135
mux.HandleFunc("POST /api/comments/{threadID}/replies", s.handleReplyComment)
136136
mux.HandleFunc("POST /api/comments/{threadID}/resolve", s.handleResolveComment)
137137
mux.HandleFunc("POST /api/comments/{threadID}/reopen", s.handleReopenComment)
138+
mux.HandleFunc("GET /api/pull/{org}/{repo}/{number}", s.handlePullRequestInfo)
138139
mux.HandleFunc("GET /api/patch/{org}/{repo}/{number}", s.handlePatch)
139140
mux.HandleFunc("/", s.handleStatic)
140141
return mux, nil
@@ -177,6 +178,19 @@ func (s *Server) handleLocalDiff(w http.ResponseWriter, r *http.Request) {
177178
_, _ = io.WriteString(w, patch)
178179
}
179180

181+
func (s *Server) handlePullRequestInfo(w http.ResponseWriter, r *http.Request) {
182+
org, repo, number, ok := prPathValues(w, r)
183+
if !ok {
184+
return
185+
}
186+
info, err := s.pullRequestInfo(r.Context(), org, repo, number)
187+
if err != nil {
188+
writeError(w, http.StatusBadGateway, err)
189+
return
190+
}
191+
writeJSON(w, http.StatusOK, info)
192+
}
193+
180194
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
181195
if target, ok := s.commentTarget(w, r); ok && !target.local {
182196
threads, err := s.listPullRequestComments(r.Context(), target.org, target.repo, target.number)

internal/server/server_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,75 @@ func TestGitHubCommentsAPIListsReviewThreads(t *testing.T) {
659659
}
660660
}
661661

662+
func TestGitHubPullRequestInfo(t *testing.T) {
663+
restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) {
664+
if strings.Contains(strings.Join(args, " "), "repos/org/repo/pulls/123") {
665+
return []byte(`{
666+
"title": "Add compact PR header",
667+
"state": "open",
668+
"draft": false,
669+
"merged": false,
670+
"user": {"login": "octocat"},
671+
"created_at": "2026-05-22T12:00:00Z",
672+
"updated_at": "2026-05-23T12:00:00Z",
673+
"additions": 10,
674+
"deletions": 2,
675+
"changed_files": 3,
676+
"commits": 4,
677+
"head": {
678+
"sha": "abc123",
679+
"ref": "feature",
680+
"label": "contrib:feature",
681+
"repo": {"full_name": "contrib/repo"}
682+
},
683+
"base": {
684+
"ref": "main",
685+
"label": "org:main",
686+
"repo": {"full_name": "org/repo"}
687+
}
688+
}`), nil
689+
}
690+
t.Fatalf("unexpected gh args: %v", args)
691+
return nil, nil
692+
})
693+
defer restore()
694+
695+
handler, err := New(Config{CWD: t.TempDir()})
696+
if err != nil {
697+
t.Fatalf("New() error = %v", err)
698+
}
699+
req := httptest.NewRequest(http.MethodGet, "/api/pull/org/repo/123", nil)
700+
rec := httptest.NewRecorder()
701+
handler.ServeHTTP(rec, req)
702+
if rec.Code != http.StatusOK {
703+
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
704+
}
705+
var got struct {
706+
Title string `json:"title"`
707+
State string `json:"state"`
708+
Author string `json:"author"`
709+
Additions int `json:"additions"`
710+
Deletions int `json:"deletions"`
711+
ChangedFiles int `json:"changedFiles"`
712+
Commits int `json:"commits"`
713+
HeadRef string `json:"headRef"`
714+
HeadLabel string `json:"headLabel"`
715+
HeadRepo string `json:"headRepo"`
716+
BaseRef string `json:"baseRef"`
717+
BaseLabel string `json:"baseLabel"`
718+
BaseRepo string `json:"baseRepo"`
719+
}
720+
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
721+
t.Fatal(err)
722+
}
723+
if got.Title != "Add compact PR header" || got.State != "open" || got.Author != "octocat" ||
724+
got.Additions != 10 || got.Deletions != 2 || got.ChangedFiles != 3 || got.Commits != 4 ||
725+
got.HeadRef != "feature" || got.HeadLabel != "contrib:feature" || got.HeadRepo != "contrib/repo" ||
726+
got.BaseRef != "main" || got.BaseLabel != "org:main" || got.BaseRepo != "org/repo" {
727+
t.Fatalf("unexpected pull request info: %+v", got)
728+
}
729+
}
730+
662731
func TestGitHubCommentsAPICreatesReviewComment(t *testing.T) {
663732
var createdArgs []string
664733
restore := stubGH(t, func(_ context.Context, args ...string) ([]byte, error) {

0 commit comments

Comments
 (0)