diff --git a/app/app.go b/app/app.go index 431a2dae..cccc5dd1 100644 --- a/app/app.go +++ b/app/app.go @@ -34,6 +34,7 @@ import ( "github.com/floatpane/matcha/internal/emailstore" "github.com/floatpane/matcha/internal/exportcmd" "github.com/floatpane/matcha/internal/fetchcmd" + "github.com/floatpane/matcha/internal/github" "github.com/floatpane/matcha/internal/idledaemon" "github.com/floatpane/matcha/internal/logging" "github.com/floatpane/matcha/internal/loglevel" @@ -45,6 +46,7 @@ import ( "github.com/floatpane/matcha/plugin" "github.com/floatpane/matcha/theme" "github.com/floatpane/matcha/tui" + "github.com/floatpane/matcha/view" "github.com/google/uuid" ) @@ -1531,8 +1533,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo mailbox = inbox.GetMailbox() } emailIndex := m.store.GetEmailIndex(email.UID, email.AccountID) - emailView := tui.NewEmailView(email, emailIndex, m.width, m.height, mailbox, m.config.DisableImages) - m.current = emailView + githubNotification := view.ParseGitHubNotification(email) + if githubNotification != nil { + m.current = tui.NewGitHubConversationView(githubNotification, email, m.width, m.height, mailbox) + } else { + emailView := tui.NewEmailView(email, emailIndex, m.width, m.height, mailbox, m.config.DisableImages) + m.current = emailView + } m.syncPluginStatus() m.syncPluginKeyBindings() m.updateCurrentWindowSize() @@ -1732,6 +1739,67 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.deleteAccount(msg.AccountID) return m, m.current.Init() + case tui.GitHubGroupBodiesFetchedMsg: + for uid, bodyData := range msg.Bodies { + if email := m.store.GetEmailByUIDAndAccount(uid, bodyData.AccountID); email != nil { + email.Body = bodyData.Body + email.BodyMIMEType = bodyData.BodyMIMEType + view.ParseGitHubNotification(*email) + } + } + if m.folderInbox != nil && m.current == m.folderInbox { + var cmd tea.Cmd + m.current, cmd = m.current.Update(msg) + return m, cmd + } + group := github.GetGroup(msg.Key) + if group == nil { + return m, nil + } + var representative fetcher.Email + for _, uid := range github.GetGroupEmailUIDs(msg.Key) { + if email := m.store.GetEmailByUIDAndAccount(uid.UID, uid.AccountID); email != nil { + representative = *email + break + } + } + if representative.UID == 0 { + return m, nil + } + m.current = tui.NewGitHubConversationView(group, representative, m.width, m.height, tui.MailboxInbox) + m.syncPluginStatus() + m.syncPluginKeyBindings() + m.updateCurrentWindowSize() + return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init())...) + + case tui.ViewGitHubGroupMsg: + email := msg.Email + if email == nil { + email = m.store.GetEmailByUIDAndAccount(msg.UID, msg.AccountID) + } + if email == nil { + return m, nil + } + m.store.AddEmailToStoresIfMissing(*email, msg.Mailbox) + folderName := m.folderName() + suppressRead := false + if m.plugins != nil { + t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName) + m.plugins.CallHook(plugin.HookEmailViewed, t) + suppressRead = m.plugins.TakeAutoReadSuppressed() + } + fetchBodiesCmd := fetchcmd.FetchGitHubGroupBodiesCmd(m.config, msg.Key, folderName, msg.Mailbox) + if m.config.EnableSplitPane && m.folderInbox != nil { + m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email) + m.current = m.folderInbox + cmd = m.markEmailReadAndQueue(msg.AccountID, msg.UID, suppressRead) + return m, tea.Batch(append(m.pluginFlagCmds(), cmd, fetchBodiesCmd, func() tea.Msg { + return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID} + })...) + } + m.current = tui.NewStatus("Fetching GitHub conversation...") + return m, tea.Batch(append(m.pluginFlagCmds(), m.current.Init(), fetchBodiesCmd)...) + case tui.ViewEmailMsg: email := msg.Email if email == nil { @@ -1829,8 +1897,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } } emailIndex := m.store.GetEmailIndex(msg.UID, msg.AccountID) - emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages) - m.current = emailView + githubNotification := view.ParseGitHubNotification(*email) + if githubNotification != nil { + m.current = tui.NewGitHubConversationView(githubNotification, *email, m.width, m.height, msg.Mailbox) + } else { + emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages) + m.current = emailView + } m.syncPluginStatus() m.syncPluginKeyBindings() cmds := []tea.Cmd{m.current.Init()} @@ -1965,6 +2038,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } return m, nil + case tui.PREditorOpenMsg: + return m, openPREditor(msg) + + case tui.PREditorFinishedMsg: + if msg.Err != nil { + return m, m.showErrorCmd(fmt.Sprintf("Editor error: %v", msg.Err)) + } + return m, submitPRReview(msg) + + case tui.PRActionResultMsg: + if msg.Err != nil { + return m, m.showErrorCmd(fmt.Sprintf("PR action failed: %v", msg.Err)) + } + return m, m.showInfoCmd("PR review submitted successfully") + case tui.GoToFilePickerMsg: if runtime.GOOS == "darwin" { return m, func() tea.Msg { diff --git a/app/pr_actions.go b/app/pr_actions.go new file mode 100644 index 00000000..51baa1c0 --- /dev/null +++ b/app/pr_actions.go @@ -0,0 +1,195 @@ +package app + +import ( + "fmt" + "os" + "os/exec" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/backend/repoapi" + "github.com/floatpane/matcha/internal/httpclient" + "github.com/floatpane/matcha/tui" +) + +// prActionLabel returns a human-readable label for the given review event, +// used as a header comment in the editor template. +func prActionLabel(action repoapi.ReviewEvent) string { + switch action { + case repoapi.ReviewApprove: + return "Approve PR" + case repoapi.ReviewRequestChanges: + return "Request Changes" + default: + return "Leave Comment" + } +} + +// openPREditor writes a markdown template to a temp file, opens $EDITOR, and +// returns a tea.Cmd that produces a PREditorFinishedMsg with the user's input. +func openPREditor(msg tui.PREditorOpenMsg) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = os.Getenv("VISUAL") + } + if editor == "" { + editor = "vi" + } + + var template strings.Builder + template.WriteString(fmt.Sprintf("\n", + prActionLabel(msg.Action), msg.Owner, msg.Repo, msg.PRNumber)) + template.WriteString("\n") + if msg.LineTarget != nil { + template.WriteString(fmt.Sprintf("\n", + msg.LineTarget.Path, msg.LineTarget.Line)) + } + template.WriteString("\n") + + tmpFile, err := os.CreateTemp("", "matcha-pr-*.md") + if err != nil { + return func() tea.Msg { + return tui.PREditorFinishedMsg{ + Action: msg.Action, + Err: fmt.Errorf("creating temp file: %w", err), + } + } + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(template.String()); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return func() tea.Msg { + return tui.PREditorFinishedMsg{ + Action: msg.Action, + Err: fmt.Errorf("writing temp file: %w", err), + } + } + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return func() tea.Msg { + return tui.PREditorFinishedMsg{ + Action: msg.Action, + Err: fmt.Errorf("closing temp file: %w", err), + } + } + } + + parts := strings.Fields(editor) + args := append(parts[1:], tmpPath) //nolint:gocritic + c := exec.Command(parts[0], args...) //nolint:noctx + return tea.ExecProcess(c, func(err error) tea.Msg { + defer func() { + _ = os.Remove(tmpPath) + }() + result := tui.PREditorFinishedMsg{ + Action: msg.Action, + Owner: msg.Owner, + Repo: msg.Repo, + PRNumber: msg.PRNumber, + Host: msg.Host, + CommitSHA: msg.CommitSHA, + LineTarget: msg.LineTarget, + } + if err != nil { + result.Err = err + return result + } + content, readErr := os.ReadFile(tmpPath) + if readErr != nil { + result.Err = readErr + return result + } + result.Body = stripEditorComments(string(content)) + return result + }) +} + +// submitPRReview builds a ReviewRequest from the editor output and calls the +// repo API client asynchronously, returning a PRActionResultMsg. +func submitPRReview(msg tui.PREditorFinishedMsg) tea.Cmd { + body := strings.TrimSpace(msg.Body) + if body == "" { + return func() tea.Msg { + return tui.PRActionResultMsg{ + Action: msg.Action, + Err: fmt.Errorf("empty comment body — nothing to submit"), + } + } + } + + host := msg.Host + if host == repoapi.HostUnknown { + host = repoapi.HostGitHub + } + + token := resolveAPIToken(host) + + req := &repoapi.ReviewRequest{ + Host: host, + Token: token, + Owner: msg.Owner, + Repo: msg.Repo, + PRNumber: msg.PRNumber, + Event: msg.Action, + Body: body, + LineComment: msg.LineTarget, + CommitSHA: msg.CommitSHA, + } + + return func() tea.Msg { + client := repoapi.NewClientWithHTTP(httpclient.New(httpclient.IMAPBatchActionTimeout)) + _, err := client.SubmitReview(req) + return tui.PRActionResultMsg{ + Action: msg.Action, + Err: err, + } + } +} + +// resolveAPIToken returns the best available token for the given host. +// For GitHub it checks GITHUB_TOKEN, then falls back to `gh auth token`. +// For GitLab it checks GITLAB_TOKEN, then falls back to `glab auth token`. +func resolveAPIToken(host repoapi.Host) string { + switch host { + case repoapi.HostGitHub: + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + return t + } + return tokenFromCLI("gh", "auth", "token") + case repoapi.HostGitLab: + if t := os.Getenv("GITLAB_TOKEN"); t != "" { + return t + } + return tokenFromCLI("glab", "auth", "token") + } + return "" +} + +// tokenFromCLI runs a CLI tool (e.g. `gh auth token`) and returns the trimmed +// stdout. Returns "" if the tool is not installed or exits non-zero. +func tokenFromCLI(name string, args ...string) string { + cmd := exec.Command(name, args...) //nolint:noctx + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// stripEditorComments removes HTML comment lines that were part of the +// template so they don't get submitted as the review body. +func stripEditorComments(content string) string { + var sb strings.Builder + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "