diff --git a/cmd/root.go b/cmd/root.go index 609aa8791..da258126d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "github.com/dlvhdr/gh-dash/v4/internal/config" "github.com/dlvhdr/gh-dash/v4/internal/git" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" dctx "github.com/dlvhdr/gh-dash/v4/internal/tui/context" @@ -35,16 +36,17 @@ var ( ) var ( - cfgFlag string + cfgFlag string + gitlabHost string logo = lipgloss.NewStyle().Foreground(dctx.LogoColor).MarginBottom(1).SetString(constants.Logo) rootCmd = &cobra.Command{ Use: "gh dash", Long: lipgloss.JoinVertical(lipgloss.Left, logo.Render(), - "A rich terminal UI for GitHub that doesn't break your flow.", + "A rich terminal UI for GitHub/GitLab that doesn't break your flow.", "Visit https://gh-dash.dev for the docs."), - Short: "A rich terminal UI for GitHub that doesn't break your flow.", + Short: "A rich terminal UI for GitHub/GitLab that doesn't break your flow.", Version: "", Example: ` # Running without arguments will either: @@ -58,6 +60,9 @@ gh dash --config /path/to/configuration/file.yml # Run with debug logging to debug.log gh dash --debug +# Connect to a GitLab instance +gh dash --gitlab gitlab.example.com + # Print version gh dash -v `, @@ -182,7 +187,22 @@ func init() { "help for gh-dash", ) + rootCmd.Flags().StringVar( + &gitlabHost, + "gitlab", + "", + "GitLab hostname (e.g., gitlab.example.com) - enables GitLab mode", + ) + rootCmd.Run = func(_ *cobra.Command, args []string) { + // Set up the provider based on flags + gitlabHostFlag, _ := rootCmd.Flags().GetString("gitlab") + if gitlabHostFlag != "" { + log.Info("Using GitLab provider", "host", gitlabHostFlag) + provider.SetProvider(provider.NewGitLabProvider(gitlabHostFlag)) + } else { + provider.SetProvider(provider.NewGitHubProvider()) + } var repo string repos := config.IsFeatureEnabled(config.FF_REPO_VIEW) if repos && len(args) > 0 { diff --git a/gitlab-config.yml b/gitlab-config.yml new file mode 100644 index 000000000..22dcd1ef5 --- /dev/null +++ b/gitlab-config.yml @@ -0,0 +1,45 @@ +# GitLab configuration for gh-dash +# Use with: ./gh-dash --gitlab gitlab.krone.at --config ./gitlab-config.yml +# +# Note: GitLab requires specifying a repo in the filters using 'repo:GROUP/PROJECT' + +keybindings: + prs: + - key: tab + builtin: nextSidebarTab + - key: shift+tab + builtin: prevSidebarTab + +prSections: + - title: Test MRs + filters: repo:hjanuschka/hjlab + - title: My Merge Requests + filters: repo:KRN/MGMT author:@me + - title: All Open MRs + filters: repo:KRN/MGMT + +issuesSections: + - title: Test Issues + filters: repo:hjanuschka/hjlab + - title: My Issues + filters: repo:KRN/MGMT author:@me + - title: Assigned to Me + filters: repo:KRN/MGMT assignee:@me + +defaults: + preview: + open: true + width: 50 + prsLimit: 20 + issuesLimit: 20 + view: prs + refetchIntervalMinutes: 30 + +theme: + ui: + sectionsShowCount: true + table: + showSeparator: true + compact: false + +confirmQuit: false diff --git a/internal/config/utils.go b/internal/config/utils.go index bfccc9621..41d113241 100644 --- a/internal/config/utils.go +++ b/internal/config/utils.go @@ -19,10 +19,8 @@ func (cfg Config) GetFullScreenDiffPagerEnv() []string { env = append( env, "LESS=CRX", - fmt.Sprintf( - "GH_PAGER=%s", - diff, - ), + fmt.Sprintf("GH_PAGER=%s", diff), + fmt.Sprintf("GLAB_PAGER=%s", diff), // For GitLab CLI ) return env diff --git a/internal/data/issueapi.go b/internal/data/issueapi.go index 9fa1763cf..d391f604c 100644 --- a/internal/data/issueapi.go +++ b/internal/data/issueapi.go @@ -10,6 +10,7 @@ import ( graphql "github.com/cli/shurcooL-graphql" "github.com/shurcooL/githubv4" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/theme" ) @@ -100,6 +101,11 @@ func makeIssuesQuery(query string) string { } func FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { + // Use GitLab provider if configured + if provider.IsGitLab() { + return fetchIssuesFromGitLab(query, limit, pageInfo) + } + var err error if client == nil { client, err = gh.DefaultGraphQLClient() @@ -146,6 +152,79 @@ func FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, e }, nil } +// fetchIssuesFromGitLab fetches issues from GitLab and converts them to the internal format +func fetchIssuesFromGitLab(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { + p := provider.GetProvider() + + var providerPageInfo *provider.PageInfo + if pageInfo != nil { + providerPageInfo = &provider.PageInfo{ + HasNextPage: pageInfo.HasNextPage, + StartCursor: pageInfo.StartCursor, + EndCursor: pageInfo.EndCursor, + } + } + + resp, err := p.FetchIssues(query, limit, providerPageInfo) + if err != nil { + return IssuesResponse{}, err + } + + issues := make([]IssueData, len(resp.Issues)) + for i, issue := range resp.Issues { + issues[i] = convertProviderIssueToData(issue) + } + + return IssuesResponse{ + Issues: issues, + TotalCount: resp.TotalCount, + PageInfo: PageInfo{ + HasNextPage: resp.PageInfo.HasNextPage, + StartCursor: resp.PageInfo.StartCursor, + EndCursor: resp.PageInfo.EndCursor, + }, + }, nil +} + +// convertProviderIssueToData converts provider.IssueData to data.IssueData +func convertProviderIssueToData(issue provider.IssueData) IssueData { + assignees := make([]Assignee, len(issue.Assignees.Nodes)) + for i, a := range issue.Assignees.Nodes { + assignees[i] = Assignee{Login: a.Login} + } + + labels := make([]Label, len(issue.Labels.Nodes)) + for i, l := range issue.Labels.Nodes { + labels[i] = Label{Name: l.Name, Color: l.Color} + } + + comments := make([]IssueComment, len(issue.Comments.Nodes)) + for i, c := range issue.Comments.Nodes { + comments[i] = IssueComment{ + Author: struct{ Login string }{Login: c.Author.Login}, + Body: c.Body, + UpdatedAt: c.UpdatedAt, + } + } + + return IssueData{ + Number: issue.Number, + Title: issue.Title, + Body: issue.Body, + State: issue.State, + Author: struct{ Login string }{Login: issue.Author.Login}, + AuthorAssociation: issue.AuthorAssociation, + UpdatedAt: issue.UpdatedAt, + CreatedAt: issue.CreatedAt, + Url: issue.Url, + Repository: Repository{NameWithOwner: issue.Repository.NameWithOwner, IsArchived: issue.Repository.IsArchived}, + Assignees: Assignees{Nodes: assignees}, + Comments: IssueComments{TotalCount: issue.Comments.TotalCount, Nodes: comments}, + Reactions: IssueReactions{TotalCount: issue.Reactions.TotalCount}, + Labels: IssueLabels{Nodes: labels}, + } +} + type IssuesResponse struct { Issues []IssueData TotalCount int @@ -183,3 +262,27 @@ func FetchIssue(issueUrl string) (IssueData, error) { return queryResult.Resource.Issue, nil } + +// FetchIssueComments fetches comments for a single issue (GitLab only) +func FetchIssueComments(issueUrl string) ([]IssueComment, error) { + if !provider.IsGitLab() { + return nil, nil // GitHub fetches comments in the main query + } + + p := provider.GetProvider() + providerComments, err := p.FetchIssueComments(issueUrl) + if err != nil { + return nil, err + } + + comments := make([]IssueComment, len(providerComments)) + for i, c := range providerComments { + comments[i] = IssueComment{ + Author: struct{ Login string }{Login: c.Author.Login}, + Body: c.Body, + UpdatedAt: c.UpdatedAt, + } + } + + return comments, nil +} diff --git a/internal/data/prapi.go b/internal/data/prapi.go index 33691e71f..3b119d481 100644 --- a/internal/data/prapi.go +++ b/internal/data/prapi.go @@ -14,6 +14,7 @@ import ( "github.com/shurcooL/githubv4" "github.com/dlvhdr/gh-dash/v4/internal/config" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/theme" ) @@ -60,7 +61,16 @@ type EnrichedPullRequestData struct { ReviewRequests ReviewRequests `graphql:"reviewRequests(last: 100)"` Reviews Reviews `graphql:"reviews(last: 100)"` SuggestedReviewers []SuggestedReviewer - Files ChangedFiles `graphql:"files(first: 5)"` + Files ChangedFiles `graphql:"files(first: 5)"` + PipelineJobs []PipelineJob // For GitLab - CI pipeline jobs +} + +// PipelineJob represents a GitLab CI job +type PipelineJob struct { + Name string + Status string // "success", "failed", "running", "pending", "skipped", "canceled" + Stage string + WebURL string } type PullRequestData struct { @@ -491,6 +501,11 @@ func IsEnrichmentCacheCleared() bool { } func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { + // Use GitLab provider if configured + if provider.IsGitLab() { + return fetchPullRequestsFromGitLab(query, limit, pageInfo) + } + var err error if client == nil { if config.IsFeatureEnabled(config.FF_MOCK_DATA) { @@ -547,7 +562,122 @@ func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequest }, nil } +// fetchPullRequestsFromGitLab fetches MRs from GitLab and converts them to the internal format +func fetchPullRequestsFromGitLab(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { + p := provider.GetProvider() + + var providerPageInfo *provider.PageInfo + if pageInfo != nil { + providerPageInfo = &provider.PageInfo{ + HasNextPage: pageInfo.HasNextPage, + StartCursor: pageInfo.StartCursor, + EndCursor: pageInfo.EndCursor, + } + } + + resp, err := p.FetchPullRequests(query, limit, providerPageInfo) + if err != nil { + return PullRequestsResponse{}, err + } + + prs := make([]PullRequestData, len(resp.Prs)) + for i, pr := range resp.Prs { + prs[i] = convertProviderPRToData(pr) + } + + return PullRequestsResponse{ + Prs: prs, + TotalCount: resp.TotalCount, + PageInfo: PageInfo{ + HasNextPage: resp.PageInfo.HasNextPage, + StartCursor: resp.PageInfo.StartCursor, + EndCursor: resp.PageInfo.EndCursor, + }, + }, nil +} + +// convertProviderPRToData converts provider.PullRequestData to data.PullRequestData +func convertProviderPRToData(pr provider.PullRequestData) PullRequestData { + assignees := make([]Assignee, len(pr.Assignees.Nodes)) + for i, a := range pr.Assignees.Nodes { + assignees[i] = Assignee{Login: a.Login} + } + + labels := make([]Label, len(pr.Labels.Nodes)) + for i, l := range pr.Labels.Nodes { + labels[i] = Label{Name: l.Name, Color: l.Color} + } + + files := make([]ChangedFile, len(pr.Files.Nodes)) + for i, f := range pr.Files.Nodes { + files[i] = ChangedFile{ + Additions: f.Additions, + Deletions: f.Deletions, + Path: f.Path, + ChangeType: f.ChangeType, + } + } + + reviewRequests := make([]ReviewRequestNode, len(pr.ReviewRequests.Nodes)) + for i, r := range pr.ReviewRequests.Nodes { + reviewRequests[i] = ReviewRequestNode{ + AsCodeOwner: r.AsCodeOwner, + } + reviewRequests[i].RequestedReviewer.User.Login = r.RequestedReviewer.User.Login + reviewRequests[i].RequestedReviewer.Team.Slug = r.RequestedReviewer.Team.Slug + reviewRequests[i].RequestedReviewer.Team.Name = r.RequestedReviewer.Team.Name + reviewRequests[i].RequestedReviewer.Bot.Login = r.RequestedReviewer.Bot.Login + reviewRequests[i].RequestedReviewer.Mannequin.Login = r.RequestedReviewer.Mannequin.Login + } + + reviews := make([]Review, len(pr.Reviews.Nodes)) + for i, r := range pr.Reviews.Nodes { + reviews[i] = Review{ + Author: struct{ Login string }{Login: r.Author.Login}, + Body: r.Body, + State: r.State, + UpdatedAt: r.UpdatedAt, + } + } + + return PullRequestData{ + Number: pr.Number, + Title: pr.Title, + Body: pr.Body, + Author: struct{ Login string }{Login: pr.Author.Login}, + AuthorAssociation: pr.AuthorAssociation, + UpdatedAt: pr.UpdatedAt, + CreatedAt: pr.CreatedAt, + Url: pr.Url, + State: pr.State, + Mergeable: pr.Mergeable, + ReviewDecision: pr.ReviewDecision, + Additions: pr.Additions, + Deletions: pr.Deletions, + HeadRefName: pr.HeadRefName, + BaseRefName: pr.BaseRefName, + HeadRepository: struct{ Name string }{Name: pr.HeadRepository.Name}, + HeadRef: struct{ Name string }{Name: pr.HeadRef.Name}, + Repository: Repository{NameWithOwner: pr.Repository.NameWithOwner, IsArchived: pr.Repository.IsArchived}, + Assignees: Assignees{Nodes: assignees}, + Comments: Comments{TotalCount: pr.Comments.TotalCount}, + ReviewThreads: ReviewThreads{TotalCount: pr.ReviewThreads.TotalCount}, + Reviews: Reviews{TotalCount: pr.Reviews.TotalCount, Nodes: reviews}, + ReviewRequests: ReviewRequests{TotalCount: pr.ReviewRequests.TotalCount, Nodes: reviewRequests}, + Files: ChangedFiles{TotalCount: pr.Files.TotalCount, Nodes: files}, + IsDraft: pr.IsDraft, + Commits: Commits{TotalCount: pr.Commits.TotalCount}, + Labels: PRLabels{Nodes: labels}, + MergeStateStatus: MergeStateStatus(pr.MergeStateStatus), + } +} + func FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) { + // Use GitLab provider if configured + if provider.IsGitLab() { + return fetchPullRequestFromGitLab(prUrl) + } + var err error if client == nil { client, err = gh.DefaultGraphQLClient() @@ -577,3 +707,121 @@ func FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) { return queryResult.Resource.PullRequest, nil } + +// fetchPullRequestFromGitLab fetches a single MR from GitLab +func fetchPullRequestFromGitLab(prUrl string) (EnrichedPullRequestData, error) { + p := provider.GetProvider() + + resp, err := p.FetchPullRequest(prUrl) + if err != nil { + return EnrichedPullRequestData{}, err + } + + comments := make([]Comment, len(resp.Comments.Nodes)) + for i, c := range resp.Comments.Nodes { + comments[i] = Comment{ + Author: struct{ Login string }{Login: c.Author.Login}, + Body: c.Body, + UpdatedAt: c.UpdatedAt, + } + } + + reviewRequests := make([]ReviewRequestNode, len(resp.ReviewRequests.Nodes)) + for i, r := range resp.ReviewRequests.Nodes { + reviewRequests[i] = ReviewRequestNode{ + AsCodeOwner: r.AsCodeOwner, + } + reviewRequests[i].RequestedReviewer.User.Login = r.RequestedReviewer.User.Login + reviewRequests[i].RequestedReviewer.Team.Slug = r.RequestedReviewer.Team.Slug + reviewRequests[i].RequestedReviewer.Team.Name = r.RequestedReviewer.Team.Name + } + + reviews := make([]Review, len(resp.Reviews.Nodes)) + for i, r := range resp.Reviews.Nodes { + reviews[i] = Review{ + Author: struct{ Login string }{Login: r.Author.Login}, + Body: r.Body, + State: r.State, + UpdatedAt: r.UpdatedAt, + } + } + + // Convert commits + allCommits := AllCommits{} + for _, c := range resp.Commits { + node := struct { + Commit struct { + AbbreviatedOid string + CommittedDate time.Time + MessageHeadline string + Author struct { + Name string + User struct{ Login string } + } + StatusCheckRollup StatusCheckRollupStats + } + }{} + node.Commit.AbbreviatedOid = c.AbbreviatedOid + node.Commit.CommittedDate = c.CommittedDate + node.Commit.MessageHeadline = c.MessageHeadline + node.Commit.Author.Name = c.Author.Name + node.Commit.Author.User.Login = c.Author.User.Login + allCommits.Nodes = append(allCommits.Nodes, node) + } + + return EnrichedPullRequestData{ + Url: resp.Url, + Number: resp.Number, + Repository: Repository{ + NameWithOwner: resp.Repository.NameWithOwner, + IsArchived: resp.Repository.IsArchived, + }, + Comments: CommentsWithBody{ + TotalCount: graphql.Int(resp.Comments.TotalCount), + Nodes: comments, + }, + ReviewThreads: ReviewThreadsWithComments{}, + ReviewRequests: ReviewRequests{ + TotalCount: resp.ReviewRequests.TotalCount, + Nodes: reviewRequests, + }, + Reviews: Reviews{ + TotalCount: resp.Reviews.TotalCount, + Nodes: reviews, + }, + AllCommits: allCommits, + Files: convertChangedFiles(resp.ChangedFiles), + PipelineJobs: convertPipelineJobs(resp.PipelineJobs), + }, nil +} + +// convertPipelineJobs converts provider PipelineJobs to data PipelineJobs +func convertPipelineJobs(providerJobs []provider.PipelineJob) []PipelineJob { + jobs := make([]PipelineJob, len(providerJobs)) + for i, j := range providerJobs { + jobs[i] = PipelineJob{ + Name: j.Name, + Status: j.Status, + Stage: j.Stage, + WebURL: j.WebURL, + } + } + return jobs +} + +// convertChangedFiles converts provider ChangedFiles to data ChangedFiles +func convertChangedFiles(providerFiles []provider.ChangedFile) ChangedFiles { + files := make([]ChangedFile, len(providerFiles)) + for i, f := range providerFiles { + files[i] = ChangedFile{ + Additions: f.Additions, + Deletions: f.Deletions, + Path: f.Path, + ChangeType: f.ChangeType, + } + } + return ChangedFiles{ + TotalCount: len(files), + Nodes: files, + } +} diff --git a/internal/data/user.go b/internal/data/user.go index 9731abfb5..5f0805d9f 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -2,9 +2,16 @@ package data import ( gh "github.com/cli/go-gh/v2/pkg/api" + + "github.com/dlvhdr/gh-dash/v4/internal/provider" ) func CurrentLoginName() (string, error) { + // Use GitLab provider if configured + if provider.IsGitLab() { + return provider.GetProvider().GetCurrentUser() + } + client, err := gh.DefaultGraphQLClient() if err != nil { return "", nil diff --git a/internal/provider/github.go b/internal/provider/github.go new file mode 100644 index 000000000..8a04478cf --- /dev/null +++ b/internal/provider/github.go @@ -0,0 +1,61 @@ +package provider + +// GitHubProvider implements the Provider interface for GitHub +// GitHub uses the gh CLI and the existing data package functions +type GitHubProvider struct { + host string +} + +// NewGitHubProvider creates a new GitHub provider +func NewGitHubProvider() *GitHubProvider { + return &GitHubProvider{ + host: "github.com", + } +} + +func (g *GitHubProvider) GetType() ProviderType { + return GitHub +} + +func (g *GitHubProvider) GetHost() string { + return g.host +} + +func (g *GitHubProvider) GetCLICommand() string { + return "gh" +} + +// FetchPullRequests is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchPullRequests should not be called directly") +} + +// FetchPullRequest is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchPullRequest should not be called directly") +} + +// FetchIssues is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.FetchIssues should not be called directly") +} + +// GetCurrentUser is not implemented for GitHub provider +// The data package handles GitHub directly +func (g *GitHubProvider) GetCurrentUser() (string, error) { + // This should not be called - data package handles GitHub directly + panic("GitHubProvider.GetCurrentUser should not be called directly") +} + +// FetchIssueComments is not implemented for GitHub provider +// GitHub fetches comments as part of the GraphQL query +func (g *GitHubProvider) FetchIssueComments(issueUrl string) ([]IssueComment, error) { + // This should not be called - GitHub fetches comments in the main query + panic("GitHubProvider.FetchIssueComments should not be called directly") +} diff --git a/internal/provider/gitlab.go b/internal/provider/gitlab.go new file mode 100644 index 000000000..3fc82d502 --- /dev/null +++ b/internal/provider/gitlab.go @@ -0,0 +1,833 @@ +package provider + +import ( + "encoding/json" + "fmt" + "net/url" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log" +) + +// GitLabProvider implements the Provider interface for GitLab +type GitLabProvider struct { + host string + labelColors map[string]map[string]string // project -> label name -> color +} + +// NewGitLabProvider creates a new GitLab provider +func NewGitLabProvider(host string) *GitLabProvider { + // Normalize host - remove protocol if present + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + host = strings.TrimSuffix(host, "/") + + return &GitLabProvider{ + host: host, + labelColors: make(map[string]map[string]string), + } +} + +func (g *GitLabProvider) GetType() ProviderType { + return GitLab +} + +func (g *GitLabProvider) GetHost() string { + return g.host +} + +func (g *GitLabProvider) GetCLICommand() string { + return "glab" +} + +// runGlab runs a glab command and returns the output +// Note: glab uses the configured host from 'glab config'. Make sure glab is configured +// with: glab auth login --hostname +func (g *GitLabProvider) runGlab(args ...string) ([]byte, error) { + log.Debug("Running glab command", "args", args, "host", g.host) + + cmd := exec.Command("glab", args...) + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + log.Error("glab command failed", "stderr", string(exitErr.Stderr), "args", args) + return nil, fmt.Errorf("glab command failed: %s", string(exitErr.Stderr)) + } + return nil, err + } + return output, nil +} + +// glabLabel represents a GitLab label with color information +type glabLabel struct { + Name string `json:"name"` + Color string `json:"color"` + TextColor string `json:"text_color"` +} + +// fetchLabelColors fetches and caches label colors for a project +func (g *GitLabProvider) fetchLabelColors(project string) { + if project == "" { + return + } + + // Check if already cached + if _, ok := g.labelColors[project]; ok { + return + } + + output, err := g.runGlab("label", "list", "--repo", project, "--output", "json", "--per-page", "100") + if err != nil { + log.Debug("Failed to fetch labels for color cache", "project", project, "err", err) + return + } + + var labels []glabLabel + if err := json.Unmarshal(output, &labels); err != nil { + log.Debug("Failed to parse labels", "err", err) + return + } + + g.labelColors[project] = make(map[string]string) + for _, l := range labels { + // Remove # prefix if present + color := strings.TrimPrefix(l.Color, "#") + g.labelColors[project][l.Name] = color + } + + log.Debug("Cached label colors", "project", project, "count", len(labels)) +} + +// getLabelColor returns the color for a label, or empty string if not found +func (g *GitLabProvider) getLabelColor(project, labelName string) string { + if projectLabels, ok := g.labelColors[project]; ok { + if color, ok := projectLabels[labelName]; ok { + return color + } + } + return "" +} + +// GitLab API response structures +type glabMergeRequest struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Draft bool `json:"draft"` + WebURL string `json:"web_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Author struct { + Username string `json:"username"` + } `json:"author"` + Assignees []struct { + Username string `json:"username"` + } `json:"assignees"` + Reviewers []struct { + Username string `json:"username"` + } `json:"reviewers"` + Labels []string `json:"labels"` + UserNotesCount int `json:"user_notes_count"` + MergeStatus string `json:"merge_status"` + HasConflicts bool `json:"has_conflicts"` + BlockingDiscussions int `json:"blocking_discussions_resolved_count"` + ChangesCount string `json:"changes_count"` + DiffRefs struct{} `json:"diff_refs"` + ProjectID int `json:"project_id"` + SourceProjectID int `json:"source_project_id"` + TargetProjectID int `json:"target_project_id"` + References struct { + Full string `json:"full"` + } `json:"references"` + Pipeline *struct { + ID int `json:"id"` + Status string `json:"status"` + } `json:"pipeline"` + HeadPipeline *struct { + ID int `json:"id"` + Status string `json:"status"` + } `json:"head_pipeline"` +} + +type glabIssue struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + WebURL string `json:"web_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Author struct { + Username string `json:"username"` + } `json:"author"` + Assignees []struct { + Username string `json:"username"` + } `json:"assignees"` + Labels []string `json:"labels"` + UserNotesCount int `json:"user_notes_count"` + Upvotes int `json:"upvotes"` + Downvotes int `json:"downvotes"` + References struct { + Full string `json:"full"` + } `json:"references"` +} + +type glabProject struct { + PathWithNamespace string `json:"path_with_namespace"` + Archived bool `json:"archived"` +} + +func (g *GitLabProvider) FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) { + // Parse the query to extract project and filters + // GitLab uses different query syntax than GitHub + // Common filters: state, labels, author, assignee, reviewer + + // Parse query for common filters + filters := g.parseQuery(query) + + // Fetch label colors for the project (if specified) + if project, ok := filters["repo"]; ok { + g.fetchLabelColors(project) + } + + args := []string{"mr", "list", "--output", "json", "--per-page", strconv.Itoa(limit)} + + // Handle pagination - use EndCursor as page number + page := 1 + if pageInfo != nil && pageInfo.EndCursor != "" { + if p, err := strconv.Atoi(pageInfo.EndCursor); err == nil { + page = p + } + } + args = append(args, "--page", strconv.Itoa(page)) + + if project, ok := filters["repo"]; ok { + args = append(args, "--repo", project) + } + + // glab mr list uses --closed and --merged flags (no flag = opened by default) + if state, ok := filters["state"]; ok { + switch state { + case "closed": + args = append(args, "--closed") + case "merged": + args = append(args, "--merged") + // "opened" is the default, no flag needed + } + } else if strings.Contains(query, "is:closed") { + args = append(args, "--closed") + } else if strings.Contains(query, "is:merged") { + args = append(args, "--merged") + } + // Note: "is:open" maps to default opened state, no flag needed + + if author, ok := filters["author"]; ok { + if author == "@me" { + args = append(args, "--author", "@me") + } else { + args = append(args, "--author", author) + } + } + + if assignee, ok := filters["assignee"]; ok { + if assignee == "@me" { + args = append(args, "--assignee", "@me") + } else { + args = append(args, "--assignee", assignee) + } + } + + if reviewer, ok := filters["reviewer"]; ok { + if reviewer == "@me" { + args = append(args, "--reviewer", "@me") + } else { + args = append(args, "--reviewer", reviewer) + } + } + + if labels, ok := filters["label"]; ok { + args = append(args, "--label", labels) + } + + if strings.Contains(query, "draft:true") { + args = append(args, "--draft") + } + + if strings.Contains(query, "review-requested:@me") { + args = append(args, "--reviewer", "@me") + } + + output, err := g.runGlab(args...) + if err != nil { + return PullRequestsResponse{}, err + } + + var mrs []glabMergeRequest + if err := json.Unmarshal(output, &mrs); err != nil { + log.Error("Failed to parse MR list", "err", err, "output", string(output)) + return PullRequestsResponse{}, fmt.Errorf("failed to parse MR list: %w", err) + } + + prs := make([]PullRequestData, 0, len(mrs)) + for _, mr := range mrs { + pr := g.convertMRtoPR(mr) + prs = append(prs, pr) + } + + log.Info("Successfully fetched MRs from GitLab", "count", len(prs), "page", page) + + // Calculate next page cursor + nextPage := strconv.Itoa(page + 1) + + return PullRequestsResponse{ + Prs: prs, + TotalCount: len(prs), + PageInfo: PageInfo{ + HasNextPage: len(prs) == limit, + EndCursor: nextPage, + }, + }, nil +} + +func (g *GitLabProvider) convertMRtoPR(mr glabMergeRequest) PullRequestData { + assignees := make([]Assignee, len(mr.Assignees)) + for i, a := range mr.Assignees { + assignees[i] = Assignee{Login: a.Username} + } + + // Extract project path from full reference (e.g., "group/project!123") + projectPath := "" + if mr.References.Full != "" { + parts := strings.Split(mr.References.Full, "!") + if len(parts) > 0 { + projectPath = parts[0] + } + } + + // Get label colors from cache + labels := make([]Label, len(mr.Labels)) + for i, l := range mr.Labels { + labels[i] = Label{Name: l, Color: g.getLabelColor(projectPath, l)} + } + + reviewRequests := make([]ReviewRequestNode, len(mr.Reviewers)) + for i, r := range mr.Reviewers { + reviewRequests[i] = ReviewRequestNode{} + reviewRequests[i].RequestedReviewer.User.Login = r.Username + } + + state := mr.State + switch state { + case "opened": + state = "OPEN" + case "merged": + state = "MERGED" + case "closed": + state = "CLOSED" + } + + mergeable := "UNKNOWN" + if mr.MergeStatus == "can_be_merged" { + mergeable = "MERGEABLE" + } else if mr.HasConflicts { + mergeable = "CONFLICTING" + } + + ciStatus := "" + if mr.Pipeline != nil { + ciStatus = mr.Pipeline.Status + } else if mr.HeadPipeline != nil { + ciStatus = mr.HeadPipeline.Status + } + + // Parse changes count (it's a string in the API) + changesCount := 0 + if mr.ChangesCount != "" { + fmt.Sscanf(mr.ChangesCount, "%d", &changesCount) + } + + return PullRequestData{ + Number: mr.IID, + Title: mr.Title, + Body: mr.Description, + Author: Author{Login: mr.Author.Username}, + AuthorAssociation: "", + UpdatedAt: mr.UpdatedAt, + CreatedAt: mr.CreatedAt, + Url: mr.WebURL, + State: state, + Mergeable: mergeable, + ReviewDecision: "", + Additions: 0, + Deletions: 0, + HeadRefName: mr.SourceBranch, + BaseRefName: mr.TargetBranch, + HeadRepository: struct{ Name string }{Name: ""}, + HeadRef: struct{ Name string }{Name: mr.SourceBranch}, + Repository: Repository{NameWithOwner: projectPath, IsArchived: false}, + Assignees: Assignees{Nodes: assignees}, + Comments: Comments{TotalCount: mr.UserNotesCount}, + ReviewThreads: ReviewThreads{TotalCount: 0}, + Reviews: Reviews{TotalCount: 0, Nodes: nil}, + ReviewRequests: ReviewRequests{TotalCount: len(reviewRequests), Nodes: reviewRequests}, + Files: ChangedFiles{TotalCount: changesCount, Nodes: nil}, + IsDraft: mr.Draft, + Commits: Commits{TotalCount: 0}, + Labels: Labels{Nodes: labels}, + MergeStateStatus: mr.MergeStatus, + CIStatus: ciStatus, + } +} + +func (g *GitLabProvider) FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) { + // Parse project and MR IID from URL + // Format: https://gitlab.example.com/group/project/-/merge_requests/123 + parsedURL, err := url.Parse(prUrl) + if err != nil { + return EnrichedPullRequestData{}, err + } + + path := parsedURL.Path + // Extract project and MR ID + re := regexp.MustCompile(`(.+?)/-/merge_requests/(\d+)`) + matches := re.FindStringSubmatch(path) + if len(matches) < 3 { + return EnrichedPullRequestData{}, fmt.Errorf("invalid MR URL format: %s", prUrl) + } + + project := strings.TrimPrefix(matches[1], "/") + mrID := matches[2] + + // Get MR details + output, err := g.runGlab("mr", "view", mrID, "--repo", project, "--output", "json") + if err != nil { + return EnrichedPullRequestData{}, err + } + + var mr glabMergeRequest + if err := json.Unmarshal(output, &mr); err != nil { + return EnrichedPullRequestData{}, fmt.Errorf("failed to parse MR: %w", err) + } + + // Get MR notes/comments via API + encodedProject := url.PathEscape(project) + notesEndpoint := fmt.Sprintf("projects/%s/merge_requests/%s/notes", encodedProject, mrID) + notesOutput, err := g.runGlab("api", notesEndpoint) + comments := CommentsWithBody{TotalCount: mr.UserNotesCount} + if err == nil && len(notesOutput) > 0 { + var notes []struct { + Body string `json:"body"` + Author struct{ Username string } `json:"author"` + CreatedAt time.Time `json:"created_at"` + System bool `json:"system"` // Filter out system notes + } + if json.Unmarshal(notesOutput, ¬es) == nil { + for _, note := range notes { + // Skip system-generated notes (like "mentioned in commit", "changed the description", etc.) + if note.System { + continue + } + comments.Nodes = append(comments.Nodes, Comment{ + Author: Author{Login: note.Author.Username}, + Body: note.Body, + UpdatedAt: note.CreatedAt, + }) + } + comments.TotalCount = len(comments.Nodes) + } + } + + // Get MR commits via API + commitsEndpoint := fmt.Sprintf("projects/%s/merge_requests/%s/commits", encodedProject, mrID) + commitsOutput, err := g.runGlab("api", commitsEndpoint) + var commits []CommitData + if err == nil && len(commitsOutput) > 0 { + var glabCommits []struct { + ID string `json:"id"` + ShortID string `json:"short_id"` + Title string `json:"title"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + CommittedDate time.Time `json:"committed_date"` + } + if json.Unmarshal(commitsOutput, &glabCommits) == nil { + for _, c := range glabCommits { + commits = append(commits, CommitData{ + AbbreviatedOid: c.ShortID, + CommittedDate: c.CommittedDate, + MessageHeadline: c.Title, + Author: struct { + Name string + User struct{ Login string } + }{ + Name: c.AuthorName, + User: struct{ Login string }{Login: ""}, + }, + }) + } + } + } + + // Get MR changes (files) via API + changesEndpoint := fmt.Sprintf("projects/%s/merge_requests/%s/changes", encodedProject, mrID) + changesOutput, err := g.runGlab("api", changesEndpoint) + var changedFiles []ChangedFile + if err == nil && len(changesOutput) > 0 { + var changesResp struct { + Changes []struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + NewFile bool `json:"new_file"` + RenamedFile bool `json:"renamed_file"` + DeletedFile bool `json:"deleted_file"` + Diff string `json:"diff"` + } `json:"changes"` + } + if json.Unmarshal(changesOutput, &changesResp) == nil { + for _, c := range changesResp.Changes { + changeType := "MODIFIED" + if c.NewFile { + changeType = "ADDED" + } else if c.DeletedFile { + changeType = "DELETED" + } else if c.RenamedFile { + changeType = "RENAMED" + } + + // Count additions and deletions from diff + additions, deletions := countDiffStats(c.Diff) + + changedFiles = append(changedFiles, ChangedFile{ + Path: c.NewPath, + ChangeType: changeType, + Additions: additions, + Deletions: deletions, + }) + } + } + } + + reviewRequests := make([]ReviewRequestNode, len(mr.Reviewers)) + for i, r := range mr.Reviewers { + reviewRequests[i] = ReviewRequestNode{} + reviewRequests[i].RequestedReviewer.User.Login = r.Username + } + + // Fetch pipeline jobs if there's a pipeline (check both pipeline and head_pipeline) + var pipelineJobs []PipelineJob + var pipelineID int + if mr.Pipeline != nil && mr.Pipeline.ID > 0 { + pipelineID = mr.Pipeline.ID + } else if mr.HeadPipeline != nil && mr.HeadPipeline.ID > 0 { + pipelineID = mr.HeadPipeline.ID + } + + if pipelineID > 0 { + jobsEndpoint := fmt.Sprintf("projects/%s/pipelines/%d/jobs", encodedProject, pipelineID) + jobsOutput, err := g.runGlab("api", jobsEndpoint) + if err == nil && len(jobsOutput) > 0 { + var jobs []struct { + Name string `json:"name"` + Status string `json:"status"` + Stage string `json:"stage"` + WebURL string `json:"web_url"` + } + if json.Unmarshal(jobsOutput, &jobs) == nil { + for _, j := range jobs { + pipelineJobs = append(pipelineJobs, PipelineJob{ + Name: j.Name, + Status: j.Status, + Stage: j.Stage, + WebURL: j.WebURL, + }) + } + } + } + } + + return EnrichedPullRequestData{ + Url: mr.WebURL, + Number: mr.IID, + Repository: Repository{NameWithOwner: project, IsArchived: false}, + Comments: comments, + ReviewThreads: ReviewThreads{TotalCount: 0}, + ReviewRequests: ReviewRequests{TotalCount: len(reviewRequests), Nodes: reviewRequests}, + Reviews: Reviews{TotalCount: 0}, + Commits: commits, + ChangedFiles: changedFiles, + PipelineJobs: pipelineJobs, + }, nil +} + +// countDiffStats counts additions and deletions from a unified diff string +func countDiffStats(diff string) (additions, deletions int) { + lines := strings.Split(diff, "\n") + for _, line := range lines { + if len(line) > 0 { + if line[0] == '+' && !strings.HasPrefix(line, "+++") { + additions++ + } else if line[0] == '-' && !strings.HasPrefix(line, "---") { + deletions++ + } + } + } + return +} + +func (g *GitLabProvider) FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) { + filters := g.parseQuery(query) + + // Fetch label colors for the project (if specified) + if project, ok := filters["repo"]; ok { + g.fetchLabelColors(project) + } + + args := []string{"issue", "list", "--output", "json", "--per-page", strconv.Itoa(limit)} + + // Handle pagination - use EndCursor as page number + page := 1 + if pageInfo != nil && pageInfo.EndCursor != "" { + if p, err := strconv.Atoi(pageInfo.EndCursor); err == nil { + page = p + } + } + args = append(args, "--page", strconv.Itoa(page)) + + if project, ok := filters["repo"]; ok { + args = append(args, "--repo", project) + } + + // glab issue list uses --closed and --opened flags (no flag = all issues) + if state, ok := filters["state"]; ok { + switch state { + case "closed": + args = append(args, "--closed") + case "opened": + args = append(args, "--opened") + } + } else if strings.Contains(query, "is:open") { + args = append(args, "--opened") + } else if strings.Contains(query, "is:closed") { + args = append(args, "--closed") + } + + if author, ok := filters["author"]; ok { + if author == "@me" { + args = append(args, "--author", "@me") + } else { + args = append(args, "--author", author) + } + } + + if assignee, ok := filters["assignee"]; ok { + if assignee == "@me" { + args = append(args, "--assignee", "@me") + } else { + args = append(args, "--assignee", assignee) + } + } + + if labels, ok := filters["label"]; ok { + args = append(args, "--label", labels) + } + + output, err := g.runGlab(args...) + if err != nil { + return IssuesResponse{}, err + } + + var glabIssues []glabIssue + if err := json.Unmarshal(output, &glabIssues); err != nil { + log.Error("Failed to parse issue list", "err", err, "output", string(output)) + return IssuesResponse{}, fmt.Errorf("failed to parse issue list: %w", err) + } + + issues := make([]IssueData, 0, len(glabIssues)) + for _, issue := range glabIssues { + issues = append(issues, g.convertGitLabIssue(issue)) + } + + log.Info("Successfully fetched issues from GitLab", "count", len(issues), "page", page) + + // Calculate next page cursor + nextPage := strconv.Itoa(page + 1) + + return IssuesResponse{ + Issues: issues, + TotalCount: len(issues), + PageInfo: PageInfo{ + HasNextPage: len(issues) == limit, + EndCursor: nextPage, + }, + }, nil +} + +func (g *GitLabProvider) convertGitLabIssue(issue glabIssue) IssueData { + assignees := make([]Assignee, len(issue.Assignees)) + for i, a := range issue.Assignees { + assignees[i] = Assignee{Login: a.Username} + } + + // Extract project path from full reference + projectPath := "" + if issue.References.Full != "" { + parts := strings.Split(issue.References.Full, "#") + if len(parts) > 0 { + projectPath = parts[0] + } + } + + // Get label colors from cache + labels := make([]Label, len(issue.Labels)) + for i, l := range issue.Labels { + labels[i] = Label{Name: l, Color: g.getLabelColor(projectPath, l)} + } + + state := issue.State + switch state { + case "opened": + state = "OPEN" + case "closed": + state = "CLOSED" + } + + return IssueData{ + Number: issue.IID, + Title: issue.Title, + Body: issue.Description, + State: state, + Author: Author{Login: issue.Author.Username}, + AuthorAssociation: "", + UpdatedAt: issue.UpdatedAt, + CreatedAt: issue.CreatedAt, + Url: issue.WebURL, + Repository: Repository{NameWithOwner: projectPath, IsArchived: false}, + Assignees: Assignees{Nodes: assignees}, + Comments: IssueComments{TotalCount: issue.UserNotesCount}, + Reactions: IssueReactions{TotalCount: issue.Upvotes + issue.Downvotes}, + Labels: Labels{Nodes: labels}, + } +} + +func (g *GitLabProvider) GetCurrentUser() (string, error) { + output, err := g.runGlab("auth", "status", "--show-token") + if err != nil { + return "", err + } + + // Parse the auth status output to get username + // Output format includes "Logged in to as " with optional leading checkmark + lines := strings.Split(string(output), "\n") + linePattern := regexp.MustCompile(`^\s*(?:[✓✔]\s*)?Logged in to\s+\S+\s+as\s+([^\s]+)(?:\s+\(.*\))?\s*$`) + for _, line := range lines { + matches := linePattern.FindStringSubmatch(line) + if len(matches) == 2 { + username := strings.TrimSpace(matches[1]) + if username != "" { + return username, nil + } + } + } + + // Fallback to the GitLab API + userOutput, err := g.runGlab("api", "user") + if err == nil && len(userOutput) > 0 { + var user struct { + Username string `json:"username"` + } + if json.Unmarshal(userOutput, &user) == nil && user.Username != "" { + return user.Username, nil + } + } + + return "", fmt.Errorf("could not determine current user") +} + +// parseQuery parses a GitHub-style query into GitLab filters +func (g *GitLabProvider) parseQuery(query string) map[string]string { + filters := make(map[string]string) + + // Split query into parts + parts := strings.Fields(query) + for _, part := range parts { + if strings.HasPrefix(part, "repo:") { + filters["repo"] = strings.TrimPrefix(part, "repo:") + } else if strings.HasPrefix(part, "author:") { + filters["author"] = strings.TrimPrefix(part, "author:") + } else if strings.HasPrefix(part, "assignee:") { + filters["assignee"] = strings.TrimPrefix(part, "assignee:") + } else if strings.HasPrefix(part, "reviewer:") || strings.HasPrefix(part, "review-requested:") { + val := strings.TrimPrefix(part, "reviewer:") + val = strings.TrimPrefix(val, "review-requested:") + filters["reviewer"] = val + } else if strings.HasPrefix(part, "label:") { + filters["label"] = strings.TrimPrefix(part, "label:") + } else if strings.HasPrefix(part, "state:") { + filters["state"] = strings.TrimPrefix(part, "state:") + } + } + + return filters +} + +// FetchIssueComments fetches comments for a single issue +func (g *GitLabProvider) FetchIssueComments(issueUrl string) ([]IssueComment, error) { + // Parse project and issue IID from URL + // Format: https://gitlab.example.com/group/project/-/issues/123 + parsedURL, err := url.Parse(issueUrl) + if err != nil { + return nil, err + } + + path := parsedURL.Path + re := regexp.MustCompile(`(.+?)/-/issues/(\d+)`) + matches := re.FindStringSubmatch(path) + if len(matches) < 3 { + return nil, fmt.Errorf("invalid issue URL format: %s", issueUrl) + } + + project := strings.TrimPrefix(matches[1], "/") + issueID := matches[2] + + // Get issue notes/comments via API + encodedProject := url.PathEscape(project) + notesEndpoint := fmt.Sprintf("projects/%s/issues/%s/notes", encodedProject, issueID) + notesOutput, err := g.runGlab("api", notesEndpoint) + if err != nil { + return nil, err + } + + var comments []IssueComment + if len(notesOutput) > 0 { + var notes []struct { + Body string `json:"body"` + Author struct{ Username string } `json:"author"` + CreatedAt time.Time `json:"created_at"` + System bool `json:"system"` + } + if err := json.Unmarshal(notesOutput, ¬es); err != nil { + return nil, err + } + for _, note := range notes { + // Skip system-generated notes + if note.System { + continue + } + comments = append(comments, IssueComment{ + Author: Author{Login: note.Author.Username}, + Body: note.Body, + UpdatedAt: note.CreatedAt, + }) + } + } + + return comments, nil +} diff --git a/internal/provider/instance.go b/internal/provider/instance.go new file mode 100644 index 000000000..69b6907b4 --- /dev/null +++ b/internal/provider/instance.go @@ -0,0 +1,45 @@ +package provider + +import ( + "sync" +) + +var ( + currentProvider Provider + providerMu sync.RWMutex +) + +// SetProvider sets the global provider instance +func SetProvider(p Provider) { + providerMu.Lock() + defer providerMu.Unlock() + currentProvider = p +} + +// GetProvider returns the current provider instance +// Defaults to GitHub if not set +func GetProvider() Provider { + providerMu.RLock() + defer providerMu.RUnlock() + if currentProvider == nil { + return NewGitHubProvider() + } + return currentProvider +} + +// IsGitLab returns true if the current provider is GitLab +func IsGitLab() bool { + p := GetProvider() + return p.GetType() == GitLab +} + +// IsGitHub returns true if the current provider is GitHub +func IsGitHub() bool { + p := GetProvider() + return p.GetType() == GitHub +} + +// GetCLICommand returns the CLI command for the current provider +func GetCLICommand() string { + return GetProvider().GetCLICommand() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 000000000..f42b8c163 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,308 @@ +package provider + +import ( + "time" +) + +// ProviderType represents the type of Git hosting provider +type ProviderType string + +const ( + GitHub ProviderType = "github" + GitLab ProviderType = "gitlab" +) + +// Provider is the interface that abstracts GitHub and GitLab APIs +type Provider interface { + // GetType returns the provider type + GetType() ProviderType + + // GetHost returns the hostname (e.g., "gitlab.krone.at" or "github.com") + GetHost() string + + // FetchPullRequests fetches pull/merge requests + FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) + + // FetchPullRequest fetches a single pull/merge request by URL + FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) + + // FetchIssues fetches issues + FetchIssues(query string, limit int, pageInfo *PageInfo) (IssuesResponse, error) + + // FetchIssueComments fetches comments for a single issue (GitLab only) + FetchIssueComments(issueUrl string) ([]IssueComment, error) + + // GetCurrentUser returns the current authenticated user + GetCurrentUser() (string, error) + + // GetCLICommand returns the CLI command name ("gh" or "glab") + GetCLICommand() string +} + +// PageInfo for pagination +type PageInfo struct { + HasNextPage bool + StartCursor string + EndCursor string +} + +// Repository represents a repository +type Repository struct { + NameWithOwner string + IsArchived bool +} + +// Author represents an author +type Author struct { + Login string +} + +// Assignee represents an assignee +type Assignee struct { + Login string +} + +// Assignees represents a list of assignees +type Assignees struct { + Nodes []Assignee +} + +// Label represents a label +type Label struct { + Color string + Name string +} + +// Labels represents a list of labels +type Labels struct { + Nodes []Label +} + +// Comment represents a comment +type Comment struct { + Author Author + Body string + UpdatedAt time.Time +} + +// Comments represents comment counts +type Comments struct { + TotalCount int +} + +// CommentsWithBody includes full comment data +type CommentsWithBody struct { + TotalCount int + Nodes []Comment +} + +// Review represents a review +type Review struct { + Author Author + Body string + State string + UpdatedAt time.Time +} + +// Reviews represents reviews +type Reviews struct { + TotalCount int + Nodes []Review +} + +// ReviewThreads represents review threads +type ReviewThreads struct { + TotalCount int +} + +// ReviewRequests represents review requests +type ReviewRequests struct { + TotalCount int + Nodes []ReviewRequestNode +} + +// ReviewRequestNode represents a single review request +type ReviewRequestNode struct { + AsCodeOwner bool + RequestedReviewer struct { + User struct{ Login string } + Team struct{ Slug, Name string } + Bot struct{ Login string } + Mannequin struct{ Login string } + } +} + +// GetReviewerDisplayName returns the display name for a reviewer +func (r ReviewRequestNode) GetReviewerDisplayName() string { + if r.RequestedReviewer.User.Login != "" { + return r.RequestedReviewer.User.Login + } + if r.RequestedReviewer.Team.Slug != "" { + return r.RequestedReviewer.Team.Slug + } + if r.RequestedReviewer.Bot.Login != "" { + return r.RequestedReviewer.Bot.Login + } + if r.RequestedReviewer.Mannequin.Login != "" { + return r.RequestedReviewer.Mannequin.Login + } + return "" +} + +// GetReviewerType returns the type of reviewer +func (r ReviewRequestNode) GetReviewerType() string { + if r.RequestedReviewer.User.Login != "" { + return "User" + } + if r.RequestedReviewer.Team.Slug != "" { + return "Team" + } + if r.RequestedReviewer.Bot.Login != "" { + return "Bot" + } + if r.RequestedReviewer.Mannequin.Login != "" { + return "Mannequin" + } + return "" +} + +// IsTeam returns true if the reviewer is a team +func (r ReviewRequestNode) IsTeam() bool { + return r.RequestedReviewer.Team.Slug != "" +} + +// ChangedFile represents a changed file +type ChangedFile struct { + Additions int + Deletions int + Path string + ChangeType string +} + +// ChangedFiles represents changed files +type ChangedFiles struct { + TotalCount int + Nodes []ChangedFile +} + +// Commits represents commits +type Commits struct { + TotalCount int +} + +// PullRequestData represents a pull/merge request +type PullRequestData struct { + Number int + Title string + Body string + Author Author + AuthorAssociation string + UpdatedAt time.Time + CreatedAt time.Time + Url string + State string + Mergeable string + ReviewDecision string + Additions int + Deletions int + HeadRefName string + BaseRefName string + HeadRepository struct{ Name string } + HeadRef struct{ Name string } + Repository Repository + Assignees Assignees + Comments Comments + ReviewThreads ReviewThreads + Reviews Reviews + ReviewRequests ReviewRequests + Files ChangedFiles + IsDraft bool + Commits Commits + Labels Labels + MergeStateStatus string + CIStatus string // "success", "failure", "pending", "" +} + +// PullRequestsResponse represents a list of pull requests +type PullRequestsResponse struct { + Prs []PullRequestData + TotalCount int + PageInfo PageInfo +} + +// EnrichedPullRequestData represents enriched pull request data +type EnrichedPullRequestData struct { + Url string + Number int + Repository Repository + Comments CommentsWithBody + ReviewThreads ReviewThreads + ReviewRequests ReviewRequests + Reviews Reviews + Commits []CommitData + ChangedFiles []ChangedFile + PipelineJobs []PipelineJob // GitLab CI jobs +} + +// PipelineJob represents a GitLab CI job +type PipelineJob struct { + Name string + Status string // "success", "failed", "running", "pending", "skipped", "canceled" + Stage string + WebURL string +} + +// CommitData represents commit data +type CommitData struct { + AbbreviatedOid string + CommittedDate time.Time + MessageHeadline string + Author struct { + Name string + User struct{ Login string } + } + StatusCheckRollup string +} + +// IssueData represents an issue +type IssueData struct { + Number int + Title string + Body string + State string + Author Author + AuthorAssociation string + UpdatedAt time.Time + CreatedAt time.Time + Url string + Repository Repository + Assignees Assignees + Comments IssueComments + Reactions IssueReactions + Labels Labels +} + +// IssueComments represents issue comments +type IssueComments struct { + Nodes []IssueComment + TotalCount int +} + +// IssueComment represents an issue comment +type IssueComment struct { + Author Author + Body string + UpdatedAt time.Time +} + +// IssueReactions represents issue reactions +type IssueReactions struct { + TotalCount int +} + +// IssuesResponse represents a list of issues +type IssuesResponse struct { + Issues []IssueData + TotalCount int + PageInfo PageInfo +} diff --git a/internal/tui/common/diff.go b/internal/tui/common/diff.go index 8eebf45a8..44b43b1d4 100644 --- a/internal/tui/common/diff.go +++ b/internal/tui/common/diff.go @@ -6,20 +6,33 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" ) -// DiffPR opens a diff view for a PR using the gh CLI. +// DiffPR opens a diff view for a PR/MR using the gh or glab CLI. // The env parameter should be the result of Config.GetFullScreenDiffPagerEnv(). func DiffPR(prNumber int, repoName string, env []string) tea.Cmd { - c := exec.Command( - "gh", - "pr", - "diff", - fmt.Sprint(prNumber), - "-R", - repoName, - ) + var c *exec.Cmd + if provider.IsGitLab() { + c = exec.Command( + "glab", + "mr", + "diff", + fmt.Sprint(prNumber), + "--repo", + repoName, + ) + } else { + c = exec.Command( + "gh", + "pr", + "diff", + fmt.Sprint(prNumber), + "-R", + repoName, + ) + } c.Env = env return tea.ExecProcess(c, func(err error) tea.Msg { diff --git a/internal/tui/components/issueview/issueview.go b/internal/tui/components/issueview/issueview.go index e9937ca35..82ed4a160 100644 --- a/internal/tui/components/issueview/issueview.go +++ b/internal/tui/components/issueview/issueview.go @@ -244,11 +244,46 @@ func (m *Model) SetSectionId(id int) { m.sectionId = id } -func (m *Model) SetRow(data *data.IssueData) { - if data == nil { +func (m *Model) SetRow(issueData *data.IssueData) { + if issueData == nil { m.issue = nil } else { - m.issue = &issuerow.Issue{Ctx: m.ctx, Data: *data} + m.issue = &issuerow.Issue{Ctx: m.ctx, Data: *issueData} + } +} + +// EnrichIssueComments fetches comments for the current issue (for GitLab) +func (m *Model) EnrichIssueComments() tea.Cmd { + if m.issue == nil { + return nil + } + // Only fetch if we don't have comments yet + if len(m.issue.Data.Comments.Nodes) > 0 { + return nil + } + issueUrl := m.issue.Data.Url + return func() tea.Msg { + comments, err := data.FetchIssueComments(issueUrl) + return IssueCommentsMsg{ + IssueUrl: issueUrl, + Comments: comments, + Err: err, + } + } +} + +// IssueCommentsMsg is sent when issue comments are fetched +type IssueCommentsMsg struct { + IssueUrl string + Comments []data.IssueComment + Err error +} + +// SetIssueComments updates the issue with fetched comments +func (m *Model) SetIssueComments(issueUrl string, comments []data.IssueComment) { + if m.issue != nil && m.issue.Data.Url == issueUrl { + m.issue.Data.Comments.Nodes = comments + m.issue.Data.Comments.TotalCount = len(comments) } } diff --git a/internal/tui/components/prssection/checkout.go b/internal/tui/components/prssection/checkout.go index d6513772f..40bdffe70 100644 --- a/internal/tui/components/prssection/checkout.go +++ b/internal/tui/components/prssection/checkout.go @@ -9,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/common" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" @@ -31,21 +32,37 @@ func (m *Model) checkout() (tea.Cmd, error) { prNumber := pr.GetNumber() taskId := fmt.Sprintf("checkout_%d", prNumber) + + label := "PR" + if provider.IsGitLab() { + label = "MR" + } + task := context.Task{ Id: taskId, - StartText: fmt.Sprintf("Checking out PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been checked out at %s", prNumber, repoPath), + StartText: fmt.Sprintf("Checking out %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been checked out at %s", label, prNumber, repoPath), State: context.TaskStart, Error: nil, } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - c := exec.Command( - "gh", - "pr", - "checkout", - fmt.Sprint(m.GetCurrRow().GetNumber()), - ) + var c *exec.Cmd + if provider.IsGitLab() { + c = exec.Command( + "glab", + "mr", + "checkout", + fmt.Sprint(m.GetCurrRow().GetNumber()), + ) + } else { + c = exec.Command( + "gh", + "pr", + "checkout", + fmt.Sprint(m.GetCurrRow().GetNumber()), + ) + } userHomeDir, _ := os.UserHomeDir() if strings.HasPrefix(repoPath, "~") { repoPath = strings.Replace(repoPath, "~", userHomeDir, 1) diff --git a/internal/tui/components/prssection/prssection.go b/internal/tui/components/prssection/prssection.go index e4cdd63e8..d3e07b0be 100644 --- a/internal/tui/components/prssection/prssection.go +++ b/internal/tui/components/prssection/prssection.go @@ -11,6 +11,7 @@ import ( "github.com/dlvhdr/gh-dash/v4/internal/config" "github.com/dlvhdr/gh-dash/v4/internal/data" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/prrow" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/section" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/table" @@ -460,14 +461,19 @@ func (m *Model) FetchNextPageSectionRows() []tea.Cmd { if m.PageInfo != nil { startCursor = m.PageInfo.StartCursor } + itemType := "PRs" + if provider.IsGitLab() { + itemType = "MRs" + } taskId := fmt.Sprintf("fetching_prs_%d_%s", m.Id, startCursor) isFirstFetch := m.LastFetchTaskId == "" m.LastFetchTaskId = taskId task := context.Task{ Id: taskId, - StartText: fmt.Sprintf(`Fetching PRs for "%s"`, m.Config.Title), + StartText: fmt.Sprintf(`Fetching %s for "%s"`, itemType, m.Config.Title), FinishedText: fmt.Sprintf( - `PRs for "%s" have been fetched`, + `%s for "%s" have been fetched`, + itemType, m.Config.Title, ), State: context.TaskStart, @@ -583,10 +589,16 @@ func assigneesContains(assignees []data.Assignee, assignee data.Assignee) bool { } func (m Model) GetItemSingularForm() string { + if provider.IsGitLab() { + return "MR" + } return "PR" } func (m Model) GetItemPluralForm() string { + if provider.IsGitLab() { + return "MRs" + } return "PRs" } diff --git a/internal/tui/components/prssection/watchChecks.go b/internal/tui/components/prssection/watchChecks.go index 6e9d70597..63ac53238 100644 --- a/internal/tui/components/prssection/watchChecks.go +++ b/internal/tui/components/prssection/watchChecks.go @@ -9,6 +9,7 @@ import ( "charm.land/log/v2" "github.com/gen2brain/beeep" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/prrow" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/tasks" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" @@ -26,25 +27,46 @@ func (m *Model) watchChecks() tea.Cmd { repoNameWithOwner := pr.GetRepoNameWithOwner() prData := pr.(*prrow.Data) taskId := fmt.Sprintf("pr_reopen_%d", prNumber) + + label := "PR" + if provider.IsGitLab() { + label = "MR" + } + task := context.Task{ Id: taskId, - StartText: fmt.Sprintf("Watching checks for PR #%d", prNumber), - FinishedText: fmt.Sprintf("Watching checks for PR #%d", prNumber), + StartText: fmt.Sprintf("Watching checks for %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("Watching checks for %s #%d", label, prNumber), State: context.TaskStart, Error: nil, } startCmd := m.Ctx.StartTask(task) return tea.Batch(startCmd, func() tea.Msg { - c := exec.Command( - "gh", - "pr", - "checks", - "--watch", - "--fail-fast", - fmt.Sprint(prNumber), - "-R", - repoNameWithOwner, - ) + var c *exec.Cmd + if provider.IsGitLab() { + // GitLab: use glab ci status to watch pipeline + c = exec.Command( + "glab", + "ci", + "status", + "--live", + "--repo", + repoNameWithOwner, + "--branch", + prData.Primary.HeadRefName, + ) + } else { + c = exec.Command( + "gh", + "pr", + "checks", + "--watch", + "--fail-fast", + fmt.Sprint(prNumber), + "-R", + repoNameWithOwner, + ) + } var outb, errb bytes.Buffer c.Stdout = &outb diff --git a/internal/tui/components/prview/checks.go b/internal/tui/components/prview/checks.go index 7e4f8d2dd..851f04595 100644 --- a/internal/tui/components/prview/checks.go +++ b/internal/tui/components/prview/checks.go @@ -72,6 +72,47 @@ func (m *Model) viewChecksStatus() (string, checkSectionStatus) { ), statusWaiting } + // For GitLab, use pipeline jobs + if len(m.pr.Data.Enriched.PipelineJobs) > 0 { + stats := m.getGitLabChecksStats() + var icon, title string + var status checkSectionStatus + + if stats.failed > 0 { + icon = m.ctx.Styles.Common.FailureGlyph + title = "Some checks were not successful" + status = statusFailure + } else if stats.inProgress > 0 { + icon = m.ctx.Styles.Common.WaitingGlyph + title = "Some checks haven't completed yet" + status = statusWaiting + } else if stats.succeeded > 0 { + icon = m.ctx.Styles.Common.SuccessGlyph + title = "All checks have passed" + status = statusSuccess + } else { + return "", statusWaiting + } + + statStrs := make([]string, 0) + if stats.failed > 0 { + statStrs = append(statStrs, fmt.Sprintf("%d failing", stats.failed)) + } + if stats.inProgress > 0 { + statStrs = append(statStrs, fmt.Sprintf("%d in progress", stats.inProgress)) + } + if stats.succeeded > 0 { + statStrs = append(statStrs, fmt.Sprintf("%d successful", stats.succeeded)) + } + if stats.skipped > 0 { + statStrs = append(statStrs, fmt.Sprintf("%d skipped", stats.skipped)) + } + + checks = m.viewCheckCategory(icon, title, strings.Join(statStrs, ", "), true) + return checks, status + } + + // For GitHub, use StatusCheckRollup stats := m.getChecksStats() var icon, title string var status checkSectionStatus @@ -106,6 +147,9 @@ func (m *Model) viewChecksStatus() (string, checkSectionStatus) { if stats.inProgress > 0 { statStrs = append(statStrs, fmt.Sprintf("%d in progress", stats.inProgress)) } + if stats.succeeded > 0 { + statStrs = append(statStrs, fmt.Sprintf("%d successful", stats.succeeded)) + } if stats.skipped > 0 { statStrs = append(statStrs, fmt.Sprintf("%d skipped", stats.skipped)) } @@ -189,75 +233,94 @@ func (m *Model) viewClosedStatus() string { } func (m *Model) viewReviewStatus() (string, checkSectionStatus) { - pr := m.pr - if pr.Data == nil { - return "", statusWaiting + w := m.getIndentedContentWidth() - 2 + + if !m.pr.Data.IsEnriched { + return m.viewCheckCategory(m.ctx.Styles.Common.WaitingGlyph, "Loading...", "", false), statusWaiting } - var icon, title, subtitle string + reviewRequests := m.pr.Data.Enriched.ReviewRequests.Nodes + reviews := m.pr.Data.Enriched.Reviews.Nodes + + var icon, title string var status checkSectionStatus - numReviewOwners := m.numRequestedReviewOwners() - numApproving, numChangesRequested, numPending, numCommented := 0, 0, 0, 0 - - for _, node := range pr.Data.Primary.Reviews.Nodes { - switch node.State { - case "APPROVED": - numApproving++ - case "CHANGES_REQUESTED": - numChangesRequested++ - case "PENDING": - numPending++ - case "COMMENTED": - numCommented++ + changesRequested := 0 + approved := 0 + pending := len(reviewRequests) + + for _, review := range reviews { + if review.State == "APPROVED" { + approved++ + } + if review.State == "CHANGES_REQUESTED" { + changesRequested++ } } - switch pr.Data.Primary.ReviewDecision { - case "APPROVED": - icon = m.ctx.Styles.Common.SuccessGlyph - title = "Changes approved" - subtitle = fmt.Sprintf("%d approving reviews", numApproving) - status = statusSuccess - case "CHANGES_REQUESTED": + if changesRequested > 0 { icon = m.ctx.Styles.Common.FailureGlyph title = "Changes requested" - subtitle = fmt.Sprintf("%d requested changes", numChangesRequested) status = statusFailure - case "REVIEW_REQUIRED": - icon = pr.Ctx.Styles.Common.WaitingGlyph - title = "Review Required" + } else if approved > 0 { + icon = m.ctx.Styles.Common.SuccessGlyph + title = fmt.Sprintf("%d approving review", approved) + if approved > 1 { + title += "s" + } + status = statusSuccess + } else if pending > 0 { + icon = m.ctx.Styles.Common.WaitingGlyph + title = "Review required" + status = statusWaiting + } else { + return "", statusNonRequested + } - branchRules := m.pr.Data.Primary.Repository.BranchProtectionRules.Nodes - if len(branchRules) > 0 && branchRules[0].RequiresCodeOwnerReviews && numApproving < 1 { - subtitle = "Code owner review required" - status = statusFailure - } else if numApproving < numReviewOwners { - subtitle = "Code owner review required" - status = statusFailure - } else if len(branchRules) > 0 && numApproving < - branchRules[0].RequiredApprovingReviewCount { - subtitle = fmt.Sprintf("Need %d more approval", - branchRules[0].RequiredApprovingReviewCount-numApproving) - status = statusWaiting - } else if numCommented > 0 { - subtitle = fmt.Sprintf("%d reviewers left comments", numCommented) - status = statusWaiting + subs := make([]string, 0) + if pending > 0 { + for _, reviewRequest := range reviewRequests { + name := "" + if reviewRequest.RequestedReviewer.User.Login != "" { + name = reviewRequest.RequestedReviewer.User.Login + } + if reviewRequest.RequestedReviewer.Team.Name != "" { + name = reviewRequest.RequestedReviewer.Team.Name + } + if reviewRequest.RequestedReviewer.Team.Slug != "" { + name = reviewRequest.RequestedReviewer.Team.Slug + } + subs = append(subs, fmt.Sprintf("%s Review pending from %s", m.ctx.Styles.Common.WaitingGlyph, name)) } - default: - icon = pr.Ctx.Styles.Common.PersonGlyph - title = "Reviews" - subtitle = "Non requested" - status = statusNonRequested } - return m.viewCheckCategory(icon, title, subtitle, false), status -} + for _, review := range reviews { + if review.State == "APPROVED" { + subs = append(subs, fmt.Sprintf("%s %s approved", m.ctx.Styles.Common.SuccessGlyph, review.Author.Login)) + } + if review.State == "CHANGES_REQUESTED" { + subs = append(subs, fmt.Sprintf("%s %s requested changes", m.ctx.Styles.Common.FailureGlyph, review.Author.Login)) + } + } -func (m *Model) viewCheckCategory(icon, title, subtitle string, isLast bool) string { - w := m.getIndentedContentWidth() - part := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, false, !isLast, false). + content := lipgloss.JoinHorizontal( + lipgloss.Top, + " ", + icon, + " ", + lipgloss.NewStyle().Width(w).Render( + lipgloss.JoinVertical( + lipgloss.Left, + m.ctx.Styles.Common.MainTextStyle.Render(title), + lipgloss.NewStyle().MarginLeft(2).Width(w).Foreground(m.ctx.Theme.FaintText).Render( + lipgloss.JoinVertical(lipgloss.Left, subs...), + ), + ), + ), + ) + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, true). BorderForeground(m.ctx.Theme.FaintBorder). Width(w). Padding(1) @@ -355,30 +418,18 @@ func renderCheckRunName(checkRun data.CheckRun) string { parts = append(parts, name) } - return lipgloss.JoinHorizontal( - lipgloss.Top, - strings.Join(parts, "/"), - ) -} - -type CheckCategory int - -const ( - CheckWaiting CheckCategory = iota - CheckFailure - CheckSuccess -) - -func (m *Model) renderCheckRunConclusion(checkRun data.CheckRun) (CheckCategory, string) { - if ghchecks.IsStatusWaiting(string(checkRun.Status)) { - return CheckWaiting, m.ctx.Styles.Common.WaitingGlyph + if m.pr.Data.Primary.Mergeable == "MERGEABLE" { + if m.pr.Data.Primary.MergeStateStatus == "BLOCKED" { + return m.viewCheckCategory(m.ctx.Styles.Common.WaitingGlyph, "Merging is blocked", "", false), statusWaiting + } + return m.viewCheckCategory(m.ctx.Styles.Common.SuccessGlyph, "This branch has no conflicts with the base branch", "Merging can be performed automatically", false), statusSuccess } - if ghchecks.IsConclusionAFailure(string(checkRun.Conclusion)) { - return CheckFailure, m.ctx.Styles.Common.FailureGlyph + if m.pr.Data.Primary.Mergeable == "CONFLICTING" { + return m.viewCheckCategory(m.ctx.Styles.Common.FailureGlyph, "This branch has conflicts that must be resolved", "", false), statusFailure } - return CheckSuccess, m.ctx.Styles.Common.SuccessGlyph + return m.viewCheckCategory(m.ctx.Styles.Common.WaitingGlyph, "Checking for ability to merge automatically", "", false), statusWaiting } func (m *Model) renderStatusContextConclusion( @@ -396,21 +447,15 @@ func (m *Model) renderStatusContextConclusion( return CheckSuccess, m.ctx.Styles.Common.SuccessGlyph } -func renderStatusContextName(statusContext data.StatusContext) string { - var parts []string - creator := strings.TrimSpace(string(statusContext.Creator.Login)) - if creator != "" { - parts = append(parts, creator) - } - - context := strings.TrimSpace(string(statusContext.Context)) - if context != "" && context != "/" { - parts = append(parts, context) - } - return lipgloss.JoinHorizontal( - lipgloss.Top, - strings.Join(parts, "/"), - ) +func (m *Model) viewClosedStatus() string { + w := m.getIndentedContentWidth() + closed := lipgloss.NewStyle().Foreground(m.ctx.Theme.ErrorText).Render(" Closed") + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.ctx.Theme.ErrorText). + Width(w). + Padding(1). + Render(closed) } func (sidebar *Model) renderChecks() string { @@ -582,34 +627,17 @@ type checksStats struct { } func (m *Model) getStatusCheckRollupStats(rollup data.StatusCheckRollupStats) checksStats { - var res checksStats allChecks := make([]data.ContextCountByState, 0) allChecks = append(allChecks, rollup.Contexts.CheckRunCountsByState...) allChecks = append(allChecks, rollup.Contexts.StatusContextCountsByState...) - for _, count := range allChecks { - state := string(count.State) - if ghchecks.IsStatusWaiting(state) { - res.inProgress += int(count.Count) - } else if ghchecks.IsConclusionAFailure(state) { - res.failed += int(count.Count) - } else if ghchecks.IsConclusionASkip(state) { - res.skipped += int(count.Count) - } else if ghchecks.IsConclusionNeutral(state) { - res.neutral += int(count.Count) - } else if ghchecks.IsConclusionASuccess(state) { - res.succeeded += int(count.Count) - } - } - - return res + return m.getStatsFromChecks(allChecks) } func (m *Model) getChecksStats() checksStats { - var res checksStats commits := m.pr.Data.Enriched.Commits.Nodes if len(commits) == 0 { - return res + return checksStats{} } lastCommit := commits[0] @@ -621,20 +649,28 @@ func (m *Model) getChecksStats() checksStats { allChecks, lastCommit.Commit.StatusCheckRollup.Contexts.StatusContextCountsByState...) - for _, count := range allChecks { - state := string(count.State) - if ghchecks.IsStatusWaiting(state) { - res.inProgress += int(count.Count) - } else if ghchecks.IsConclusionAFailure(state) { - res.failed += int(count.Count) - } else if ghchecks.IsConclusionASkip(state) { - res.skipped += int(count.Count) - } else if ghchecks.IsConclusionNeutral(state) { - res.neutral += int(count.Count) - } else if ghchecks.IsConclusionASuccess(state) { - res.succeeded += int(count.Count) + return m.getStatsFromChecks(allChecks) +} + +// getGitLabChecksStats calculates stats from GitLab pipeline jobs +func (m *Model) getGitLabChecksStats() checksStats { + stats := checksStats{} + for _, job := range m.pr.Data.Enriched.PipelineJobs { + switch job.Status { + case "success": + stats.succeeded++ + case "failed": + stats.failed++ + case "running", "pending", "created": + stats.inProgress++ + case "skipped": + stats.skipped++ + case "canceled": + stats.failed++ } } + return stats +} // Count check suites that don't appear in statusCheckRollup for _, suite := range lastCommit.Commit.CheckSuites.Nodes { @@ -648,14 +684,73 @@ func (m *Model) getChecksStats() checksStats { return res } -func (m *Model) numRequestedReviewOwners() int { - numOwners := 0 +type CheckCategory int - for _, node := range m.pr.Data.Primary.ReviewRequests.Nodes { - if node.AsCodeOwner { - numOwners++ - } +const ( + CheckSuccess CheckCategory = iota + CheckFailure + CheckWaiting +) + +func (sidebar *Model) renderCheckRunConclusion(checkRun data.CheckRun) (CheckCategory, string) { + switch checkRun.Conclusion { + case ghchecks.CheckRunStateSuccess: + return CheckSuccess, sidebar.ctx.Styles.Common.SuccessGlyph + case ghchecks.CheckRunStateFailure: + return CheckFailure, sidebar.ctx.Styles.Common.FailureGlyph + case ghchecks.CheckRunStateStartupFailure: + return CheckFailure, sidebar.ctx.Styles.Common.FailureGlyph + case ghchecks.CheckRunStateSkipped: + skipped := lipgloss.NewStyle().Foreground(sidebar.ctx.Theme.FaintText).Render("⊘") + return CheckSuccess, skipped + case ghchecks.CheckRunStateStale: + return CheckWaiting, sidebar.ctx.Styles.Common.WaitingGlyph + case ghchecks.CheckRunStateNeutral: + neutral := lipgloss.NewStyle().Foreground(sidebar.ctx.Theme.FaintText).Render("◦") + return CheckSuccess, neutral + case ghchecks.CheckRunStateCancelled: + cancelled := lipgloss.NewStyle().Foreground(sidebar.ctx.Theme.FaintText).Render("⊘") + return CheckFailure, cancelled + case ghchecks.CheckRunStateActionRequired: + return CheckWaiting, sidebar.ctx.Styles.Common.WaitingGlyph + case ghchecks.CheckRunStateTimedOut: + return CheckFailure, sidebar.ctx.Styles.Common.FailureGlyph + } + + return CheckWaiting, sidebar.ctx.Styles.Common.WaitingGlyph +} + +func (sidebar *Model) renderStatusContextConclusion(statusContext data.StatusContext) (CheckCategory, string) { + state := string(statusContext.State) + switch strings.ToUpper(state) { + case "SUCCESS": + return CheckSuccess, sidebar.ctx.Styles.Common.SuccessGlyph + case "FAILURE", "ERROR": + return CheckFailure, sidebar.ctx.Styles.Common.FailureGlyph } - return numOwners + return CheckWaiting, sidebar.ctx.Styles.Common.WaitingGlyph +} + +func renderCheckRunName(checkRun data.CheckRun) string { + if checkRun.CheckSuite.WorkflowRun.Workflow.Name != "" { + return fmt.Sprintf("%s / %s", checkRun.CheckSuite.WorkflowRun.Workflow.Name, checkRun.Name) + } + + if checkRun.CheckSuite.Creator.Login != "" { + return fmt.Sprintf("%s (%s)", checkRun.Name, checkRun.CheckSuite.Creator.Login) + } + + return string(checkRun.Name) +} + +func renderStatusContextName(statusContext data.StatusContext) string { + if statusContext.Creator.Login != "" { + return fmt.Sprintf("%s (%s)", statusContext.Context, statusContext.Creator.Login) + } + return string(statusContext.Context) +} + +func sizePerSection(total, size int) int { + return int(math.Max(1, math.Floor(float64(total)/float64(size)))) } diff --git a/internal/tui/components/prview/files.go b/internal/tui/components/prview/files.go index 1c764b959..fa1eaab9f 100644 --- a/internal/tui/components/prview/files.go +++ b/internal/tui/components/prview/files.go @@ -25,13 +25,25 @@ func (m *Model) renderChangesOverview() string { BorderForeground(m.ctx.Theme.FaintBorder). Width(m.getIndentedContentWidth()) + // Use enriched data if available (for GitLab) + filesCount := m.pr.Data.Primary.Files.TotalCount + commitsCount := m.pr.Data.Primary.Commits.TotalCount + if m.pr.Data.IsEnriched { + if m.pr.Data.Enriched.Files.TotalCount > 0 { + filesCount = m.pr.Data.Enriched.Files.TotalCount + } + if len(m.pr.Data.Enriched.AllCommits.Nodes) > 0 { + commitsCount = len(m.pr.Data.Enriched.AllCommits.Nodes) + } + } + time := lipgloss.NewStyle().Render(utils.TimeElapsed(m.pr.Data.Primary.UpdatedAt)) return box.Render( lipgloss.JoinVertical(lipgloss.Left, changes.Render( lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Foreground(m.ctx.Theme.FaintText).Render(" "), - fmt.Sprintf("%d files changed", m.pr.Data.Primary.Files.TotalCount), + lipgloss.NewStyle().Foreground(m.ctx.Theme.FaintText).Render(" "), + fmt.Sprintf("%d files changed", filesCount), " ", m.pr.RenderLines(false)), ), @@ -52,7 +64,16 @@ func (m *Model) renderChangesOverview() string { func (m *Model) renderChangedFiles() string { files := make([]string, 0) - for _, file := range m.pr.Data.Primary.Files.Nodes { + + // Use enriched data if available (GitLab populates this), otherwise use primary + var fileNodes []data.ChangedFile + if m.pr.Data.IsEnriched && len(m.pr.Data.Enriched.Files.Nodes) > 0 { + fileNodes = m.pr.Data.Enriched.Files.Nodes + } else { + fileNodes = m.pr.Data.Primary.Files.Nodes + } + + for _, file := range fileNodes { files = append(files, m.renderFile(file)) } @@ -92,17 +113,17 @@ func (m *Model) renderFile(file data.ChangedFile) string { func (m *Model) renderChangeTypeIcon(changeType string) string { switch changeType { case "ADDED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.SuccessText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.SuccessText).Render("") case "DELETED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.ErrorText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.ErrorText).Render("") case "RENAMED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") case "COPIED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") case "MODIFIED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") case "CHANGED": - return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") + return lipgloss.NewStyle().Foreground(m.ctx.Theme.WarningText).Render("") default: return "" } diff --git a/internal/tui/components/prview/prview.go b/internal/tui/components/prview/prview.go index e0581c5c2..807e76eb7 100644 --- a/internal/tui/components/prview/prview.go +++ b/internal/tui/components/prview/prview.go @@ -534,9 +534,9 @@ func (m *Model) EnrichCurrRow() tea.Cmd { if m == nil || m.pr == nil || m.pr.Data.IsEnriched { return nil } - url := m.pr.Data.Primary.Url + prUrl := m.pr.Data.Primary.Url return func() tea.Msg { - d, err := data.FetchPullRequest(url) + d, err := data.FetchPullRequest(prUrl) return EnrichedPrMsg{ Id: m.sectionId, Type: prssection.SectionType, @@ -730,9 +730,9 @@ func (m *Model) SetSummaryViewLess() { m.summaryViewMore = false } -func (m *Model) SetEnrichedPR(data data.EnrichedPullRequestData) { - if m.pr.Data.Primary.Url == data.Url { - m.pr.Data.Enriched = data +func (m *Model) SetEnrichedPR(enrichedData data.EnrichedPullRequestData) { + if m.pr.Data.Primary.Url == enrichedData.Url { + m.pr.Data.Enriched = enrichedData m.pr.Data.IsEnriched = true } } diff --git a/internal/tui/components/tasks/issue.go b/internal/tui/components/tasks/issue.go index ff290fa71..140c87b46 100644 --- a/internal/tui/components/tasks/issue.go +++ b/internal/tui/components/tasks/issue.go @@ -3,11 +3,13 @@ package tasks import ( "fmt" "os/exec" + "strings" "time" tea "charm.land/bubbletea/v2" "github.com/dlvhdr/gh-dash/v4/internal/data" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" "github.com/dlvhdr/gh-dash/v4/internal/utils" ) @@ -33,7 +35,7 @@ func CloseIssue( "issue", "close", fmt.Sprint(issueNumber), - "-R", + repoFlag(), issue.GetRepoNameWithOwner(), }, Section: section, @@ -60,7 +62,7 @@ func ReopenIssue( "issue", "reopen", fmt.Sprint(issueNumber), - "-R", + repoFlag(), issue.GetRepoNameWithOwner(), }, Section: section, @@ -82,15 +84,28 @@ func AssignIssue( usernames []string, ) tea.Cmd { issueNumber := issue.GetNumber() - args := []string{ - "issue", - "edit", - fmt.Sprint(issueNumber), - "-R", - issue.GetRepoNameWithOwner(), - } - for _, assignee := range usernames { - args = append(args, "--add-assignee", assignee) + var args []string + if provider.IsGitLab() { + args = []string{ + "issue", + "update", + fmt.Sprint(issueNumber), + "--repo", + issue.GetRepoNameWithOwner(), + "--assignee", + strings.Join(usernames, ","), + } + } else { + args = []string{ + "issue", + "edit", + fmt.Sprint(issueNumber), + "-R", + issue.GetRepoNameWithOwner(), + } + for _, assignee := range usernames { + args = append(args, "--add-assignee", assignee) + } } return fireTask(ctx, GitHubTask{ Id: fmt.Sprintf("issue_assign_%d", issueNumber), @@ -121,15 +136,27 @@ func UnassignIssue( usernames []string, ) tea.Cmd { issueNumber := issue.GetNumber() - args := []string{ - "issue", - "edit", - fmt.Sprint(issueNumber), - "-R", - issue.GetRepoNameWithOwner(), - } - for _, assignee := range usernames { - args = append(args, "--remove-assignee", assignee) + var args []string + if provider.IsGitLab() { + args = []string{ + "issue", + "update", + fmt.Sprint(issueNumber), + "--repo", + issue.GetRepoNameWithOwner(), + "--unassign", + } + } else { + args = []string{ + "issue", + "edit", + fmt.Sprint(issueNumber), + "-R", + issue.GetRepoNameWithOwner(), + } + for _, assignee := range usernames { + args = append(args, "--remove-assignee", assignee) + } } return fireTask(ctx, GitHubTask{ Id: fmt.Sprintf("issue_unassign_%d", issueNumber), @@ -160,9 +187,19 @@ func CommentOnIssue( body string, ) tea.Cmd { issueNumber := issue.GetNumber() - return fireTask(ctx, GitHubTask{ - Id: fmt.Sprintf("issue_comment_%d", issueNumber), - Args: []string{ + var args []string + if provider.IsGitLab() { + args = []string{ + "issue", + "note", + fmt.Sprint(issueNumber), + "--repo", + issue.GetRepoNameWithOwner(), + "-m", + body, + } + } else { + args = []string{ "issue", "comment", fmt.Sprint(issueNumber), @@ -170,7 +207,11 @@ func CommentOnIssue( issue.GetRepoNameWithOwner(), "-b", body, - }, + } + } + return fireTask(ctx, GitHubTask{ + Id: fmt.Sprintf("issue_comment_%d", issueNumber), + Args: args, Section: section, StartText: fmt.Sprintf("Commenting on issue #%d", issueNumber), FinishedText: fmt.Sprintf("Commented on issue #%d", issueNumber), @@ -195,13 +236,6 @@ func LabelIssue( existingLabels []data.Label, ) tea.Cmd { issueNumber := issue.GetNumber() - args := []string{ - "issue", - "edit", - fmt.Sprint(issueNumber), - "-R", - issue.GetRepoNameWithOwner(), - } labelsMap := make(map[string]bool) for _, label := range labels { @@ -213,14 +247,38 @@ func LabelIssue( existingLabelsColorMap[label.Name] = label.Color } - for _, label := range existingLabels { - if _, ok := labelsMap[label.Name]; !ok { - args = append(args, "--remove-label", label.Name) + var args []string + if provider.IsGitLab() { + args = []string{ + "issue", + "update", + fmt.Sprint(issueNumber), + "--repo", + issue.GetRepoNameWithOwner(), + "--label", + strings.Join(labels, ","), + } + for _, label := range existingLabels { + if _, ok := labelsMap[label.Name]; !ok { + args = append(args, "--unlabel", label.Name) + } + } + } else { + args = []string{ + "issue", + "edit", + fmt.Sprint(issueNumber), + "-R", + issue.GetRepoNameWithOwner(), + } + for _, label := range existingLabels { + if _, ok := labelsMap[label.Name]; !ok { + args = append(args, "--remove-label", label.Name) + } + } + for _, label := range labels { + args = append(args, "--add-label", label) } - } - - for _, label := range labels { - args = append(args, "--add-label", label) } return fireTask(ctx, GitHubTask{ diff --git a/internal/tui/components/tasks/pr.go b/internal/tui/components/tasks/pr.go index 23b450f0d..1a223bfa3 100644 --- a/internal/tui/components/tasks/pr.go +++ b/internal/tui/components/tasks/pr.go @@ -10,6 +10,7 @@ import ( "charm.land/log/v2" "github.com/dlvhdr/gh-dash/v4/internal/data" + "github.com/dlvhdr/gh-dash/v4/internal/provider" "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" "github.com/dlvhdr/gh-dash/v4/internal/utils" @@ -41,6 +42,30 @@ func buildTaskId(prefix string, prNumber int) string { return fmt.Sprintf("%s_%d", prefix, prNumber) } +// prSubCmd returns "mr" for GitLab, "pr" for GitHub. +func prSubCmd() string { + if provider.IsGitLab() { + return "mr" + } + return "pr" +} + +// prLabel returns "MR" for GitLab, "PR" for GitHub. +func prLabel() string { + if provider.IsGitLab() { + return "MR" + } + return "PR" +} + +// repoFlag returns "--repo" for GitLab (glab), "-R" for GitHub (gh). +func repoFlag() string { + if provider.IsGitLab() { + return "--repo" + } + return "-R" +} + type GitHubTask struct { Id string Args []string @@ -59,10 +84,11 @@ func fireTask(ctx *context.ProgramContext, task GitHubTask) tea.Cmd { Error: nil, } + cliCmd := provider.GetCLICommand() startCmd := ctx.StartTask(start) return tea.Batch(startCmd, func() tea.Msg { - log.Info("Running task", "cmd", "gh "+strings.Join(task.Args, " ")) - c := exec.Command("gh", task.Args...) + log.Info("Running task", "cmd", cliCmd+" "+strings.Join(task.Args, " ")) + c := exec.Command(cliCmd, task.Args...) err := c.Run() return constants.TaskFinishedMsg{ @@ -76,19 +102,20 @@ func fireTask(ctx *context.ProgramContext, task GitHubTask) tea.Cmd { } func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch string) tea.Cmd { + label := prLabel() return fireTask(ctx, GitHubTask{ Id: fmt.Sprintf("branch_open_%s", branch), Args: []string{ - "pr", + prSubCmd(), "view", "--web", branch, - "-R", + repoFlag(), ctx.RepoUrl, }, Section: section, - StartText: fmt.Sprintf("Opening PR for branch %s", branch), - FinishedText: fmt.Sprintf("PR for branch %s has been opened", branch), + StartText: fmt.Sprintf("Opening %s for branch %s", label, branch), + FinishedText: fmt.Sprintf("%s for branch %s has been opened", label, branch), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{} }, @@ -97,18 +124,19 @@ func OpenBranchPR(ctx *context.ProgramContext, section SectionIdentifier, branch func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { prNumber := pr.GetNumber() + label := prLabel() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_reopen", prNumber), Args: []string{ - "pr", + prSubCmd(), "reopen", fmt.Sprint(prNumber), - "-R", + repoFlag(), pr.GetRepoNameWithOwner(), }, Section: section, - StartText: fmt.Sprintf("Reopening PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been reopened", prNumber), + StartText: fmt.Sprintf("Reopening %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been reopened", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, @@ -120,18 +148,19 @@ func ReopenPR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Ro func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { prNumber := pr.GetNumber() + label := prLabel() return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_close", prNumber), Args: []string{ - "pr", + prSubCmd(), "close", fmt.Sprint(prNumber), - "-R", + repoFlag(), pr.GetRepoNameWithOwner(), }, Section: section, - StartText: fmt.Sprintf("Closing PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been closed", prNumber), + StartText: fmt.Sprintf("Closing %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been closed", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, @@ -143,18 +172,35 @@ func ClosePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { prNumber := pr.GetNumber() - return fireTask(ctx, GitHubTask{ - Id: buildTaskId("pr_ready", prNumber), - Args: []string{ + label := prLabel() + + // GitLab uses 'mr update --ready', GitHub uses 'pr ready' + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "update", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + "--ready", + } + } else { + args = []string{ "pr", "ready", fmt.Sprint(prNumber), "-R", pr.GetRepoNameWithOwner(), - }, + } + } + + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_ready", prNumber), + Args: args, Section: section, - StartText: fmt.Sprintf("Marking PR #%d as ready for review", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been marked as ready for review", prNumber), + StartText: fmt.Sprintf("Marking %s #%d as ready for review", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been marked as ready for review", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, @@ -166,20 +212,21 @@ func PRReady(ctx *context.ProgramContext, section SectionIdentifier, pr data.Row func MergePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { prNumber := pr.GetNumber() + label := prLabel() c := exec.Command( - "gh", - "pr", + provider.GetCLICommand(), + prSubCmd(), "merge", fmt.Sprint(prNumber), - "-R", + repoFlag(), pr.GetRepoNameWithOwner(), ) taskId := fmt.Sprintf("merge_%d", prNumber) task := context.Task{ Id: taskId, - StartText: fmt.Sprintf("Merging PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been merged", prNumber), + StartText: fmt.Sprintf("Merging %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been merged", label, prNumber), State: context.TaskStart, Error: nil, } @@ -208,20 +255,21 @@ func CreatePR( title string, ) tea.Cmd { c := exec.Command( - "gh", - "pr", + provider.GetCLICommand(), + prSubCmd(), "create", "--title", title, - "-R", + repoFlag(), ctx.RepoUrl, ) + label := prLabel() taskId := fmt.Sprintf("create_pr_%s", title) task := context.Task{ Id: taskId, - StartText: fmt.Sprintf(`Creating PR "%s"`, title), - FinishedText: fmt.Sprintf(`PR "%s" has been created`, title), + StartText: fmt.Sprintf(`Creating %s "%s"`, label, title), + FinishedText: fmt.Sprintf(`%s "%s" has been created`, label, title), State: context.TaskStart, Error: nil, } @@ -242,22 +290,37 @@ func CreatePR( func UpdatePR(ctx *context.ProgramContext, section SectionIdentifier, pr data.RowData) tea.Cmd { prNumber := pr.GetNumber() - return fireTask(ctx, GitHubTask{ - Id: buildTaskId("pr_update", prNumber), - Args: []string{ + label := prLabel() + + // GitLab uses 'mr rebase', GitHub uses 'pr update-branch' + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "rebase", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + } + } else { + args = []string{ "pr", "update-branch", fmt.Sprint(prNumber), "-R", pr.GetRepoNameWithOwner(), - }, + } + } + + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_update", prNumber), + Args: args, Section: section, - StartText: fmt.Sprintf("Updating PR #%d", prNumber), - FinishedText: fmt.Sprintf("PR #%d has been updated", prNumber), + StartText: fmt.Sprintf("Updating %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been updated", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, - IsClosed: utils.BoolPtr(true), } }, }) @@ -270,22 +333,36 @@ func AssignPR( usernames []string, ) tea.Cmd { prNumber := pr.GetNumber() - args := []string{ - "pr", - "edit", - fmt.Sprint(prNumber), - "-R", - pr.GetRepoNameWithOwner(), - } - for _, assignee := range usernames { - args = append(args, "--add-assignee", assignee) + label := prLabel() + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "update", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + "--assignee", + strings.Join(usernames, ","), + } + } else { + args = []string{ + "pr", + "edit", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + } + for _, assignee := range usernames { + args = append(args, "--add-assignee", assignee) + } } return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_assign", prNumber), Args: args, Section: section, - StartText: fmt.Sprintf("Assigning pr #%d to %s", prNumber, usernames), - FinishedText: fmt.Sprintf("pr #%d has been assigned to %s", prNumber, usernames), + StartText: fmt.Sprintf("Assigning %s #%d to %s", label, prNumber, usernames), + FinishedText: fmt.Sprintf("%s #%d has been assigned to %s", label, prNumber, usernames), Msg: func(c *exec.Cmd, err error) tea.Msg { returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} for _, assignee := range usernames { @@ -309,22 +386,35 @@ func UnassignPR( usernames []string, ) tea.Cmd { prNumber := pr.GetNumber() - args := []string{ - "pr", - "edit", - fmt.Sprint(prNumber), - "-R", - pr.GetRepoNameWithOwner(), - } - for _, assignee := range usernames { - args = append(args, "--remove-assignee", assignee) + label := prLabel() + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "update", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + "--unassign", + } + } else { + args = []string{ + "pr", + "edit", + fmt.Sprint(prNumber), + "-R", + pr.GetRepoNameWithOwner(), + } + for _, assignee := range usernames { + args = append(args, "--remove-assignee", assignee) + } } return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_unassign", prNumber), Args: args, Section: section, - StartText: fmt.Sprintf("Unassigning %s from pr #%d", usernames, prNumber), - FinishedText: fmt.Sprintf("%s unassigned from pr #%d", usernames, prNumber), + StartText: fmt.Sprintf("Unassigning %s from %s #%d", usernames, label, prNumber), + FinishedText: fmt.Sprintf("%s unassigned from %s #%d", usernames, label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { returnedAssignees := data.Assignees{Nodes: []data.Assignee{}} for _, assignee := range usernames { @@ -348,9 +438,20 @@ func CommentOnPR( body string, ) tea.Cmd { prNumber := pr.GetNumber() - return fireTask(ctx, GitHubTask{ - Id: buildTaskId("pr_comment", prNumber), - Args: []string{ + label := prLabel() + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "note", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + "-m", + body, + } + } else { + args = []string{ "pr", "comment", fmt.Sprint(prNumber), @@ -358,10 +459,14 @@ func CommentOnPR( pr.GetRepoNameWithOwner(), "-b", body, - }, + } + } + return fireTask(ctx, GitHubTask{ + Id: buildTaskId("pr_comment", prNumber), + Args: args, Section: section, - StartText: fmt.Sprintf("Commenting on PR #%d", prNumber), - FinishedText: fmt.Sprintf("Commented on PR #%d", prNumber), + StartText: fmt.Sprintf("Commenting on %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("Commented on %s #%d", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, @@ -382,23 +487,35 @@ func ApprovePR( comment string, ) tea.Cmd { prNumber := pr.GetNumber() - args := []string{ - "pr", - "review", - "-R", - pr.GetRepoNameWithOwner(), - fmt.Sprint(prNumber), - "--approve", - } - if comment != "" { - args = append(args, "--body", comment) + label := prLabel() + var args []string + if provider.IsGitLab() { + args = []string{ + "mr", + "approve", + fmt.Sprint(prNumber), + "--repo", + pr.GetRepoNameWithOwner(), + } + } else { + args = []string{ + "pr", + "review", + "-R", + pr.GetRepoNameWithOwner(), + fmt.Sprint(prNumber), + "--approve", + } + if comment != "" { + args = append(args, "--body", comment) + } } return fireTask(ctx, GitHubTask{ Id: buildTaskId("pr_approve", prNumber), Args: args, Section: section, - StartText: fmt.Sprintf("Approving pr #%d", prNumber), - FinishedText: fmt.Sprintf("pr #%d has been approved", prNumber), + StartText: fmt.Sprintf("Approving %s #%d", label, prNumber), + FinishedText: fmt.Sprintf("%s #%d has been approved", label, prNumber), Msg: func(c *exec.Cmd, err error) tea.Msg { return UpdatePRMsg{ PrNumber: prNumber, diff --git a/internal/tui/ui.go b/internal/tui/ui.go index 80a249bf4..22ae816ba 100644 --- a/internal/tui/ui.go +++ b/internal/tui/ui.go @@ -188,14 +188,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.prView.IsTextInputBoxFocused() { m.prView, cmd = m.prView.Update(msg) - m.syncSidebar() - return m, cmd + syncCmd := m.syncSidebar() + return m, tea.Batch(cmd, syncCmd) } if m.issueSidebar.IsTextInputBoxFocused() { m.issueSidebar, cmd, _ = m.issueSidebar.Update(msg) - m.syncSidebar() - return m, cmd + syncCmd := m.syncSidebar() + return m, tea.Batch(cmd, syncCmd) } if m.footer.ShowConfirmQuit && (msg.String() == "y" || msg.String() == "enter") { @@ -271,6 +271,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.TogglePreview): m.sidebar.IsOpen = !m.sidebar.IsOpen m.syncMainContentWidth() + if m.sidebar.IsOpen { + cmd = m.syncSidebar() + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keys.Refresh): if currSection != nil { @@ -772,6 +776,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case notificationssection.UpdateNotificationCommentsMsg: cmds = append(cmds, m.updateNotificationSections(msg)) + case issueview.IssueCommentsMsg: + log.Info("IssueCommentsMsg received", "url", msg.IssueUrl, "comments", len(msg.Comments), "err", msg.Err) + if msg.Err == nil { + m.issueSidebar.SetIssueComments(msg.IssueUrl, msg.Comments) + // Just update the sidebar content, don't call syncSidebar which would overwrite + m.sidebar.SetContent(m.issueSidebar.View()) + } else { + log.Error("failed fetching issue comments", "err", msg.Err) + } + case spinner.TickMsg: if len(m.tasks) > 0 { taskSpinner, internalTickCmd := m.taskSpinner.Update(msg)