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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type App struct {
AllDiagnostics map[string][]ui.Diagnostic
Keybindings []config.KeyBinding
LspNotified map[string]bool
CommentPanel *ui.CommentPanelWidget
Reg *command.Registry
Running *bool
quitPending bool
Expand Down
5 changes: 5 additions & 0 deletions internal/app/commands_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,9 @@ func registerPRCommands(app *App) {
Keywords: []string{"git", "pull request", "github"},
Handler: func() { app.Changes.RemovePRGroups() },
})
reg.Register(command.Command{
ID: "pr.showComments", Title: "Git: Show PR Comments",
Keywords: []string{"git", "pull request", "github", "comments", "review"},
Handler: app.ShowPRCommentsDialog,
})
}
34 changes: 34 additions & 0 deletions internal/app/eventloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,40 @@ func RunEventLoop(
dv.FinishLoading()
}
}
case *PRCommentsResult:
if v.Err != nil {
app.StatusError("Failed to fetch PR comments: " + v.Err.Error())
if app.CommentPanel != nil {
app.CommentPanel.Loading = false
}
} else {
if app.CommentPanel != nil {
var items []ui.CommentItem
for _, c := range v.Comments {
items = append(items, ui.CommentItem{
ID: c.ID,
Author: c.User,
Timestamp: c.CreatedAt,
Body: c.Body,
FilePath: c.Path,
Line: c.Line,
IsInline: c.IsInline,
InReplyTo: c.InReplyTo,
})
}
app.CommentPanel.SetComments(items)
count := len(items)
app.StatusNotify(fmt.Sprintf("Loaded %d comment(s)", count))
}
}
case *PRCommentSubmitResult:
if v.Err != nil {
app.StatusError("Failed to submit comment: " + v.Err.Error())
} else {
app.StatusNotify("Comment submitted")
// Refresh comments
app.FetchPRComments(v.Owner, v.Repo, v.Number, v.Title)
}
case *PrFetchResult:
app.Changes.Loading = false
if v.Err != nil {
Expand Down
144 changes: 144 additions & 0 deletions internal/app/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"

"github.com/eugenioenko/ttt/internal/github"
"github.com/eugenioenko/ttt/internal/ui"

"github.com/gdamore/tcell/v2"
)
Expand All @@ -22,6 +23,25 @@ type DiffContentResult struct {
Err error
}

// PRCommentsResult carries async comment fetch results back to the event loop.
type PRCommentsResult struct {
Owner string
Repo string
Number int
Title string
Comments []github.PRComment
Err error
}

// PRCommentSubmitResult carries async comment submission results.
type PRCommentSubmitResult struct {
Owner string
Repo string
Number int
Title string
Err error
}

func (a *App) FetchAndOpenPR(url string) {
owner, repo, number, err := github.ParsePRURL(url)
if err != nil {
Expand Down Expand Up @@ -49,3 +69,127 @@ func (a *App) FetchAndOpenPR(url string) {
a.Screen.PostEvent(tcell.NewEventInterrupt(&PrFetchResult{URL: url, Info: info, Diffs: diffs}))
}()
}

// FetchPRComments fetches PR comments asynchronously and shows them in the panel.
func (a *App) FetchPRComments(owner, repo string, number int, title string) {
// If a panel is already open, reuse it (just refresh comments)
if a.CommentPanel != nil {
a.CommentPanel.Loading = true
} else {
// Create and show the panel immediately with loading state
panel := ui.NewCommentPanelWidget(fmt.Sprintf("PR #%d: %s", number, title))
panel.Borders = a.Borders
panel.Loading = true
panel.OnClose = func() {
a.DismissCommentPanel()
}
panel.OnOpenFile = func(path string, line int) {
a.EditorGroup.OpenFile(path)
if line > 0 {
a.EditorGroup.GoToLine(line)
}
a.Root.SetFocus(a.EditorGroup)
}
panel.OnSubmit = func(body string) {
a.submitPRComment(owner, repo, number, title, body)
}
a.CommentPanel = panel
a.Root.PushOverlay(ui.Overlay{Widget: panel, Modal: false})
a.Root.SetFocus(panel)
}

// Fetch comments in background
go func() {
comments, err := github.FetchPRComments(owner, repo, number)
a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentsResult{
Owner: owner,
Repo: repo,
Number: number,
Title: title,
Comments: comments,
Err: err,
}))
}()
}

// submitPRComment sends a comment to a PR and refreshes the thread.
func (a *App) submitPRComment(owner, repo string, number int, title, body string) {
a.StatusNotify("Submitting comment...")
go func() {
err := github.AddPRComment(owner, repo, number, body)
a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentSubmitResult{
Owner: owner,
Repo: repo,
Number: number,
Title: title,
Err: err,
}))
}()
}

// DismissCommentPanel removes the comment panel overlay.
func (a *App) DismissCommentPanel() {
if a.CommentPanel != nil {
a.Root.PopOverlay()
a.CommentPanel = nil
a.FocusEditor()
}
}

