Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 99 additions & 9 deletions cmd/bd/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 ""
}
Expand All @@ -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 <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 <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.
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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"
Expand Down
152 changes: 150 additions & 2 deletions cmd/bd/gitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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", ""},
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Loading
Loading