diff --git a/docs/src/content/docs/configuration/issue-section.mdx b/docs/src/content/docs/configuration/issue-section.mdx index 5fdc91f8b..034e5164e 100644 --- a/docs/src/content/docs/configuration/issue-section.mdx +++ b/docs/src/content/docs/configuration/issue-section.mdx @@ -7,6 +7,7 @@ title: Issue Sections Defines a section in the dashboard's issues view. - Every section must define a [`title`] and [`filters`]. +- When you define [`host`] for a section, that section fetches data from that GitHub host. - When you define [`limit`] for a section, that value overrides the [`defaults.issuesLimit`] setting. - When you define [`layout`] for a section, that value overrides the @@ -14,6 +15,7 @@ Defines a section in the dashboard's issues view. [`title`]: #issues-title-title [`filters`]: #issues-filters-filters +[`host`]: #github-host-host [`limit`]: #issues-fetch-limit-limit [`layout`]: #issues-section-layout-layout [`defaults.issuesLimit`]: /configuration/defaults/#issue-fetch-limit-issueslimit @@ -52,6 +54,30 @@ For more information about writing filters for searching GitHub, see [Searching] [Searching]: /configuration/searching +## GitHub Host (`host`) + +| Type | Default | +| :----- | :------ | +| String | (empty) | + +This setting specifies the GitHub host for this section. Use this when you need to fetch +issues from a GitHub Enterprise instance or a different GitHub host. + +When not specified or empty, the section uses the default GitHub host (github.com or the +host configured in your `gh` CLI). + +### Example + +```yaml +issuesSections: + - title: Enterprise Issues + filters: is:open author:@me + host: github.enterprise.com + - title: GitHub.com Issues + filters: is:open author:@me + # no host = default (github.com) +``` + ## Issues Section Layout (`layout`) You can define how a Issues section displays items in its table by setting options for the diff --git a/docs/src/content/docs/configuration/notification-section.mdx b/docs/src/content/docs/configuration/notification-section.mdx index 84ff9d726..a1e1aece8 100644 --- a/docs/src/content/docs/configuration/notification-section.mdx +++ b/docs/src/content/docs/configuration/notification-section.mdx @@ -7,11 +7,13 @@ title: Notification Sections Defines sections in the dashboard's Notifications view. - Every section must define a [`title`] and [`filters`]. +- When you define [`host`] for a section, that section fetches data from that GitHub host. - When you define [`limit`] for a section, that value overrides the [`defaults.notificationsLimit`] setting. [`title`]: #notification-title-title [`filters`]: #notification-filters-filters +[`host`]: #github-host-host [`limit`]: #notification-fetch-limit-limit [`defaults.notificationsLimit`]: /configuration/defaults/#notifications-fetch-limit-notificationslimit @@ -116,6 +118,30 @@ This setting defines the filters for notifications in the section's table. Notif - **Explicit `is:unread`**: Shows only unread notifications, excluding bookmarked read notifications. This overrides the `includeReadNotifications` setting. - **Reason filters**: Applied client-side after fetching from GitHub's API +## GitHub Host (`host`) + +| Type | Default | +| :----- | :------ | +| String | (empty) | + +This setting specifies the GitHub host for this section. Use this when you need to fetch +notifications from a GitHub Enterprise instance or a different GitHub host. + +When not specified or empty, the section uses the default GitHub host (github.com or the +host configured in your `gh` CLI). + +### Example + +```yaml +notificationsSections: + - title: Enterprise Notifications + filters: "" + host: github.enterprise.com + - title: GitHub.com Notifications + filters: "" + # no host = default (github.com) +``` + ## Notification Fetch Limit (`limit`) | Type | Minimum | Default | diff --git a/docs/src/content/docs/configuration/pr-section.mdx b/docs/src/content/docs/configuration/pr-section.mdx index e47fe011e..e2987b699 100644 --- a/docs/src/content/docs/configuration/pr-section.mdx +++ b/docs/src/content/docs/configuration/pr-section.mdx @@ -7,6 +7,7 @@ title: PR Sections Defines a section in the dashboard's PRs view. - Every section must define a [`title`] and [`filters`]. +- When you define [`host`] for a section, that section fetches data from that GitHub host. - When you define [`limit`] for a section, that value overrides the [`defaults.prsLimit`] setting. - When you define [`layout`] for a section, that value overrides the @@ -14,6 +15,7 @@ Defines a section in the dashboard's PRs view. [`title`]: #pr-title-title [`filters`]: #pr-filters-filters +[`host`]: #github-host-host [`limit`]: #pr-fetch-limit-limit [`layout`]: #pr-section-layout-layout [`defaults.prsLimit`]: /configuration/defaults/#pr-fetch-limit @@ -52,6 +54,30 @@ For more information about writing filters for searching GitHub, see [Searching] [Searching]: /configuration/searching +## GitHub Host (`host`) + +| Type | Default | +| :----- | :------ | +| String | (empty) | + +This setting specifies the GitHub host for this section. Use this when you need to fetch +PRs from a GitHub Enterprise instance or a different GitHub host. + +When not specified or empty, the section uses the default GitHub host (github.com or the +host configured in your `gh` CLI). + +### Example + +```yaml +prSections: + - title: Enterprise PRs + filters: is:open author:@me + host: github.enterprise.com + - title: GitHub.com PRs + filters: is:open author:@me + # no host = default (github.com) +``` + ## PR Section Layout (`layout`) You can define how a PR section displays items in its table by setting options for the diff --git a/internal/config/parser.go b/internal/config/parser.go index ed7dfd713..34188b9b5 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -84,6 +84,7 @@ const ( type SectionConfig struct { Title string Filters string + Host string `yaml:"host,omitempty"` Limit *int `yaml:"limit,omitempty"` Type *ViewType `yaml:"type,omitempty"` } @@ -91,6 +92,7 @@ type SectionConfig struct { type PrsSectionConfig struct { Title string Filters string + Host string `yaml:"host,omitempty"` Limit *int `yaml:"limit,omitempty"` Layout PrsLayoutConfig `yaml:"layout,omitempty"` Type *ViewType `yaml:"type,omitempty"` @@ -99,6 +101,7 @@ type PrsSectionConfig struct { type IssuesSectionConfig struct { Title string Filters string + Host string `yaml:"host,omitempty"` Limit *int `yaml:"limit,omitempty"` Layout IssuesLayoutConfig `yaml:"layout,omitempty"` } @@ -106,7 +109,8 @@ type IssuesSectionConfig struct { type NotificationsSectionConfig struct { Title string Filters string - Limit *int `yaml:"limit,omitempty"` + Host string `yaml:"host,omitempty"` + Limit *int `yaml:"limit,omitempty"` } type PreviewConfig struct { diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go index e0a5de8d9..43853d169 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -221,3 +221,57 @@ func setupConfigEnvVar(t *testing.T) func() { os.Unsetenv("GH_DASH_CONFIG") } } + +func TestHostFieldParsing(t *testing.T) { + t.Run("Should parse host field in section configs", func(t *testing.T) { + clearEnv := setXDGConfigHomeEnvVar(t, "testdata") + defer clearEnv() + + cwd := Testwd(t) + parsed, err := ParseConfig(Location{ + ConfigFlag: path.Join(cwd, "./testdata/host-config.yml"), + }) + testutils.AssertNoError(t, err) + + // Check PR sections + require.Len(t, parsed.PRSections, 2) + assert.Equal(t, "github.enterprise.com", parsed.PRSections[0].Host) + assert.Equal(t, "", parsed.PRSections[1].Host) // no host = empty string + + // Check Issues sections + require.Len(t, parsed.IssuesSections, 2) + assert.Equal(t, "github.enterprise.com", parsed.IssuesSections[0].Host) + assert.Equal(t, "", parsed.IssuesSections[1].Host) + + // Check Notifications sections + require.Len(t, parsed.NotificationsSections, 2) + assert.Equal(t, "github.enterprise.com", parsed.NotificationsSections[0].Host) + assert.Equal(t, "", parsed.NotificationsSections[1].Host) + }) + + t.Run("ToSectionConfig should include host field", func(t *testing.T) { + prCfg := PrsSectionConfig{ + Title: "Test", + Filters: "is:open", + Host: "github.enterprise.com", + } + sectionCfg := prCfg.ToSectionConfig() + assert.Equal(t, "github.enterprise.com", sectionCfg.Host) + + issuesCfg := IssuesSectionConfig{ + Title: "Test", + Filters: "is:open", + Host: "github.enterprise.com", + } + sectionCfg = issuesCfg.ToSectionConfig() + assert.Equal(t, "github.enterprise.com", sectionCfg.Host) + + notifCfg := NotificationsSectionConfig{ + Title: "Test", + Filters: "", + Host: "github.enterprise.com", + } + sectionCfg = notifCfg.ToSectionConfig() + assert.Equal(t, "github.enterprise.com", sectionCfg.Host) + }) +} diff --git a/internal/config/testdata/host-config.yml b/internal/config/testdata/host-config.yml new file mode 100644 index 000000000..bf9f20e51 --- /dev/null +++ b/internal/config/testdata/host-config.yml @@ -0,0 +1,22 @@ +# Test config for per-section host field +prSections: + - title: Enterprise PRs + filters: is:open author:@me + host: github.enterprise.com + - title: GitHub.com PRs + filters: is:open author:@me + # no host = default + +issuesSections: + - title: Enterprise Issues + filters: is:open author:@me + host: github.enterprise.com + - title: GitHub.com Issues + filters: is:open author:@me + +notificationsSections: + - title: Enterprise Notifications + filters: "" + host: github.enterprise.com + - title: GitHub.com Notifications + filters: "" diff --git a/internal/config/utils.go b/internal/config/utils.go index bfccc9621..b676c5f70 100644 --- a/internal/config/utils.go +++ b/internal/config/utils.go @@ -32,6 +32,7 @@ func (cfg PrsSectionConfig) ToSectionConfig() SectionConfig { return SectionConfig{ Title: cfg.Title, Filters: cfg.Filters, + Host: cfg.Host, Limit: cfg.Limit, Type: cfg.Type, } @@ -41,6 +42,7 @@ func (cfg IssuesSectionConfig) ToSectionConfig() SectionConfig { return SectionConfig{ Title: cfg.Title, Filters: cfg.Filters, + Host: cfg.Host, Limit: cfg.Limit, } } @@ -49,6 +51,7 @@ func (cfg NotificationsSectionConfig) ToSectionConfig() SectionConfig { return SectionConfig{ Title: cfg.Title, Filters: cfg.Filters, + Host: cfg.Host, Limit: cfg.Limit, } } diff --git a/internal/data/assignee.go b/internal/data/assignee.go index b2bc9edfc..96418babc 100644 --- a/internal/data/assignee.go +++ b/internal/data/assignee.go @@ -1,5 +1,7 @@ package data +import "slices" + type Assignees struct { Nodes []Assignee } @@ -7,3 +9,34 @@ type Assignees struct { type Assignee struct { Login string } + +// AssigneesFromLogins creates an Assignees struct from a slice of login names. +func AssigneesFromLogins(logins []string) Assignees { + nodes := make([]Assignee, len(logins)) + for i, login := range logins { + nodes[i] = Assignee{Login: login} + } + return Assignees{Nodes: nodes} +} + +// AddAssignees returns a new slice with addedAssignees appended (deduped). +func AddAssignees(assignees, addedAssignees []Assignee) []Assignee { + result := assignees + for _, a := range addedAssignees { + if !slices.Contains(result, a) { + result = append(result, a) + } + } + return result +} + +// RemoveAssignees returns a new slice with removedAssignees filtered out. +func RemoveAssignees(assignees, removedAssignees []Assignee) []Assignee { + result := make([]Assignee, 0, len(assignees)) + for _, a := range assignees { + if !slices.Contains(removedAssignees, a) { + result = append(result, a) + } + } + return result +} diff --git a/internal/data/issueapi.go b/internal/data/issueapi.go index 9e5d0bc1c..bb74d708e 100644 --- a/internal/data/issueapi.go +++ b/internal/data/issueapi.go @@ -94,11 +94,8 @@ func makeIssuesQuery(query string) string { return fmt.Sprintf("is:issue archived:false %s sort:updated", query) } -func FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { - var err error - if client == nil { - client, err = gh.DefaultGraphQLClient() - } +func FetchIssues(query string, limit int, pageInfo *PageInfo, host string) (IssuesResponse, error) { + c, err := getGraphQLClientForHost(host) if err != nil { return IssuesResponse{}, err @@ -123,7 +120,7 @@ func FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, e "endCursor": (*graphql.String)(endCursor), } log.Debug("Fetching issues", "query", query, "limit", limit, "endCursor", endCursor) - err = client.Query("SearchIssues", &queryResult, variables) + err = c.Query("SearchIssues", &queryResult, variables) if err != nil { return IssuesResponse{}, err } diff --git a/internal/data/notificationapi.go b/internal/data/notificationapi.go index baef701d9..eb622015d 100644 --- a/internal/data/notificationapi.go +++ b/internal/data/notificationapi.go @@ -1,8 +1,10 @@ package data import ( + "encoding/json" "fmt" "strings" + "sync" "time" "github.com/charmbracelet/log" @@ -33,7 +35,11 @@ const ( ReasonSecurityAlert = "security_alert" ) -var restClient *gh.RESTClient +var ( + restClient *gh.RESTClient + hostRESTClients = make(map[string]*gh.RESTClient) + hostRESTClientsMu sync.Mutex +) type NotificationSubject struct { Title string `json:"title"` @@ -109,6 +115,23 @@ func getRESTClient() (*gh.RESTClient, error) { return restClient, err } +func getRESTClientForHost(host string) (*gh.RESTClient, error) { + if host == "" { + return getRESTClient() + } + hostRESTClientsMu.Lock() + defer hostRESTClientsMu.Unlock() + if c, ok := hostRESTClients[host]; ok { + return c, nil + } + c, err := gh.NewRESTClient(gh.ClientOptions{Host: host}) + if err != nil { + return nil, err + } + hostRESTClients[host] = c + return c, nil +} + // NotificationReadState represents the read state filter for notifications type NotificationReadState string @@ -118,8 +141,8 @@ const ( NotificationStateAll NotificationReadState = "all" // Both read and unread ) -func FetchNotifications(limit int, repoFilters []string, readState NotificationReadState, pageInfo *PageInfo) (NotificationsResponse, error) { - client, err := getRESTClient() +func FetchNotifications(limit int, repoFilters []string, readState NotificationReadState, pageInfo *PageInfo, host string) (NotificationsResponse, error) { + client, err := getRESTClientForHost(host) if err != nil { return NotificationsResponse{}, err } @@ -210,8 +233,8 @@ func FetchNotifications(limit int, repoFilters []string, readState NotificationR // FetchNotificationByThreadId fetches a single notification by its thread ID. // This is useful for fetching bookmarked or session-marked-read notifications // that may not appear in the regular notifications list. -func FetchNotificationByThreadId(threadId string) (*NotificationData, error) { - client, err := getRESTClient() +func FetchNotificationByThreadId(threadId string, host string) (*NotificationData, error) { + client, err := getRESTClientForHost(host) if err != nil { return nil, err } @@ -228,8 +251,8 @@ func FetchNotificationByThreadId(threadId string) (*NotificationData, error) { return ¬ification, nil } -func MarkNotificationDone(threadId string) error { - client, err := getRESTClient() +func MarkNotificationDone(threadId string, host string) error { + client, err := getRESTClientForHost(host) if err != nil { return err } @@ -246,8 +269,8 @@ func MarkNotificationDone(threadId string) error { return nil } -func MarkNotificationRead(threadId string) error { - client, err := getRESTClient() +func MarkNotificationRead(threadId string, host string) error { + client, err := getRESTClientForHost(host) if err != nil { return err } @@ -265,8 +288,8 @@ func MarkNotificationRead(threadId string) error { return nil } -func UnsubscribeFromThread(threadId string) error { - client, err := getRESTClient() +func UnsubscribeFromThread(threadId string, host string) error { + client, err := getRESTClientForHost(host) if err != nil { return err } @@ -284,8 +307,8 @@ func UnsubscribeFromThread(threadId string) error { return nil } -func MarkAllNotificationsRead() error { - client, err := getRESTClient() +func MarkAllNotificationsRead(host string) error { + client, err := getRESTClientForHost(host) if err != nil { return err } @@ -327,27 +350,29 @@ type WorkflowRunsResponse struct { // FetchCommentAuthor fetches the author of a comment from its API URL // apiUrl is like: https://api.github.com/repos/owner/repo/issues/comments/123456 -func FetchCommentAuthor(apiUrl string) (string, error) { +func FetchCommentAuthor(apiUrl string, host string) (string, error) { if apiUrl == "" { return "", nil } - client, err := getRESTClient() + // Get an authenticated HTTP client for this host + httpClient, err := gh.NewHTTPClient(gh.ClientOptions{Host: host}) if err != nil { return "", err } - // Extract the path from the full URL - const apiPrefix = "https://api.github.com/" - path := apiUrl - if len(apiUrl) > len(apiPrefix) && apiUrl[:len(apiPrefix)] == apiPrefix { - path = apiUrl[len(apiPrefix):] + // Fetch the URL directly - no path manipulation needed + resp, err := httpClient.Get(apiUrl) + if err != nil { + log.Debug("Failed to fetch comment author", "url", apiUrl, "err", err) + return "", err } + defer resp.Body.Close() var response CommentResponse - err = client.Get(path, &response) + err = json.NewDecoder(resp.Body).Decode(&response) if err != nil { - log.Debug("Failed to fetch comment author", "url", apiUrl, "err", err) + log.Debug("Failed to decode comment response", "url", apiUrl, "err", err) return "", err } @@ -391,8 +416,8 @@ func FindBestWorkflowRunMatch(runs []WorkflowRun, notificationUpdatedAt time.Tim // based on the notification's updated_at timestamp. Returns the HTML URL of the matching run. // The title parameter is the notification subject title (e.g., "CI / build (push)") // which may help identify the correct workflow run. -func FetchRecentWorkflowRun(repo string, notificationUpdatedAt time.Time, title string) (string, error) { - client, err := getRESTClient() +func FetchRecentWorkflowRun(repo string, notificationUpdatedAt time.Time, title string, host string) (string, error) { + client, err := getRESTClientForHost(host) if err != nil { return "", err } diff --git a/internal/data/prapi.go b/internal/data/prapi.go index ecafd6c9a..5e7130a7f 100644 --- a/internal/data/prapi.go +++ b/internal/data/prapi.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "sync" "time" "github.com/charmbracelet/log" @@ -467,6 +468,8 @@ type PullRequestsResponse struct { var ( client *gh.GraphQLClient cachedClient *gh.GraphQLClient + hostGraphQLClients = make(map[string]*gh.GraphQLClient) + hostGraphQLClientsMu sync.Mutex ) func SetClient(c *gh.GraphQLClient) { @@ -486,18 +489,47 @@ func IsEnrichmentCacheCleared() bool { return cachedClient == nil } -func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { - var err error - if client == nil { +// getGraphQLClientForHost returns a cached GraphQL client for the given host, +// or the default client when host is empty. +func getGraphQLClientForHost(host string) (*gh.GraphQLClient, error) { + if host == "" { + if client != nil { + return client, nil + } if config.IsFeatureEnabled(config.FF_MOCK_DATA) { log.Info("using mock data", "server", "https://localhost:3000") http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - client, err = gh.NewGraphQLClient(gh.ClientOptions{Host: "localhost:3000", AuthToken: "fake-token"}) - } else { - client, err = gh.DefaultGraphQLClient() + c, err := gh.NewGraphQLClient(gh.ClientOptions{Host: "localhost:3000", AuthToken: "fake-token"}) + if err != nil { + return nil, err + } + client = c + return client, nil + } + c, err := gh.DefaultGraphQLClient() + if err != nil { + return nil, err } + client = c + return client, nil } + hostGraphQLClientsMu.Lock() + defer hostGraphQLClientsMu.Unlock() + if c, ok := hostGraphQLClients[host]; ok { + return c, nil + } + c, err := gh.NewGraphQLClient(gh.ClientOptions{Host: host}) + if err != nil { + return nil, err + } + hostGraphQLClients[host] = c + return c, nil +} + +func FetchPullRequests(query string, limit int, pageInfo *PageInfo, host string) (PullRequestsResponse, error) { + c, err := getGraphQLClientForHost(host) + if err != nil { return PullRequestsResponse{}, err } @@ -521,7 +553,7 @@ func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequest "endCursor": (*graphql.String)(endCursor), } log.Debug("Fetching PRs", "query", query, "limit", limit, "endCursor", endCursor) - err = client.Query("SearchPullRequests", &queryResult, variables) + err = c.Query("SearchPullRequests", &queryResult, variables) if err != nil { return PullRequestsResponse{}, err } diff --git a/internal/data/utils.go b/internal/data/utils.go index f757ab182..2223f9b9f 100644 --- a/internal/data/utils.go +++ b/internal/data/utils.go @@ -16,6 +16,15 @@ type RowData interface { GetUpdatedAt() time.Time } +// RepoWithHost returns "HOST/OWNER/REPO" when host is non-empty, +// or "OWNER/REPO" when empty. This matches the gh CLI's -R flag format. +func RepoWithHost(repo string, host string) string { + if host != "" { + return host + "/" + repo + } + return repo +} + func GetAuthorRoleIcon(role string, theme theme.Theme) string { // https://docs.github.com/en/graphql/reference/enums#commentauthorassociation switch role { diff --git a/internal/tui/components/issuessection/issuessection.go b/internal/tui/components/issuessection/issuessection.go index b998a756d..9bc27cfd7 100644 --- a/internal/tui/components/issuessection/issuessection.go +++ b/internal/tui/components/issuessection/issuessection.go @@ -2,7 +2,6 @@ package issuessection import ( "fmt" - "slices" "time" "github.com/charmbracelet/bubbles/key" @@ -92,9 +91,9 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { sid := tasks.SectionIdentifier{Id: m.Id, Type: SectionType} switch action { case "close": - cmd = tasks.CloseIssue(m.Ctx, sid, issue) + cmd = tasks.CloseIssue(m.Ctx, sid, issue, m.Config.Host) case "reopen": - cmd = tasks.ReopenIssue(m.Ctx, sid, issue) + cmd = tasks.ReopenIssue(m.Ctx, sid, issue, m.Config.Host) } } @@ -138,11 +137,11 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { currIssue.Comments.Nodes = append(currIssue.Comments.Nodes, *msg.NewComment) } if msg.AddedAssignees != nil { - currIssue.Assignees.Nodes = addAssignees( + currIssue.Assignees.Nodes = data.AddAssignees( currIssue.Assignees.Nodes, msg.AddedAssignees.Nodes) } if msg.RemovedAssignees != nil { - currIssue.Assignees.Nodes = removeAssignees( + currIssue.Assignees.Nodes = data.RemoveAssignees( currIssue.Assignees.Nodes, msg.RemovedAssignees.Nodes) } m.Issues[i] = currIssue @@ -323,7 +322,7 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { if limit == nil { limit = &m.Ctx.Config.Defaults.IssuesLimit } - res, err := data.FetchIssues(m.GetFilters(), *limit, m.PageInfo) + res, err := data.FetchIssues(m.GetFilters(), *limit, m.PageInfo, m.Config.Host) if err != nil { return constants.TaskFinishedMsg{ SectionId: m.Id, @@ -391,34 +390,6 @@ type SectionIssuesFetchedMsg struct { TaskId string } -func addAssignees(assignees, addedAssignees []data.Assignee) []data.Assignee { - newAssignees := assignees - for _, assignee := range addedAssignees { - if !assigneesContains(newAssignees, assignee) { - newAssignees = append(newAssignees, assignee) - } - } - - return newAssignees -} - -func removeAssignees( - assignees, removedAssignees []data.Assignee, -) []data.Assignee { - newAssignees := []data.Assignee{} - for _, assignee := range assignees { - if !assigneesContains(removedAssignees, assignee) { - newAssignees = append(newAssignees, assignee) - } - } - - return newAssignees -} - -func assigneesContains(assignees []data.Assignee, assignee data.Assignee) bool { - return slices.Contains(assignees, assignee) -} - func (m Model) GetItemSingularForm() string { return "Issue" } diff --git a/internal/tui/components/issueview/issueview.go b/internal/tui/components/issueview/issueview.go index 225515355..34d87fbc2 100644 --- a/internal/tui/components/issueview/issueview.go +++ b/internal/tui/components/issueview/issueview.go @@ -42,6 +42,7 @@ type Model struct { ctx *context.ProgramContext issue *issuerow.Issue sectionId int + host string width int ShowConfirmCancel bool @@ -128,7 +129,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd, *IssueAction) { case tea.KeyCtrlD: if len(strings.Trim(m.inputBox.Value(), " ")) != 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: issuessection.SectionType} - cmd = tasks.CommentOnIssue(m.ctx, sid, m.issue.Data, m.inputBox.Value()) + cmd = tasks.CommentOnIssue(m.ctx, sid, m.issue.Data, m.inputBox.Value(), m.host) } m.inputBox.Blur() m.isCommenting = false @@ -161,7 +162,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd, *IssueAction) { labels := allLabels(m.inputBox.Value()) if len(labels) > 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: issuessection.SectionType} - cmd = tasks.LabelIssue(m.ctx, sid, m.issue.Data, labels, m.issue.Data.Labels.Nodes) + cmd = tasks.LabelIssue(m.ctx, sid, m.issue.Data, labels, m.issue.Data.Labels.Nodes, m.host) } m.inputBox.Blur() m.isLabeling = false @@ -204,7 +205,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd, *IssueAction) { usernames := strings.Fields(m.inputBox.Value()) if len(usernames) > 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: issuessection.SectionType} - cmd = tasks.AssignIssue(m.ctx, sid, m.issue.Data, usernames) + cmd = tasks.AssignIssue(m.ctx, sid, m.issue.Data, usernames, m.host) } m.inputBox.Blur() m.isAssigning = false @@ -224,7 +225,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd, *IssueAction) { usernames := strings.Fields(m.inputBox.Value()) if len(usernames) > 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: issuessection.SectionType} - cmd = tasks.UnassignIssue(m.ctx, sid, m.issue.Data, usernames) + cmd = tasks.UnassignIssue(m.ctx, sid, m.issue.Data, usernames, m.host) } m.inputBox.Blur() m.isUnassigning = false @@ -391,6 +392,10 @@ func (m *Model) SetSectionId(id int) { m.sectionId = id } +func (m *Model) SetHost(host string) { + m.host = host +} + func (m *Model) SetRow(data *data.IssueData) { if data == nil { m.issue = nil diff --git a/internal/tui/components/notificationssection/commands.go b/internal/tui/components/notificationssection/commands.go index 262ad1c63..ddd1608c7 100644 --- a/internal/tui/components/notificationssection/commands.go +++ b/internal/tui/components/notificationssection/commands.go @@ -29,6 +29,7 @@ func (m *Model) markAsDone() tea.Cmd { notificationId := notification.GetId() updatedAt := notification.Notification.UpdatedAt + host := m.Config.Host taskId := fmt.Sprintf("notification_done_%s", notificationId) task := context.Task{ Id: taskId, @@ -39,7 +40,7 @@ func (m *Model) markAsDone() tea.Cmd { } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - err := markNotificationDoneFunc(notificationId) + err := markNotificationDoneFunc(notificationId, host) if err == nil { // Persist to done store so it stays hidden across sessions data.GetDoneStore().MarkDone(notificationId, updatedAt) @@ -66,6 +67,7 @@ func (m *Model) markAllAsDone() tea.Cmd { } count := len(m.Notifications) + host := m.Config.Host taskId := "notification_done_all" task := context.Task{ Id: taskId, @@ -90,7 +92,7 @@ func (m *Model) markAllAsDone() tea.Cmd { doneStore := data.GetDoneStore() var lastErr error for _, e := range entries { - if err := data.MarkNotificationDone(e.id); err != nil { + if err := markNotificationDoneFunc(e.id, host); err != nil { lastErr = err } else { // Persist to done store so it stays hidden across sessions @@ -119,6 +121,7 @@ func (m *Model) markAllAsDone() tea.Cmd { } func (m *Model) markAllAsRead() tea.Cmd { + host := m.Config.Host taskId := "notification_read_all" task := context.Task{ Id: taskId, @@ -129,7 +132,7 @@ func (m *Model) markAllAsRead() tea.Cmd { } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - err := data.MarkAllNotificationsRead() + err := data.MarkAllNotificationsRead(host) if err != nil { return constants.TaskFinishedMsg{ SectionId: m.Id, @@ -168,6 +171,7 @@ func (m *Model) markAsRead() tea.Cmd { } notificationId := notification.GetId() + host := m.Config.Host taskId := fmt.Sprintf("notification_read_%s", notificationId) task := context.Task{ Id: taskId, @@ -178,7 +182,7 @@ func (m *Model) markAsRead() tea.Cmd { } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - err := data.MarkNotificationRead(notificationId) + err := data.MarkNotificationRead(notificationId, host) return constants.TaskFinishedMsg{ SectionId: m.Id, SectionType: SectionType, @@ -199,6 +203,7 @@ func (m *Model) unsubscribe() tea.Cmd { } notificationId := notification.GetId() + host := m.Config.Host taskId := fmt.Sprintf("notification_unsubscribe_%s", notificationId) task := context.Task{ Id: taskId, @@ -209,7 +214,7 @@ func (m *Model) unsubscribe() tea.Cmd { } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - err := data.UnsubscribeFromThread(notificationId) + err := data.UnsubscribeFromThread(notificationId, host) return constants.TaskFinishedMsg{ SectionId: m.Id, SectionType: SectionType, @@ -242,10 +247,11 @@ func (m *Model) openInBrowser() tea.Cmd { notificationId := notification.GetId() notificationUrl := notification.GetUrl() + host := m.Config.Host return tea.Batch( func() tea.Msg { - _ = data.MarkNotificationRead(notificationId) + _ = data.MarkNotificationRead(notificationId, host) return UpdateNotificationReadStateMsg{ Id: notificationId, Unread: false, diff --git a/internal/tui/components/notificationssection/commands_test.go b/internal/tui/components/notificationssection/commands_test.go index 958d8eeb9..752e295fd 100644 --- a/internal/tui/components/notificationssection/commands_test.go +++ b/internal/tui/components/notificationssection/commands_test.go @@ -127,7 +127,7 @@ func TestCheckoutPRErrorMessage(t *testing.T) { func TestMarkAsDoneStoresCorrectTimestamp(t *testing.T) { // Mock the API call to succeed without network access. origFunc := markNotificationDoneFunc - markNotificationDoneFunc = func(string) error { return nil } + markNotificationDoneFunc = func(string, string) error { return nil } defer func() { markNotificationDoneFunc = origFunc }() // Set up a DoneStore backed by a temp file so we don't touch real state. diff --git a/internal/tui/components/notificationssection/notificationssection.go b/internal/tui/components/notificationssection/notificationssection.go index 10ab47869..4dd828d42 100644 --- a/internal/tui/components/notificationssection/notificationssection.go +++ b/internal/tui/components/notificationssection/notificationssection.go @@ -592,6 +592,9 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { // Capture config limit for the closure limit := m.Ctx.Config.Defaults.NotificationsLimit + // Capture host for the closure + host := m.Config.Host + // Build reason filter map for O(1) lookup reasonFilterMap := make(map[string]bool, len(filters.ReasonFilters)) for _, reason := range filters.ReasonFilters { @@ -625,7 +628,7 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { var lastPageInfo data.PageInfo isFirstPage := pageInfo == nil for { - res, err := data.FetchNotifications(limit, filters.RepoFilters, readState, currentPageInfo) + res, err := data.FetchNotifications(limit, filters.RepoFilters, readState, currentPageInfo, host) if err != nil { return constants.TaskFinishedMsg{ SectionId: m.Id, @@ -685,7 +688,7 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { wg.Add(1) go func(threadId string) { defer wg.Done() - notification, err := data.FetchNotificationByThreadId(threadId) + notification, err := data.FetchNotificationByThreadId(threadId, host) results <- fetchResult{notification: notification, err: err} }(id) } @@ -927,6 +930,9 @@ func (m *Model) fetchCommentCountsForNotifications(notifications []notificationr log.Debug("fetchCommentCountsForNotifications called", "numNotifications", len(notifications)) + // Capture host for closures + host := m.Config.Host + for _, notif := range notifications { // Copy values for closure capture notifId := notif.GetId() @@ -952,7 +958,7 @@ func (m *Model) fetchCommentCountsForNotifications(notifications []notificationr return nil } count := countNewPRComments(pr, readAt) - actor, _ := data.FetchCommentAuthor(commentUrl) + actor, _ := data.FetchCommentAuthor(commentUrl, host) if actor == "" { actor = pr.Author.Login } @@ -976,7 +982,7 @@ func (m *Model) fetchCommentCountsForNotifications(notifications []notificationr return nil } count := countNewIssueComments(issue, readAt) - actor, _ := data.FetchCommentAuthor(commentUrl) + actor, _ := data.FetchCommentAuthor(commentUrl, host) if actor == "" { actor = issue.Author.Login } @@ -997,7 +1003,7 @@ func (m *Model) fetchCommentCountsForNotifications(notifications []notificationr title := notif.Notification.Subject.Title cmds = append(cmds, func() tea.Msg { log.Debug("Fetching workflow run for CheckSuite", "id", id, "repo", repo) - url, err := data.FetchRecentWorkflowRun(repo, updatedAt, title) + url, err := data.FetchRecentWorkflowRun(repo, updatedAt, title, host) if err != nil { log.Error("Failed to fetch workflow run", "id", id, "err", err) return nil diff --git a/internal/tui/components/prssection/prssection.go b/internal/tui/components/prssection/prssection.go index 3029624de..aeedcfabf 100644 --- a/internal/tui/components/prssection/prssection.go +++ b/internal/tui/components/prssection/prssection.go @@ -2,7 +2,6 @@ package prssection import ( "fmt" - "slices" "time" "github.com/charmbracelet/bubbles/key" @@ -94,15 +93,15 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { if input == "" || input == "Y" || input == "y" { switch action { case "close": - cmd = tasks.ClosePR(m.Ctx, sid, pr) + cmd = tasks.ClosePR(m.Ctx, sid, pr, m.Config.Host) case "reopen": - cmd = tasks.ReopenPR(m.Ctx, sid, pr) + cmd = tasks.ReopenPR(m.Ctx, sid, pr, m.Config.Host) case "ready": - cmd = tasks.PRReady(m.Ctx, sid, pr) + cmd = tasks.PRReady(m.Ctx, sid, pr, m.Config.Host) case "merge": - cmd = tasks.MergePR(m.Ctx, sid, pr) + cmd = tasks.MergePR(m.Ctx, sid, pr, m.Config.Host) case "update": - cmd = tasks.UpdatePR(m.Ctx, sid, pr) + cmd = tasks.UpdatePR(m.Ctx, sid, pr, m.Config.Host) case "approveWorkflows": cmd = tasks.ApproveWorkflows(m.Ctx, sid, pr) } @@ -167,11 +166,11 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { currPr.Enriched.Comments.Nodes, *msg.NewComment) } if msg.AddedAssignees != nil { - currPr.Primary.Assignees.Nodes = addAssignees( + currPr.Primary.Assignees.Nodes = data.AddAssignees( currPr.Primary.Assignees.Nodes, msg.AddedAssignees.Nodes) } if msg.RemovedAssignees != nil { - currPr.Primary.Assignees.Nodes = removeAssignees( + currPr.Primary.Assignees.Nodes = data.RemoveAssignees( currPr.Primary.Assignees.Nodes, msg.RemovedAssignees.Nodes) } if msg.ReadyForReview != nil && *msg.ReadyForReview { @@ -462,7 +461,7 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { limit = &m.Ctx.Config.Defaults.PrsLimit } - res, err := data.FetchPullRequests(m.GetFilters(), *limit, m.PageInfo) + res, err := data.FetchPullRequests(m.GetFilters(), *limit, m.PageInfo, m.Config.Host) if err != nil { return constants.TaskFinishedMsg{ SectionId: m.Id, @@ -534,34 +533,6 @@ func FetchAllSections( return sections, tea.Batch(fetchPRsCmds...) } -func addAssignees(assignees, addedAssignees []data.Assignee) []data.Assignee { - newAssignees := assignees - for _, assignee := range addedAssignees { - if !assigneesContains(newAssignees, assignee) { - newAssignees = append(newAssignees, assignee) - } - } - - return newAssignees -} - -func removeAssignees( - assignees, removedAssignees []data.Assignee, -) []data.Assignee { - newAssignees := []data.Assignee{} - for _, assignee := range assignees { - if !assigneesContains(removedAssignees, assignee) { - newAssignees = append(newAssignees, assignee) - } - } - - return newAssignees -} - -func assigneesContains(assignees []data.Assignee, assignee data.Assignee) bool { - return slices.Contains(assignees, assignee) -} - func (m Model) GetItemSingularForm() string { return "PR" } diff --git a/internal/tui/components/prview/prview.go b/internal/tui/components/prview/prview.go index 072d36cb7..091c60a20 100644 --- a/internal/tui/components/prview/prview.go +++ b/internal/tui/components/prview/prview.go @@ -33,6 +33,7 @@ var ( type Model struct { ctx *context.ProgramContext sectionId int + host string pr *prrow.PullRequest width int carousel carousel.Model @@ -85,7 +86,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case tea.KeyCtrlD: if len(strings.Trim(m.inputBox.Value(), " ")) != 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: prssection.SectionType} - cmd = tasks.CommentOnPR(m.ctx, sid, m.pr.Data.Primary, m.inputBox.Value()) + cmd = tasks.CommentOnPR(m.ctx, sid, m.pr.Data.Primary, m.inputBox.Value(), m.host) } m.inputBox.Blur() m.isCommenting = false @@ -121,7 +122,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { comment = m.inputBox.Value() } sid := tasks.SectionIdentifier{Id: m.sectionId, Type: prssection.SectionType} - cmd = tasks.ApprovePR(m.ctx, sid, m.pr.Data.Primary, comment) + cmd = tasks.ApprovePR(m.ctx, sid, m.pr.Data.Primary, comment, m.host) m.inputBox.Blur() m.isApproving = false return m, cmd @@ -143,7 +144,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { usernames := strings.Fields(m.inputBox.Value()) if len(usernames) > 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: prssection.SectionType} - cmd = tasks.AssignPR(m.ctx, sid, m.pr.Data.Primary, usernames) + cmd = tasks.AssignPR(m.ctx, sid, m.pr.Data.Primary, usernames, m.host) } m.inputBox.Blur() m.isAssigning = false @@ -163,7 +164,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { usernames := strings.Fields(m.inputBox.Value()) if len(usernames) > 0 { sid := tasks.SectionIdentifier{Id: m.sectionId, Type: prssection.SectionType} - cmd = tasks.UnassignPR(m.ctx, sid, m.pr.Data.Primary, usernames) + cmd = tasks.UnassignPR(m.ctx, sid, m.pr.Data.Primary, usernames, m.host) } m.inputBox.Blur() m.isUnassigning = false @@ -555,6 +556,10 @@ func (m *Model) SetSectionId(id int) { m.sectionId = id } +func (m *Model) SetHost(host string) { + m.host = host +} + func (m *Model) SetRow(d *prrow.Data) { if d == nil { m.pr = nil diff --git a/internal/tui/components/reposection/commands.go b/internal/tui/components/reposection/commands.go index e21efe5e6..0d162b85e 100644 --- a/internal/tui/components/reposection/commands.go +++ b/internal/tui/components/reposection/commands.go @@ -251,7 +251,7 @@ func (m *Model) fetchPRsCmd() tea.Cmd { if limit == nil { limit = &m.Ctx.Config.Defaults.PrsLimit } - res, err := data.FetchPullRequests(fmt.Sprintf("author:@me repo:%s", git.GetRepoShortName(m.Ctx.RepoUrl)), *limit, nil) + res, err := data.FetchPullRequests(fmt.Sprintf("author:@me repo:%s", git.GetRepoShortName(m.Ctx.RepoUrl)), *limit, nil, "") if err != nil { return constants.TaskFinishedMsg{ SectionId: 0, @@ -285,7 +285,7 @@ func (m *Model) fetchPRCmd(branch string) []tea.Cmd { } startCmd := m.Ctx.StartTask(task) return []tea.Cmd{startCmd, func() tea.Msg { - res, err := data.FetchPullRequests(fmt.Sprintf("author:@me repo:%s head:%s", git.GetRepoShortName(m.Ctx.RepoUrl), branch), 1, nil) + res, err := data.FetchPullRequests(fmt.Sprintf("author:@me repo:%s head:%s", git.GetRepoShortName(m.Ctx.RepoUrl), branch), 1, nil, "") log.Debug("Fetching PRs", "res", res) if err != nil { return constants.TaskFinishedMsg{ @@ -369,7 +369,7 @@ func (m *Model) onRefreshPrsMsg() []tea.Cmd { func (m *Model) OpenGithub() tea.Cmd { row := m.CurrRow() b := m.getFilteredBranches()[row] - return tasks.OpenBranchPR(m.Ctx, tasks.SectionIdentifier{Id: 0, Type: SectionType}, b.Data.Name) + return tasks.OpenBranchPR(m.Ctx, tasks.SectionIdentifier{Id: 0, Type: SectionType}, b.Data.Name, "") } func (m *Model) deleteBranch() tea.Cmd { diff --git a/internal/tui/components/reposection/reposection.go b/internal/tui/components/reposection/reposection.go index 3d364b201..b8e04c7ab 100644 --- a/internal/tui/components/reposection/reposection.go +++ b/internal/tui/components/reposection/reposection.go @@ -107,7 +107,7 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { case "new": cmd = m.newBranch(input) case "create_pr": - cmd = tasks.CreatePR(m.Ctx, sid, branch, input) + cmd = tasks.CreatePR(m.Ctx, sid, branch, input, "") default: pr := findPRForRef(m.Prs, branch) if input == "" || input == "Y" || input == "y" { @@ -115,15 +115,15 @@ func (m *Model) Update(msg tea.Msg) (section.Section, tea.Cmd) { case "delete": cmd = m.deleteBranch() case "close": - cmd = tasks.ClosePR(m.Ctx, sid, pr) + cmd = tasks.ClosePR(m.Ctx, sid, pr, "") case "reopen": - cmd = tasks.ReopenPR(m.Ctx, sid, pr) + cmd = tasks.ReopenPR(m.Ctx, sid, pr, "") case "ready": - cmd = tasks.PRReady(m.Ctx, sid, pr) + cmd = tasks.PRReady(m.Ctx, sid, pr, "") case "merge": - cmd = tasks.MergePR(m.Ctx, sid, pr) + cmd = tasks.MergePR(m.Ctx, sid, pr, "") case "update": - cmd = tasks.UpdatePR(m.Ctx, sid, pr) + cmd = tasks.UpdatePR(m.Ctx, sid, pr, "") } } } diff --git a/internal/tui/components/tasks/issue.go b/internal/tui/components/tasks/issue.go index f6aed2692..69e4d6514 100644 --- a/internal/tui/components/tasks/issue.go +++ b/internal/tui/components/tasks/issue.go @@ -21,16 +21,16 @@ type UpdateIssueMsg struct { RemovedAssignees *data.Assignees } -func CloseIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData) tea.Cmd { +func CloseIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, host string) tea.Cmd { issueNumber := issue.GetNumber() return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_close_%d", issueNumber), + Id: buildTaskId("issue_close", issueNumber), Args: []string{ "issue", "close", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Closing issue #%d", issueNumber), @@ -44,16 +44,16 @@ func CloseIssue(ctx *context.ProgramContext, section SectionIdentifier, issue da }) } -func ReopenIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData) tea.Cmd { +func ReopenIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, host string) tea.Cmd { issueNumber := issue.GetNumber() return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_reopen_%d", issueNumber), + Id: buildTaskId("issue_reopen", issueNumber), Args: []string{ "issue", "reopen", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Reopening issue #%d", issueNumber), @@ -67,29 +67,26 @@ func ReopenIssue(ctx *context.ProgramContext, section SectionIdentifier, issue d }) } -func AssignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, usernames []string) tea.Cmd { +func AssignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, usernames []string, host string) tea.Cmd { issueNumber := issue.GetNumber() args := []string{ "issue", "edit", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), } for _, assignee := range usernames { args = append(args, "--add-assignee", assignee) } return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_assign_%d", issueNumber), + Id: buildTaskId("issue_assign", issueNumber), Args: args, Section: section, StartText: fmt.Sprintf("Assigning issue #%d to %s", issueNumber, usernames), FinishedText: fmt.Sprintf("Issue #%d has been assigned to %s", issueNumber, usernames), Msg: func(c *exec.Cmd, err error) tea.Msg { - returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} - for _, assignee := range usernames { - returnedAssignees.Nodes = append(returnedAssignees.Nodes, data.Assignee{Login: assignee}) - } + returnedAssignees := data.AssigneesFromLogins(usernames) return UpdateIssueMsg{ IssueNumber: issueNumber, AddedAssignees: &returnedAssignees, @@ -98,29 +95,26 @@ func AssignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue d }) } -func UnassignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, usernames []string) tea.Cmd { +func UnassignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, usernames []string, host string) tea.Cmd { issueNumber := issue.GetNumber() args := []string{ "issue", "edit", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), } for _, assignee := range usernames { args = append(args, "--remove-assignee", assignee) } return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_unassign_%d", issueNumber), + Id: buildTaskId("issue_unassign", issueNumber), Args: args, Section: section, StartText: fmt.Sprintf("Unassigning %s from issue #%d", usernames, issueNumber), FinishedText: fmt.Sprintf("%s unassigned from issue #%d", usernames, issueNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { - returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} - for _, assignee := range usernames { - returnedAssignees.Nodes = append(returnedAssignees.Nodes, data.Assignee{Login: assignee}) - } + returnedAssignees := data.AssigneesFromLogins(usernames) return UpdateIssueMsg{ IssueNumber: issueNumber, RemovedAssignees: &returnedAssignees, @@ -129,16 +123,16 @@ func UnassignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue }) } -func CommentOnIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, body string) tea.Cmd { +func CommentOnIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, body string, host string) tea.Cmd { issueNumber := issue.GetNumber() return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_comment_%d", issueNumber), + Id: buildTaskId("issue_comment", issueNumber), Args: []string{ "issue", "comment", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), "-b", body, }, @@ -158,14 +152,14 @@ func CommentOnIssue(ctx *context.ProgramContext, section SectionIdentifier, issu }) } -func LabelIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, labels []string, existingLabels []data.Label) tea.Cmd { +func LabelIssue(ctx *context.ProgramContext, section SectionIdentifier, issue data.RowData, labels []string, existingLabels []data.Label, host string) tea.Cmd { issueNumber := issue.GetNumber() args := []string{ "issue", "edit", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), } labelsMap := make(map[string]bool) @@ -189,7 +183,7 @@ func LabelIssue(ctx *context.ProgramContext, section SectionIdentifier, issue da } return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_label_%d", issueNumber), + Id: buildTaskId("issue_label", issueNumber), Args: args, Section: section, StartText: fmt.Sprintf("Labeling issue #%d to %s", issueNumber, labels), diff --git a/internal/tui/components/tasks/issue_test.go b/internal/tui/components/tasks/issue_test.go index 7f808de3c..c6806ad45 100644 --- a/internal/tui/components/tasks/issue_test.go +++ b/internal/tui/components/tasks/issue_test.go @@ -107,7 +107,7 @@ func TestCloseIssue(t *testing.T) { repoName: tt.repoName, } - cmd := CloseIssue(ctx, section, issue) + cmd := CloseIssue(ctx, section, issue, "") require.NotNil(t, cmd, "CloseIssue should return a non-nil command") }) @@ -129,7 +129,7 @@ func TestCloseIssue_TaskConfiguration(t *testing.T) { repoName: "test/repo", } - _ = CloseIssue(ctx, section, issue) + _ = CloseIssue(ctx, section, issue, "") require.Equal(t, "issue_close_42", capturedTask.Id) require.Equal(t, "Closing issue #42", capturedTask.StartText) @@ -172,7 +172,7 @@ func TestReopenIssue(t *testing.T) { repoName: tt.repoName, } - cmd := ReopenIssue(ctx, section, issue) + cmd := ReopenIssue(ctx, section, issue, "") require.NotNil(t, cmd, "ReopenIssue should return a non-nil command") }) @@ -194,7 +194,7 @@ func TestReopenIssue_TaskConfiguration(t *testing.T) { repoName: "example/project", } - _ = ReopenIssue(ctx, section, issue) + _ = ReopenIssue(ctx, section, issue, "") require.Equal(t, "issue_reopen_99", capturedTask.Id) require.Equal(t, "Reopening issue #99", capturedTask.StartText) @@ -229,7 +229,7 @@ func TestCloseIssue_SectionIdentifierPropagation(t *testing.T) { section := SectionIdentifier{Id: tt.sectionId, Type: tt.sectionType} issue := mockIssue{number: 1, repoName: "o/r"} - cmd := CloseIssue(ctx, section, issue) + cmd := CloseIssue(ctx, section, issue, "") require.NotNil(t, cmd) }) @@ -262,7 +262,7 @@ func TestReopenIssue_SectionIdentifierPropagation(t *testing.T) { section := SectionIdentifier{Id: tt.sectionId, Type: tt.sectionType} issue := mockIssue{number: 1, repoName: "o/r"} - cmd := ReopenIssue(ctx, section, issue) + cmd := ReopenIssue(ctx, section, issue, "") require.NotNil(t, cmd) }) @@ -291,7 +291,7 @@ func TestCloseIssue_UsesCorrectIssueNumber(t *testing.T) { } issue := mockIssue{number: num, repoName: "o/r"} - CloseIssue(ctx, SectionIdentifier{}, issue) + CloseIssue(ctx, SectionIdentifier{}, issue, "") expectedId := fmt.Sprintf("issue_close_%d", num) require.Equal(t, expectedId, capturedTask.Id) @@ -314,7 +314,7 @@ func TestReopenIssue_UsesCorrectIssueNumber(t *testing.T) { } issue := mockIssue{number: num, repoName: "o/r"} - ReopenIssue(ctx, SectionIdentifier{}, issue) + ReopenIssue(ctx, SectionIdentifier{}, issue, "") expectedId := fmt.Sprintf("issue_reopen_%d", num) require.Equal(t, expectedId, capturedTask.Id) diff --git a/internal/tui/components/tasks/pr.go b/internal/tui/components/tasks/pr.go index 18392fc3d..d7a91454e 100644 --- a/internal/tui/components/tasks/pr.go +++ b/internal/tui/components/tasks/pr.go @@ -74,7 +74,7 @@ func fireTask(ctx *context.ProgramContext, task GitHubTask) tea.Cmd { }) } -func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch string) tea.Cmd { +func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch string, host string) tea.Cmd { return fireTask(ctx, GitHubTask{ Id: fmt.Sprintf("branch_open_%s", branch), Args: []string{ @@ -83,7 +83,7 @@ func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch "--web", branch, "-R", - ctx.RepoUrl, + data.RepoWithHost(ctx.RepoUrl, host), }, Section: section, StartText: fmt.Sprintf("Opening PR for branch %s", branch), @@ -94,7 +94,7 @@ func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch }) } -func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { +func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, host string) tea.Cmd { prNumber := pr.GetNumber() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_reopen", prNumber), @@ -103,7 +103,7 @@ func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro "reopen", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Reopening PR #%d", prNumber), @@ -117,7 +117,7 @@ func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro }) } -func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { +func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, host string) tea.Cmd { prNumber := pr.GetNumber() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_close", prNumber), @@ -126,7 +126,7 @@ func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row "close", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Closing PR #%d", prNumber), @@ -140,7 +140,7 @@ func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row }) } -func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { +func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, host string) tea.Cmd { prNumber := pr.GetNumber() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_ready", prNumber), @@ -149,7 +149,7 @@ func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row "ready", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Marking PR #%d as ready for review", prNumber), @@ -163,7 +163,7 @@ func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row }) } -func MergePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { +func MergePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, host string) tea.Cmd { prNumber := pr.GetNumber() c := exec.Command( "gh", @@ -171,7 +171,7 @@ func MergePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row "merge", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), ) taskId := fmt.Sprintf("merge_%d", prNumber) @@ -200,7 +200,7 @@ func MergePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row })) } -func CreatePR(ctx *context.ProgramContext, section SectionIdentifier, branchName string, title string) tea.Cmd { +func CreatePR(ctx *context.ProgramContext, section SectionIdentifier, branchName string, title string, host string) tea.Cmd { c := exec.Command( "gh", "pr", @@ -208,7 +208,7 @@ func CreatePR(ctx *context.ProgramContext, section SectionIdentifier, branchName "--title", title, "-R", - ctx.RepoUrl, + data.RepoWithHost(ctx.RepoUrl, host), ) taskId := fmt.Sprintf("create_pr_%s", title) @@ -234,7 +234,7 @@ func CreatePR(ctx *context.ProgramContext, section SectionIdentifier, branchName })) } -func UpdatePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { +func UpdatePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, host string) tea.Cmd { prNumber := pr.GetNumber() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_update", prNumber), @@ -243,7 +243,7 @@ func UpdatePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro "update-branch", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Updating PR #%d", prNumber), @@ -257,14 +257,14 @@ func UpdatePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro }) } -func AssignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, usernames []string) tea.Cmd { +func AssignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, usernames []string, host string) tea.Cmd { prNumber := pr.GetNumber() args := []string{ "pr", "edit", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), } for _, assignee := range usernames { args = append(args, "--add-assignee", assignee) @@ -276,10 +276,7 @@ func AssignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro StartText: fmt.Sprintf("Assigning pr #%d to %s", prNumber, usernames), FinishedText: fmt.Sprintf("pr #%d has been assigned to %s", prNumber, usernames), Msg: func(c *exec.Cmd, err error) tea.Msg { - returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} - for _, assignee := range usernames { - returnedAssignees.Nodes = append(returnedAssignees.Nodes, data.Assignee{Login: assignee}) - } + returnedAssignees := data.AssigneesFromLogins(usernames) return UpdatePRMsg{ PrNumber: prNumber, AddedAssignees: &returnedAssignees, @@ -288,14 +285,14 @@ func AssignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro }) } -func UnassignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, usernames []string) tea.Cmd { +func UnassignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, usernames []string, host string) tea.Cmd { prNumber := pr.GetNumber() args := []string{ "pr", "edit", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), } for _, assignee := range usernames { args = append(args, "--remove-assignee", assignee) @@ -307,10 +304,7 @@ func UnassignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data. StartText: fmt.Sprintf("Unassigning %s from pr #%d", usernames, prNumber), FinishedText: fmt.Sprintf("%s unassigned from pr #%d", usernames, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { - returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} - for _, assignee := range usernames { - returnedAssignees.Nodes = append(returnedAssignees.Nodes, data.Assignee{Login: assignee}) - } + returnedAssignees := data.AssigneesFromLogins(usernames) return UpdatePRMsg{ PrNumber: prNumber, RemovedAssignees: &returnedAssignees, @@ -319,7 +313,7 @@ func UnassignPR(ctx *context.ProgramContext, section SectionIdentifier, pr data. }) } -func CommentOnPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, body string) tea.Cmd { +func CommentOnPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, body string, host string) tea.Cmd { prNumber := pr.GetNumber() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_comment", prNumber), @@ -328,7 +322,7 @@ func CommentOnPR(ctx *context.ProgramContext, section SectionIdentifier, pr data "comment", fmt.Sprint(prNumber), "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), "-b", body, }, @@ -348,13 +342,13 @@ func CommentOnPR(ctx *context.ProgramContext, section SectionIdentifier, pr data }) } -func ApprovePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, comment string) tea.Cmd { +func ApprovePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData, comment string, host string) tea.Cmd { prNumber := pr.GetNumber() args := []string{ "pr", "review", "-R", - pr.GetRepoNameWithOwner(), + data.RepoWithHost(pr.GetRepoNameWithOwner(), host), fmt.Sprint(prNumber), "--approve", } diff --git a/internal/tui/modelUtils.go b/internal/tui/modelUtils.go index dca13210f..2855905fa 100644 --- a/internal/tui/modelUtils.go +++ b/internal/tui/modelUtils.go @@ -26,6 +26,13 @@ import ( "github.com/dlvhdr/gh-dash/v4/internal/tui/markdown" ) +func (m *Model) getCurrSectionHost() string { + if currSection := m.getCurrSection(); currSection != nil { + return currSection.GetConfig().Host + } + return "" +} + func (m *Model) getCurrSection() section.Section { sections := m.getCurrentViewSections() if len(sections) == 0 || m.currSectionId >= len(sections) { @@ -299,25 +306,14 @@ func (m *Model) executeCustomCommand(cmd string) tea.Cmd { } func (m *Model) notify(text string) tea.Cmd { - id := fmt.Sprint(time.Now().Unix()) - startCmd := m.ctx.StartTask( - context.Task{ - Id: id, - StartText: text, - FinishedText: text, - State: context.TaskStart, - }) - - finishCmd := func() tea.Msg { - return constants.TaskFinishedMsg{ - TaskId: id, - } - } - - return tea.Sequence(startCmd, finishCmd) + return m.notifyWithErr(text, nil) } func (m *Model) notifyErr(text string) tea.Cmd { + return m.notifyWithErr(text, errors.New(text)) +} + +func (m *Model) notifyWithErr(text string, err error) tea.Cmd { id := fmt.Sprint(time.Now().Unix()) startCmd := m.ctx.StartTask( context.Task{ @@ -330,7 +326,7 @@ func (m *Model) notifyErr(text string) tea.Cmd { finishCmd := func() tea.Msg { return constants.TaskFinishedMsg{ TaskId: id, - Err: errors.New(text), + Err: err, } } diff --git a/internal/tui/ui.go b/internal/tui/ui.go index 2ee75065b..7d6b8a368 100644 --- a/internal/tui/ui.go +++ b/internal/tui/ui.go @@ -687,7 +687,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { keys.SetNotificationSubject(keys.NotificationSubjectPR) // Update sidebar with PR view width := m.sidebar.GetSidebarContentWidth() + sectionHost := m.getCurrSectionHost() m.prView.SetSectionId(0) + m.prView.SetHost(sectionHost) m.prView.SetRow(m.notificationView.GetSubjectPR()) m.prView.SetWidth(width) m.prView.SetEnrichedPR(msg.PR) @@ -714,7 +716,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { keys.SetNotificationSubject(keys.NotificationSubjectIssue) // Update sidebar with Issue view width := m.sidebar.GetSidebarContentWidth() + sectionHost := m.getCurrSectionHost() m.issueSidebar.SetSectionId(0) + m.issueSidebar.SetHost(sectionHost) m.issueSidebar.SetRow(m.notificationView.GetSubjectIssue()) m.issueSidebar.SetWidth(width) m.sidebar.SetContent(m.issueSidebar.View()) @@ -1050,17 +1054,22 @@ func (m *Model) syncSidebar() tea.Cmd { return nil } + // Get host from current section config + sectionHost := m.getCurrSectionHost() + switch row := currRowData.(type) { case branch.BranchData: cmd = m.branchSidebar.SetRow(&row) m.sidebar.SetContent(m.branchSidebar.View()) case *prrow.Data: m.prView.SetSectionId(m.currSectionId) + m.prView.SetHost(sectionHost) m.prView.SetRow(row) m.prView.SetWidth(width) m.sidebar.SetContent(m.prView.View()) case *data.IssueData: m.issueSidebar.SetSectionId(m.currSectionId) + m.issueSidebar.SetHost(sectionHost) m.issueSidebar.SetRow(row) m.issueSidebar.SetWidth(width) m.sidebar.SetContent(m.issueSidebar.View()) @@ -1072,11 +1081,13 @@ func (m *Model) syncSidebar() tea.Cmd { // Use cached data if m.notificationView.GetSubjectPR() != nil { m.prView.SetSectionId(0) + m.prView.SetHost(sectionHost) m.prView.SetRow(m.notificationView.GetSubjectPR()) m.prView.SetWidth(width) m.sidebar.SetContent(m.prView.View()) } else if m.notificationView.GetSubjectIssue() != nil { m.issueSidebar.SetSectionId(0) + m.issueSidebar.SetHost(sectionHost) m.issueSidebar.SetRow(m.notificationView.GetSubjectIssue()) m.issueSidebar.SetWidth(width) m.sidebar.SetContent(m.issueSidebar.View()) @@ -1209,6 +1220,9 @@ func (m *Model) loadNotificationContent() tea.Cmd { subjectUrl := row.GetUrl() latestCommentUrl := row.GetLatestCommentUrl() + // Get host from current section config + host := m.getCurrSectionHost() + // Show loading indicator width := m.sidebar.GetSidebarContentWidth() m.notificationView.SetRow(row) @@ -1219,7 +1233,7 @@ func (m *Model) loadNotificationContent() tea.Cmd { case "PullRequest": return tea.Batch( func() tea.Msg { - _ = data.MarkNotificationRead(notifId) + _ = data.MarkNotificationRead(notifId, host) return notificationssection.UpdateNotificationReadStateMsg{ Id: notifId, Unread: false, @@ -1238,7 +1252,7 @@ func (m *Model) loadNotificationContent() tea.Cmd { case "Issue": return tea.Batch( func() tea.Msg { - _ = data.MarkNotificationRead(notifId) + _ = data.MarkNotificationRead(notifId, host) return notificationssection.UpdateNotificationReadStateMsg{ Id: notifId, Unread: false, @@ -1259,7 +1273,7 @@ func (m *Model) loadNotificationContent() tea.Cmd { // since we can't show rich content for these types return tea.Batch( func() tea.Msg { - _ = data.MarkNotificationRead(notifId) + _ = data.MarkNotificationRead(notifId, host) return notificationssection.UpdateNotificationReadStateMsg{ Id: notifId, Unread: false, @@ -1652,26 +1666,29 @@ func (m *Model) executeNotificationAction(action string) tea.Cmd { pr := m.notificationView.GetSubjectPR() issue := m.notificationView.GetSubjectIssue() + // Get host from current section config + sectionHost := m.getCurrSectionHost() + switch action { case "pr_close": if pr != nil { - return tasks.ClosePR(m.ctx, sid, pr) + return tasks.ClosePR(m.ctx, sid, pr, sectionHost) } case "pr_reopen": if pr != nil { - return tasks.ReopenPR(m.ctx, sid, pr) + return tasks.ReopenPR(m.ctx, sid, pr, sectionHost) } case "pr_ready": if pr != nil { - return tasks.PRReady(m.ctx, sid, pr) + return tasks.PRReady(m.ctx, sid, pr, sectionHost) } case "pr_merge": if pr != nil { - return tasks.MergePR(m.ctx, sid, pr) + return tasks.MergePR(m.ctx, sid, pr, sectionHost) } case "pr_update": if pr != nil { - return tasks.UpdatePR(m.ctx, sid, pr) + return tasks.UpdatePR(m.ctx, sid, pr, sectionHost) } case "pr_approveWorkflows": if pr != nil { @@ -1679,11 +1696,11 @@ func (m *Model) executeNotificationAction(action string) tea.Cmd { } case "issue_close": if issue != nil { - return tasks.CloseIssue(m.ctx, sid, issue) + return tasks.CloseIssue(m.ctx, sid, issue, sectionHost) } case "issue_reopen": if issue != nil { - return tasks.ReopenIssue(m.ctx, sid, issue) + return tasks.ReopenIssue(m.ctx, sid, issue, sectionHost) } }