// ShowPRCommentsForGroup opens the comment panel for a PR group.
func (a *App) ShowPRCommentsForGroup() {
// Find the first PR group in changes
for _, g := range a.Changes.Groups {
if g.IsPR {
owner := g.PROwner
repo := g.PRRepo
// Parse number from group name
var number int
fmt.Sscanf(g.Name, "PR #%d:", &number)
if number == 0 {
continue
}
title := g.Name
a.FetchPRComments(owner, repo, number, title)
return
}
}
a.StatusWarn("No PR open. Use 'Git: Review PR' first.")
}

// ShowPRCommentsDialog prompts for a PR URL and opens comments.
func (a *App) ShowPRCommentsDialog() {
if a.Root.HasOverlay() {
return
}
if !github.IsGHInstalled() {
a.StatusError("GitHub CLI (gh) is required. Install from https://cli.github.com/")
return
}
// If there's already a PR open, show its comments directly
for _, g := range a.Changes.Groups {
if g.IsPR {
a.ShowPRCommentsForGroup()
return
}
}
// Otherwise prompt for URL
dialog := ui.NewInputDialogWidget("PR Comments", "https://github.com/owner/repo/pull/123", "")
dialog.ConfirmLabel = "Show Comments"
dialog.Borders = a.Borders
dialog.OnSubmit = func(url string) {
a.DismissDialog()
if url != "" {
owner, repo, number, err := github.ParsePRURL(url)
if err != nil {
a.StatusError("Invalid PR URL: " + err.Error())
return
}
a.FetchPRComments(owner, repo, number, fmt.Sprintf("PR #%d", number))
}
}
dialog.OnDismiss = func() {
a.DismissDialog()
}
a.ShowDialog(dialog)
}
111 changes: 111 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,117 @@ func FetchFileContent(owner, repo, path, ref string) (string, error) {
return string(out), nil
}

// PRComment represents a single comment on a pull request.
type PRComment struct {
ID int
Body string
User string
CreatedAt string
Path string // non-empty for inline/review comments
Line int // line number for inline comments
IsInline bool
InReplyTo int // ID of parent comment for threaded replies
}

// FetchPRComments fetches both review (inline) comments and general issue
// comments for a pull request and returns them merged chronologically.
func FetchPRComments(owner, repo string, number int) ([]PRComment, error) {
var comments []PRComment

// Fetch inline/review comments
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number)
cmd := exec.Command("gh", "api", endpoint, "--paginate",
"--jq", `[.[] | {id: .id, body: .body, user: .user.login, created_at: .created_at, path: .path, line: (.line // .original_line // 0), in_reply_to_id: (.in_reply_to_id // 0)}]`)
out, err := cmd.Output()
if err == nil && len(out) > 0 {
var items []struct {
ID int `json:"id"`
Body string `json:"body"`
User string `json:"user"`
CreatedAt string `json:"created_at"`
Path string `json:"path"`
Line int `json:"line"`
InReplyToID int `json:"in_reply_to_id"`
}
if err := json.Unmarshal(out, &items); err == nil {
for _, item := range items {
comments = append(comments, PRComment{
ID: item.ID,
Body: item.Body,
User: item.User,
CreatedAt: item.CreatedAt,
Path: item.Path,
Line: item.Line,
IsInline: true,
InReplyTo: item.InReplyToID,
})
}
}
}

// Fetch general issue comments
endpoint = fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number)
cmd = exec.Command("gh", "api", endpoint, "--paginate",
"--jq", `[.[] | {id: .id, body: .body, user: .user.login, created_at: .created_at}]`)
out, err = cmd.Output()
if err == nil && len(out) > 0 {
var items []struct {
ID int `json:"id"`
Body string `json:"body"`
User string `json:"user"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal(out, &items); err == nil {
for _, item := range items {
comments = append(comments, PRComment{
ID: item.ID,
Body: item.Body,
User: item.User,
CreatedAt: item.CreatedAt,
IsInline: false,
})
}
}
}

// Sort by creation time
sortCommentsByTime(comments)
return comments, nil
}

func sortCommentsByTime(comments []PRComment) {
for i := 1; i < len(comments); i++ {
for j := i; j > 0 && comments[j].CreatedAt < comments[j-1].CreatedAt; j-- {
comments[j], comments[j-1] = comments[j-1], comments[j]
}
}
}

// AddPRComment adds a general comment to a pull request.
func AddPRComment(owner, repo string, number int, body string) error {
endpoint := fmt.Sprintf("repos/%s/%s/issues/%d/comments", owner, repo, number)
cmd := exec.Command("gh", "api", endpoint, "-X", "POST", "-f", "body="+body)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("add comment failed: %s", string(out))
}
return nil
}

// AddPRReviewComment adds an inline review comment to a pull request.
func AddPRReviewComment(owner, repo string, number int, body, path string, line int, commitSHA string) error {
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number)
cmd := exec.Command("gh", "api", endpoint, "-X", "POST",
"-f", "body="+body,
"-f", "path="+path,
"-F", fmt.Sprintf("line=%d", line),
"-f", "commit_id="+commitSHA,
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("add review comment failed: %s", string(out))
}
return nil
}

func SplitMultiFileDiff(unified string) map[string]string {
result := make(map[string]string)
lines := strings.Split(unified, "\n")
Expand Down
Loading
Loading