diff --git a/cmd/bd/gitlab.go b/cmd/bd/gitlab.go index 680058eaaf..41696f25fd 100644 --- a/cmd/bd/gitlab.go +++ b/cmd/bd/gitlab.go @@ -20,9 +20,11 @@ import ( // GitLabConfig holds GitLab connection configuration. type GitLabConfig struct { - URL string // GitLab instance URL (e.g., "https://gitlab.com") - Token string // Personal access token - ProjectID string // Project ID or URL-encoded path + URL string // GitLab instance URL (e.g., "https://gitlab.com") + Token string // Personal access token + ProjectID string // Project ID or URL-encoded path + GroupID string // Optional group ID for group-level issue fetching + DefaultProjectID string // Project ID for creating issues in group mode } // gitlabCmd is the root command for GitLab operations. @@ -32,9 +34,11 @@ var gitlabCmd = &cobra.Command{ Long: `Commands for syncing issues between beads and GitLab. Configuration can be set via 'bd config' or environment variables: - gitlab.url / GITLAB_URL - GitLab instance URL - gitlab.token / GITLAB_TOKEN - Personal access token - gitlab.project_id / GITLAB_PROJECT_ID - Project ID or path`, + gitlab.url / GITLAB_URL - GitLab instance URL + gitlab.token / GITLAB_TOKEN - Personal access token + gitlab.project_id / GITLAB_PROJECT_ID - Project ID or path + gitlab.group_id / GITLAB_GROUP_ID - Group ID for group-level sync + gitlab.default_project_id / GITLAB_DEFAULT_PROJECT_ID - Project for creating issues in group mode`, } // gitlabSyncCmd synchronizes issues between beads and GitLab. @@ -74,6 +78,12 @@ var ( gitlabPreferLocal bool gitlabPreferGitLab bool gitlabPreferNewer bool + + // Filter flags for sync + gitlabFilterLabel string + gitlabFilterProject string + gitlabFilterMilestone string + gitlabFilterAssignee string ) // issueIDCounter is used to generate unique issue IDs. @@ -171,6 +181,12 @@ func init() { gitlabSyncCmd.Flags().BoolVar(&gitlabPreferGitLab, "prefer-gitlab", false, "On conflict, use GitLab version") gitlabSyncCmd.Flags().BoolVar(&gitlabPreferNewer, "prefer-newer", false, "On conflict, use most recent version (default)") + // Filter flags (override config defaults) + gitlabSyncCmd.Flags().StringVar(&gitlabFilterLabel, "label", "", "Filter by labels (comma-separated, AND logic)") + gitlabSyncCmd.Flags().StringVar(&gitlabFilterProject, "project", "", "Filter to issues from this project ID (group mode)") + gitlabSyncCmd.Flags().StringVar(&gitlabFilterMilestone, "milestone", "", "Filter by milestone title") + gitlabSyncCmd.Flags().StringVar(&gitlabFilterAssignee, "assignee", "", "Filter by assignee username") + // Register gitlab command with root rootCmd.AddCommand(gitlabCmd) } @@ -183,6 +199,8 @@ func getGitLabConfig() GitLabConfig { config.URL = getGitLabConfigValue(ctx, "gitlab.url") config.Token = getGitLabConfigValue(ctx, "gitlab.token") config.ProjectID = getGitLabConfigValue(ctx, "gitlab.project_id") + config.GroupID = getGitLabConfigValue(ctx, "gitlab.group_id") + config.DefaultProjectID = getGitLabConfigValue(ctx, "gitlab.default_project_id") return config } @@ -226,6 +244,18 @@ func gitlabConfigToEnvVar(key string) string { return "GITLAB_TOKEN" case "gitlab.project_id": return "GITLAB_PROJECT_ID" + case "gitlab.group_id": + return "GITLAB_GROUP_ID" + case "gitlab.default_project_id": + return "GITLAB_DEFAULT_PROJECT_ID" + case "gitlab.filter_labels": + return "GITLAB_FILTER_LABELS" + case "gitlab.filter_project": + return "GITLAB_FILTER_PROJECT" + case "gitlab.filter_milestone": + return "GITLAB_FILTER_MILESTONE" + case "gitlab.filter_assignee": + return "GITLAB_FILTER_ASSIGNEE" default: return "" } @@ -239,8 +269,8 @@ func validateGitLabConfig(config GitLabConfig) error { if config.Token == "" { return fmt.Errorf("gitlab.token is not configured. Set via 'bd config gitlab.token ' or GITLAB_TOKEN environment variable") } - if config.ProjectID == "" { - return fmt.Errorf("gitlab.project_id is not configured. Set via 'bd config gitlab.project_id ' or GITLAB_PROJECT_ID environment variable") + if config.ProjectID == "" && config.GroupID == "" { + return fmt.Errorf("gitlab.project_id or gitlab.group_id is not configured. Set via 'bd config' or environment variables") } // Reject non-HTTPS URLs to prevent sending tokens in cleartext. // Allow http://localhost and http://127.0.0.1 for local development/testing. @@ -267,7 +297,11 @@ func maskGitLabToken(token string) string { // getGitLabClient creates a GitLab client from the current configuration. func getGitLabClient(config GitLabConfig) *gitlab.Client { - return gitlab.NewClient(config.Token, config.URL, config.ProjectID) + client := gitlab.NewClient(config.Token, config.URL, config.ProjectID) + if config.GroupID != "" { + client = client.WithGroupID(config.GroupID) + } + return client } // runGitLabStatus implements the gitlab status command. @@ -280,6 +314,37 @@ func runGitLabStatus(cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintf(out, "URL: %s\n", config.URL) _, _ = fmt.Fprintf(out, "Token: %s\n", maskGitLabToken(config.Token)) _, _ = fmt.Fprintf(out, "Project ID: %s\n", config.ProjectID) + if config.GroupID != "" { + _, _ = fmt.Fprintf(out, "Group ID: %s\n", config.GroupID) + _, _ = fmt.Fprintf(out, "Sync Mode: group (fetches from all projects in group)\n") + if config.DefaultProjectID != "" { + _, _ = fmt.Fprintf(out, "Default Project ID: %s (for creating new issues)\n", config.DefaultProjectID) + } + } else { + _, _ = fmt.Fprintf(out, "Sync Mode: project\n") + } + + // Show configured filters + ctx := context.Background() + filterLabels := getGitLabConfigValue(ctx, "gitlab.filter_labels") + filterProject := getGitLabConfigValue(ctx, "gitlab.filter_project") + filterMilestone := getGitLabConfigValue(ctx, "gitlab.filter_milestone") + filterAssignee := getGitLabConfigValue(ctx, "gitlab.filter_assignee") + if filterLabels != "" || filterProject != "" || filterMilestone != "" || filterAssignee != "" { + _, _ = fmt.Fprintf(out, "\nFilters:\n") + if filterLabels != "" { + _, _ = fmt.Fprintf(out, " Labels: %s\n", filterLabels) + } + if filterProject != "" { + _, _ = fmt.Fprintf(out, " Project: %s\n", filterProject) + } + if filterMilestone != "" { + _, _ = fmt.Fprintf(out, " Milestone: %s\n", filterMilestone) + } + if filterAssignee != "" { + _, _ = fmt.Fprintf(out, " Assignee: %s\n", filterAssignee) + } + } // Validate configuration if err := validateGitLabConfig(config); err != nil { @@ -360,6 +425,11 @@ func runGitLabSync(cmd *cobra.Command, args []string) error { return fmt.Errorf("initializing GitLab tracker: %w", err) } + // Apply CLI filter overrides (take precedence over config defaults) + if cliFilter := buildCLIFilter(); cliFilter != nil { + gt.SetFilter(cliFilter) + } + // Create the sync engine engine := tracker.NewEngine(gt, store, actor) engine.OnMessage = func(msg string) { _, _ = fmt.Fprintln(out, " "+msg) } @@ -422,6 +492,26 @@ func runGitLabSync(cmd *cobra.Command, args []string) error { return nil } +// buildCLIFilter constructs an IssueFilter from CLI flags. +// Returns nil if no filter flags were provided. +func buildCLIFilter() *gitlab.IssueFilter { + if gitlabFilterLabel == "" && gitlabFilterProject == "" && + gitlabFilterMilestone == "" && gitlabFilterAssignee == "" { + return nil + } + filter := &gitlab.IssueFilter{ + Labels: gitlabFilterLabel, + Milestone: gitlabFilterMilestone, + Assignee: gitlabFilterAssignee, + } + if gitlabFilterProject != "" { + if pid, err := strconv.Atoi(gitlabFilterProject); err == nil { + filter.ProjectID = pid + } + } + return filter +} + // buildGitLabPullHooks creates PullHooks for GitLab-specific pull behavior. func buildGitLabPullHooks(ctx context.Context) *tracker.PullHooks { prefix := "bd" diff --git a/cmd/bd/gitlab_test.go b/cmd/bd/gitlab_test.go index 29cc03db40..b3404dbcb2 100644 --- a/cmd/bd/gitlab_test.go +++ b/cmd/bd/gitlab_test.go @@ -49,15 +49,30 @@ func TestGitLabConfigValidation(t *testing.T) { wantError: "gitlab.token", }, { - name: "missing project_id", + name: "missing project_id and group_id", config: GitLabConfig{URL: "https://gitlab.com", Token: "tok"}, - wantError: "gitlab.project_id", + wantError: "gitlab.project_id or gitlab.group_id", }, { name: "all present", config: GitLabConfig{URL: "https://gitlab.com", Token: "tok", ProjectID: "1"}, wantError: "", }, + { + name: "group_id only (no project_id) is valid", + config: GitLabConfig{URL: "https://gitlab.com", Token: "tok", GroupID: "mygroup"}, + wantError: "", + }, + { + name: "group_id with default_project_id is valid", + config: GitLabConfig{URL: "https://gitlab.com", Token: "tok", GroupID: "mygroup", DefaultProjectID: "123"}, + wantError: "", + }, + { + name: "both project_id and group_id is valid", + config: GitLabConfig{URL: "https://gitlab.com", Token: "tok", ProjectID: "1", GroupID: "mygroup"}, + wantError: "", + }, { name: "plain HTTP rejected", config: GitLabConfig{URL: "http://gitlab.example.com", Token: "tok", ProjectID: "1"}, @@ -131,6 +146,12 @@ func TestGitLabConfigEnvVar(t *testing.T) { {"gitlab.url", "GITLAB_URL"}, {"gitlab.token", "GITLAB_TOKEN"}, {"gitlab.project_id", "GITLAB_PROJECT_ID"}, + {"gitlab.group_id", "GITLAB_GROUP_ID"}, + {"gitlab.default_project_id", "GITLAB_DEFAULT_PROJECT_ID"}, + {"gitlab.filter_labels", "GITLAB_FILTER_LABELS"}, + {"gitlab.filter_project", "GITLAB_FILTER_PROJECT"}, + {"gitlab.filter_milestone", "GITLAB_FILTER_MILESTONE"}, + {"gitlab.filter_assignee", "GITLAB_FILTER_ASSIGNEE"}, {"gitlab.unknown", ""}, } @@ -169,6 +190,49 @@ func TestGitLabClientCreation(t *testing.T) { } } +// TestGitLabConfigFromEnv_GroupID verifies group config is read from environment variables. +func TestGitLabConfigFromEnv_GroupID(t *testing.T) { + oldDBPath, oldStore := dbPath, store + dbPath, store = "", nil + t.Cleanup(func() { dbPath, store = oldDBPath, oldStore }) + + t.Setenv("GITLAB_URL", "https://gitlab.example.com") + t.Setenv("GITLAB_TOKEN", "test-token-123") + t.Setenv("GITLAB_GROUP_ID", "mygroup") + t.Setenv("GITLAB_DEFAULT_PROJECT_ID", "456") + + config := getGitLabConfig() + + if config.GroupID != "mygroup" { + t.Errorf("GroupID = %q, want %q", config.GroupID, "mygroup") + } + if config.DefaultProjectID != "456" { + t.Errorf("DefaultProjectID = %q, want %q", config.DefaultProjectID, "456") + } +} + +// TestGitLabClientCreation_WithGroupID verifies client is created with GroupID when configured. +func TestGitLabClientCreation_WithGroupID(t *testing.T) { + oldDBPath, oldStore := dbPath, store + dbPath, store = "", nil + t.Cleanup(func() { dbPath, store = oldDBPath, oldStore }) + + t.Setenv("GITLAB_URL", "https://gitlab.test.com") + t.Setenv("GITLAB_TOKEN", "test-token-abc") + t.Setenv("GITLAB_PROJECT_ID", "99") + t.Setenv("GITLAB_GROUP_ID", "mygroup") + + config := getGitLabConfig() + client := getGitLabClient(config) + + if client.GroupID != "mygroup" { + t.Errorf("client.GroupID = %q, want %q", client.GroupID, "mygroup") + } + if client.ProjectID != "99" { + t.Errorf("client.ProjectID = %q, want %q", client.ProjectID, "99") + } +} + // TestGitLabCmdRegistration verifies the gitlab command and subcommands are registered. func TestGitLabCmdRegistration(t *testing.T) { // Check that gitlabCmd has expected subcommands @@ -196,3 +260,87 @@ func TestGitLabCmdRegistration(t *testing.T) { t.Error("gitlabCmd missing 'projects' subcommand") } } + +// TestBuildCLIFilter_NoFlags verifies nil when no flags set. +func TestBuildCLIFilter_NoFlags(t *testing.T) { + // Save and restore global flag state + savedLabel, savedProject, savedMilestone, savedAssignee := gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee + t.Cleanup(func() { + gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee = savedLabel, savedProject, savedMilestone, savedAssignee + }) + + gitlabFilterLabel = "" + gitlabFilterProject = "" + gitlabFilterMilestone = "" + gitlabFilterAssignee = "" + + filter := buildCLIFilter() + if filter != nil { + t.Errorf("buildCLIFilter() = %+v, want nil when no flags set", filter) + } +} + +// TestBuildCLIFilter_WithFlags verifies filter is built from flags. +func TestBuildCLIFilter_WithFlags(t *testing.T) { + savedLabel, savedProject, savedMilestone, savedAssignee := gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee + t.Cleanup(func() { + gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee = savedLabel, savedProject, savedMilestone, savedAssignee + }) + + gitlabFilterLabel = "bug,backend" + gitlabFilterProject = "42" + gitlabFilterMilestone = "Sprint 1" + gitlabFilterAssignee = "kyriakos" + + filter := buildCLIFilter() + if filter == nil { + t.Fatal("buildCLIFilter() = nil, want non-nil") + } + if filter.Labels != "bug,backend" { + t.Errorf("Labels = %q, want %q", filter.Labels, "bug,backend") + } + if filter.ProjectID != 42 { + t.Errorf("ProjectID = %d, want 42", filter.ProjectID) + } + if filter.Milestone != "Sprint 1" { + t.Errorf("Milestone = %q, want %q", filter.Milestone, "Sprint 1") + } + if filter.Assignee != "kyriakos" { + t.Errorf("Assignee = %q, want %q", filter.Assignee, "kyriakos") + } +} + +// TestBuildCLIFilter_PartialFlags verifies filter works with some flags. +func TestBuildCLIFilter_PartialFlags(t *testing.T) { + savedLabel, savedProject, savedMilestone, savedAssignee := gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee + t.Cleanup(func() { + gitlabFilterLabel, gitlabFilterProject, gitlabFilterMilestone, gitlabFilterAssignee = savedLabel, savedProject, savedMilestone, savedAssignee + }) + + gitlabFilterLabel = "frontend" + gitlabFilterProject = "" + gitlabFilterMilestone = "" + gitlabFilterAssignee = "" + + filter := buildCLIFilter() + if filter == nil { + t.Fatal("buildCLIFilter() = nil, want non-nil") + } + if filter.Labels != "frontend" { + t.Errorf("Labels = %q, want %q", filter.Labels, "frontend") + } + if filter.ProjectID != 0 { + t.Errorf("ProjectID = %d, want 0", filter.ProjectID) + } +} + +// TestSyncCmdHasFilterFlags verifies filter flags are registered on sync command. +func TestSyncCmdHasFilterFlags(t *testing.T) { + flags := []string{"label", "project", "milestone", "assignee"} + for _, name := range flags { + f := gitlabSyncCmd.Flags().Lookup(name) + if f == nil { + t.Errorf("sync command missing --%s flag", name) + } + } +} diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index 91bf727caa..8abada9943 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -25,6 +25,19 @@ func NewClient(token, baseURL, projectID string) *Client { } } +// WithGroupID returns a new client configured to fetch issues at the group level. +// When GroupID is set, FetchIssues and FetchIssuesSince use /groups/:id/issues +// instead of /projects/:id/issues. Issue creation still uses the project endpoint. +func (c *Client) WithGroupID(groupID string) *Client { + return &Client{ + Token: c.Token, + BaseURL: c.BaseURL, + ProjectID: c.ProjectID, + GroupID: groupID, + HTTPClient: c.HTTPClient, + } +} + // WithHTTPClient returns a new client configured to use the specified HTTP client. // This is useful for testing or customizing timeouts and transport settings. func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { @@ -32,6 +45,7 @@ func (c *Client) WithHTTPClient(httpClient *http.Client) *Client { Token: c.Token, BaseURL: c.BaseURL, ProjectID: c.ProjectID, + GroupID: c.GroupID, HTTPClient: httpClient, } } @@ -43,6 +57,7 @@ func (c *Client) WithEndpoint(endpoint string) *Client { Token: c.Token, BaseURL: endpoint, ProjectID: c.ProjectID, + GroupID: c.GroupID, HTTPClient: c.HTTPClient, } } @@ -53,6 +68,16 @@ func (c *Client) projectPath() string { return url.PathEscape(c.ProjectID) } +// issuesBasePath returns the API path prefix for listing issues. +// When GroupID is set, returns /groups/:id/issues (group-level). +// Otherwise, returns /projects/:id/issues (project-level). +func (c *Client) issuesBasePath() string { + if c.GroupID != "" { + return "/groups/" + url.PathEscape(c.GroupID) + "/issues" + } + return "/projects/" + c.projectPath() + "/issues" +} + // buildURL constructs a full API URL from path and optional query parameters. func (c *Client) buildURL(path string, params map[string]string) string { u := c.BaseURL + DefaultAPIEndpoint + path @@ -141,9 +166,46 @@ func (c *Client) doRequest(ctx context.Context, method, urlStr string, body inte return nil, nil, fmt.Errorf("max retries (%d) exceeded: %w", MaxRetries+1, lastErr) } -// FetchIssues retrieves issues from GitLab with optional filtering by state. +// applyFilter adds IssueFilter fields as query parameters to the params map. +// ProjectID filtering is done client-side (not supported by GitLab API on group endpoints). +func applyFilter(params map[string]string, filter *IssueFilter) { + if filter == nil { + return + } + if filter.Labels != "" { + params["labels"] = filter.Labels + } + if filter.Milestone != "" { + params["milestone"] = filter.Milestone + } + if filter.Assignee != "" { + params["assignee_username"] = filter.Assignee + } +} + +// filterByProject removes issues that don't match the filter's ProjectID. +// Returns the input slice unmodified if filter is nil or ProjectID is 0. +func filterByProject(issues []Issue, filter *IssueFilter) []Issue { + if filter == nil || filter.ProjectID == 0 { + return issues + } + filtered := make([]Issue, 0, len(issues)) + for _, issue := range issues { + if issue.ProjectID == filter.ProjectID { + filtered = append(filtered, issue) + } + } + return filtered +} + +// FetchIssues retrieves issues from GitLab with optional filtering by state and IssueFilter. // state can be: "opened", "closed", or "all". -func (c *Client) FetchIssues(ctx context.Context, state string) ([]Issue, error) { +func (c *Client) FetchIssues(ctx context.Context, state string, filters ...*IssueFilter) ([]Issue, error) { + var filter *IssueFilter + if len(filters) > 0 { + filter = filters[0] + } + var allIssues []Issue page := 1 @@ -162,8 +224,9 @@ func (c *Client) FetchIssues(ctx context.Context, state string) ([]Issue, error) if state != "" && state != "all" { params["state"] = state } + applyFilter(params, filter) - urlStr := c.buildURL("/projects/"+c.projectPath()+"/issues", params) + urlStr := c.buildURL(c.issuesBasePath(), params) respBody, headers, err := c.doRequest(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, fmt.Errorf("failed to fetch issues: %w", err) @@ -189,12 +252,17 @@ func (c *Client) FetchIssues(ctx context.Context, state string) ([]Issue, error) } } - return allIssues, nil + return filterByProject(allIssues, filter), nil } // FetchIssuesSince retrieves issues from GitLab that have been updated since the given time. // This enables incremental sync by only fetching issues modified after the last sync. -func (c *Client) FetchIssuesSince(ctx context.Context, state string, since time.Time) ([]Issue, error) { +func (c *Client) FetchIssuesSince(ctx context.Context, state string, since time.Time, filters ...*IssueFilter) ([]Issue, error) { + var filter *IssueFilter + if len(filters) > 0 { + filter = filters[0] + } + var allIssues []Issue page := 1 @@ -216,8 +284,9 @@ func (c *Client) FetchIssuesSince(ctx context.Context, state string, since time. if state != "" && state != "all" { params["state"] = state } + applyFilter(params, filter) - urlStr := c.buildURL("/projects/"+c.projectPath()+"/issues", params) + urlStr := c.buildURL(c.issuesBasePath(), params) respBody, headers, err := c.doRequest(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, fmt.Errorf("failed to fetch issues since %s: %w", sinceStr, err) @@ -243,7 +312,7 @@ func (c *Client) FetchIssuesSince(ctx context.Context, state string, since time. } } - return allIssues, nil + return filterByProject(allIssues, filter), nil } // CreateIssue creates a new issue in GitLab. diff --git a/internal/gitlab/client_test.go b/internal/gitlab/client_test.go index f5f6ebaa81..51a6e69062 100644 --- a/internal/gitlab/client_test.go +++ b/internal/gitlab/client_test.go @@ -954,3 +954,347 @@ func TestFetchIssuesSince_ContextCancellation(t *testing.T) { // was caught by our loop check (returns partial) or by doRequest (returns nil) t.Logf("Loop stopped after %d requests, %d issues returned, error: %v", requestCount.Load(), len(issues), err) } + +// TestWithGroupID verifies the builder pattern for group-level issue fetching. +func TestWithGroupID(t *testing.T) { + client := NewClient("token", "https://gitlab.example.com", "123"). + WithGroupID("mygroup") + + if client.GroupID != "mygroup" { + t.Errorf("GroupID = %q, want %q", client.GroupID, "mygroup") + } + if client.ProjectID != "123" { + t.Errorf("ProjectID = %q, want %q", client.ProjectID, "123") + } + if client.Token != "token" { + t.Errorf("Token = %q, want %q", client.Token, "token") + } +} + +// TestWithGroupID_PreservesGroupID verifies that WithHTTPClient and WithEndpoint preserve GroupID. +func TestWithGroupID_PreservesGroupID(t *testing.T) { + client := NewClient("token", "https://gitlab.example.com", "123"). + WithGroupID("mygroup"). + WithHTTPClient(&http.Client{Timeout: 60 * time.Second}). + WithEndpoint("https://custom.gitlab.com") + + if client.GroupID != "mygroup" { + t.Errorf("GroupID = %q after chaining, want %q", client.GroupID, "mygroup") + } +} + +// TestIssuesBasePath_Project verifies issuesBasePath returns project path when no GroupID. +func TestIssuesBasePath_Project(t *testing.T) { + client := NewClient("token", "https://gitlab.example.com", "123") + got := client.issuesBasePath() + want := "/projects/123/issues" + if got != want { + t.Errorf("issuesBasePath() = %q, want %q", got, want) + } +} + +// TestIssuesBasePath_Group verifies issuesBasePath returns group path when GroupID is set. +func TestIssuesBasePath_Group(t *testing.T) { + client := NewClient("token", "https://gitlab.example.com", "123"). + WithGroupID("mygroup") + got := client.issuesBasePath() + want := "/groups/mygroup/issues" + if got != want { + t.Errorf("issuesBasePath() = %q, want %q", got, want) + } +} + +// TestIssuesBasePath_GroupPathEncoded verifies group paths with slashes are URL-encoded. +func TestIssuesBasePath_GroupPathEncoded(t *testing.T) { + client := NewClient("token", "https://gitlab.example.com", "123"). + WithGroupID("parent/child") + got := client.issuesBasePath() + want := "/groups/parent%2Fchild/issues" + if got != want { + t.Errorf("issuesBasePath() = %q, want %q", got, want) + } +} + +// TestFetchIssues_GroupLevel verifies FetchIssues uses group endpoint when GroupID is set. +func TestFetchIssues_GroupLevel(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{ + {ID: 1, IID: 1, ProjectID: 10, Title: "Group issue 1"}, + {ID: 2, IID: 5, ProjectID: 20, Title: "Group issue 2"}, + }) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123").WithGroupID("42") + ctx := context.Background() + + issues, err := client.FetchIssues(ctx, "opened") + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + // Should use /groups/42/issues, not /projects/123/issues + if !strings.Contains(capturedPath, "/groups/42/issues") { + t.Errorf("URL path = %s, want to contain /groups/42/issues", capturedPath) + } + if strings.Contains(capturedPath, "/projects/") { + t.Errorf("URL path = %s, should NOT contain /projects/ in group mode", capturedPath) + } + if len(issues) != 2 { + t.Errorf("FetchIssues() returned %d issues, want 2", len(issues)) + } +} + +// TestFetchIssuesSince_GroupLevel verifies FetchIssuesSince uses group endpoint. +func TestFetchIssuesSince_GroupLevel(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123").WithGroupID("mygroup") + ctx := context.Background() + + _, err := client.FetchIssuesSince(ctx, "all", time.Now().Add(-24*time.Hour)) + if err != nil { + t.Fatalf("FetchIssuesSince() error = %v", err) + } + + if !strings.Contains(capturedPath, "/groups/mygroup/issues") { + t.Errorf("URL path = %s, want to contain /groups/mygroup/issues", capturedPath) + } +} + +// TestCreateIssue_StillUsesProject verifies CreateIssue always uses project endpoint, +// even when GroupID is set (issues are created at project level). +func TestCreateIssue_StillUsesProject(t *testing.T) { + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Issue{ID: 1, IID: 1, Title: "New"}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "456").WithGroupID("mygroup") + ctx := context.Background() + + _, err := client.CreateIssue(ctx, "New", "Desc", nil) + if err != nil { + t.Fatalf("CreateIssue() error = %v", err) + } + + // CreateIssue should still use /projects/ endpoint + if !strings.Contains(capturedPath, "/projects/456/issues") { + t.Errorf("CreateIssue URL path = %s, want to contain /projects/456/issues", capturedPath) + } +} + +// TestFetchIssues_WithLabelFilter verifies labels filter is passed as query param. +func TestFetchIssues_WithLabelFilter(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123") + ctx := context.Background() + + filter := &IssueFilter{Labels: "bug,backend"} + _, err := client.FetchIssues(ctx, "opened", filter) + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + if !strings.Contains(capturedURL, "labels=bug%2Cbackend") && !strings.Contains(capturedURL, "labels=bug,backend") { + t.Errorf("URL = %s, want to contain labels param", capturedURL) + } +} + +// TestFetchIssues_WithMilestoneFilter verifies milestone filter is passed as query param. +func TestFetchIssues_WithMilestoneFilter(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123") + ctx := context.Background() + + filter := &IssueFilter{Milestone: "Sprint 1"} + _, err := client.FetchIssues(ctx, "all", filter) + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + if !strings.Contains(capturedURL, "milestone=") { + t.Errorf("URL = %s, want to contain milestone param", capturedURL) + } +} + +// TestFetchIssues_WithAssigneeFilter verifies assignee filter is passed as query param. +func TestFetchIssues_WithAssigneeFilter(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123") + ctx := context.Background() + + filter := &IssueFilter{Assignee: "kyriakos"} + _, err := client.FetchIssues(ctx, "all", filter) + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + if !strings.Contains(capturedURL, "assignee_username=kyriakos") { + t.Errorf("URL = %s, want to contain assignee_username=kyriakos", capturedURL) + } +} + +// TestFetchIssues_WithProjectFilter verifies client-side project filtering. +func TestFetchIssues_WithProjectFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{ + {ID: 1, IID: 1, ProjectID: 10, Title: "Project 10 issue"}, + {ID: 2, IID: 2, ProjectID: 20, Title: "Project 20 issue"}, + {ID: 3, IID: 3, ProjectID: 10, Title: "Another project 10 issue"}, + }) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123").WithGroupID("mygroup") + ctx := context.Background() + + filter := &IssueFilter{ProjectID: 10} + issues, err := client.FetchIssues(ctx, "all", filter) + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + if len(issues) != 2 { + t.Errorf("FetchIssues() returned %d issues, want 2 (filtered to project 10)", len(issues)) + } + for _, issue := range issues { + if issue.ProjectID != 10 { + t.Errorf("issue.ProjectID = %d, want 10", issue.ProjectID) + } + } +} + +// TestFetchIssues_NilFilter verifies nil filter doesn't affect behavior. +func TestFetchIssues_NilFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{{ID: 1, IID: 1, Title: "Issue"}}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123") + ctx := context.Background() + + // No filter arg + issues, err := client.FetchIssues(ctx, "all") + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + if len(issues) != 1 { + t.Errorf("FetchIssues() returned %d issues, want 1", len(issues)) + } + + // Explicit nil filter + issues, err = client.FetchIssues(ctx, "all", nil) + if err != nil { + t.Fatalf("FetchIssues(nil) error = %v", err) + } + if len(issues) != 1 { + t.Errorf("FetchIssues(nil) returned %d issues, want 1", len(issues)) + } +} + +// TestFetchIssuesSince_WithFilter verifies filters work on FetchIssuesSince too. +func TestFetchIssuesSince_WithFilter(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{ + {ID: 1, IID: 1, ProjectID: 10, Title: "Match"}, + {ID: 2, IID: 2, ProjectID: 20, Title: "No match"}, + }) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123").WithGroupID("mygroup") + ctx := context.Background() + + filter := &IssueFilter{Labels: "backend", Assignee: "user1", ProjectID: 10} + issues, err := client.FetchIssuesSince(ctx, "all", time.Now().Add(-24*time.Hour), filter) + if err != nil { + t.Fatalf("FetchIssuesSince() error = %v", err) + } + + // API params should be set + if !strings.Contains(capturedURL, "labels=backend") { + t.Errorf("URL = %s, want to contain labels=backend", capturedURL) + } + if !strings.Contains(capturedURL, "assignee_username=user1") { + t.Errorf("URL = %s, want to contain assignee_username=user1", capturedURL) + } + // Client-side project filter + if len(issues) != 1 { + t.Errorf("FetchIssuesSince() returned %d issues, want 1 (filtered to project 10)", len(issues)) + } +} + +// TestFetchIssues_CombinedFilters verifies multiple filters compose correctly. +func TestFetchIssues_CombinedFilters(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]Issue{}) + })) + defer server.Close() + + client := NewClient("token", server.URL, "123") + ctx := context.Background() + + filter := &IssueFilter{ + Labels: "bug,critical", + Milestone: "v1.0", + Assignee: "dev1", + } + _, err := client.FetchIssues(ctx, "opened", filter) + if err != nil { + t.Fatalf("FetchIssues() error = %v", err) + } + + if !strings.Contains(capturedURL, "milestone=v1.0") { + t.Errorf("URL = %s, want to contain milestone=v1.0", capturedURL) + } + if !strings.Contains(capturedURL, "assignee_username=dev1") { + t.Errorf("URL = %s, want to contain assignee_username=dev1", capturedURL) + } + if !strings.Contains(capturedURL, "state=opened") { + t.Errorf("URL = %s, want to contain state=opened", capturedURL) + } +} diff --git a/internal/gitlab/tracker.go b/internal/gitlab/tracker.go index c615b2116e..3d3d79c386 100644 --- a/internal/gitlab/tracker.go +++ b/internal/gitlab/tracker.go @@ -27,6 +27,7 @@ type Tracker struct { client *Client config *MappingConfig store storage.Storage + filter *IssueFilter // Optional filters for issue fetching } func (t *Tracker) Name() string { return "gitlab" } @@ -46,16 +47,67 @@ func (t *Tracker) Init(ctx context.Context, store storage.Storage) error { baseURL = "https://gitlab.com" } - projectID, err := t.getConfig(ctx, "gitlab.project_id", "GITLAB_PROJECT_ID") - if err != nil || projectID == "" { + projectID, _ := t.getConfig(ctx, "gitlab.project_id", "GITLAB_PROJECT_ID") + groupID, _ := t.getConfig(ctx, "gitlab.group_id", "GITLAB_GROUP_ID") + defaultProjectID, _ := t.getConfig(ctx, "gitlab.default_project_id", "GITLAB_DEFAULT_PROJECT_ID") + + // When group_id is set, default_project_id is used for creating issues. + // When group_id is not set, project_id is required. + if groupID == "" && projectID == "" { return fmt.Errorf("GitLab project ID not configured (set gitlab.project_id or GITLAB_PROJECT_ID)") } + // For group mode, use default_project_id as the project for creating issues. + // If default_project_id is not set, fall back to project_id. + if groupID != "" && projectID == "" { + if defaultProjectID != "" { + projectID = defaultProjectID + } + } + t.client = NewClient(token, baseURL, projectID) + if groupID != "" { + t.client = t.client.WithGroupID(groupID) + } t.config = DefaultMappingConfig() + + // Load optional filter config + t.filter = t.loadFilterConfig(ctx) + return nil } +// loadFilterConfig reads filter configuration from store/env. +// Returns nil if no filters are configured. +func (t *Tracker) loadFilterConfig(ctx context.Context) *IssueFilter { + labels, _ := t.getConfig(ctx, "gitlab.filter_labels", "GITLAB_FILTER_LABELS") + projectStr, _ := t.getConfig(ctx, "gitlab.filter_project", "GITLAB_FILTER_PROJECT") + milestone, _ := t.getConfig(ctx, "gitlab.filter_milestone", "GITLAB_FILTER_MILESTONE") + assignee, _ := t.getConfig(ctx, "gitlab.filter_assignee", "GITLAB_FILTER_ASSIGNEE") + + if labels == "" && projectStr == "" && milestone == "" && assignee == "" { + return nil + } + + filter := &IssueFilter{ + Labels: labels, + Milestone: milestone, + Assignee: assignee, + } + if projectStr != "" { + if pid, err := strconv.Atoi(projectStr); err == nil { + filter.ProjectID = pid + } + } + return filter +} + +// SetFilter overrides the tracker's issue filter. +// CLI flags use this to override config-based defaults. +func (t *Tracker) SetFilter(filter *IssueFilter) { + t.filter = filter +} + func (t *Tracker) Validate() error { if t.client == nil { return fmt.Errorf("GitLab tracker not initialized") @@ -79,9 +131,9 @@ func (t *Tracker) FetchIssues(ctx context.Context, opts tracker.FetchOptions) ([ } if opts.Since != nil { - issues, err = t.client.FetchIssuesSince(ctx, state, *opts.Since) + issues, err = t.client.FetchIssuesSince(ctx, state, *opts.Since, t.filter) } else { - issues, err = t.client.FetchIssues(ctx, state) + issues, err = t.client.FetchIssues(ctx, state, t.filter) } if err != nil { return nil, err diff --git a/internal/gitlab/types.go b/internal/gitlab/types.go index 85d66a24d1..45490c75b2 100644 --- a/internal/gitlab/types.go +++ b/internal/gitlab/types.go @@ -35,11 +35,21 @@ const ( MaxPages = 1000 ) +// IssueFilter holds optional filters for fetching issues. +// All fields are optional; zero values mean "no filter". +type IssueFilter struct { + Labels string // Comma-separated label names (AND logic) + ProjectID int // Filter to issues from this project (client-side, group mode only) + Milestone string // Milestone title + Assignee string // Assignee username +} + // Client provides methods to interact with the GitLab REST API. type Client struct { Token string // GitLab personal access token or OAuth token BaseURL string // GitLab instance URL (e.g., "https://gitlab.com/api/v4") ProjectID string // Project ID or URL-encoded path (e.g., "group/project") + GroupID string // Optional group ID or path for group-level issue fetching HTTPClient *http.Client // Optional custom HTTP client }