diff --git a/pkg/campaign/command.go b/pkg/campaign/command.go index b8462d3a9e6..b54a5e0c700 100644 --- a/pkg/campaign/command.go +++ b/pkg/campaign/command.go @@ -106,13 +106,16 @@ fields to match your initiative. With --project flag, a GitHub Project will be created with: - Required fields: Campaign Id, Worker Workflow, Priority, Size, Start Date, End Date - Views: Progress Board (board), Task Tracker (table), Campaign Roadmap (roadmap) +- Linked to a repository (best-effort): defaults to current repo; override with --repo; disable with --no-link-repo - The project URL will be automatically added to the campaign spec Examples: ` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization-winter2025 --force ` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 --project --owner @me - ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg`, + ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg + ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg --no-link-repo + ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg --repo myorg/myrepo`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -139,6 +142,8 @@ Examples: force, _ := cmd.Flags().GetBool("force") createProject, _ := cmd.Flags().GetBool("project") owner, _ := cmd.Flags().GetString("owner") + repo, _ := cmd.Flags().GetString("repo") + noLinkRepo, _ := cmd.Flags().GetBool("no-link-repo") verbose, _ := cmd.Flags().GetBool("verbose") cwd, err := os.Getwd() @@ -186,6 +191,8 @@ Examples: CampaignID: id, CampaignName: campaignName, Owner: owner, + LinkRepo: repo, + NoLinkRepo: noLinkRepo, Verbose: verbose, } @@ -220,6 +227,8 @@ Examples: newCmd.Flags().Bool("force", false, "Overwrite existing spec file if it already exists") newCmd.Flags().Bool("project", false, "Create a GitHub Project with required views and fields") newCmd.Flags().String("owner", "", "GitHub organization or user for the project (required with --project). Use '@me' for personal projects") + newCmd.Flags().StringP("repo", "r", "", "Repository to link the created project to (owner/name). Defaults to current repo") + newCmd.Flags().Bool("no-link-repo", false, "Disable best-effort project-to-repo linking") newCmd.Flags().Bool("verbose", false, "Enable verbose output") cmd.AddCommand(newCmd) diff --git a/pkg/campaign/project.go b/pkg/campaign/project.go index 3d392f1e28e..b74dbaf1379 100644 --- a/pkg/campaign/project.go +++ b/pkg/campaign/project.go @@ -1,10 +1,13 @@ package campaign import ( + "bytes" "encoding/json" "fmt" "os" "os/exec" + "regexp" + "strconv" "strings" "github.com/githubnext/gh-aw/pkg/console" @@ -18,6 +21,8 @@ type ProjectCreationConfig struct { CampaignID string CampaignName string Owner string // GitHub org or user + LinkRepo string // Optional: owner/name to link the project to + NoLinkRepo bool // Disable best-effort repo linking Verbose bool } @@ -51,6 +56,27 @@ func CreateCampaignProject(config ProjectCreationConfig) (*ProjectCreationResult console.LogVerbose(config.Verbose, "Created project fields") + // Create standard views (board/table/roadmap) + if err := createProjectViews(config, projectURL); err != nil { + return nil, fmt.Errorf("failed to create project views: %w", err) + } + + console.LogVerbose(config.Verbose, "Created project views") + + // Best-effort: link the project to the current repository. + // This should not block campaign creation if linking fails due to permissions. + if err := linkProjectToRepo(config, projectURL); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + "Could not link project to repository automatically: "+err.Error(), + )) + } + + // Ensure the Progress Board's typical grouping field (Status) includes a + // "Review Required" option, which effectively becomes a new column. + if err := ensureStatusOption(config, projectURL, "Review Required"); err != nil { + return nil, fmt.Errorf("failed to update project status options: %w", err) + } + result := &ProjectCreationResult{ ProjectURL: projectURL, ProjectNumber: projectNumber, @@ -59,19 +85,604 @@ func CreateCampaignProject(config ProjectCreationConfig) (*ProjectCreationResult return result, nil } +func linkProjectToRepo(config ProjectCreationConfig, projectURL string) error { + if config.NoLinkRepo { + console.LogVerbose(config.Verbose, "Skipping project-to-repo linking (--no-link-repo)") + return nil + } + + nameWithOwner := strings.TrimSpace(config.LinkRepo) + if nameWithOwner == "" { + var err error + nameWithOwner, err = getCurrentRepoNameWithOwner() + if err != nil { + return err + } + } + + owner, repo, err := parseRepoNameWithOwner(nameWithOwner) + if err != nil { + return err + } + + info, err := parseProjectURL(projectURL) + if err != nil { + return err + } + + // GitHub only allows linking when the project and repository share the same owner. + // Avoid calling the mutation to prevent a noisy (and expected) GraphQL validation error. + if !strings.EqualFold(info.ownerLogin, owner) { + return fmt.Errorf( + "project is owned by %q but current repository is %q; GitHub only allows linking projects to repositories owned by the same account/org. Re-run with --owner %s (or --owner @me for personal repos) from the repo you want linked", + info.ownerLogin, + nameWithOwner, + owner, + ) + } + + projectID, err := getProjectID(info) + if err != nil { + return err + } + + repoID, err := getRepositoryID(owner, repo) + if err != nil { + return err + } + + if err := linkProjectV2ToRepository(projectID, repoID); err != nil { + return err + } + + console.LogVerbose(config.Verbose, fmt.Sprintf("Linked project to repository: %s", nameWithOwner)) + return nil +} + +func parseRepoNameWithOwner(nameWithOwner string) (string, string, error) { + trimmed := strings.TrimSpace(nameWithOwner) + owner, repo, ok := strings.Cut(trimmed, "/") + owner = strings.TrimSpace(owner) + repo = strings.TrimSpace(repo) + owner = strings.TrimPrefix(owner, "@") + if !ok || owner == "" || repo == "" { + return "", "", fmt.Errorf("invalid repository %q; expected format owner/name", nameWithOwner) + } + return owner, repo, nil +} + +func getCurrentRepoNameWithOwner() (string, error) { + cmd := exec.Command("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("gh repo view failed: %w\nOutput: %s", err, string(out)) + } + nameWithOwner := strings.TrimSpace(string(out)) + if nameWithOwner == "" { + return "", fmt.Errorf("failed to determine current repository (empty nameWithOwner)") + } + return nameWithOwner, nil +} + +func getRepositoryID(owner, name string) (string, error) { + query := `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { id } + }` + + cmd := exec.Command( + "gh", + "api", + "graphql", + "-F", + "owner="+owner, + "-F", + "name="+name, + "-f", + "query="+query, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) + } + + var resp struct { + Data struct { + Repository struct { + ID string `json:"id"` + } `json:"repository"` + } `json:"data"` + } + + if err := json.Unmarshal(out, &resp); err != nil { + return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) + } + + if resp.Data.Repository.ID == "" { + return "", fmt.Errorf("failed to find repository ID for %s/%s", owner, name) + } + + return resp.Data.Repository.ID, nil +} + +func getProjectID(info projectURLInfo) (string, error) { + query := "" + if info.scope == "orgs" { + query = `query($login: String!, $number: Int!) { + organization(login: $login) { + projectV2(number: $number) { id } + } + }` + } else { + query = `query($login: String!, $number: Int!) { + user(login: $login) { + projectV2(number: $number) { id } + } + }` + } + + cmd := exec.Command( + "gh", + "api", + "graphql", + "-F", + "login="+info.ownerLogin, + "-F", + fmt.Sprintf("number=%d", info.projectNumber), + "-f", + "query="+query, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) + } + + if info.scope == "orgs" { + var resp struct { + Data struct { + Organization struct { + ProjectV2 struct { + ID string `json:"id"` + } `json:"projectV2"` + } `json:"organization"` + } `json:"data"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) + } + if resp.Data.Organization.ProjectV2.ID == "" { + return "", fmt.Errorf("failed to find project ID in GraphQL response") + } + return resp.Data.Organization.ProjectV2.ID, nil + } + + var resp struct { + Data struct { + User struct { + ProjectV2 struct { + ID string `json:"id"` + } `json:"projectV2"` + } `json:"user"` + } `json:"data"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) + } + if resp.Data.User.ProjectV2.ID == "" { + return "", fmt.Errorf("failed to find project ID in GraphQL response") + } + return resp.Data.User.ProjectV2.ID, nil +} + +func linkProjectV2ToRepository(projectID, repositoryID string) error { + mutation := `mutation($input: LinkProjectV2ToRepositoryInput!) { + linkProjectV2ToRepository(input: $input) { + clientMutationId + } + }` + + requestBody := map[string]any{ + "query": mutation, + "variables": map[string]any{ + "input": map[string]any{ + "projectId": projectID, + "repositoryId": repositoryID, + }, + }, + } + + requestJSON, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to marshal GraphQL request body: %w", err) + } + + cmd := exec.Command("gh", "api", "graphql", "--input", "-") + cmd.Stdin = bytes.NewReader(requestJSON) + + out, err := cmd.CombinedOutput() + if err != nil { + // If it's already linked, treat it as success. + msg := string(out) + if strings.Contains(strings.ToLower(msg), "already") && strings.Contains(strings.ToLower(msg), "link") { + return nil + } + return fmt.Errorf("gh api graphql link failed: %w\nOutput: %s", err, msg) + } + + return nil +} + +type projectURLInfo struct { + scope string // "users" or "orgs" + ownerLogin string + projectNumber int +} + +var projectURLRegexp = regexp.MustCompile(`github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)`) + +func parseProjectURL(projectURL string) (projectURLInfo, error) { + match := projectURLRegexp.FindStringSubmatch(projectURL) + if match == nil { + return projectURLInfo{}, fmt.Errorf("invalid project URL: %q. Expected format: https://github.com/orgs/myorg/projects/123", projectURL) + } + + projectNumber, err := strconv.Atoi(match[3]) + if err != nil { + return projectURLInfo{}, fmt.Errorf("invalid project number in URL %q: %w", projectURL, err) + } + + return projectURLInfo{ + scope: match[1], + ownerLogin: match[2], + projectNumber: projectNumber, + }, nil +} + +func createProjectViews(config ProjectCreationConfig, projectURL string) error { + projectLog.Printf("Creating standard views for project URL: %s", projectURL) + + info, err := parseProjectURL(projectURL) + if err != nil { + return err + } + + views := []struct { + name string + layout string + }{ + {name: "Progress Board", layout: "board"}, + {name: "Task Tracker", layout: "table"}, + {name: "Campaign Roadmap", layout: "roadmap"}, + } + + for _, view := range views { + if err := createProjectView(info, view.name, view.layout); err != nil { + return fmt.Errorf("failed to create view %q (%s): %w", view.name, view.layout, err) + } + console.LogVerbose(config.Verbose, fmt.Sprintf("Created view: %s (%s)", view.name, view.layout)) + } + + return nil +} + +func createProjectView(info projectURLInfo, name, layout string) error { + path := "" + if info.scope == "orgs" { + path = fmt.Sprintf("/orgs/%s/projectsV2/%d/views", info.ownerLogin, info.projectNumber) + } else { + path = fmt.Sprintf("/users/%s/projectsV2/%d/views", info.ownerLogin, info.projectNumber) + } + + cmd := exec.Command( + "gh", + "api", + "--method", + "POST", + path, + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-f", + "name="+name, + "-f", + "layout="+layout, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gh api failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +type singleSelectOption struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type statusFieldLookup struct { + ProjectID string + FieldID string + Options []singleSelectOption +} + +func ensureStatusOption(config ProjectCreationConfig, projectURL string, optionName string) error { + projectLog.Printf("Ensuring Status option %q exists for project URL: %s", optionName, projectURL) + + info, err := parseProjectURL(projectURL) + if err != nil { + return err + } + + lookup, err := getStatusField(config, info) + if err != nil { + return err + } + + updatedOptions, changed := ensureSingleSelectOptionBefore( + lookup.Options, + singleSelectOption{Name: optionName, Color: "BLUE", Description: "Needs review before moving to Done"}, + "Done", + ) + if !changed { + console.LogVerbose(config.Verbose, fmt.Sprintf("Status option already present and ordered: %s", optionName)) + return nil + } + + if err := updateSingleSelectFieldOptions(lookup.FieldID, updatedOptions); err != nil { + return err + } + + console.LogVerbose(config.Verbose, fmt.Sprintf("Ensured Status option is ordered before Done: %s", optionName)) + return nil +} + +func ensureSingleSelectOptionBefore(options []singleSelectOption, desired singleSelectOption, beforeName string) ([]singleSelectOption, bool) { + var existing *singleSelectOption + without := make([]singleSelectOption, 0, len(options)) + for _, opt := range options { + if opt.Name == desired.Name { + if existing == nil { + copyOpt := opt + existing = ©Opt + } + continue + } + without = append(without, opt) + } + + toInsert := desired + if existing != nil { + toInsert = *existing + toInsert.Color = desired.Color + if desired.Description != "" { + toInsert.Description = desired.Description + } + } + + insertAt := len(without) + for i, opt := range without { + if opt.Name == beforeName { + insertAt = i + break + } + } + + withInserted := make([]singleSelectOption, 0, len(without)+1) + withInserted = append(withInserted, without[:insertAt]...) + withInserted = append(withInserted, toInsert) + withInserted = append(withInserted, without[insertAt:]...) + + return withInserted, !singleSelectOptionsEqual(options, withInserted) +} + +func singleSelectOptionsEqual(a, b []singleSelectOption) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func getStatusField(config ProjectCreationConfig, info projectURLInfo) (statusFieldLookup, error) { + // Query the project ID and the built-in Status single-select field. + // We use the REST URL components (org/user + number) to locate the project. + query := "" + if info.scope == "orgs" { + query = `query($login: String!, $number: Int!) { + organization(login: $login) { + projectV2(number: $number) { + id + fields(first: 100) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { name color description } + } + } + } + } + } + }` + } else { + query = `query($login: String!, $number: Int!) { + user(login: $login) { + projectV2(number: $number) { + id + fields(first: 100) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { name color description } + } + } + } + } + } + }` + } + + cmd := exec.Command( + "gh", + "api", + "graphql", + "-F", + "login="+info.ownerLogin, + "-F", + fmt.Sprintf("number=%d", info.projectNumber), + "-f", + "query="+query, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return statusFieldLookup{}, fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) + } + + // Parse response + if info.scope == "orgs" { + var resp struct { + Data struct { + Organization struct { + ProjectV2 struct { + ID string `json:"id"` + Fields struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Options []singleSelectOption `json:"options"` + } `json:"nodes"` + } `json:"fields"` + } `json:"projectV2"` + } `json:"organization"` + } `json:"data"` + } + + if err := json.Unmarshal(out, &resp); err != nil { + return statusFieldLookup{}, fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) + } + + projectID := resp.Data.Organization.ProjectV2.ID + if projectID == "" { + return statusFieldLookup{}, fmt.Errorf("failed to find project ID in GraphQL response") + } + + for _, node := range resp.Data.Organization.ProjectV2.Fields.Nodes { + if node.Name == "Status" { + return statusFieldLookup{ProjectID: projectID, FieldID: node.ID, Options: node.Options}, nil + } + } + } else { + var resp struct { + Data struct { + User struct { + ProjectV2 struct { + ID string `json:"id"` + Fields struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Options []singleSelectOption `json:"options"` + } `json:"nodes"` + } `json:"fields"` + } `json:"projectV2"` + } `json:"user"` + } `json:"data"` + } + + if err := json.Unmarshal(out, &resp); err != nil { + return statusFieldLookup{}, fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) + } + + projectID := resp.Data.User.ProjectV2.ID + if projectID == "" { + return statusFieldLookup{}, fmt.Errorf("failed to find project ID in GraphQL response") + } + + for _, node := range resp.Data.User.ProjectV2.Fields.Nodes { + if node.Name == "Status" { + return statusFieldLookup{ProjectID: projectID, FieldID: node.ID, Options: node.Options}, nil + } + } + } + + return statusFieldLookup{}, fmt.Errorf("failed to locate Status field on project") +} + +func updateSingleSelectFieldOptions(fieldID string, options []singleSelectOption) error { + mutation := `mutation($input: UpdateProjectV2FieldInput!) { + updateProjectV2Field(input: $input) { + projectV2Field { + ... on ProjectV2SingleSelectField { + name + options { name } + } + } + } + }` + + input := map[string]any{ + "fieldId": fieldID, + "singleSelectOptions": options, + } + + requestBody := map[string]any{ + "query": mutation, + "variables": map[string]any{ + "input": input, + }, + } + + requestJSON, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("failed to marshal GraphQL request body: %w", err) + } + + cmd := exec.Command("gh", "api", "graphql", "--input", "-") + cmd.Stdin = bytes.NewReader(requestJSON) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gh api graphql update failed: %w\nOutput: %s", err, string(out)) + } + + return nil +} + // isGHCLIAvailable checks if the gh CLI is installed and available func isGHCLIAvailable() bool { cmd := exec.Command("gh", "--version") return cmd.Run() == nil } +func normalizeProjectOwner(owner string) string { + trimmed := strings.TrimSpace(owner) + if strings.EqualFold(trimmed, "@me") { + return "@me" + } + trimmed = strings.TrimPrefix(trimmed, "@") + return trimmed +} + // createProject creates a new GitHub Project and returns its URL and number func createProject(config ProjectCreationConfig) (string, int, error) { projectLog.Printf("Creating project with title: %s", config.CampaignName) + owner := normalizeProjectOwner(config.Owner) + // Create project using gh CLI cmd := exec.Command("gh", "project", "create", - "--owner", config.Owner, + "--owner", owner, "--title", config.CampaignName, "--format", "json") @@ -127,9 +738,11 @@ func createProjectFields(config ProjectCreationConfig, projectNumber int) error func createField(config ProjectCreationConfig, projectNumber int, name, dataType string, options []string) error { projectLog.Printf("Creating field: name=%s, type=%s", name, dataType) + owner := normalizeProjectOwner(config.Owner) + args := []string{ "project", "field-create", fmt.Sprintf("%d", projectNumber), - "--owner", config.Owner, + "--owner", owner, "--name", name, "--data-type", dataType, } diff --git a/pkg/campaign/project_owner_normalization_test.go b/pkg/campaign/project_owner_normalization_test.go new file mode 100644 index 00000000000..47637cd5c71 --- /dev/null +++ b/pkg/campaign/project_owner_normalization_test.go @@ -0,0 +1,46 @@ +package campaign + +import "testing" + +func TestNormalizeProjectOwner(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "plain login unchanged", + in: "mnkiefer", + want: "mnkiefer", + }, + { + name: "strip leading at", + in: "@mnkiefer", + want: "mnkiefer", + }, + { + name: "keep special @me", + in: "@me", + want: "@me", + }, + { + name: "trim whitespace", + in: " @mnkiefer ", + want: "mnkiefer", + }, + { + name: "empty stays empty", + in: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeProjectOwner(tt.in) + if got != tt.want { + t.Fatalf("normalizeProjectOwner(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/pkg/campaign/project_repo_parsing_test.go b/pkg/campaign/project_repo_parsing_test.go new file mode 100644 index 00000000000..d49d903852c --- /dev/null +++ b/pkg/campaign/project_repo_parsing_test.go @@ -0,0 +1,70 @@ +package campaign + +import "testing" + +func TestParseRepoNameWithOwner(t *testing.T) { + tests := []struct { + name string + in string + wantOwner string + wantRepo string + wantErr bool + }{ + { + name: "basic", + in: "githubnext/gh-aw", + wantOwner: "githubnext", + wantRepo: "gh-aw", + }, + { + name: "trims whitespace", + in: " githubnext / gh-aw ", + wantOwner: "githubnext", + wantRepo: "gh-aw", + }, + { + name: "strips leading at on owner", + in: "@mnkiefer/gh-aw", + wantOwner: "mnkiefer", + wantRepo: "gh-aw", + }, + { + name: "missing slash", + in: "githubnext", + wantErr: true, + }, + { + name: "empty owner", + in: "/repo", + wantErr: true, + }, + { + name: "empty repo", + in: "owner/", + wantErr: true, + }, + { + name: "empty", + in: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := parseRepoNameWithOwner(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("parseRepoNameWithOwner(%q) expected error", tt.in) + } + return + } + if err != nil { + t.Fatalf("parseRepoNameWithOwner(%q) unexpected error: %v", tt.in, err) + } + if owner != tt.wantOwner || repo != tt.wantRepo { + t.Fatalf("parseRepoNameWithOwner(%q) = (%q, %q), want (%q, %q)", tt.in, owner, repo, tt.wantOwner, tt.wantRepo) + } + }) + } +} diff --git a/pkg/campaign/project_status_options_test.go b/pkg/campaign/project_status_options_test.go new file mode 100644 index 00000000000..08d3142d796 --- /dev/null +++ b/pkg/campaign/project_status_options_test.go @@ -0,0 +1,89 @@ +package campaign + +import "testing" + +func TestEnsureSingleSelectOptionBefore(t *testing.T) { + tests := []struct { + name string + options []singleSelectOption + want []singleSelectOption + changed bool + }{ + { + name: "inserts before Done when missing", + options: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "In Progress", Color: "BLUE", Description: ""}, + {Name: "Done", Color: "GREEN", Description: ""}, + }, + want: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "In Progress", Color: "BLUE", Description: ""}, + {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + {Name: "Done", Color: "GREEN", Description: ""}, + }, + changed: true, + }, + { + name: "moves existing option before Done", + options: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "Done", Color: "GREEN", Description: ""}, + {Name: "Review Required", Color: "PINK", Description: "keep"}, + }, + want: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + {Name: "Done", Color: "GREEN", Description: ""}, + }, + changed: true, + }, + { + name: "no change when already before Done", + options: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + {Name: "Done", Color: "GREEN", Description: ""}, + }, + want: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + {Name: "Done", Color: "GREEN", Description: ""}, + }, + changed: false, + }, + { + name: "appends when Done missing", + options: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + }, + want: []singleSelectOption{ + {Name: "Todo", Color: "GRAY", Description: ""}, + {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + }, + changed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, changed := ensureSingleSelectOptionBefore( + tt.options, + singleSelectOption{Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, + "Done", + ) + + if changed != tt.changed { + t.Fatalf("changed=%v, want %v", changed, tt.changed) + } + if len(got) != len(tt.want) { + t.Fatalf("len(got)=%d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d]=%+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/pkg/campaign/project_views_test.go b/pkg/campaign/project_views_test.go new file mode 100644 index 00000000000..0ac3b2b5348 --- /dev/null +++ b/pkg/campaign/project_views_test.go @@ -0,0 +1,52 @@ +package campaign + +import "testing" + +func TestParseProjectURL(t *testing.T) { + tests := []struct { + name string + input string + wantScope string + wantOwner string + wantNum int + wantErr bool + }{ + { + name: "org project", + input: "https://github.com/orgs/githubnext/projects/123", + wantScope: "orgs", + wantOwner: "githubnext", + wantNum: 123, + }, + { + name: "user project", + input: "https://github.com/users/mnkiefer/projects/7", + wantScope: "users", + wantOwner: "mnkiefer", + wantNum: 7, + }, + { + name: "invalid url", + input: "https://github.com/githubnext/projects/123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseProjectURL(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("parseProjectURL error: %v", err) + } + if got.scope != tt.wantScope || got.ownerLogin != tt.wantOwner || got.projectNumber != tt.wantNum { + t.Fatalf("parseProjectURL(%q) = %+v, want scope=%q owner=%q number=%d", tt.input, got, tt.wantScope, tt.wantOwner, tt.wantNum) + } + }) + } +}