Skip to content
Draft
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
96 changes: 92 additions & 4 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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 {
Expand Down
195 changes: 195 additions & 0 deletions app/pr_actions.go
Original file line number Diff line number Diff line change
@@ -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 {

Check failure on line 18 in app/pr_actions.go

View workflow job for this annotation

GitHub Actions / lint

missing cases in switch of type repoapi.ReviewEvent: repoapi.ReviewComment (exhaustive)
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("<!-- %s on %s/%s#%d -->\n",
prActionLabel(msg.Action), msg.Owner, msg.Repo, msg.PRNumber))
template.WriteString("<!-- Write your markdown below. Save and quit to submit. -->\n")
if msg.LineTarget != nil {
template.WriteString(fmt.Sprintf("<!-- Line comment on %s:%d -->\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 {

Check failure on line 156 in app/pr_actions.go

View workflow job for this annotation

GitHub Actions / lint

missing cases in switch of type repoapi.Host: repoapi.HostUnknown (exhaustive)
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, "<!--") {
continue
}
sb.WriteString(line)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
Loading
Loading