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 @@ -41,6 +41,7 @@ type App struct {
Explorer *ui.ExplorerWidget
Search *ui.SearchWidget
Changes *ui.ChangesWidget
Reviews *ui.ReviewsWidget
MenuBar *ui.MenuBarWidget
StatusBar *ui.StatusBarWidget
Status *view.StatusBar
Expand Down
31 changes: 31 additions & 0 deletions internal/app/callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ func (a *App) ShowSidebarMoreMenu(sx, sy int) {
ui.MenuSep(),
{Label: "Help", Command: "changes.help"},
}
case "reviews":
items = []ui.ContextMenuItem{
{Label: "Refresh", Command: "reviews.refresh"},
}
}
if len(items) > 0 {
openContextMenu(a, items, sx, sy)
Expand Down Expand Up @@ -513,6 +517,33 @@ func registerWidgetCallbacks(app *App) {
app.Changes.OnCommit = app.CommitChanges
app.Changes.OnConfirmDiscard = app.ConfirmDiscard

app.Reviews.OnOpenFile = func(path string, line int) {
app.EditorGroup.OpenFile(path)
if line > 0 {
app.EditorGroup.GoToLine(line)
}
app.Root.SetFocus(app.EditorGroup)
}
app.Reviews.OnAddComment = func(body string) {
owner := app.Reviews.Owner
repo := app.Reviews.Repo
number := app.Reviews.Number
if owner == "" || number == 0 {
app.StatusWarn("No PR loaded for comments")
return
}
go func() {
err := github.AddPRComment(owner, repo, number, body)
app.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentAddResult{
Owner: owner,
Repo: repo,
Number: number,
Err: err,
}))
}()
app.StatusNotify("Posting comment...")
}

app.ContentSplit.OnResize = func(height int) {
if height <= 0 {
app.ContentSplit.ShowBottom = false
Expand Down
6 changes: 6 additions & 0 deletions internal/app/commands_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ func registerGitCommands(app *App) {
Handler: func() { app.Changes.Refresh() },
})

reg.Register(command.Command{
ID: "reviews.refresh", Title: "PR: Refresh Comments",
Keywords: []string{"pr", "reviews", "comments", "reload"},
Handler: func() { app.RefreshPRComments() },
})

reg.Register(command.Command{
ID: "changes.stage", Title: "Git: Stage File",
Keywords: []string{"git", "changes", "add"},
Expand Down
18 changes: 18 additions & 0 deletions internal/app/eventloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,24 @@ func RunEventLoop(
app.Root.SetFocus(app.Changes)
app.Sidebar.SetPanelDirty("changes", app.Changes.TotalChanges() > 0)
app.StatusNotify(fmt.Sprintf("Opened PR #%d: %s (%d files)", v.Info.Number, v.Info.Title, len(v.Info.Files)))
// Also fetch comments for the PR.
app.Reviews.SetPR(v.Info.Owner, v.Info.Repo, v.Info.Number)
app.FetchPRComments(v.Info.Owner, v.Info.Repo, v.Info.Number)
}
case *PRCommentsFetchResult:
app.Reviews.Loading = false
if v.Err != nil {
app.StatusError("Failed to fetch PR comments: " + v.Err.Error())
} else {
app.Reviews.SetComments(v.Comments)
app.Sidebar.SetPanelDirty("reviews", len(v.Comments) > 0)
}
case *PRCommentAddResult:
if v.Err != nil {
app.StatusError("Failed to post comment: " + v.Err.Error())
} else {
app.StatusNotify("Comment posted")
app.RefreshPRComments()
}
}
redraw()
Expand Down
41 changes: 41 additions & 0 deletions internal/app/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,47 @@ type DiffContentResult struct {
Err error
}

// PRCommentsFetchResult delivers fetched PR comments to the event loop.
type PRCommentsFetchResult struct {
Owner string
Repo string
Number int
Comments []github.PRComment
Err error
}

// PRCommentAddResult delivers the result of posting a new comment.
type PRCommentAddResult struct {
Owner string
Repo string
Number int
Err error
}

// FetchPRComments fetches comments for a PR asynchronously and posts the
// result back to the event loop.
func (a *App) FetchPRComments(owner, repo string, number int) {
a.Reviews.Loading = true
go func() {
comments, err := github.FetchPRComments(owner, repo, number)
a.Screen.PostEvent(tcell.NewEventInterrupt(&PRCommentsFetchResult{
Owner: owner,
Repo: repo,
Number: number,
Comments: comments,
Err: err,
}))
}()
}

// RefreshPRComments re-fetches comments for the currently loaded PR.
func (a *App) RefreshPRComments() {
if a.Reviews.Owner == "" || a.Reviews.Number == 0 {
return
}
a.FetchPRComments(a.Reviews.Owner, a.Reviews.Repo, a.Reviews.Number)
}

func (a *App) FetchAndOpenPR(url string) {
owner, repo, number, err := github.ParsePRURL(url)
if err != nil {
Expand Down
53 changes: 28 additions & 25 deletions internal/app/widgets.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package app

import (
"os"
"path/filepath"
"strings"
"github.com/eugenioenko/ttt/internal/config"
"github.com/eugenioenko/ttt/internal/github"
"github.com/eugenioenko/ttt/internal/term"
"github.com/eugenioenko/ttt/internal/ui"
"github.com/eugenioenko/ttt/internal/view"
"github.com/eugenioenko/ttt/internal/workspace"
"os"
"path/filepath"
"strings"
)

func isPRURL(arg string) bool {
Expand Down Expand Up @@ -150,11 +150,13 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work
search.SetWorkDirs(ws.Paths())
search.Debounce.DelayMs = cfg.Settings.Search.Debounce
changes := ui.NewChangesWidget(ws.Paths()...)
reviews := ui.NewReviewsWidget()

sidebar := ui.NewSidebarWidget()
sidebar.AddPanel("explorer", "Explore", explorer)
sidebar.AddPanel("search", "Find", search)
sidebar.AddPanel("changes", "Changes", changes)
sidebar.AddPanel("reviews", "Reviews", reviews)
hasFolders := len(ws.Paths()) > 0
sidebar.Visible = hasFolders
sidebar.Borders = borders
Expand All @@ -177,27 +179,28 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work
root.SetFocus(editorGroup)

return &App{
Root: root,
EditorGroup: editorGroup,
Sidebar: sidebar,
SplitPanel: splitPanel,
ContentSplit: contentSplit,
BottomPanel: bottomPanel,
Explorer: explorer,
Search: search,
Changes: changes,
MenuBar: menuBar,
StatusBar: statusBar,
Status: status,
Borders: borders,
Settings: &cfg.Settings,
Workspace: ws,
Palette: BuildTerminalPalettePtr(cfg.Theme),
TerminalPanel: terminalPanel,
Problems: problems,
References: references,
DocVersions: make(map[string]int),
AllDiagnostics: make(map[string][]ui.Diagnostic),
LspNotified: make(map[string]bool),
Root: root,
EditorGroup: editorGroup,
Sidebar: sidebar,
SplitPanel: splitPanel,
ContentSplit: contentSplit,
BottomPanel: bottomPanel,
Explorer: explorer,
Search: search,
Changes: changes,
Reviews: reviews,
MenuBar: menuBar,
StatusBar: statusBar,
Status: status,
Borders: borders,
Settings: &cfg.Settings,
Workspace: ws,
Palette: BuildTerminalPalettePtr(cfg.Theme),
TerminalPanel: terminalPanel,
Problems: problems,
References: references,
DocVersions: make(map[string]int),
AllDiagnostics: make(map[string][]ui.Diagnostic),
LspNotified: make(map[string]bool),
}
}
120 changes: 120 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,126 @@ 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
Line int
IsInline bool
}

// FetchPRComments fetches both inline review comments and general issue comments
// for the given pull request.
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")
out, err := cmd.Output()
if err == nil && len(out) > 0 {
var reviewComments []struct {
ID int `json:"id"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
CreatedAt string `json:"created_at"`
Path string `json:"path"`
Line *int `json:"line"`
}
if err := json.Unmarshal(out, &reviewComments); err == nil {
for _, rc := range reviewComments {
line := 0
if rc.Line != nil {
line = *rc.Line
}
comments = append(comments, PRComment{
ID: rc.ID,
Body: rc.Body,
User: rc.User.Login,
CreatedAt: rc.CreatedAt,
Path: rc.Path,
Line: line,
IsInline: true,
})
}
}
}

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

return comments, nil
}

// 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, "-f", "body="+body, "--method", "POST")
if _, err := cmd.Output(); err != nil {
return fmt.Errorf("gh api comment failed: %w", err)
}
return nil
}

// AddPRInlineComment adds an inline review comment to a pull request.
func AddPRInlineComment(owner, repo string, number int, body, path string, line int) error {
headSHA, err := fetchPRHeadSHA(owner, repo, number)
if err != nil {
return fmt.Errorf("fetch head SHA: %w", err)
}
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/comments", owner, repo, number)
cmd := exec.Command("gh", "api", endpoint,
"-f", "body="+body,
"-f", "path="+path,
"-F", fmt.Sprintf("line=%d", line),
"-f", "commit_id="+headSHA,
"--method", "POST")
if _, err := cmd.Output(); err != nil {
return fmt.Errorf("gh api inline comment failed: %w", err)
}
return nil
}

func fetchPRHeadSHA(owner, repo string, number int) (string, error) {
query := fmt.Sprintf(`query { repository(owner: %q, name: %q) { pullRequest(number: %d) { headRefOid } } }`,
owner, repo, number)
cmd := exec.Command("gh", "api", "graphql", "-f", "query="+query,
"--jq", ".data.repository.pullRequest.headRefOid")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("gh graphql failed: %w", err)
}
return strings.TrimSpace(string(out)), nil
}

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