From 279947934db005c8027de14b17219a62e9671cb1 Mon Sep 17 00:00:00 2001 From: Philip Germanov Date: Fri, 23 Jan 2026 19:10:27 +0200 Subject: [PATCH 1/4] feat: add per-section host field for multi-host support --- internal/config/parser.go | 6 ++- internal/config/parser_test.go | 54 +++++++++++++++++++ internal/config/testdata/host-config.yml | 22 ++++++++ internal/config/utils.go | 3 ++ internal/data/issueapi.go | 13 +++-- internal/data/notificationapi.go | 11 +++- internal/data/prapi.go | 13 +++-- .../components/issuessection/issuessection.go | 2 +- .../notificationssection.go | 5 +- .../tui/components/prssection/prssection.go | 2 +- .../tui/components/reposection/commands.go | 4 +- 11 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 internal/config/testdata/host-config.yml diff --git a/internal/config/parser.go b/internal/config/parser.go index 2d79c6413..6b7363add 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -80,6 +80,7 @@ const ( type SectionConfig struct { Title string Filters string + Host string `yaml:"host,omitempty"` Limit *int `yaml:"limit,omitempty"` Type *ViewType `yaml:"type,omitempty"` } @@ -87,6 +88,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"` @@ -95,6 +97,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"` } @@ -102,7 +105,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 feb3b3ad0..6682cda14 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -173,3 +173,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/issueapi.go b/internal/data/issueapi.go index 7404239cd..bde75a142 100644 --- a/internal/data/issueapi.go +++ b/internal/data/issueapi.go @@ -94,10 +94,17 @@ func makeIssuesQuery(query string) string { return fmt.Sprintf("is:issue %s sort:updated", query) } -func FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { +func FetchIssues(query string, limit int, pageInfo *PageInfo, host string) (IssuesResponse, error) { var err error - if client == nil { + var c *gh.GraphQLClient + + if host != "" { + c, err = gh.NewGraphQLClient(gh.ClientOptions{Host: host}) + } else if client == nil { client, err = gh.DefaultGraphQLClient() + c = client + } else { + c = client } if err != nil { @@ -123,7 +130,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 4f4963b2e..f1309936b 100644 --- a/internal/data/notificationapi.go +++ b/internal/data/notificationapi.go @@ -108,6 +108,13 @@ func getRESTClient() (*gh.RESTClient, error) { return restClient, err } +func getRESTClientForHost(host string) (*gh.RESTClient, error) { + if host == "" { + return getRESTClient() + } + return gh.NewRESTClient(gh.ClientOptions{Host: host}) +} + // NotificationReadState represents the read state filter for notifications type NotificationReadState string @@ -117,8 +124,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 } diff --git a/internal/data/prapi.go b/internal/data/prapi.go index 03361ec9f..1dadcb0c4 100644 --- a/internal/data/prapi.go +++ b/internal/data/prapi.go @@ -448,9 +448,13 @@ func SetClient(c *gh.GraphQLClient) { cachedClient = c } -func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { +func FetchPullRequests(query string, limit int, pageInfo *PageInfo, host string) (PullRequestsResponse, error) { var err error - if client == nil { + var c *gh.GraphQLClient + + if host != "" { + c, err = gh.NewGraphQLClient(gh.ClientOptions{Host: host}) + } else if 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} @@ -458,6 +462,9 @@ func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequest } else { client, err = gh.DefaultGraphQLClient() } + c = client + } else { + c = client } if err != nil { @@ -483,7 +490,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/tui/components/issuessection/issuessection.go b/internal/tui/components/issuessection/issuessection.go index f92a62be9..f384b451f 100644 --- a/internal/tui/components/issuessection/issuessection.go +++ b/internal/tui/components/issuessection/issuessection.go @@ -319,7 +319,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, diff --git a/internal/tui/components/notificationssection/notificationssection.go b/internal/tui/components/notificationssection/notificationssection.go index 14eb8f30e..8220c3082 100644 --- a/internal/tui/components/notificationssection/notificationssection.go +++ b/internal/tui/components/notificationssection/notificationssection.go @@ -580,6 +580,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 { @@ -613,7 +616,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, diff --git a/internal/tui/components/prssection/prssection.go b/internal/tui/components/prssection/prssection.go index e0174b757..31e47a20f 100644 --- a/internal/tui/components/prssection/prssection.go +++ b/internal/tui/components/prssection/prssection.go @@ -453,7 +453,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, diff --git a/internal/tui/components/reposection/commands.go b/internal/tui/components/reposection/commands.go index e21efe5e6..f1a237489 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{ From 9aedd5aa57f9a968f0f6d40b8a3ec8ae86d01411 Mon Sep 17 00:00:00 2001 From: Philip Germanov Date: Thu, 29 Jan 2026 14:23:14 +0200 Subject: [PATCH 2/4] docs: add host docs for pr, issue and notification sections --- .../docs/configuration/issue-section.mdx | 26 +++++++++++++++++++ .../configuration/notification-section.mdx | 26 +++++++++++++++++++ .../content/docs/configuration/pr-section.mdx | 26 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/docs/src/content/docs/configuration/issue-section.mdx b/docs/src/content/docs/configuration/issue-section.mdx index eea2be33e..6e4051988 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 @@ -50,6 +52,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 a5a73cfff..c12d30983 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 - **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 98a728d6c..4f7430348 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 @@ -50,6 +52,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 From bce56f6ba08177a3077576696932c4a61288635e Mon Sep 17 00:00:00 2001 From: Philip Germanov Date: Fri, 6 Mar 2026 18:10:20 +0200 Subject: [PATCH 3/4] feat: add per-section host support to all task functions Extend host parameter to issue and PR task functions (CloseIssue, ReopenIssue, AssignIssue, UnassignIssue, CommentOnIssue, LabelIssue, AssignPR, UnassignPR, CommentOnPR, ApprovePR) so gh CLI commands target the correct GitHub host for enterprise configurations. Also resolves merge conflicts from main. --- internal/data/notificationapi.go | 45 ++++++++-------- internal/data/utils.go | 9 ++++ .../components/issuessection/issuessection.go | 4 +- .../tui/components/issueview/issueview.go | 13 +++-- .../notificationssection/commands.go | 18 ++++--- .../notificationssection/commands_test.go | 2 +- .../notificationssection.go | 11 ++-- .../tui/components/prssection/prssection.go | 10 ++-- internal/tui/components/prview/prview.go | 13 +++-- .../tui/components/reposection/commands.go | 2 +- .../tui/components/reposection/reposection.go | 12 ++--- internal/tui/components/tasks/issue.go | 24 ++++----- internal/tui/components/tasks/issue_test.go | 16 +++--- internal/tui/components/tasks/pr.go | 44 ++++++++-------- internal/tui/ui.go | 52 +++++++++++++++---- 15 files changed, 169 insertions(+), 106 deletions(-) diff --git a/internal/data/notificationapi.go b/internal/data/notificationapi.go index 6c046f32c..5e9164fbf 100644 --- a/internal/data/notificationapi.go +++ b/internal/data/notificationapi.go @@ -1,6 +1,7 @@ package data import ( + "encoding/json" "fmt" "strings" "time" @@ -217,8 +218,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 } @@ -235,8 +236,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 } @@ -253,8 +254,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 } @@ -272,8 +273,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 } @@ -291,8 +292,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 } @@ -334,27 +335,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 } @@ -398,8 +401,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/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 2245263bd..b709b556e 100644 --- a/internal/tui/components/issuessection/issuessection.go +++ b/internal/tui/components/issuessection/issuessection.go @@ -92,9 +92,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) } } 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 bcb697e8c..4dd828d42 100644 --- a/internal/tui/components/notificationssection/notificationssection.go +++ b/internal/tui/components/notificationssection/notificationssection.go @@ -688,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) } @@ -930,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() @@ -955,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 } @@ -979,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 } @@ -1000,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 b5aa034e4..2621ce338 100644 --- a/internal/tui/components/prssection/prssection.go +++ b/internal/tui/components/prssection/prssection.go @@ -94,15 +94,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) } 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 f1a237489..0d162b85e 100644 --- a/internal/tui/components/reposection/commands.go +++ b/internal/tui/components/reposection/commands.go @@ -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..414059d1c 100644 --- a/internal/tui/components/tasks/issue.go +++ b/internal/tui/components/tasks/issue.go @@ -21,7 +21,7 @@ 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), @@ -30,7 +30,7 @@ func CloseIssue(ctx *context.ProgramContext, section SectionIdentifier, issue da "close", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Closing issue #%d", issueNumber), @@ -44,7 +44,7 @@ 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), @@ -53,7 +53,7 @@ func ReopenIssue(ctx *context.ProgramContext, section SectionIdentifier, issue d "reopen", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), }, Section: section, StartText: fmt.Sprintf("Reopening issue #%d", issueNumber), @@ -67,14 +67,14 @@ 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) @@ -98,14 +98,14 @@ 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) @@ -129,7 +129,7 @@ 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), @@ -138,7 +138,7 @@ func CommentOnIssue(ctx *context.ProgramContext, section SectionIdentifier, issu "comment", fmt.Sprint(issueNumber), "-R", - issue.GetRepoNameWithOwner(), + data.RepoWithHost(issue.GetRepoNameWithOwner(), host), "-b", body, }, @@ -158,14 +158,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) 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..3bb471656 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) @@ -288,14 +288,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) @@ -319,7 +319,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 +328,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 +348,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/ui.go b/internal/tui/ui.go index 2ee75065b..b77e78731 100644 --- a/internal/tui/ui.go +++ b/internal/tui/ui.go @@ -687,7 +687,12 @@ 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 := "" + if currSection := m.getCurrSection(); currSection != nil { + sectionHost = currSection.GetConfig().Host + } 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 +719,12 @@ 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 := "" + if currSection := m.getCurrSection(); currSection != nil { + sectionHost = currSection.GetConfig().Host + } 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 +1060,25 @@ func (m *Model) syncSidebar() tea.Cmd { return nil } + // Get host from current section config + sectionHost := "" + if currSection := m.getCurrSection(); currSection != nil { + sectionHost = currSection.GetConfig().Host + } + 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 +1090,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 +1229,12 @@ func (m *Model) loadNotificationContent() tea.Cmd { subjectUrl := row.GetUrl() latestCommentUrl := row.GetLatestCommentUrl() + // Get host from current section config + host := "" + if currSection := m.getCurrSection(); currSection != nil { + host = currSection.GetConfig().Host + } + // Show loading indicator width := m.sidebar.GetSidebarContentWidth() m.notificationView.SetRow(row) @@ -1219,7 +1245,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 +1264,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 +1285,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 +1678,32 @@ func (m *Model) executeNotificationAction(action string) tea.Cmd { pr := m.notificationView.GetSubjectPR() issue := m.notificationView.GetSubjectIssue() + // Get host from current section config + sectionHost := "" + if currSection := m.getCurrSection(); currSection != nil { + sectionHost = currSection.GetConfig().Host + } + 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 +1711,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) } } From b2e73b02da77d836855423d4d4c059a8431e1990 Mon Sep 17 00:00:00 2001 From: Philip Germanov Date: Fri, 6 Mar 2026 18:30:27 +0200 Subject: [PATCH 4/4] refactor: deduplicate helpers and cache API clients for per-section hosts - Extract AssigneesFromLogins, AddAssignees, RemoveAssignees to data/assignee.go (removes identical copies from prssection and issuessection) - Cache GraphQL and REST clients per host to avoid re-creating them on every API call - Extract getCurrSectionHost() helper to eliminate repeated 3-line pattern in ui.go - Reuse buildTaskId() in issue task functions instead of inline fmt.Sprintf - Consolidate notify/notifyErr into shared notifyWithErr --- internal/data/assignee.go | 33 ++++++++++++ internal/data/issueapi.go | 12 +---- internal/data/notificationapi.go | 19 ++++++- internal/data/prapi.go | 51 ++++++++++++++----- .../components/issuessection/issuessection.go | 33 +----------- .../tui/components/prssection/prssection.go | 33 +----------- internal/tui/components/tasks/issue.go | 22 +++----- internal/tui/components/tasks/pr.go | 10 +--- internal/tui/modelUtils.go | 30 +++++------ internal/tui/ui.go | 25 ++------- 10 files changed, 121 insertions(+), 147 deletions(-) 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 c7a0cbc59..bb74d708e 100644 --- a/internal/data/issueapi.go +++ b/internal/data/issueapi.go @@ -95,17 +95,7 @@ func makeIssuesQuery(query string) string { } func FetchIssues(query string, limit int, pageInfo *PageInfo, host string) (IssuesResponse, error) { - var err error - var c *gh.GraphQLClient - - if host != "" { - c, err = gh.NewGraphQLClient(gh.ClientOptions{Host: host}) - } else if client == nil { - client, err = gh.DefaultGraphQLClient() - c = client - } else { - c = client - } + c, err := getGraphQLClientForHost(host) if err != nil { return IssuesResponse{}, err diff --git a/internal/data/notificationapi.go b/internal/data/notificationapi.go index 5e9164fbf..eb622015d 100644 --- a/internal/data/notificationapi.go +++ b/internal/data/notificationapi.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "sync" "time" "github.com/charmbracelet/log" @@ -34,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"` @@ -114,7 +119,17 @@ func getRESTClientForHost(host string) (*gh.RESTClient, error) { if host == "" { return getRESTClient() } - return gh.NewRESTClient(gh.ClientOptions{Host: host}) + 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 diff --git a/internal/data/prapi.go b/internal/data/prapi.go index d1a92d0a6..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,25 +489,47 @@ func IsEnrichmentCacheCleared() bool { return cachedClient == nil } -func FetchPullRequests(query string, limit int, pageInfo *PageInfo, host string) (PullRequestsResponse, error) { - var err error - var c *gh.GraphQLClient - - if host != "" { - c, err = gh.NewGraphQLClient(gh.ClientOptions{Host: host}) - } else 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 } - c = client - } else { - c = client + 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 } diff --git a/internal/tui/components/issuessection/issuessection.go b/internal/tui/components/issuessection/issuessection.go index b709b556e..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" @@ -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 @@ -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/prssection/prssection.go b/internal/tui/components/prssection/prssection.go index 2621ce338..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" @@ -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 { @@ -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/tasks/issue.go b/internal/tui/components/tasks/issue.go index 414059d1c..69e4d6514 100644 --- a/internal/tui/components/tasks/issue.go +++ b/internal/tui/components/tasks/issue.go @@ -24,7 +24,7 @@ type UpdateIssueMsg struct { 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", @@ -47,7 +47,7 @@ func CloseIssue(ctx *context.ProgramContext, section SectionIdentifier, issue da 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", @@ -80,16 +80,13 @@ func AssignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue d 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, @@ -111,16 +108,13 @@ func UnassignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue 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, @@ -132,7 +126,7 @@ func UnassignIssue(ctx *context.ProgramContext, section SectionIdentifier, issue 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", @@ -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/pr.go b/internal/tui/components/tasks/pr.go index 3bb471656..d7a91454e 100644 --- a/internal/tui/components/tasks/pr.go +++ b/internal/tui/components/tasks/pr.go @@ -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, @@ -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, 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 b77e78731..7d6b8a368 100644 --- a/internal/tui/ui.go +++ b/internal/tui/ui.go @@ -687,10 +687,7 @@ 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 := "" - if currSection := m.getCurrSection(); currSection != nil { - sectionHost = currSection.GetConfig().Host - } + sectionHost := m.getCurrSectionHost() m.prView.SetSectionId(0) m.prView.SetHost(sectionHost) m.prView.SetRow(m.notificationView.GetSubjectPR()) @@ -719,10 +716,7 @@ 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 := "" - if currSection := m.getCurrSection(); currSection != nil { - sectionHost = currSection.GetConfig().Host - } + sectionHost := m.getCurrSectionHost() m.issueSidebar.SetSectionId(0) m.issueSidebar.SetHost(sectionHost) m.issueSidebar.SetRow(m.notificationView.GetSubjectIssue()) @@ -1061,10 +1055,7 @@ func (m *Model) syncSidebar() tea.Cmd { } // Get host from current section config - sectionHost := "" - if currSection := m.getCurrSection(); currSection != nil { - sectionHost = currSection.GetConfig().Host - } + sectionHost := m.getCurrSectionHost() switch row := currRowData.(type) { case branch.BranchData: @@ -1230,10 +1221,7 @@ func (m *Model) loadNotificationContent() tea.Cmd { latestCommentUrl := row.GetLatestCommentUrl() // Get host from current section config - host := "" - if currSection := m.getCurrSection(); currSection != nil { - host = currSection.GetConfig().Host - } + host := m.getCurrSectionHost() // Show loading indicator width := m.sidebar.GetSidebarContentWidth() @@ -1679,10 +1667,7 @@ func (m *Model) executeNotificationAction(action string) tea.Cmd { issue := m.notificationView.GetSubjectIssue() // Get host from current section config - sectionHost := "" - if currSection := m.getCurrSection(); currSection != nil { - sectionHost = currSection.GetConfig().Host - } + sectionHost := m.getCurrSectionHost() switch action { case "pr_close":