Skip to content
Closed
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
58 changes: 40 additions & 18 deletions cmd/bd/ado.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import (

// ADOConfig holds Azure DevOps connection configuration.
type ADOConfig struct {
PAT string // Personal access token
Org string // Organization name
Project string // Project name
URL string // Custom base URL (for on-prem)
PAT string // Personal access token
Org string // Organization name
Project string // Primary project name (backward compat)
Projects []string // All project names
URL string // Custom base URL (for on-prem)
}

// adoCmd is the root command for Azure DevOps operations.
Expand All @@ -32,10 +33,11 @@ var adoCmd = &cobra.Command{
Long: `Commands for syncing issues between beads and Azure DevOps.

Configuration can be set via 'bd config' or environment variables:
ado.org / AZURE_DEVOPS_ORG - Organization name
ado.project / AZURE_DEVOPS_PROJECT - Project name
ado.pat / AZURE_DEVOPS_PAT - Personal access token
ado.url / AZURE_DEVOPS_URL - Custom base URL (on-prem)`,
ado.org / AZURE_DEVOPS_ORG - Organization name
ado.project / AZURE_DEVOPS_PROJECT - Project name (single)
ado.projects / AZURE_DEVOPS_PROJECTS - Project names (comma-separated)
ado.pat / AZURE_DEVOPS_PAT - Personal access token
ado.url / AZURE_DEVOPS_URL - Custom base URL (on-prem)`,
}

// adoSyncCmd synchronizes issues between beads and Azure DevOps.
Expand Down Expand Up @@ -156,6 +158,7 @@ func init() {
adoSyncCmd.Flags().StringVar(&adoFilterIterationPath, "iteration-path", "", "Filter to ADO iteration path (e.g., \"Project\\Sprint 1\")")
adoSyncCmd.Flags().StringVar(&adoFilterTypes, "types", "", "Filter to work item types, comma-separated (e.g., \"Bug,Task,User Story\")")
adoSyncCmd.Flags().StringVar(&adoFilterStates, "states", "", "Filter to ADO states, comma-separated (e.g., \"New,Active,Resolved\")")
adoSyncCmd.Flags().StringSlice("project", nil, "Project name(s) to sync (overrides configured project/projects)")

// Register ado command with root
rootCmd.AddCommand(adoCmd)
Expand All @@ -168,9 +171,16 @@ func getADOConfig() ADOConfig {

cfg.PAT = getADOConfigValue(ctx, "ado.pat")
cfg.Org = getADOConfigValue(ctx, "ado.org")
cfg.Project = getADOConfigValue(ctx, "ado.project")
cfg.URL = getADOConfigValue(ctx, "ado.url")

// Resolve projects from all sources.
pluralVal := getADOConfigValue(ctx, "ado.projects")
singularVal := getADOConfigValue(ctx, "ado.project")
cfg.Projects = tracker.ResolveProjectIDs(nil, pluralVal, singularVal)
if len(cfg.Projects) > 0 {
cfg.Project = cfg.Projects[0]
}

return cfg
}

Expand Down Expand Up @@ -213,6 +223,8 @@ func adoConfigToEnvVar(key string) string {
return "AZURE_DEVOPS_ORG"
case "ado.project":
return "AZURE_DEVOPS_PROJECT"
case "ado.projects":
return "AZURE_DEVOPS_PROJECTS"
case "ado.url":
return "AZURE_DEVOPS_URL"
default:
Expand All @@ -228,8 +240,8 @@ func validateADOConfig(cfg ADOConfig) error {
if cfg.Org == "" && cfg.URL == "" {
return fmt.Errorf("ado.org not configured: set via 'bd config ado.org <org>' or AZURE_DEVOPS_ORG env var")
}
if cfg.Project == "" {
return fmt.Errorf("ado.project not configured: set via 'bd config ado.project <project>' or AZURE_DEVOPS_PROJECT env var")
if len(cfg.Projects) == 0 {
return fmt.Errorf("no ADO project configured\nSet via 'bd config set ado.project <project>'\nOr: 'bd config set ado.projects \"proj1,proj2\"'\nOr: AZURE_DEVOPS_PROJECT env var")
}
return nil
}
Expand Down Expand Up @@ -328,12 +340,13 @@ func buildADOPullFilters(ctx context.Context, cmd *cobra.Command) *ado.PullFilte

// adoStatusResult holds the JSON output for the ado status command.
type adoStatusResult struct {
Org string `json:"org"`
Project string `json:"project"`
HasToken bool `json:"has_token"`
URL string `json:"url,omitempty"`
Configured bool `json:"configured"`
Error string `json:"error,omitempty"`
Org string `json:"org"`
Project string `json:"project"`
Projects []string `json:"projects,omitempty"`
HasToken bool `json:"has_token"`
URL string `json:"url,omitempty"`
Configured bool `json:"configured"`
Error string `json:"error,omitempty"`
}

// runADOStatus implements the ado status command.
Expand All @@ -344,6 +357,7 @@ func runADOStatus(cmd *cobra.Command, _ []string) error {
result := adoStatusResult{
Org: cfg.Org,
Project: cfg.Project,
Projects: cfg.Projects,
HasToken: cfg.PAT != "",
URL: cfg.URL,
}
Expand All @@ -361,7 +375,11 @@ func runADOStatus(cmd *cobra.Command, _ []string) error {
_, _ = fmt.Fprintln(out, "Azure DevOps Configuration")
_, _ = fmt.Fprintln(out, "==========================")
_, _ = fmt.Fprintf(out, "Organization: %s\n", cfg.Org)
_, _ = fmt.Fprintf(out, "Project: %s\n", cfg.Project)
if len(cfg.Projects) <= 1 {
_, _ = fmt.Fprintf(out, "Project: %s\n", cfg.Project)
} else {
_, _ = fmt.Fprintf(out, "Projects: %s (%d projects)\n", strings.Join(cfg.Projects, ", "), len(cfg.Projects))
}
_, _ = fmt.Fprintf(out, "PAT: %s\n", maskADOToken(cfg.PAT))
if cfg.URL != "" {
_, _ = fmt.Fprintf(out, "Base URL: %s\n", cfg.URL)
Expand Down Expand Up @@ -471,6 +489,10 @@ func runADOSync(cmd *cobra.Command, _ []string) error {

// Create and initialize the ADO tracker
at := &ado.Tracker{}
cliProjects, _ := cmd.Flags().GetStringSlice("project")
if len(cliProjects) > 0 {
at.SetProjects(tracker.DeduplicateStrings(cliProjects))
}
if err := at.Init(ctx, store); err != nil {
return fmt.Errorf("initializing Azure DevOps tracker: %w", err)
}
Expand Down
10 changes: 5 additions & 5 deletions cmd/bd/ado_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,26 @@ func TestADOConfigValidation(t *testing.T) {
}{
{
name: "missing PAT",
config: ADOConfig{Org: "org", Project: "proj"},
config: ADOConfig{Org: "org", Projects: []string{"proj"}},
wantError: "ado.pat",
},
{
name: "missing org and URL",
config: ADOConfig{PAT: "tok", Project: "proj"},
config: ADOConfig{PAT: "tok", Projects: []string{"proj"}},
wantError: "ado.org",
},
{
name: "missing project",
config: ADOConfig{PAT: "tok", Org: "org"},
wantError: "ado.project",
wantError: "no ADO project",
},
{
name: "all present",
config: ADOConfig{PAT: "tok", Org: "org", Project: "proj"},
config: ADOConfig{PAT: "tok", Org: "org", Project: "proj", Projects: []string{"proj"}},
},
{
name: "URL substitutes for org",
config: ADOConfig{PAT: "tok", URL: "https://tfs.corp.com", Project: "proj"},
config: ADOConfig{PAT: "tok", URL: "https://tfs.corp.com", Project: "proj", Projects: []string{"proj"}},
},
}

Expand Down
44 changes: 35 additions & 9 deletions cmd/bd/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ var jiraCmd = &cobra.Command{
Configuration:
bd config set jira.url "https://company.atlassian.net"
bd config set jira.project "PROJ"
bd config set jira.projects "PROJ1,PROJ2" # Multiple projects
bd config set jira.api_token "YOUR_TOKEN"
bd config set jira.username "[email protected]" # For Jira Cloud
bd config set jira.push_prefix "hippo" # Only push hippo-* issues to Jira
bd config set jira.push_prefix "proj1,proj2" # Multiple prefixes (comma-separated)

Environment variables (alternative to config):
JIRA_API_TOKEN - Jira API token
JIRA_USERNAME - Jira username/email
JIRA_API_TOKEN - Jira API token
JIRA_USERNAME - Jira username/email
JIRA_PROJECTS - Comma-separated project keys

Examples:
bd jira sync --pull # Import issues from Jira
Expand Down Expand Up @@ -80,6 +82,7 @@ func init() {
jiraSyncCmd.Flags().Bool("prefer-jira", false, "Prefer Jira version on conflicts")
jiraSyncCmd.Flags().Bool("create-only", false, "Only create new issues, don't update existing")
jiraSyncCmd.Flags().String("state", "all", "Issue state to sync: open, closed, all")
jiraSyncCmd.Flags().StringSlice("project", nil, "Project key(s) to sync (overrides configured project/projects)")

jiraCmd.AddCommand(jiraSyncCmd)
jiraCmd.AddCommand(jiraStatusCmd)
Expand Down Expand Up @@ -115,6 +118,10 @@ func runJiraSync(cmd *cobra.Command, args []string) {

// Create and initialize the Jira tracker
jt := &jira.Tracker{}
cliProjects, _ := cmd.Flags().GetStringSlice("project")
if len(cliProjects) > 0 {
jt.SetProjectKeys(tracker.DeduplicateStrings(cliProjects))
}
if err := jt.Init(ctx, store); err != nil {
FatalError("initializing Jira tracker: %v", err)
}
Expand Down Expand Up @@ -210,10 +217,14 @@ func runJiraStatus(cmd *cobra.Command, args []string) {
}

jiraURL, _ := store.GetConfig(ctx, "jira.url")
jiraProject, _ := store.GetConfig(ctx, "jira.project")
lastSync, _ := store.GetConfig(ctx, "jira.last_sync")

configured := jiraURL != "" && jiraProject != ""
// Resolve project keys from all config sources.
pluralProjects, _ := store.GetConfig(ctx, "jira.projects")
singularProject, _ := store.GetConfig(ctx, "jira.project")
projectKeys := tracker.ResolveProjectIDs(nil, pluralProjects, singularProject)

configured := jiraURL != "" && len(projectKeys) > 0

allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
Expand All @@ -231,10 +242,16 @@ func runJiraStatus(cmd *cobra.Command, args []string) {
}

if jsonOutput {
// Backward compat: include jira_project as first project, plus full list.
primaryProject := ""
if len(projectKeys) > 0 {
primaryProject = projectKeys[0]
}
outputJSON(map[string]interface{}{
"configured": configured,
"jira_url": jiraURL,
"jira_project": jiraProject,
"jira_project": primaryProject,
"jira_projects": projectKeys,
"last_sync": lastSync,
"total_issues": len(allIssues),
"with_jira_ref": withJiraRef,
Expand All @@ -253,13 +270,18 @@ func runJiraStatus(cmd *cobra.Command, args []string) {
fmt.Println("To configure Jira integration:")
fmt.Println(" bd config set jira.url \"https://company.atlassian.net\"")
fmt.Println(" bd config set jira.project \"PROJ\"")
fmt.Println(" bd config set jira.projects \"PROJ1,PROJ2\" # multiple projects")
fmt.Println(" bd config set jira.api_token \"YOUR_TOKEN\"")
fmt.Println(" bd config set jira.username \"[email protected]\"")
return
}

fmt.Printf("Jira URL: %s\n", jiraURL)
fmt.Printf("Project: %s\n", jiraProject)
if len(projectKeys) == 1 {
fmt.Printf("Project: %s\n", projectKeys[0])
} else {
fmt.Printf("Projects: %s (%d projects)\n", strings.Join(projectKeys, ", "), len(projectKeys))
}
if lastSync != "" {
fmt.Printf("Last Sync: %s\n", lastSync)
} else {
Expand All @@ -284,13 +306,17 @@ func validateJiraConfig() error {

ctx := rootCtx
jiraURL, _ := store.GetConfig(ctx, "jira.url")
jiraProject, _ := store.GetConfig(ctx, "jira.project")

if jiraURL == "" {
return fmt.Errorf("jira.url not configured\nRun: bd config set jira.url \"https://company.atlassian.net\"")
}
if jiraProject == "" {
return fmt.Errorf("jira.project not configured\nRun: bd config set jira.project \"PROJ\"")

// Check for project configuration (singular or plural).
pluralProjects, _ := store.GetConfig(ctx, "jira.projects")
singularProject, _ := store.GetConfig(ctx, "jira.project")
projectKeys := tracker.ResolveProjectIDs(nil, pluralProjects, singularProject)
if len(projectKeys) == 0 {
return fmt.Errorf("no Jira project configured\nRun: bd config set jira.project \"PROJ\"\nOr: bd config set jira.projects \"PROJ1,PROJ2\"")
}

apiToken, _ := store.GetConfig(ctx, "jira.api_token")
Expand Down
Loading
Loading