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
21 changes: 21 additions & 0 deletions docs/src/content/docs/guides/campaigns/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gh aw campaign status incident # Filter status by ID or name
gh aw campaign status --json # JSON status output

gh aw campaign new my-campaign # Scaffold new spec (advanced)
gh aw campaign new my-campaign --project --owner @me # Create with GitHub Project
gh aw campaign validate # Validate all specs
gh aw campaign validate --no-strict # Report without failing
```
Expand Down Expand Up @@ -123,6 +124,26 @@ Creates `.github/workflows/my-campaign-id.campaign.md` with basic structure. You
3. Compile the spec with `gh aw compile`
4. Test thoroughly before running

### Create campaign with project board

Create a campaign spec and automatically generate a GitHub Project with required fields and views:

```bash
gh aw campaign new my-campaign-id --project --owner @me
```

Or for an organization:

```bash
gh aw campaign new my-campaign-id --project --owner myorg
```

This creates:
- Campaign spec file at `.github/workflows/my-campaign-id.campaign.md`
- GitHub Project with standard views (Progress Board, Task Tracker, Campaign Roadmap)
- Required custom fields (Campaign Id, Worker Workflow, Priority, Size, Start Date, End Date)
- Updates the spec file with the project URL

The automated flow handles all this for you.

## Common workflows
Expand Down
77 changes: 75 additions & 2 deletions pkg/campaign/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/githubnext/gh-aw/pkg/console"
Expand Down Expand Up @@ -102,9 +103,16 @@ Markdown body. You can then
update owners, workflows, memory paths, metrics-glob, and governance
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)
- 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 modernization-winter2025 --force
` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 --project --owner @me
` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
Expand All @@ -129,6 +137,9 @@ Examples:

id := args[0]
force, _ := cmd.Flags().GetBool("force")
createProject, _ := cmd.Flags().GetBool("project")
owner, _ := cmd.Flags().GetString("owner")
verbose, _ := cmd.Flags().GetBool("verbose")

cwd, err := os.Getwd()
if err != nil {
Expand All @@ -141,13 +152,75 @@ Examples:
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(
"Created campaign spec at "+path+". Open this file and fill in owners, workflows, memory-paths, and other details.",
"Created campaign spec at "+path,
))

// Create project if requested
if createProject {
if owner == "" {
return fmt.Errorf("--owner is required when using --project flag. Use '@me' for your personal projects or specify an organization name")
}

// Load the spec to get the campaign name
specs, err := LoadSpecs(cwd)
if err != nil {
return fmt.Errorf("failed to load campaign spec: %w", err)
}

// Find the newly created spec
var campaignName string
for _, spec := range specs {
if spec.ID == id {
campaignName = spec.Name
break
}
}

if campaignName == "" {
campaignName = id
}

fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Creating GitHub Project..."))

projectConfig := ProjectCreationConfig{
CampaignID: id,
CampaignName: campaignName,
Owner: owner,
Verbose: verbose,
}

result, err := CreateCampaignProject(projectConfig)
if err != nil {
return fmt.Errorf("failed to create project: %w", err)
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(
fmt.Sprintf("Created project: %s", result.ProjectURL),
))

// Update the spec file with the project URL
fullPath := filepath.Join(cwd, path)
if err := UpdateSpecWithProjectURL(fullPath, result.ProjectURL); err != nil {
return fmt.Errorf("failed to update spec with project URL: %w", err)
}

fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(
"Updated campaign spec with project URL",
))
} else {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(
"Open the file and fill in owners, workflows, memory-paths, and other details.",
))
}

return nil
},
}

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().Bool("verbose", false, "Enable verbose output")
cmd.AddCommand(newCmd)

// Subcommand: campaign validate
Expand Down
180 changes: 180 additions & 0 deletions pkg/campaign/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package campaign

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/logger"
)

var projectLog = logger.New("campaign:project")

// ProjectCreationConfig holds configuration for creating a campaign project
type ProjectCreationConfig struct {
CampaignID string
CampaignName string
Owner string // GitHub org or user
Verbose bool
}

// ProjectCreationResult holds the result of project creation
type ProjectCreationResult struct {
ProjectURL string
ProjectNumber int
}

// CreateCampaignProject creates a GitHub Project with required views and fields for a campaign
func CreateCampaignProject(config ProjectCreationConfig) (*ProjectCreationResult, error) {
projectLog.Printf("Creating campaign project for campaign ID: %s", config.CampaignID)

// Check if gh CLI is available
if !isGHCLIAvailable() {
return nil, fmt.Errorf("GitHub CLI (gh) is not available. Install it from https://cli.github.com/")
}

// Create the project
projectURL, projectNumber, err := createProject(config)
if err != nil {
return nil, fmt.Errorf("failed to create project: %w", err)
}

console.LogVerbose(config.Verbose, fmt.Sprintf("Created project: %s", projectURL))

// Create required fields
if err := createProjectFields(config, projectNumber); err != nil {
return nil, fmt.Errorf("failed to create project fields: %w", err)
}

console.LogVerbose(config.Verbose, "Created project fields")

result := &ProjectCreationResult{
ProjectURL: projectURL,
ProjectNumber: projectNumber,
}

return result, nil
}

// isGHCLIAvailable checks if the gh CLI is installed and available
func isGHCLIAvailable() bool {
cmd := exec.Command("gh", "--version")
return cmd.Run() == nil
}

// 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)

// Create project using gh CLI
cmd := exec.Command("gh", "project", "create",
"--owner", config.Owner,
"--title", config.CampaignName,
"--format", "json")

output, err := cmd.CombinedOutput()
if err != nil {
return "", 0, fmt.Errorf("failed to create project: %w\nOutput: %s", err, string(output))
}

// Parse JSON output to get project URL and number
var result struct {
URL string `json:"url"`
Number int `json:"number"`
}

if err := json.Unmarshal(output, &result); err != nil {
return "", 0, fmt.Errorf("failed to parse project creation output: %w\nOutput: %s", err, string(output))
}

projectLog.Printf("Project created: URL=%s, Number=%d", result.URL, result.Number)
return result.URL, result.Number, nil
}

// createProjectFields creates the required fields for a campaign project
func createProjectFields(config ProjectCreationConfig, projectNumber int) error {
projectLog.Printf("Creating fields for project number: %d", projectNumber)

// Define required fields
fields := []struct {
name string
dataType string
options []string // For SINGLE_SELECT fields
}{
{"Campaign Id", "TEXT", nil},
{"Worker Workflow", "TEXT", nil},
{"Priority", "SINGLE_SELECT", []string{"High", "Medium", "Low"}},
{"Size", "SINGLE_SELECT", []string{"Small", "Medium", "Large"}},
{"Start Date", "DATE", nil},
{"End Date", "DATE", nil},
}

// Create each field
for _, field := range fields {
if err := createField(config, projectNumber, field.name, field.dataType, field.options); err != nil {
return fmt.Errorf("failed to create field '%s': %w", field.name, err)
}
console.LogVerbose(config.Verbose, fmt.Sprintf("Created field: %s", field.name))
}

return nil
}

// createField creates a single field in the project
func createField(config ProjectCreationConfig, projectNumber int, name, dataType string, options []string) error {
projectLog.Printf("Creating field: name=%s, type=%s", name, dataType)

args := []string{
"project", "field-create", fmt.Sprintf("%d", projectNumber),
"--owner", config.Owner,
"--name", name,
"--data-type", dataType,
}

// Add options for SINGLE_SELECT fields
if dataType == "SINGLE_SELECT" && len(options) > 0 {
args = append(args, "--single-select-options", strings.Join(options, ","))
}

cmd := exec.Command("gh", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create field: %w\nOutput: %s", err, string(output))
}

return nil
}

// UpdateSpecWithProjectURL updates a campaign spec file with the project URL
func UpdateSpecWithProjectURL(specPath, projectURL string) error {
projectLog.Printf("Updating spec file %s with project URL: %s", specPath, projectURL)

// Read the spec file
content, err := os.ReadFile(specPath)
if err != nil {
return fmt.Errorf("failed to read spec file: %w", err)
}

specContent := string(content)

// Replace the placeholder project URL with the actual one
placeholderURL := "https://github.com/orgs/ORG/projects/1"
if !strings.Contains(specContent, placeholderURL) {
// If placeholder doesn't exist, the spec might have been manually edited
projectLog.Print("Placeholder project URL not found, spec may have been edited")
return nil
}

updatedContent := strings.Replace(specContent, placeholderURL, projectURL, 1)

// Write the updated content back
if err := os.WriteFile(specPath, []byte(updatedContent), 0o644); err != nil {
return fmt.Errorf("failed to write updated spec file: %w", err)
}

projectLog.Print("Successfully updated spec file with project URL")
return nil
}
Loading
Loading