From a3349375bb1f740fcbaf911083e270d76fa30087 Mon Sep 17 00:00:00 2001 From: Lea Date: Sun, 5 Jul 2026 22:27:09 +0400 Subject: [PATCH 1/2] feat: GitHub email support Co-authored-by: Steve Signed-off-by: drew --- app/app.go | 81 ++++- fetcher/fetcher.go | 28 ++ internal/fetchcmd/fetchcmd.go | 39 +++ internal/github/client.go | 147 +++++++++ internal/github/store.go | 216 ++++++++++++ tui/email_view.go | 83 +++-- tui/folder_inbox.go | 21 +- tui/github_conversation_view.go | 565 ++++++++++++++++++++++++++++++++ tui/inbox.go | 114 +++---- tui/inbox_github_grouping.go | 211 ++++++++++++ tui/messages.go | 21 ++ view/github_parser.go | 290 ++++++++++++++++ view/github_parser_test.go | 290 ++++++++++++++++ view/html.go | 4 + 14 files changed, 1991 insertions(+), 119 deletions(-) create mode 100644 internal/github/client.go create mode 100644 internal/github/store.go create mode 100644 tui/github_conversation_view.go create mode 100644 tui/inbox_github_grouping.go create mode 100644 view/github_parser.go create mode 100644 view/github_parser_test.go diff --git a/app/app.go b/app/app.go index 431a2dae..bbb727a2 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()} diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index eaeec308..00630a33 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1253,6 +1253,34 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint textPartEncoding = plainPartEncoding bodyMIMEType = mimeTextPlain } + if textPartID == "" && extractedBody == "" { + if data, err := fetchWholeMessage(); err == nil { + reader, readErr := mail.CreateReader(bytes.NewReader(data)) + if readErr == nil && reader != nil { + for { + p, err := reader.NextPart() + if err != nil { + break + } + cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) + if cType == mimeTextHTML && body == "" { + b, readErr := io.ReadAll(p.Body) + if readErr == nil { + body = string(b) + bodyMIMEType = mimeTextHTML + break + } + } else if cType == mimeTextPlain && body == "" { + b, readErr := io.ReadAll(p.Body) + if readErr == nil { + body = string(b) + bodyMIMEType = mimeTextPlain + } + } + } + } + } + } if os.Getenv("DEBUG_KITTY_IMAGES") != "" { msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID) log.Print(msg) diff --git a/internal/fetchcmd/fetchcmd.go b/internal/fetchcmd/fetchcmd.go index 6a07b1f6..30d16c8a 100644 --- a/internal/fetchcmd/fetchcmd.go +++ b/internal/fetchcmd/fetchcmd.go @@ -12,6 +12,7 @@ import ( "github.com/floatpane/matcha/backend" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" "github.com/floatpane/matcha/internal/httpclient" "github.com/floatpane/matcha/tui" ) @@ -288,6 +289,44 @@ func FetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folde } } +// FetchGitHubGroupBodiesCmd fetches bodies for all emails in a GitHub notification group. +func FetchGitHubGroupBodiesCmd(cfg *config.Config, key github.EventKey, folderName string, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + uids := github.GetGroupEmailUIDs(key) + bodies := make(map[uint32]tui.GitHubBodyData) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, uid := range uids { + wg.Add(1) + go func(u github.EmailUID) { + defer wg.Done() + account := cfg.GetAccountByID(u.AccountID) + if account == nil { + return + } + body, bodyMIMEType, _, err := fetcher.FetchFolderEmailBody(account, folderName, u.UID) + if err != nil { + return + } + mu.Lock() + bodies[u.UID] = tui.GitHubBodyData{ + Body: body, + BodyMIMEType: bodyMIMEType, + AccountID: u.AccountID, + } + mu.Unlock() + }(uid) + } + wg.Wait() + + return tui.GitHubGroupBodiesFetchedMsg{ + Key: key, + Bodies: bodies, + } + } +} + // MarkEmailAsReadCmd marks a single email as read in its folder mailbox. func MarkEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd { return func() tea.Msg { diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 00000000..b11ebacd --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,147 @@ +package github + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/floatpane/matcha/internal/httpclient" +) + +const apiTimeout = 10 * time.Second + +type PRDetails struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + User GitHubUser `json:"user"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Merged bool `json:"merged"` + Mergeable *bool `json:"mergeable"` + Head BranchRef `json:"head"` + Base BranchRef `json:"base"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` + Commits int `json:"commits"` + Comments int `json:"comments"` + ReviewComments int `json:"review_comments"` + Draft bool `json:"draft"` + Labels []Label `json:"labels"` + Assignees []GitHubUser `json:"assignees"` +} + +type GitHubUser struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + HTMLURL string `json:"html_url"` +} + +type BranchRef struct { + Ref string `json:"ref"` + Sha string `json:"sha"` + Repo Repository `json:"repo"` +} + +type Repository struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` +} + +type Label struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type Client struct { + token string +} + +func NewClient() *Client { + token := os.Getenv("GITHUB_TOKEN") + return &Client{token: token} +} + +func NewClientWithToken(token string) *Client { + return &Client{token: token} +} + +func (c *Client) FetchPRDetails(owner, repo string, number int) (*PRDetails, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%d", owner, repo, number) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + client := httpclient.New(apiTimeout) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body)) + } + + var details PRDetails + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + return nil, err + } + return &details, nil +} + +func (c *Client) FetchIssueDetails(owner, repo string, number int) (*IssueDetails, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, number) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + client := httpclient.New(apiTimeout) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body)) + } + + var details IssueDetails + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + return nil, err + } + return &details, nil +} + +type IssueDetails struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + User GitHubUser `json:"user"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Labels []Label `json:"labels"` + Assignees []GitHubUser `json:"assignees"` + Comments int `json:"comments"` +} diff --git a/internal/github/store.go b/internal/github/store.go new file mode 100644 index 00000000..703d591d --- /dev/null +++ b/internal/github/store.go @@ -0,0 +1,216 @@ +package github + +import ( + "sort" + "sync" + "time" + + "github.com/floatpane/matcha/fetcher" +) + +type EventType string + +const ( + EventOpened EventType = "opened" + EventClosed EventType = "closed" + EventMerged EventType = "merged" + EventCommented EventType = "commented" + EventReview EventType = "review" + EventReviewRequest EventType = "review_requested" + EventPush EventType = "push" + EventLabel EventType = "label" + EventAssign EventType = "assign" + EventUnknown EventType = "unknown" +) + +type EventKey struct { + OrgName string + RepoName string + IssueNumber int + IsPR bool +} + +type Event struct { + EventType EventType + Actor string + ActorLogin string + Body string + Timestamp time.Time + IsSystem bool + SystemMsg string + RawEmail fetcher.Email +} + +type NotificationGroup struct { + Key EventKey + Title string + State string + IsPR bool + PRDetails *PRDetails + IssueDetails *IssueDetails + Events []Event + EmailUIDs []EmailUID + LastUpdated time.Time +} + +type EmailUID struct { + UID uint32 + AccountID string +} + +var ( + store = make(map[EventKey]*NotificationGroup) + storeMu sync.RWMutex + maxGroups = 100 +) + +func GetOrCreateGroup(key EventKey, title, state string, isPR bool) *NotificationGroup { + storeMu.Lock() + defer storeMu.Unlock() + + group, exists := store[key] + if !exists { + group = &NotificationGroup{ + Key: key, + Title: title, + State: state, + IsPR: isPR, + } + store[key] = group + pruneStore() + } + return group +} + +func GetGroup(key EventKey) *NotificationGroup { + storeMu.RLock() + defer storeMu.RUnlock() + return store[key] +} + +func AddEmailToGroup(key EventKey, uid uint32, accountID string) { + if uid == 0 { + return + } + storeMu.Lock() + defer storeMu.Unlock() + group, exists := store[key] + if !exists { + return + } + for _, existing := range group.EmailUIDs { + if existing.UID == uid && existing.AccountID == accountID { + return + } + } + group.EmailUIDs = append(group.EmailUIDs, EmailUID{UID: uid, AccountID: accountID}) +} + +func GetGroupEmailUIDs(key EventKey) []EmailUID { + storeMu.RLock() + defer storeMu.RUnlock() + group, exists := store[key] + if !exists { + return nil + } + return append([]EmailUID(nil), group.EmailUIDs...) +} + +func AddEvent(key EventKey, title, state string, isPR bool, event Event) { + group := GetOrCreateGroup(key, title, state, isPR) + storeMu.Lock() + defer storeMu.Unlock() + + if event.RawEmail.UID != 0 { + for _, existing := range group.Events { + if existing.RawEmail.UID == event.RawEmail.UID && existing.RawEmail.AccountID == event.RawEmail.AccountID { + return + } + } + } + + group.Events = append(group.Events, event) + group.LastUpdated = event.Timestamp + if event.Timestamp.After(group.LastUpdated) { + group.LastUpdated = event.Timestamp + } + if title != "" && group.Title == "" { + group.Title = title + } + if state != "" { + group.State = state + } + sort.Slice(group.Events, func(i, j int) bool { + return group.Events[i].Timestamp.Before(group.Events[j].Timestamp) + }) +} + +func UpdateEventBody(key EventKey, uid uint32, accountID string, body string, eventType EventType) { + if uid == 0 { + return + } + storeMu.Lock() + defer storeMu.Unlock() + group, exists := store[key] + if !exists { + return + } + for i, event := range group.Events { + if event.RawEmail.UID == uid && event.RawEmail.AccountID == accountID { + group.Events[i].Body = body + group.Events[i].EventType = eventType + return + } + } +} + +func SetPRDetails(key EventKey, details *PRDetails) { + storeMu.Lock() + defer storeMu.Unlock() + group, exists := store[key] + if !exists { + return + } + group.PRDetails = details + if details.Title != "" { + group.Title = details.Title + } + if details.State != "" { + group.State = details.State + } + if details.Merged { + group.State = "merged" + } +} + +func SetIssueDetails(key EventKey, details *IssueDetails) { + storeMu.Lock() + defer storeMu.Unlock() + group, exists := store[key] + if !exists { + return + } + group.IssueDetails = details + if details.Title != "" { + group.Title = details.Title + } + if details.State != "" { + group.State = details.State + } +} + +func pruneStore() { + if len(store) <= maxGroups { + return + } + var keys []EventKey + for k := range store { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + return store[keys[i]].LastUpdated.Before(store[keys[j]].LastUpdated) + }) + for i := 0; i < len(keys)-maxGroups; i++ { + delete(store, keys[i]) + } +} diff --git a/tui/email_view.go b/tui/email_view.go index 213eb8a7..97ffe78b 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -51,6 +51,32 @@ func applyBodyTransform(body string, email fetcher.Email) string { return BodyTransformer(body, email) } +func renderEmailBody(email fetcher.Email, width int, showImages bool) (string, []view.ImagePlacement, bool) { + githubNotification := view.ParseGitHubNotification(email) + if githubNotification != nil { + ghView := NewGitHubConversationView(githubNotification, email, width, 0, MailboxInbox) + content := ghView.RenderContent() + return content, nil, true + } + + patchInfo := view.DetectPatch(email.Body, email.BodyMIMEType, email.Subject, email.From) + if patchInfo != nil { + patchBody, ok := view.RenderPatchBody(email.Body, email.BodyMIMEType, email.Subject, email.From, width) + if ok { + return applyBodyTransform(patchBody, email), nil, false + } + } + + inlineImages := inlineImagesFromAttachments(email.Attachments) + body, placements, err := view.ProcessBodyWithInline(email.Body, email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !showImages) + if err != nil { + body = fmt.Sprintf("Error rendering body: %v", err) + placements = nil + } + body = applyBodyTransform(body, email) + return body, placements, false +} + type EmailView struct { viewport viewport.Model email fetcher.Email @@ -78,6 +104,8 @@ type EmailView struct { rowOffset int // vertical offset for image rendering in split pane (vertical layout) isPatch bool patchInfo *view.PatchInfo + isGitHub bool + githubBody string } func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView { @@ -129,32 +157,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma } email.Attachments = filteredAtts - // Pass the styles from the tui package to the view package - inlineImages := inlineImagesFromAttachments(email.Attachments) - // Initial state for showImages matches config unless overridden later showImages := !disableImages - // Detect git patches and render with diff syntax highlighting. - patchInfo := view.DetectPatch(email.Body, email.BodyMIMEType, email.Subject, email.From) - isPatch := patchInfo != nil - - body, placements, err := view.ProcessBodyWithInline(email.Body, email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !showImages) - if err != nil { - body = fmt.Sprintf("Error rendering body: %v", err) - } - body = applyBodyTransform(body, email) - - // If a patch was detected, replace the body with the patch-rendered version - // (commit message + highlighted diff + metadata banner). - if isPatch { - // The viewport will be created below with this width; pass it in so the - // diff code box can be sized to fit. - patchBody, ok := view.RenderPatchBody(email.Body, email.BodyMIMEType, email.Subject, email.From, width) - if ok { - body = applyBodyTransform(patchBody, email) - } - } + body, placements, isGitHub := renderEmailBody(email, width, showImages) // Create header and compute heights that reduce viewport space. header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) @@ -197,8 +203,8 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma calendarEvent: calendarEvent, originalICSData: originalICSData, isPreviewMode: false, - isPatch: isPatch, - patchInfo: patchInfo, + isGitHub: isGitHub, + githubBody: body, } } @@ -242,6 +248,10 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.WindowSizeMsg: m.handleWindowSize(msg) + case GitHubGroupBodiesFetchedMsg: + if m.isGitHub { + m.regenerateBody() + } } m.viewport, cmd = m.viewport.Update(msg) @@ -415,12 +425,7 @@ func rsvpResponseFromKey(key string, kb config.KeybindsConfig) string { // regenerateBody re-renders the email body after a state change such as // toggling image display. func (m *EmailView) regenerateBody() { - inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) - if err != nil { - body = fmt.Sprintf("Error rendering body: %v", err) - } - body = applyBodyTransform(body, m.email) + body, placements, _ := renderEmailBody(m.email, m.viewport.Width(), m.showImages) m.imagePlacements = placements wrapped := wrapBodyToWidth(body, m.viewport.Width()) m.viewport.SetContent(wrapped + "\n") @@ -439,17 +444,7 @@ func (m *EmailView) handleWindowSize(msg tea.WindowSizeMsg) { m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight) ClearKittyGraphics() - inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) - if err != nil { - body = fmt.Sprintf("Error rendering body: %v", err) - } - body = applyBodyTransform(body, m.email) - if m.isPatch { - if patchBody, ok := view.RenderPatchBody(m.email.Body, m.email.BodyMIMEType, m.email.Subject, m.email.From, m.viewport.Width()); ok { - body = applyBodyTransform(patchBody, m.email) - } - } + body, placements, _ := renderEmailBody(m.email, m.viewport.Width(), m.showImages) m.imagePlacements = placements wrapped := wrapBodyToWidth(body, m.viewport.Width()) m.viewport.SetContent(wrapped + "\n") @@ -473,7 +468,9 @@ func (m *EmailView) View() tea.View { } var v tea.View - if calendarView != "" { + if m.isGitHub { + v = tea.NewView(fmt.Sprintf("%s\n%s", m.viewport.View(), help)) + } else if calendarView != "" { v = tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help)) } else { v = tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)) diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 4e3886e2..d39b0821 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -14,6 +14,7 @@ import ( overlay "github.com/floatpane/bubble-overlay" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" "github.com/floatpane/matcha/theme" ) @@ -131,6 +132,10 @@ type FolderInbox struct { // splitOrientation controls the split-pane layout direction. // Either config.SplitPaneHorizontal (default) or config.SplitPaneVertical. splitOrientation string + + // pendingGitHubGroupKey holds the group key when group bodies arrive + // before the preview pane is ready. + pendingGitHubGroupKey *github.EventKey } // SetSplitOrientation sets the split pane orientation. When the preview is @@ -517,21 +522,29 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocycl email.Body = msg.Body email.BodyMIMEType = msg.BodyMIMEType email.Attachments = msg.Attachments - // Create preview pane with column/row offsets for image rendering. if m.isVerticalSplit() { previewWidth := m.calculateInboxWidthVertical() previewHeight := m.calculatePreviewHeight() colOffset := sidebarWidth + 2 - // In vertical split the preview starts below the inbox pane, so - // images must be pushed down by the inbox pane height + border row. rowOffset := m.calculateInboxHeight() + 1 m.previewPane = NewEmailViewPreview(*email, previewWidth, previewHeight, colOffset, rowOffset, m.disableImages) } else { previewWidth := m.calculatePreviewWidth() inboxWidth := m.calculateInboxWidth() - colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding + colOffset := sidebarWidth + 2 + inboxWidth + 2 m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, 0, m.disableImages) } + if m.pendingGitHubGroupKey != nil && m.previewPane != nil && m.previewPane.isGitHub { + m.previewPane.regenerateBody() + m.pendingGitHubGroupKey = nil + } + return m, nil + + case GitHubGroupBodiesFetchedMsg: + if m.previewPane != nil && m.previewPane.isGitHub { + m.previewPane.regenerateBody() + } + m.pendingGitHubGroupKey = &msg.Key return m, nil } diff --git a/tui/github_conversation_view.go b/tui/github_conversation_view.go new file mode 100644 index 00000000..fbb75da3 --- /dev/null +++ b/tui/github_conversation_view.go @@ -0,0 +1,565 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" + "github.com/floatpane/matcha/view" +) + +var ( + githubHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1) + + githubRepoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")) + + githubIssueNumStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + githubTitleStyle = lipgloss.NewStyle(). + Bold(true). + PaddingTop(1) + + githubBadgeOpenStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). + Bold(true). + Padding(0, 1). + Background(lipgloss.Color("235")) + + githubBadgeClosedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true). + Padding(0, 1). + Background(lipgloss.Color("235")) + + githubBadgeMergedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("99")). + Bold(true). + Padding(0, 1). + Background(lipgloss.Color("235")) + + githubStatsStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + MarginTop(1) + + githubBranchStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("250")). + Background(lipgloss.Color("235")). + Padding(0, 1). + MarginRight(1) + + githubCommentBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + githubSystemEventStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Italic(true). + Padding(0, 1). + MarginTop(1) + + githubAuthorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Bold(true) + + githubTimestampStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + githubBodyStyle = lipgloss.NewStyle(). + MarginTop(1) + + githubDividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1). + MarginBottom(1) + + githubSectionHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("42")). + Background(lipgloss.Color("235")). + Padding(0, 1). + MarginTop(1). + MarginBottom(1) +) + +type GitHubConversationView struct { + group *github.NotificationGroup + width int + height int + email fetcher.Email + mailbox MailboxKind + viewport viewport.Model +} + +func NewGitHubConversationView(group *github.NotificationGroup, email fetcher.Email, width, height int, mailbox MailboxKind) *GitHubConversationView { + vp := viewport.New() + vp.SetWidth(width) + vp.SetHeight(height - 2) + return &GitHubConversationView{ + group: group, + width: width, + height: height, + email: email, + mailbox: mailbox, + viewport: vp, + } +} + +func (m *GitHubConversationView) Init() tea.Cmd { + return nil +} + +func (m *GitHubConversationView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + kb := config.Keybinds + if msg.String() == kb.Global.Cancel { + ClearKittyGraphics() + return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} } + } + case tea.WindowSizeMsg: + m.viewport.SetWidth(msg.Width) + m.viewport.SetHeight(msg.Height - 2) + m.viewport.SetContent(m.RenderContent()) + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *GitHubConversationView) View() tea.View { + if m.group == nil { + return tea.NewView("Invalid GitHub notification") + } + m.viewport.SetContent(m.RenderContent()) + return tea.NewView(m.viewport.View()) +} + +func (m *GitHubConversationView) RenderContent() string { + var sb strings.Builder + + sb.WriteString(m.renderHeader()) + sb.WriteString("\n") + + if m.group.IsPR && m.group.PRDetails != nil { + sb.WriteString(m.renderPRDetails()) + sb.WriteString("\n") + } else if m.group.IssueDetails != nil { + sb.WriteString(m.renderIssueDetails()) + sb.WriteString("\n") + } + + sb.WriteString(githubDividerStyle.Render(strings.Repeat("─", m.width-2))) + sb.WriteString("\n") + + filtered := filterEvents(m.group.Events) + grouped := groupEventsByType(filtered) + for i, section := range grouped { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(githubSectionHeaderStyle.Render(section.Header)) + sb.WriteString("\n") + for _, event := range section.Events { + sb.WriteString(m.renderEvent(event)) + } + } + + sb.WriteString("\n") + sb.WriteString(HelpStyle.Render(fmt.Sprintf("%s back", config.Keybinds.Global.Cancel))) + + return sb.String() +} + +func (m *GitHubConversationView) renderHeader() string { + var parts []string + + repoDisplay := m.group.Key.RepoName + if m.group.Key.OrgName != "" { + repoDisplay = fmt.Sprintf("%s/%s", m.group.Key.OrgName, m.group.Key.RepoName) + } + repoURL := fmt.Sprintf("https://github.com/%s", repoDisplay) + parts = append(parts, githubRepoStyle.Render(view.Hyperlink(repoURL, repoDisplay))) + + if m.group.Key.IssueNumber > 0 { + var itemURL string + prefix := "#" + if m.group.IsPR { + prefix = "PR #" + itemURL = fmt.Sprintf("https://github.com/%s/pull/%d", repoDisplay, m.group.Key.IssueNumber) + } else { + itemURL = fmt.Sprintf("https://github.com/%s/issues/%d", repoDisplay, m.group.Key.IssueNumber) + } + parts = append(parts, githubIssueNumStyle.Render(view.Hyperlink(itemURL, fmt.Sprintf("%s%d", prefix, m.group.Key.IssueNumber)))) + } + + headerLine := strings.Join(parts, " ") + + title := m.group.Title + if title == "" { + title = m.email.Subject + } + + badge := m.renderStateBadge() + + var header strings.Builder + header.WriteString(githubHeaderStyle.Render(headerLine)) + header.WriteString("\n") + header.WriteString(githubTitleStyle.Render(title)) + header.WriteString("\n") + header.WriteString(badge) + + return header.String() +} + +func (m *GitHubConversationView) renderStateBadge() string { + state := strings.ToLower(m.group.State) + switch state { + case "open": + return githubBadgeOpenStyle.Render("● Open") + case "closed": + return githubBadgeClosedStyle.Render("● Closed") + case "merged": + return githubBadgeMergedStyle.Render("● Merged") + default: + return "" + } +} + +func (m *GitHubConversationView) renderPRDetails() string { + pr := m.group.PRDetails + if pr == nil { + return "" + } + + var sb strings.Builder + + if pr.User.Login != "" { + userURL := fmt.Sprintf("https://github.com/%s", pr.User.Login) + sb.WriteString(githubAuthorStyle.Render(view.Hyperlink(userURL, pr.User.Login))) + sb.WriteString("\n") + } + + if pr.Head.Ref != "" && pr.Base.Ref != "" { + sb.WriteString(githubBranchStyle.Render(pr.Head.Ref)) + sb.WriteString("→ ") + sb.WriteString(githubBranchStyle.Render(pr.Base.Ref)) + sb.WriteString("\n") + } + + var stats []string + if pr.Commits > 0 { + stats = append(stats, fmt.Sprintf("%d commits", pr.Commits)) + } + if pr.ChangedFiles > 0 { + stats = append(stats, fmt.Sprintf("%d files changed", pr.ChangedFiles)) + } + if pr.Additions > 0 || pr.Deletions > 0 { + stats = append(stats, fmt.Sprintf("+%d -%d", pr.Additions, pr.Deletions)) + } + if pr.Comments > 0 { + stats = append(stats, fmt.Sprintf("%d comments", pr.Comments)) + } + if pr.ReviewComments > 0 { + stats = append(stats, fmt.Sprintf("%d review comments", pr.ReviewComments)) + } + if len(stats) > 0 { + sb.WriteString(githubStatsStyle.Render(strings.Join(stats, " • "))) + sb.WriteString("\n") + } + + if pr.Body != "" { + renderedBody, _, err := view.ProcessBody(pr.Body, "", H1Style, H2Style, BodyStyle, false) + if err != nil { + renderedBody = pr.Body + } + sb.WriteString(githubBodyStyle.Render(renderedBody)) + sb.WriteString("\n") + } + + if len(pr.Labels) > 0 { + var labels []string + for _, label := range pr.Labels { + labels = append(labels, label.Name) + } + sb.WriteString(githubStatsStyle.Render("Labels: " + strings.Join(labels, ", "))) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m *GitHubConversationView) renderIssueDetails() string { + issue := m.group.IssueDetails + if issue == nil { + return "" + } + + var sb strings.Builder + + if issue.User.Login != "" { + userURL := fmt.Sprintf("https://github.com/%s", issue.User.Login) + sb.WriteString(githubAuthorStyle.Render(view.Hyperlink(userURL, issue.User.Login))) + sb.WriteString("\n") + } + + var stats []string + if issue.Comments > 0 { + stats = append(stats, fmt.Sprintf("%d comments", issue.Comments)) + } + if len(stats) > 0 { + sb.WriteString(githubStatsStyle.Render(strings.Join(stats, " • "))) + sb.WriteString("\n") + } + + if issue.Body != "" { + renderedBody, _, err := view.ProcessBody(issue.Body, "", H1Style, H2Style, BodyStyle, false) + if err != nil { + renderedBody = issue.Body + } + sb.WriteString(githubBodyStyle.Render(renderedBody)) + sb.WriteString("\n") + } + + if len(issue.Labels) > 0 { + var labels []string + for _, label := range issue.Labels { + labels = append(labels, label.Name) + } + sb.WriteString(githubStatsStyle.Render("Labels: " + strings.Join(labels, ", "))) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m *GitHubConversationView) renderEvent(event github.Event) string { + if event.IsSystem { + return githubSystemEventStyle.Render(event.SystemMsg) + "\n" + } + + var sb strings.Builder + + timestamp := formatTime(event.Timestamp) + action := m.formatEventType(event.EventType) + + login := event.ActorLogin + if login == "" { + login = event.Actor + } + actorURL := fmt.Sprintf("https://github.com/%s", login) + styledActor := githubAuthorStyle.Render(view.Hyperlink(actorURL, event.Actor)) + + if event.EventType == github.EventCommented { + commentURL := m.commentURL(event) + action = view.Hyperlink(commentURL, action) + } + + sb.WriteString(styledActor) + sb.WriteString(" ") + sb.WriteString(githubTimestampStyle.Render(fmt.Sprintf("%s %s", action, timestamp))) + sb.WriteString("\n") + + if event.Body != "" { + renderedBody, _, err := view.ProcessBody(event.Body, "", H1Style, H2Style, BodyStyle, false) + if err != nil { + renderedBody = event.Body + } + sb.WriteString(githubCommentBoxStyle.Render(renderedBody)) + } + + return sb.String() +} + +func (m *GitHubConversationView) commentURL(event github.Event) string { + repoDisplay := m.group.Key.RepoName + if m.group.Key.OrgName != "" { + repoDisplay = fmt.Sprintf("%s/%s", m.group.Key.OrgName, m.group.Key.RepoName) + } + base := fmt.Sprintf("https://github.com/%s", repoDisplay) + if m.group.IsPR { + base = fmt.Sprintf("%s/pull/%d", base, m.group.Key.IssueNumber) + } else { + base = fmt.Sprintf("%s/issues/%d", base, m.group.Key.IssueNumber) + } + commentID := extractCommentID(event.RawEmail.MessageID) + if commentID != "" { + return fmt.Sprintf("%s#issuecomment-%s", base, commentID) + } + return base +} + +func extractCommentID(messageID string) string { + if messageID == "" { + return "" + } + messageID = strings.Trim(messageID, "<>") + parts := strings.Split(messageID, "/") + for i, p := range parts { + if p == "pull" || p == "issues" { + if i+2 < len(parts) { + commentPart := parts[i+2] + commentPart = strings.TrimSuffix(commentPart, "@github.com") + commentPart = strings.TrimPrefix(commentPart, "c") + if commentPart != "" { + return commentPart + } + } + } + } + return "" +} + +func (m *GitHubConversationView) formatEventType(eventType github.EventType) string { + switch eventType { + case github.EventOpened: + return "opened" + case github.EventClosed: + return "closed" + case github.EventMerged: + return "merged" + case github.EventCommented: + return "commented" + case github.EventReview: + return "reviewed" + case github.EventReviewRequest: + return "requested review" + case github.EventPush: + return "pushed commits" + case github.EventLabel: + return "added label" + case github.EventAssign: + return "was assigned" + default: + return "notified" + } +} + +func formatTime(t time.Time) string { + now := time.Now() + diff := now.Sub(t) + + switch { + case diff < time.Minute: + return "just now" + case diff < time.Hour: + minutes := int(diff.Minutes()) + return fmt.Sprintf("%d minute%s ago", minutes, plural(minutes)) + case diff < 24*time.Hour: + hours := int(diff.Hours()) + return fmt.Sprintf("%d hour%s ago", hours, plural(hours)) + case diff < 7*24*time.Hour: + days := int(diff.Hours() / 24) + return fmt.Sprintf("%d day%s ago", days, plural(days)) + default: + return t.Format("Jan 2, 2006") + } +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func filterEvents(events []github.Event) []github.Event { + var filtered []github.Event + for _, event := range events { + if event.IsSystem && event.SystemMsg != "" { + filtered = append(filtered, event) + continue + } + if strings.TrimSpace(event.Body) != "" { + filtered = append(filtered, event) + } + } + return filtered +} + +type eventSection struct { + Header string + Events []github.Event +} + +func groupEventsByType(events []github.Event) []eventSection { + if len(events) == 0 { + return nil + } + + groups := make(map[github.EventType][]github.Event) + order := []github.EventType{} + seen := make(map[github.EventType]bool) + + for _, event := range events { + if !seen[event.EventType] { + seen[event.EventType] = true + order = append(order, event.EventType) + } + groups[event.EventType] = append(groups[event.EventType], event) + } + + var sections []eventSection + for _, eventType := range order { + sectionEvents := groups[eventType] + if len(sectionEvents) == 0 { + continue + } + sections = append(sections, eventSection{ + Header: sectionHeader(eventType, len(sectionEvents)), + Events: sectionEvents, + }) + } + return sections +} + +func sectionHeader(eventType github.EventType, count int) string { + name := eventTypeName(eventType) + if count == 1 { + return fmt.Sprintf("1 %s", name) + } + return fmt.Sprintf("%d %s", count, pluralize(name)) +} + +func pluralize(word string) string { + if strings.HasSuffix(strings.ToLower(word), "y") { + return word[:len(word)-1] + "ies" + } + return word + "s" +} + +func eventTypeName(eventType github.EventType) string { + switch eventType { + case github.EventOpened: + return "Opened" + case github.EventClosed: + return "Closed" + case github.EventMerged: + return "Merged" + case github.EventCommented: + return "Comment" + case github.EventReview: + return "Review" + case github.EventReviewRequest: + return "Review Request" + case github.EventPush: + return "Push" + case github.EventLabel: + return "Label Change" + case github.EventAssign: + return "Assignment" + default: + return "Activity" + } +} diff --git a/tui/inbox.go b/tui/inbox.go index af7efd88..1e82d7cd 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -14,6 +14,7 @@ import ( threading "github.com/floatpane/jwz-go" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" "github.com/floatpane/matcha/theme" ) @@ -46,6 +47,10 @@ type item struct { threadChild bool threadDepth int expanded bool + isGitHubGroup bool + githubGroupKey github.EventKey + githubGroupCount int + githubGroupPR bool } func (i item) Title() string { return i.title } @@ -91,6 +96,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list statusStyle = readEmailStyle statusIcon = "\uf2b6" } + if i.isGitHubGroup { + statusIcon = "\uf09b" + } if i.threadRoot && i.threadCount > 1 { if i.expanded { statusIcon = "▾" @@ -171,6 +179,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list if i.threadRoot && i.threadCount > 1 { subject = fmt.Sprintf("%s (%d)", subject, i.threadCount) } + if i.isGitHubGroup { + subject = fmt.Sprintf("%s [%d]", subject, i.githubGroupCount) + } if subjectBudget < 4 { subjectBudget = 4 } @@ -646,82 +657,38 @@ func extractEmailAddress(value string) string { func (m *Inbox) itemsForEmails(displayEmails []fetcher.Email, showAccountLabel bool) []list.Item { if !m.isThreaded() { - items := make([]list.Item, len(displayEmails)) - for i, email := range displayEmails { - items[i] = m.itemForEmail(email, i, showAccountLabel) + grouped := groupGitHubEmails(displayEmails) + items := make([]list.Item, 0, len(grouped)) + for _, entry := range grouped { + if entry.IsGroup { + items = append(items, m.itemForGitHubGroup(entry, showAccountLabel)) + } else { + items = append(items, m.itemForEmail(entry.Email, entry.Index, showAccountLabel)) + } } return items } - emailIndex := make(map[string]int, len(displayEmails)) - headers := make([]threading.EmailHeader, 0, len(displayEmails)) - for i, email := range displayEmails { - id := inboxEmailID(email) - emailIndex[id] = i - headers = append(headers, threading.EmailHeader{ - ID: email.MessageID, - InReplyTo: email.InReplyTo, - References: email.References, - Subject: email.Subject, - Date: email.Date, - EmailID: id, - Sender: email.From, - }) - } - + // In threaded mode, group GitHub emails separately and thread the rest. + githubEmails, regularEmails, githubIndices := partitionGitHubEmails(displayEmails) var items []list.Item - for _, thread := range threading.Build(headers) { - key := threadItemKey(thread.Root) - root := firstEmailNode(thread.Root) - if root == nil { - continue - } - idx := emailIndex[root.EmailID] - rootEmail := displayEmails[idx] - latest := latestEmailNode(thread.Root) - if latest == nil { - latest = root - } - rootItem := m.itemForEmail(rootEmail, idx, showAccountLabel) - rootItem.title = firstNonEmpty(root.Subject, thread.Subject) - rootItem.desc = latest.Sender - rootItem.date = thread.LatestAt - rootItem.isRead = threadRead(displayEmails, emailIndex, thread.Root) - rootItem.threadKey = key - rootItem.threadCount = thread.Count - rootItem.threadRoot = true - rootItem.expanded = m.expanded[key] - - if m.expanded[key] { - // When expanded, insert a thread header row for collapsing, - // then render the root email as a clickable item, then children. - headerItem := item{ - title: firstNonEmpty(root.Subject, thread.Subject), - desc: latest.Sender, - date: thread.LatestAt, - isRead: threadRead(displayEmails, emailIndex, thread.Root), - threadKey: key, - threadCount: thread.Count, - threadRoot: true, - threadHeader: true, - expanded: true, + if len(githubEmails) > 0 { + grouped := groupGitHubEmails(githubEmails) + for _, entry := range grouped { + if entry.IsGroup { + items = append(items, m.itemForGitHubGroup(entry, showAccountLabel)) + } else { + originalIndex := githubIndices[entry.Index] + items = append(items, m.itemForEmail(entry.Email, originalIndex, showAccountLabel)) } - items = append(items, headerItem) - - // Root email as a clickable item - clickableRoot := m.itemForEmail(rootEmail, idx, showAccountLabel) - clickableRoot.threadKey = key - clickableRoot.threadCount = thread.Count - clickableRoot.threadChild = true - clickableRoot.threadDepth = 1 - items = append(items, clickableRoot) - - items = appendThreadChildren(items, m, displayEmails, emailIndex, showAccountLabel, thread.Root.Children, 2) - } else { - items = append(items, rootItem) } } + + if len(regularEmails) > 0 { + regularItems := m.itemsForEmailsThreaded(regularEmails, showAccountLabel) + items = append(items, regularItems...) + } return items } @@ -898,7 +865,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.lastClickTime = now m.lastClickY = msg.Y if isDoubleClick { - if selectedItem, ok := m.list.SelectedItem().(item); ok && selectedItem.uid != 0 { + if selectedItem, ok := m.list.SelectedItem().(item); ok && (selectedItem.uid != 0 || selectedItem.isGitHubGroup) { i := selectedItem.originalIndex uid := selectedItem.uid accountID := selectedItem.accountID @@ -906,6 +873,17 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo if m.searchActive { email = m.GetEmailAtIndex(i) } + if selectedItem.isGitHubGroup { + return m, func() tea.Msg { + return ViewGitHubGroupMsg{ + Key: selectedItem.githubGroupKey, + UID: uid, + AccountID: accountID, + Mailbox: m.mailbox, + Email: email, + } + } + } return m, func() tea.Msg { return ViewEmailMsg{Index: i, UID: uid, AccountID: accountID, Mailbox: m.mailbox, Email: email} } diff --git a/tui/inbox_github_grouping.go b/tui/inbox_github_grouping.go new file mode 100644 index 00000000..119d38d2 --- /dev/null +++ b/tui/inbox_github_grouping.go @@ -0,0 +1,211 @@ +package tui + +import ( + "fmt" + "time" + + "charm.land/bubbles/v2/list" + threading "github.com/floatpane/jwz-go" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" + "github.com/floatpane/matcha/view" +) + +func (m *Inbox) itemForGitHubGroup(entry gitHubGroupEntry, showAccountLabel bool) item { + accountEmail := "" + if showAccountLabel { + accountEmail = m.accountLabelForEmail(entry.Email) + } + + repoDisplay := entry.Key.RepoName + if entry.Key.OrgName != "" { + repoDisplay = fmt.Sprintf("%s/%s", entry.Key.OrgName, entry.Key.RepoName) + } + prefix := "#" + if entry.Key.IsPR { + prefix = "PR #" + } + + title := entry.Title + if title == "" { + title = entry.Email.Subject + } + title = fmt.Sprintf("%s%d: %s", prefix, entry.Key.IssueNumber, title) + + desc := fmt.Sprintf("%s • %d notifications", repoDisplay, entry.Count) + if entry.LatestSender != "" { + desc = fmt.Sprintf("%s • latest by %s • %d notifications", repoDisplay, entry.LatestSender, entry.Count) + } + + return item{ + title: title, + desc: desc, + originalIndex: entry.Index, + uid: entry.Email.UID, + accountID: entry.Email.AccountID, + accountEmail: accountEmail, + date: entry.LatestDate, + isRead: entry.AllRead, + isGitHubGroup: true, + githubGroupKey: entry.Key, + githubGroupCount: entry.Count, + githubGroupPR: entry.Key.IsPR, + } +} + +type gitHubGroupEntry struct { + Key github.EventKey + Title string + Email fetcher.Email + Index int + Count int + LatestDate time.Time + LatestSender string + AllRead bool + IsGroup bool +} + +func groupGitHubEmails(emails []fetcher.Email) []gitHubGroupEntry { + groups := make(map[github.EventKey]*gitHubGroupEntry) + order := []github.EventKey{} + + for i, email := range emails { + key, ok := view.ExtractGitHubKey(email) + if !ok { + order = append(order, github.EventKey{IssueNumber: -1 - i}) + groups[github.EventKey{IssueNumber: -1 - i}] = &gitHubGroupEntry{ + Email: email, + Index: i, + IsGroup: false, + } + continue + } + + view.ParseGitHubNotification(email) + + existing, found := groups[key] + if !found { + order = append(order, key) + groups[key] = &gitHubGroupEntry{ + Key: key, + Title: extractGitHubTitle(email), + Email: email, + Index: i, + Count: 1, + LatestDate: email.Date, + LatestSender: parseSenderName(email.From), + AllRead: email.IsRead, + IsGroup: true, + } + } else { + existing.Count++ + if email.Date.After(existing.LatestDate) { + existing.LatestDate = email.Date + existing.LatestSender = parseSenderName(email.From) + existing.Email = email + existing.Index = i + } + if !email.IsRead { + existing.AllRead = false + } + } + github.AddEmailToGroup(key, email.UID, email.AccountID) + } + + result := make([]gitHubGroupEntry, 0, len(order)) + for _, key := range order { + result = append(result, *groups[key]) + } + return result +} + +func partitionGitHubEmails(emails []fetcher.Email) (githubEmails []fetcher.Email, regularEmails []fetcher.Email, githubIndices map[int]int) { + githubIndices = make(map[int]int) + for i, email := range emails { + if _, ok := view.ExtractGitHubKey(email); ok { + githubIndices[len(githubEmails)] = i + githubEmails = append(githubEmails, email) + } else { + regularEmails = append(regularEmails, email) + } + } + return githubEmails, regularEmails, githubIndices +} + +func extractGitHubTitle(email fetcher.Email) string { + _, _, _, title, _ := view.ExtractGitHubMetadata(email) + if title != "" { + return title + } + return email.Subject +} + +func (m *Inbox) itemsForEmailsThreaded(displayEmails []fetcher.Email, showAccountLabel bool) []list.Item { + emailIndex := make(map[string]int, len(displayEmails)) + headers := make([]threading.EmailHeader, 0, len(displayEmails)) + for i, email := range displayEmails { + id := inboxEmailID(email) + emailIndex[id] = i + headers = append(headers, threading.EmailHeader{ + ID: email.MessageID, + InReplyTo: email.InReplyTo, + References: email.References, + Subject: email.Subject, + Date: email.Date, + EmailID: id, + Sender: email.From, + }) + } + + var items []list.Item + for _, thread := range threading.Build(headers) { + key := threadItemKey(thread.Root) + root := firstEmailNode(thread.Root) + if root == nil { + continue + } + idx := emailIndex[root.EmailID] + rootEmail := displayEmails[idx] + latest := latestEmailNode(thread.Root) + if latest == nil { + latest = root + } + + rootItem := m.itemForEmail(rootEmail, idx, showAccountLabel) + rootItem.title = firstNonEmpty(root.Subject, thread.Subject) + rootItem.desc = latest.Sender + rootItem.date = thread.LatestAt + rootItem.isRead = threadRead(displayEmails, emailIndex, thread.Root) + rootItem.threadKey = key + rootItem.threadCount = thread.Count + rootItem.threadRoot = true + rootItem.expanded = m.expanded[key] + + if m.expanded[key] { + headerItem := item{ + title: firstNonEmpty(root.Subject, thread.Subject), + desc: latest.Sender, + date: thread.LatestAt, + isRead: threadRead(displayEmails, emailIndex, thread.Root), + threadKey: key, + threadCount: thread.Count, + threadRoot: true, + threadHeader: true, + expanded: true, + } + items = append(items, headerItem) + + clickableRoot := m.itemForEmail(rootEmail, idx, showAccountLabel) + clickableRoot.threadKey = key + clickableRoot.threadCount = thread.Count + clickableRoot.threadChild = true + clickableRoot.threadDepth = 1 + items = append(items, clickableRoot) + + items = appendThreadChildren(items, m, displayEmails, emailIndex, showAccountLabel, thread.Root.Children, 2) + } else { + items = append(items, rootItem) + } + } + return items +} diff --git a/tui/messages.go b/tui/messages.go index 9ad52062..b65a61e9 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -6,6 +6,7 @@ import ( "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/daemonrpc" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" ) // NotifyMsg is returned as a tea.Cmd by sub-components to request @@ -36,6 +37,26 @@ type ViewEmailMsg struct { Email *fetcher.Email } +type ViewGitHubGroupMsg struct { + Key github.EventKey + UID uint32 + AccountID string + Mailbox MailboxKind + Email *fetcher.Email +} + +type GitHubGroupBodiesFetchedMsg struct { + Key github.EventKey + Bodies map[uint32]GitHubBodyData + Err error +} + +type GitHubBodyData struct { + Body string + BodyMIMEType string + AccountID string +} + type SendEmailMsg struct { To string Cc string // Cc recipient(s) diff --git a/view/github_parser.go b/view/github_parser.go new file mode 100644 index 00000000..43ab51f7 --- /dev/null +++ b/view/github_parser.go @@ -0,0 +1,290 @@ +package view + +import ( + "regexp" + "strings" + "time" + + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" +) + +type GitHubNotification = github.NotificationGroup + +type GitHubComment struct { + Author string + Body string + Timestamp time.Time + IsSystem bool + SystemMsg string +} + +var ( + githubSenderRe = regexp.MustCompile(`([^/<]+)<[^>]*>`) + repoTitleRe = regexp.MustCompile(`\[([^\]]+)\]\s*(.*)`) + prNumberRe = regexp.MustCompile(`#(\d+)`) + githubFooterRe = regexp.MustCompile(`(?i)(You are receiving this because|Reply to this email directly|View it on GitHub|Unsubscribe)`) + githubLoginRe = regexp.MustCompile(`([A-Za-z0-9][A-Za-z0-9\-]*)\s+(?:left a comment|commented|opened|closed|merged|reviewed|requested|pushed|added|assigned|approved)`) +) + +func ParseGitHubNotification(email fetcher.Email) *github.NotificationGroup { + if !isGitHubEmail(email) { + return nil + } + + orgName, repoName, issueNumber, title, isPR := extractMetadata(email) + if issueNumber == 0 { + return nil + } + + eventType, state := determineEventType(email) + actor := extractActor(email) + actorLogin := extractActorLogin(email) + body := extractBodyContent(email) + + key := github.EventKey{ + OrgName: orgName, + RepoName: repoName, + IssueNumber: issueNumber, + IsPR: isPR, + } + + event := github.Event{ + EventType: eventType, + Actor: actor, + ActorLogin: actorLogin, + Body: body, + Timestamp: email.Date, + RawEmail: email, + } + + if systemMsg := detectSystemEvent(body); systemMsg != "" { + event.IsSystem = true + event.SystemMsg = systemMsg + event.Body = "" + } + + if eventType == github.EventUnknown && body == "" && !event.IsSystem { + group := github.GetGroup(key) + if group == nil { + group = github.GetOrCreateGroup(key, title, state, isPR) + } + github.AddEmailToGroup(key, email.UID, email.AccountID) + go fetchDetails(key, orgName, repoName, issueNumber, isPR) + return group + } + + existing := github.GetGroup(key) + if existing != nil && email.UID != 0 { + github.AddEmailToGroup(key, email.UID, email.AccountID) + for _, e := range existing.Events { + if e.RawEmail.UID == email.UID && e.RawEmail.AccountID == email.AccountID { + if body != "" && e.Body == "" { + github.UpdateEventBody(key, email.UID, email.AccountID, body, eventType) + } + go fetchDetails(key, orgName, repoName, issueNumber, isPR) + return existing + } + } + } + + github.AddEvent(key, title, state, isPR, event) + github.AddEmailToGroup(key, email.UID, email.AccountID) + group := github.GetGroup(key) + + go fetchDetails(key, orgName, repoName, issueNumber, isPR) + + return group +} + +func isGitHubEmail(email fetcher.Email) bool { + for _, to := range email.To { + lower := strings.ToLower(to) + if strings.Contains(lower, "notifications@github.com") || strings.Contains(lower, "@noreply.github.com") || strings.Contains(lower, "@no-reply.github.com") { + return true + } + } + lowerFrom := strings.ToLower(email.From) + if strings.Contains(lowerFrom, "notifications@github.com") || strings.Contains(lowerFrom, "@noreply.github.com") || strings.Contains(lowerFrom, "@no-reply.github.com") { + return true + } + return false +} + +func extractMetadata(email fetcher.Email) (orgName, repoName string, issueNumber int, title string, isPR bool) { + subject := email.Subject + + matches := repoTitleRe.FindStringSubmatch(subject) + if len(matches) >= 3 { + repoPart := matches[1] + parts := strings.Split(repoPart, "/") + if len(parts) == 2 { + orgName = parts[0] + repoName = parts[1] + } else { + repoName = repoPart + } + title = strings.TrimSpace(matches[2]) + numMatches := prNumberRe.FindStringSubmatch(title) + if len(numMatches) >= 2 { + title = strings.TrimSpace(prNumberRe.ReplaceAllString(title, "")) + title = strings.TrimSpace(strings.ReplaceAll(title, "(PR )", "")) + title = strings.TrimSpace(strings.ReplaceAll(title, "PR ", "")) + } + } + + numMatches := prNumberRe.FindStringSubmatch(subject) + if len(numMatches) >= 2 { + var n int + for _, c := range numMatches[1] { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + issueNumber = n + } + + lowerSubject := strings.ToLower(subject) + if strings.Contains(lowerSubject, "pull request") || strings.Contains(lowerSubject, "pr #") { + isPR = true + } + + return orgName, repoName, issueNumber, title, isPR +} + +func extractActor(email fetcher.Email) string { + fromMatch := githubSenderRe.FindStringSubmatch(email.From) + if len(fromMatch) >= 2 { + return strings.TrimSpace(fromMatch[1]) + } + return email.From +} + +func extractActorLogin(email fetcher.Email) string { + loginMatch := githubLoginRe.FindStringSubmatch(email.Body) + if len(loginMatch) >= 2 { + return loginMatch[1] + } + return "" +} + +func determineEventType(email fetcher.Email) (github.EventType, string) { + subject := strings.ToLower(email.Subject) + body := strings.ToLower(email.Body) + state := "" + + switch { + case strings.Contains(subject, "merged"): + state = "merged" + return github.EventMerged, state + case strings.Contains(subject, "closed"): + state = "closed" + return github.EventClosed, state + case strings.Contains(subject, "pull request"): + state = "open" + return github.EventOpened, state + case strings.Contains(subject, "issue"): + state = "open" + return github.EventOpened, state + case strings.Contains(body, "commented"): + return github.EventCommented, state + case strings.Contains(body, "review"): + return github.EventReview, state + case strings.Contains(body, "requested your review"): + return github.EventReviewRequest, state + case strings.Contains(body, "pushed"): + return github.EventPush, state + default: + return github.EventUnknown, state + } +} + +func extractBodyContent(email fetcher.Email) string { + body := email.Body + + lines := strings.Split(body, "\n") + var cleanLines []string + inFooter := false + + for _, line := range lines { + if githubFooterRe.MatchString(line) { + inFooter = true + continue + } + if inFooter { + continue + } + if strings.HasPrefix(line, ">") || strings.HasPrefix(line, "---") { + continue + } + cleanLines = append(cleanLines, line) + } + + return strings.TrimSpace(strings.Join(cleanLines, "\n")) +} + +func detectSystemEvent(body string) string { + lower := strings.ToLower(body) + systemPatterns := []string{ + "added the label", + "removed the label", + "assigned", + "unassigned", + "changed the title", + "marked as duplicate", + "referenced this issue", + "merged commit", + "closed this", + "reopened this", + } + for _, pattern := range systemPatterns { + if strings.Contains(lower, pattern) { + firstLine := strings.Split(body, "\n")[0] + return strings.TrimSpace(firstLine) + } + } + return "" +} + +func ExtractGitHubKey(email fetcher.Email) (github.EventKey, bool) { + if !isGitHubEmail(email) { + return github.EventKey{}, false + } + orgName, repoName, issueNumber, _, isPR := extractMetadata(email) + if issueNumber == 0 { + return github.EventKey{}, false + } + return github.EventKey{ + OrgName: orgName, + RepoName: repoName, + IssueNumber: issueNumber, + IsPR: isPR, + }, true +} + +func ExtractGitHubMetadata(email fetcher.Email) (orgName, repoName string, issueNumber int, title string, isPR bool) { + if !isGitHubEmail(email) { + return "", "", 0, "", false + } + return extractMetadata(email) +} + +func fetchDetails(key github.EventKey, orgName, repoName string, issueNumber int, isPR bool) { + client := github.NewClient() + if orgName == "" || repoName == "" { + return + } + if isPR { + details, err := client.FetchPRDetails(orgName, repoName, issueNumber) + if err != nil { + return + } + github.SetPRDetails(key, details) + } else { + details, err := client.FetchIssueDetails(orgName, repoName, issueNumber) + if err != nil { + return + } + github.SetIssueDetails(key, details) + } +} diff --git a/view/github_parser_test.go b/view/github_parser_test.go new file mode 100644 index 00000000..a8917c85 --- /dev/null +++ b/view/github_parser_test.go @@ -0,0 +1,290 @@ +package view + +import ( + "testing" + "time" + + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/github" +) + +func TestParseGitHubNotification(t *testing.T) { + tests := []struct { + name string + email fetcher.Email + expectGitHub bool + expectOrgName string + expectRepo string + expectTitle string + expectNumber int + }{ + { + name: "GitHub PR notification", + email: fetcher.Email{ + From: "John Doe ", + To: []string{"user@example.com"}, + Subject: "[org/repo] Add new feature #123", + Body: "John opened this pull request", + Date: time.Now(), + }, + expectGitHub: true, + expectOrgName: "org", + expectRepo: "repo", + expectTitle: "Add new feature", + expectNumber: 123, + }, + { + name: "GitHub repo-specific notification", + email: fetcher.Email{ + From: "matcha ", + To: []string{"user@example.com"}, + Subject: "[floatpane/matcha] Fix email parsing #456", + Body: "Jane commented on this issue", + Date: time.Now(), + }, + expectGitHub: true, + expectOrgName: "floatpane", + expectRepo: "matcha", + expectTitle: "Fix email parsing", + expectNumber: 456, + }, + { + name: "GitHub issue notification", + email: fetcher.Email{ + From: "Jane Smith ", + To: []string{"user@example.com"}, + Subject: "[myorg/myrepo] Bug in login flow #456", + Body: "Jane commented on this issue", + Date: time.Now(), + }, + expectGitHub: true, + expectOrgName: "myorg", + expectRepo: "myrepo", + expectTitle: "Bug in login flow", + expectNumber: 456, + }, + { + name: "Non-GitHub email", + email: fetcher.Email{ + From: "Someone ", + To: []string{"user@example.com"}, + Subject: "Regular email", + Body: "This is not from GitHub", + Date: time.Now(), + }, + expectGitHub: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseGitHubNotification(tt.email) + if tt.expectGitHub && result == nil { + t.Fatal("expected GitHub notification but got nil") + } + if !tt.expectGitHub && result != nil { + t.Fatal("expected nil but got GitHub notification") + } + if result != nil { + if result.Key.OrgName != tt.expectOrgName { + t.Errorf("expected org %q, got %q", tt.expectOrgName, result.Key.OrgName) + } + if result.Key.RepoName != tt.expectRepo { + t.Errorf("expected repo %q, got %q", tt.expectRepo, result.Key.RepoName) + } + if result.Title != tt.expectTitle { + t.Errorf("expected title %q, got %q", tt.expectTitle, result.Title) + } + if result.Key.IssueNumber != tt.expectNumber { + t.Errorf("expected number %d, got %d", tt.expectNumber, result.Key.IssueNumber) + } + } + }) + } +} + +func TestIsGitHubEmail(t *testing.T) { + tests := []struct { + name string + email fetcher.Email + expect bool + }{ + { + name: "From notifications@github.com", + email: fetcher.Email{ + From: "GitHub ", + }, + expect: true, + }, + { + name: "To notifications@github.com", + email: fetcher.Email{ + To: []string{"notifications@github.com"}, + }, + expect: true, + }, + { + name: "From repo@no-reply.github.com", + email: fetcher.Email{ + From: "matcha ", + }, + expect: true, + }, + { + name: "To repo@no-reply.github.com", + email: fetcher.Email{ + To: []string{"myrepo@no-reply.github.com"}, + }, + expect: true, + }, + { + name: "To repo@noreply.github.com (no hyphen)", + email: fetcher.Email{ + To: []string{"matcha@noreply.github.com"}, + }, + expect: true, + }, + { + name: "Regular email", + email: fetcher.Email{ + From: "User ", + To: []string{"recipient@example.com"}, + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isGitHubEmail(tt.email) + if result != tt.expect { + t.Errorf("expected %v, got %v", tt.expect, result) + } + }) + } +} + +func TestExtractBodyContent(t *testing.T) { + email := fetcher.Email{ + Body: `John commented: + +This is a great feature! + +--- +Reply to this email directly +View it on GitHub +Unsubscribe`, + } + + result := extractBodyContent(email) + + expected := "John commented:\n\nThis is a great feature!" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestDetermineEventType(t *testing.T) { + tests := []struct { + name string + subject string + body string + expected github.EventType + expectedState string + isPR bool + }{ + { + name: "Closed PR", + subject: "[org/repo] PR closed #123", + body: "", + expected: github.EventClosed, + expectedState: "closed", + }, + { + name: "Merged PR", + subject: "[org/repo] PR merged #123", + body: "", + expected: github.EventMerged, + expectedState: "merged", + isPR: true, + }, + { + name: "Opened PR", + subject: "[org/repo] Pull request #123", + body: "", + expected: github.EventOpened, + expectedState: "open", + isPR: true, + }, + { + name: "Opened issue", + subject: "[org/repo] Issue #123", + body: "", + expected: github.EventOpened, + expectedState: "open", + }, + { + name: "Comment", + subject: "[org/repo] Something", + body: "John commented on this", + expected: github.EventCommented, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := fetcher.Email{ + Subject: tt.subject, + Body: tt.body, + } + eventType, state := determineEventType(email) + if eventType != tt.expected { + t.Errorf("expected event type %v, got %v", tt.expected, eventType) + } + if tt.expectedState != "" && state != tt.expectedState { + t.Errorf("expected state %q, got %q", tt.expectedState, state) + } + }) + } +} + +func TestGroupingByPR(t *testing.T) { + key := github.EventKey{ + OrgName: "floatpane", + RepoName: "matcha", + IssueNumber: 1655, + IsPR: true, + } + + emails := []fetcher.Email{ + { + From: "Lea ", + To: []string{"matcha@noreply.github.com"}, + Subject: "Re: [floatpane/matcha] fix: remove limit for headers (PR #1655)", + Body: "LeaWhoCodes left a comment\n\nThis is a demo feature.", + Date: time.Now().Add(-2 * time.Hour), + }, + { + From: "Drew ", + To: []string{"matcha@noreply.github.com"}, + Subject: "Re: [floatpane/matcha] fix: remove limit for headers (PR #1655)", + Body: "Drew commented\n\nLooks good to me.", + Date: time.Now().Add(-1 * time.Hour), + }, + } + + for _, email := range emails { + ParseGitHubNotification(email) + } + + group := github.GetGroup(key) + if group == nil { + t.Fatal("expected group to exist") + } + if len(group.Events) != 2 { + t.Errorf("expected 2 events, got %d", len(group.Events)) + } + if group.Key.IssueNumber != 1655 { + t.Errorf("expected issue number 1655, got %d", group.Key.IssueNumber) + } +} diff --git a/view/html.go b/view/html.go index 5ce16b8e..b6f5b765 100644 --- a/view/html.go +++ b/view/html.go @@ -172,6 +172,10 @@ func hyperlinkSupported() bool { // hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL. func hyperlink(url, text string) string { + return Hyperlink(url, text) +} + +func Hyperlink(url, text string) string { url = strings.TrimSpace(url) text = stripTerminalControls(text) if text == "" { From 2904a003c46d4686f244992927c1d73dcde184ab Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 7 Jul 2026 16:22:25 +0400 Subject: [PATCH 2/2] feat: pr actions Signed-off-by: drew --- app/app.go | 15 ++ app/pr_actions.go | 195 ++++++++++++++++++++++ backend/repoapi/client.go | 242 +++++++++++++++++++++++++++ backend/repoapi/client_test.go | 285 ++++++++++++++++++++++++++++++++ backend/repoapi/types.go | 189 +++++++++++++++++++++ config/default_keybinds.json | 5 +- config/keybinds.go | 6 + config/keybinds_test.go | 2 +- internal/github/client.go | 77 +++++---- tui/email_view.go | 48 ++++++ tui/github_conversation_view.go | 87 +++++++++- tui/inbox.go | 32 ++-- tui/messages.go | 54 +++++- view/github_parser.go | 10 +- 14 files changed, 1183 insertions(+), 64 deletions(-) create mode 100644 app/pr_actions.go create mode 100644 backend/repoapi/client.go create mode 100644 backend/repoapi/client_test.go create mode 100644 backend/repoapi/types.go diff --git a/app/app.go b/app/app.go index bbb727a2..cccc5dd1 100644 --- a/app/app.go +++ b/app/app.go @@ -2038,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, "