diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 2b28aeee2a..b5c0e1015f 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -560,6 +560,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all upgradeCmd := cli.NewUpgradeCommand() completionCmd := cli.NewCompletionCommand() hashCmd := cli.NewHashCommand() + projectCmd := cli.NewProjectCommand() // Assign commands to groups // Setup Commands @@ -594,6 +595,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all prCmd.GroupID = "utilities" completionCmd.GroupID = "utilities" hashCmd.GroupID = "utilities" + projectCmd.GroupID = "utilities" // version command is intentionally left without a group (common practice) @@ -622,6 +624,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(fixCmd) rootCmd.AddCommand(completionCmd) rootCmd.AddCommand(hashCmd) + rootCmd.AddCommand(projectCmd) } func main() { diff --git a/docs/src/content/docs/reference/tokens.md b/docs/src/content/docs/reference/tokens.md index 02a3ac8f31..e8868f14f9 100644 --- a/docs/src/content/docs/reference/tokens.md +++ b/docs/src/content/docs/reference/tokens.md @@ -17,7 +17,7 @@ For GitHub Agentic Workflows, you only need to create a few **optional** secrets | Copilot workflows (CLI, engine, agent sessions, etc.) | `COPILOT_GITHUB_TOKEN` | Needs Copilot Requests permission. For org-owned repos, needs org permissions: Members (read-only), GitHub Copilot Business (read-only). | | Cross-repo Project Ops / remote GitHub tools | `GH_AW_GITHUB_TOKEN` | PAT or app token with cross-repo access. | | Assigning agents/bots to issues or pull requests | `GH_AW_AGENT_TOKEN` | Used by `assign-to-agent` and Copilot assignee/reviewer flows. | -| Any GitHub Projects v2 operations | `GH_AW_PROJECT_GITHUB_TOKEN` | **Required** for `update-project`. Default `GITHUB_TOKEN` cannot access Projects v2 API. | +| Any GitHub Projects v2 operations | `GH_AW_PROJECT_GITHUB_TOKEN` | **Required** for `project new` CLI command, `create-project`, and `update-project`. Default `GITHUB_TOKEN` cannot access Projects v2 API. | | Isolating Model Context Protocol (MCP) server permissions (advanced optional) | `GH_AW_GITHUB_MCP_SERVER_TOKEN` | Only if you want MCP to use a different token than other jobs. | > [!TIP] @@ -160,7 +160,11 @@ The compiler automatically sets `GITHUB_MCP_SERVER_TOKEN` and passes it as `GITH **Type**: Personal Access Token (required for Projects v2 operations) -A specialized token for GitHub Projects v2 operations used by the [`update-project`](/gh-aw/reference/safe-outputs/#project-board-updates-update-project) safe output. **Required** because the default `GITHUB_TOKEN` cannot access the GitHub Projects v2 GraphQL API. +A specialized token for GitHub Projects v2 operations used by: +- The [`project new`](/gh-aw/setup/cli/#project-new) CLI command for creating projects +- The [`update-project`](/gh-aw/reference/safe-outputs/#project-board-updates-update-project) safe output for updating projects + +**Required** because the default `GITHUB_TOKEN` cannot access the GitHub Projects v2 GraphQL API. **When to use**: diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index 9162a21e60..df60e1cad9 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -564,6 +564,36 @@ gh aw completion powershell # Generate powershell script See [Shell Completions](#shell-completions) for detailed installation instructions. +#### `project` + +Create and manage GitHub Projects V2 boards. Use this to create project boards for tracking issues, pull requests, and tasks. + +##### `project new` + +Create a new GitHub Project V2 owned by a user or organization. Optionally link the project to a specific repository. + +```bash wrap +gh aw project new "My Project" --owner @me # Create user project +gh aw project new "Team Board" --owner myorg # Create org project +gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo +``` + +**Options:** +- `--owner` (required): Project owner - use `@me` for current user or specify organization name +- `--link`: Repository to link project to (format: `owner/repo`) + +**Token Requirements:** + +> [!IMPORTANT] +> The default `GITHUB_TOKEN` cannot create projects. You must use a Personal Access Token (PAT) with Projects permissions: +> +> - **Classic PAT**: `project` scope (user projects) or `project` + `repo` (org projects) +> - **Fine-grained PAT**: Organization permissions → Projects: Read & Write +> +> Configure via `GH_AW_PROJECT_GITHUB_TOKEN` environment variable or use `gh auth login` with a suitable token. + +**Related:** See [Tokens Reference](/gh-aw/reference/tokens/) for complete token configuration guide. + ## Shell Completions Enable tab completion for workflow names, engines, and paths. diff --git a/pkg/cli/project_command.go b/pkg/cli/project_command.go new file mode 100644 index 0000000000..16c72b63c0 --- /dev/null +++ b/pkg/cli/project_command.go @@ -0,0 +1,339 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/githubnext/gh-aw/pkg/workflow" + "github.com/spf13/cobra" +) + +var projectLog = logger.New("cli:project") + +// ProjectConfig holds configuration for creating a GitHub Project +type ProjectConfig struct { + Title string // Project title + Owner string // Owner login (user or org) + OwnerType string // "user" or "org" + Description string // Project description (note: not currently supported by GitHub Projects V2 API during creation) + Repo string // Repository to link project to (optional, format: owner/repo) + Verbose bool // Verbose output +} + +// NewProjectCommand creates the project command +func NewProjectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Manage GitHub Projects V2", + Long: `Manage GitHub Projects V2 boards linked to repositories. + +GitHub Projects V2 provides kanban-style project boards for tracking issues, +pull requests, and tasks across repositories. + +This command allows you to create new projects owned by users or organizations +and optionally link them to specific repositories. + +Examples: + gh aw project new "My Project" --owner @me # Create user project + gh aw project new "Team Board" --owner myorg # Create org project + gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo`, + } + + // Add subcommands + cmd.AddCommand(NewProjectNewCommand()) + + return cmd +} + +// NewProjectNewCommand creates the "project new" subcommand +func NewProjectNewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "new ", + Short: "Create a new GitHub Project V2", + Long: `Create a new GitHub Project V2 board owned by a user or organization. + +The project can optionally be linked to a specific repository. + +Token Requirements: + The default GITHUB_TOKEN cannot create projects. You must use a PAT with: + - Classic PAT: 'project' scope (user projects) or 'project' + 'repo' (org projects) + - Fine-grained PAT: Organization permissions → Projects: Read & Write + + Set GH_AW_PROJECT_GITHUB_TOKEN environment variable or configure your gh CLI + with a token that has the required permissions. + +Examples: + gh aw project new "My Project" --owner @me # Create user project + gh aw project new "Team Board" --owner myorg # Create org project + gh aw project new "Bugs" --owner myorg --link myorg/myrepo # Create and link to repo`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + owner, _ := cmd.Flags().GetString("owner") + link, _ := cmd.Flags().GetString("link") + verbose, _ := cmd.Flags().GetBool("verbose") + + if owner == "" { + return fmt.Errorf("--owner flag is required. Use '@me' for current user or specify org name") + } + + config := ProjectConfig{ + Title: args[0], + Owner: owner, + Repo: link, + Verbose: verbose, + } + + return RunProjectNew(cmd.Context(), config) + }, + } + + cmd.Flags().StringP("owner", "o", "", "Project owner: '@me' for current user or organization name (required)") + cmd.Flags().StringP("link", "l", "", "Repository to link project to (format: owner/repo)") + _ = cmd.MarkFlagRequired("owner") + + return cmd +} + +// RunProjectNew executes the project creation logic +func RunProjectNew(ctx context.Context, config ProjectConfig) error { + projectLog.Printf("Creating project: title=%s, owner=%s, repo=%s", config.Title, config.Owner, config.Repo) + + // Resolve owner type + ownerType := "org" + ownerLogin := config.Owner + if config.Owner == "@me" { + ownerType = "user" + // Get current user + currentUser, err := getCurrentUser(ctx) + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + ownerLogin = currentUser + console.LogVerbose(config.Verbose, fmt.Sprintf("Resolved @me to user: %s", ownerLogin)) + } + + config.OwnerType = ownerType + config.Owner = ownerLogin + + // Validate owner exists + if err := validateOwner(ctx, config.OwnerType, config.Owner, config.Verbose); err != nil { + return fmt.Errorf("owner validation failed: %w", err) + } + + // Get owner ID + ownerId, err := getOwnerNodeId(ctx, config.OwnerType, config.Owner, config.Verbose) + if err != nil { + return fmt.Errorf("failed to get owner ID: %w", err) + } + + // Create project + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Creating project '%s' for %s %s...", config.Title, config.OwnerType, config.Owner))) + + project, err := createProject(ctx, ownerId, config.Title, config.Verbose) + if err != nil { + return fmt.Errorf("failed to create project: %w", err) + } + + // Link to repository if specified + if config.Repo != "" { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Linking project to repository %s...", config.Repo))) + if err := linkProjectToRepo(ctx, project["id"].(string), config.Repo, config.Verbose); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to link project to repository: %v", err))) + } else { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Project linked to repository")) + } + } + + // Output success + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Created project #%v: %s", project["number"], config.Title))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" URL: %s", project["url"]))) + + return nil +} + +// getCurrentUser gets the current authenticated user's login +func getCurrentUser(ctx context.Context) (string, error) { + projectLog.Print("Getting current user") + + output, err := workflow.RunGH("Fetching user info...", "api", "user", "--jq", ".login") + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } + + login := strings.TrimSpace(string(output)) + if login == "" { + return "", fmt.Errorf("failed to get current user login") + } + + return login, nil +} + +// validateOwner validates that the owner exists +func validateOwner(ctx context.Context, ownerType, owner string, verbose bool) error { + projectLog.Printf("Validating %s: %s", ownerType, owner) + console.LogVerbose(verbose, fmt.Sprintf("Validating %s exists: %s", ownerType, owner)) + + var query string + if ownerType == "org" { + query = fmt.Sprintf(`query { organization(login: "%s") { id login } }`, escapeGraphQLString(owner)) + } else { + query = fmt.Sprintf(`query { user(login: "%s") { id login } }`, escapeGraphQLString(owner)) + } + + _, err := workflow.RunGH("Validating owner...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query)) + if err != nil { + if ownerType == "org" { + return fmt.Errorf("organization '%s' not found or not accessible", owner) + } + return fmt.Errorf("user '%s' not found or not accessible", owner) + } + + console.LogVerbose(verbose, fmt.Sprintf("✓ %s '%s' validated", capitalizeFirst(ownerType), owner)) + return nil +} + +// capitalizeFirst capitalizes the first letter of a string +func capitalizeFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// getOwnerNodeId gets the node ID for the owner +func getOwnerNodeId(ctx context.Context, ownerType, owner string, verbose bool) (string, error) { + projectLog.Printf("Getting node ID for %s: %s", ownerType, owner) + console.LogVerbose(verbose, fmt.Sprintf("Getting node ID for %s: %s", ownerType, owner)) + + var query string + var jqPath string + if ownerType == "org" { + query = fmt.Sprintf(`query { organization(login: "%s") { id } }`, escapeGraphQLString(owner)) + jqPath = ".data.organization.id" + } else { + query = fmt.Sprintf(`query { user(login: "%s") { id } }`, escapeGraphQLString(owner)) + jqPath = ".data.user.id" + } + + output, err := workflow.RunGH("Getting owner ID...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query), "--jq", jqPath) + if err != nil { + return "", fmt.Errorf("failed to get owner node ID: %w", err) + } + + nodeId := strings.TrimSpace(string(output)) + if nodeId == "" { + return "", fmt.Errorf("failed to get owner node ID from response") + } + + console.LogVerbose(verbose, fmt.Sprintf("✓ Got node ID: %s", nodeId)) + return nodeId, nil +} + +// createProject creates a GitHub Project V2 +func createProject(ctx context.Context, ownerId, title string, verbose bool) (map[string]any, error) { + projectLog.Printf("Creating project: ownerId=%s, title=%s", ownerId, title) + console.LogVerbose(verbose, fmt.Sprintf("Creating project with owner ID: %s", ownerId)) + + mutation := fmt.Sprintf(`mutation { + createProjectV2(input: { ownerId: "%s", title: "%s" }) { + projectV2 { + id + number + title + url + } + } + }`, ownerId, escapeGraphQLString(title)) + + output, err := workflow.RunGH("Creating project...", "api", "graphql", "-f", fmt.Sprintf("query=%s", mutation)) + if err != nil { + // Check for permission errors + if strings.Contains(err.Error(), "INSUFFICIENT_SCOPES") || strings.Contains(err.Error(), "NOT_FOUND") { + return nil, fmt.Errorf("insufficient permissions. You need a PAT with Projects access (classic: 'project' scope, fine-grained: Organization → Projects: Read & Write). Set GH_AW_PROJECT_GITHUB_TOKEN or configure gh CLI with a suitable token") + } + return nil, fmt.Errorf("GraphQL mutation failed: %w", err) + } + + // Parse response + var response map[string]any + if err := json.Unmarshal(output, &response); err != nil { + return nil, fmt.Errorf("failed to parse GraphQL response: %w", err) + } + + // Extract project data + data, ok := response["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid response: missing 'data' field") + } + + createResult, ok := data["createProjectV2"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid response: missing 'createProjectV2' field") + } + + project, ok := createResult["projectV2"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid response: missing 'projectV2' field") + } + + console.LogVerbose(verbose, fmt.Sprintf("✓ Project created: #%v", project["number"])) + return project, nil +} + +// linkProjectToRepo links a project to a repository +func linkProjectToRepo(ctx context.Context, projectId, repoSlug string, verbose bool) error { + projectLog.Printf("Linking project %s to repository %s", projectId, repoSlug) + console.LogVerbose(verbose, fmt.Sprintf("Linking project to repository: %s", repoSlug)) + + // Parse repo slug + parts := strings.Split(repoSlug, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repository format. Expected 'owner/repo', got '%s'", repoSlug) + } + repoOwner := parts[0] + repoName := parts[1] + + // Get repository ID + query := fmt.Sprintf(`query { repository(owner: "%s", name: "%s") { id } }`, escapeGraphQLString(repoOwner), escapeGraphQLString(repoName)) + output, err := workflow.RunGH("Getting repository ID...", "api", "graphql", "-f", fmt.Sprintf("query=%s", query), "--jq", ".data.repository.id") + if err != nil { + return fmt.Errorf("repository '%s' not found: %w", repoSlug, err) + } + + repoId := strings.TrimSpace(string(output)) + if repoId == "" { + return fmt.Errorf("failed to get repository ID") + } + + // Link project to repository + mutation := fmt.Sprintf(`mutation { + linkProjectV2ToRepository(input: { projectId: "%s", repositoryId: "%s" }) { + repository { + id + } + } + }`, projectId, repoId) + + _, err = workflow.RunGH("Linking project to repository...", "api", "graphql", "-f", fmt.Sprintf("query=%s", mutation)) + if err != nil { + return fmt.Errorf("failed to link project to repository: %w", err) + } + + console.LogVerbose(verbose, fmt.Sprintf("✓ Linked project to repository %s", repoSlug)) + return nil +} + +// escapeGraphQLString escapes special characters in GraphQL strings +func escapeGraphQLString(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\r") + s = strings.ReplaceAll(s, "\t", "\\t") + return s +} diff --git a/pkg/cli/project_command_test.go b/pkg/cli/project_command_test.go new file mode 100644 index 0000000000..0f5cedbd97 --- /dev/null +++ b/pkg/cli/project_command_test.go @@ -0,0 +1,163 @@ +//go:build !integration + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewProjectCommand(t *testing.T) { + cmd := NewProjectCommand() + require.NotNil(t, cmd, "Command should be created") + assert.Equal(t, "project", cmd.Use, "Command name should be 'project'") + assert.Contains(t, cmd.Short, "GitHub Projects V2", "Short description should mention Projects V2") + assert.NotEmpty(t, cmd.Commands(), "Command should have subcommands") +} + +func TestNewProjectNewCommand(t *testing.T) { + cmd := NewProjectNewCommand() + require.NotNil(t, cmd, "Command should be created") + assert.Equal(t, "new <title>", cmd.Use, "Command usage should be 'new <title>'") + assert.Contains(t, cmd.Short, "Create a new GitHub Project V2", "Short description should be about creating projects") + + // Check flags + ownerFlag := cmd.Flags().Lookup("owner") + require.NotNil(t, ownerFlag, "Should have --owner flag") + assert.Equal(t, "o", ownerFlag.Shorthand, "Owner flag should have short form 'o'") + + linkFlag := cmd.Flags().Lookup("link") + require.NotNil(t, linkFlag, "Should have --link flag") + assert.Equal(t, "l", linkFlag.Shorthand, "Link flag should have short form 'l'") +} + +func TestEscapeGraphQLString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain text", + input: "Hello World", + expected: "Hello World", + }, + { + name: "with quotes", + input: `Project "Alpha"`, + expected: `Project \"Alpha\"`, + }, + { + name: "with backslash", + input: `Path\to\file`, + expected: `Path\\to\\file`, + }, + { + name: "with newline", + input: "Line 1\nLine 2", + expected: "Line 1\\nLine 2", + }, + { + name: "with tab", + input: "Name\tValue", + expected: "Name\\tValue", + }, + { + name: "complex string", + input: "Test \"project\"\nWith\ttabs\\and backslashes", + expected: "Test \\\"project\\\"\\nWith\\ttabs\\\\and backslashes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeGraphQLString(tt.input) + assert.Equal(t, tt.expected, result, "GraphQL string should be properly escaped") + }) + } +} + +func TestProjectConfig(t *testing.T) { + tests := []struct { + name string + config ProjectConfig + description string + }{ + { + name: "user project", + config: ProjectConfig{ + Title: "My Project", + Owner: "testuser", + OwnerType: "user", + }, + description: "Should create user project", + }, + { + name: "org project", + config: ProjectConfig{ + Title: "Team Board", + Owner: "myorg", + OwnerType: "org", + }, + description: "Should create org project", + }, + { + name: "project with repo", + config: ProjectConfig{ + Title: "Bugs", + Owner: "myorg", + OwnerType: "org", + Repo: "myorg/myrepo", + }, + description: "Should create project linked to repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NotEmpty(t, tt.config.Title, "Project title should not be empty") + assert.NotEmpty(t, tt.config.Owner, "Project owner should not be empty") + assert.NotEmpty(t, tt.config.OwnerType, "Owner type should not be empty") + assert.Contains(t, []string{"user", "org"}, tt.config.OwnerType, "Owner type should be 'user' or 'org'") + }) + } +} + +func TestProjectNewCommandArgs(t *testing.T) { + cmd := NewProjectNewCommand() + + tests := []struct { + name string + args []string + shouldErr bool + }{ + { + name: "no arguments", + args: []string{}, + shouldErr: true, + }, + { + name: "one argument", + args: []string{"My Project"}, + shouldErr: false, + }, + { + name: "too many arguments", + args: []string{"My Project", "Extra"}, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cmd.Args(cmd, tt.args) + if tt.shouldErr { + assert.Error(t, err, "Should return error for invalid arguments") + } else { + assert.NoError(t, err, "Should not return error for valid arguments") + } + }) + } +}