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
95 changes: 93 additions & 2 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ func NewAddCommand(validateEngine func(string) error) *cobra.Command {
Long: `Add one or more workflows from repositories to .github/workflows.

Examples:
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor@v1.0.0
` + constants.CLIExtensionPrefix + ` add githubnext/agentics # List available workflows
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor # Add specific workflow
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor@v1.0.0 # Add with version
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/workflows/ci-doctor.md@main
` + constants.CLIExtensionPrefix + ` add https://github.com/githubnext/agentics/blob/main/workflows/ci-doctor.md
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor --pr --force

Workflow specifications:
- Two parts: "owner/repo[@version]" (lists available workflows in the repository)
- Three parts: "owner/repo/workflow-name[@version]" (implicitly looks in workflows/ directory)
- Four+ parts: "owner/repo/workflows/workflow-name.md[@version]" (requires explicit .md extension)
- GitHub URL: "https://github.com/owner/repo/blob/branch/path/to/workflow.md"
Expand Down Expand Up @@ -126,6 +128,12 @@ func AddWorkflows(workflows []string, number int, verbose bool, engineOverride s
}
}

// Check if this is a repo-only specification (owner/repo instead of owner/repo/workflow)
// If so, list available workflows and exit
if len(workflows) == 1 && isRepoOnlySpec(workflows[0]) {
return handleRepoOnlySpec(workflows[0], verbose)
}

// If creating a PR, check prerequisites
if createPR {
// Check if GitHub CLI is available
Expand Down Expand Up @@ -195,6 +203,89 @@ func AddWorkflows(workflows []string, number int, verbose bool, engineOverride s
return addWorkflowsNormal(processedWorkflows, number, verbose, engineOverride, name, force, appendText, noGitattributes)
}

// handleRepoOnlySpec handles the case when user provides only owner/repo without workflow name
// It installs the package and lists available workflows
func handleRepoOnlySpec(repoSpec string, verbose bool) error {
addLog.Printf("Handling repo-only specification: %s", repoSpec)

// Parse the repository specification to extract repo slug and version
spec, err := parseRepoSpec(repoSpec)
if err != nil {
return fmt.Errorf("invalid repository specification '%s': %w", repoSpec, err)
}

// Install the repository
repoWithVersion := spec.RepoSlug
if spec.Version != "" {
repoWithVersion = fmt.Sprintf("%s@%s", spec.RepoSlug, spec.Version)
}

if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Installing repository %s...", repoWithVersion)))
}

if err := InstallPackage(repoWithVersion, verbose); err != nil {
return fmt.Errorf("failed to install repository %s: %w", repoWithVersion, err)
}

// List workflows in the installed package
workflows, err := listWorkflowsInPackage(spec.RepoSlug, verbose)
if err != nil {
return fmt.Errorf("failed to list workflows in %s: %w", spec.RepoSlug, err)
}

// Display the list of available workflows
if len(workflows) == 0 {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No workflows found in repository %s", spec.RepoSlug)))
return nil
}

fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Please specify which workflow you want to add from %s:", spec.RepoSlug)))
fmt.Fprintln(os.Stderr, "")

for _, workflow := range workflows {
// Extract workflow name from path (remove .md extension and path)
workflowName := strings.TrimSuffix(filepath.Base(workflow), ".md")

// For workflows in workflows/ directory, show simplified name
if strings.HasPrefix(workflow, "workflows/") {
workflowName = strings.TrimSuffix(strings.TrimPrefix(workflow, "workflows/"), ".md")
}

// Build the full command
fullSpec := fmt.Sprintf("%s/%s", spec.RepoSlug, workflowName)
if spec.Version != "" {
fullSpec += "@" + spec.Version
}

fmt.Fprintf(os.Stderr, " • %s\n", workflowName)
if verbose {
fmt.Fprintf(os.Stderr, " Command: %s add %s\n", constants.CLIExtensionPrefix, fullSpec)
}
}

fmt.Fprintln(os.Stderr, "")
fmt.Fprintf(os.Stderr, "Example:\n")
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The label should be plural 'Examples:' to match the pattern used elsewhere in the codebase (see line 28), or keep it singular but add a colon for consistency.

Suggested change
fmt.Fprintf(os.Stderr, "Example:\n")
fmt.Fprintf(os.Stderr, "Examples:\n")

Copilot uses AI. Check for mistakes.

// Show example with first workflow
exampleWorkflow := workflows[0]
exampleName := strings.TrimSuffix(filepath.Base(exampleWorkflow), ".md")
if strings.HasPrefix(exampleWorkflow, "workflows/") {
exampleName = strings.TrimSuffix(strings.TrimPrefix(exampleWorkflow, "workflows/"), ".md")
}

exampleSpec := fmt.Sprintf("%s/%s", spec.RepoSlug, exampleName)
if spec.Version != "" {
exampleSpec += "@" + spec.Version
}

fmt.Fprintf(os.Stderr, " %s add %s\n", constants.CLIExtensionPrefix, exampleSpec)
fmt.Fprintln(os.Stderr, "")

return nil
}

// addWorkflowsNormal handles normal workflow addition without PR creation
func addWorkflowsNormal(workflows []*WorkflowSpec, number int, verbose bool, engineOverride string, name string, force bool, appendText string, noGitattributes bool) error {
// Create file tracker for all operations
Expand Down
82 changes: 82 additions & 0 deletions pkg/cli/add_repo_only_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package cli

import (
"os"
"testing"
)

func TestIsRepoOnlySpec(t *testing.T) {
tests := []struct {
name string
spec string
expected bool
}{
{
name: "repo only without version",
spec: "githubnext/agentics",
expected: true,
},
{
name: "repo only with version",
spec: "githubnext/agentics@v1.0.0",
expected: true,
},
{
name: "full spec with workflow",
spec: "githubnext/agentics/ci-doctor",
expected: false,
},
{
name: "full spec with workflow and version",
spec: "githubnext/agentics/ci-doctor@main",
expected: false,
},
{
name: "full spec with path",
spec: "githubnext/agentics/workflows/ci-doctor.md",
expected: false,
},
{
name: "GitHub URL",
spec: "https://github.com/githubnext/agentics/blob/main/workflows/ci-doctor.md",
expected: false,
},
{
name: "local path",
spec: "./workflows/my-workflow.md",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isRepoOnlySpec(tt.spec)
if result != tt.expected {
t.Errorf("isRepoOnlySpec(%q) = %v, want %v", tt.spec, result, tt.expected)
}
})
}
}

func TestListWorkflowsInPackage(t *testing.T) {
// Since we can't easily mock getPackagesDir, we'll just verify the function
// handles missing packages correctly
workflows, err := listWorkflowsInPackage("absolutely-nonexistent-repo-xyz123/test-repo-abc456", false)
if err == nil {
t.Errorf("Expected error for nonexistent package, got nil. Workflows: %v", workflows)
}
}

func TestHandleRepoOnlySpecIntegration(t *testing.T) {
// This is more of an integration test that would require GitHub authentication
// We'll skip it in normal test runs
if os.Getenv("GITHUB_TOKEN") == "" && os.Getenv("GH_TOKEN") == "" {
t.Skip("Skipping integration test: no GitHub token available")
}

// Test with verbose output to see the workflow listing
// Note: This will actually try to install the package
// err := handleRepoOnlySpec("githubnext/agentics", true)
// For now, we'll just verify the function exists and can be called
// without testing the actual GitHub interaction
}
61 changes: 61 additions & 0 deletions pkg/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,67 @@ type WorkflowSourceInfo struct {
CommitSHA string // The actual commit SHA used when the package was installed
}

// listWorkflowsInPackage lists all available workflows in an installed package
func listWorkflowsInPackage(repoSlug string, verbose bool) ([]string, error) {
packagesLog.Printf("Listing workflows in package: %s", repoSlug)

packagesDir, err := getPackagesDir()
if err != nil {
return nil, fmt.Errorf("failed to get packages directory: %w", err)
}

packagePath := filepath.Join(packagesDir, repoSlug)

// Check if package exists
if _, err := os.Stat(packagePath); os.IsNotExist(err) {
return nil, fmt.Errorf("package not found: %s", repoSlug)
}

var workflows []string

// Walk through the package directory to find all .md files
err = filepath.Walk(packagePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

// Skip directories and non-markdown files
if info.IsDir() || !strings.HasSuffix(info.Name(), ".md") {
return nil
}

// Skip metadata files
if info.Name() == ".commit-sha" {
return nil
}

Comment on lines +275 to +279
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is checking a filename against a file that should be .commit-sha, but the previous check already filters out directories and only processes .md files (line 271). Since .commit-sha doesn't end with .md, this check is unreachable and unnecessary.

Suggested change
// Skip metadata files
if info.Name() == ".commit-sha" {
return nil
}

Copilot uses AI. Check for mistakes.
// Get relative path from package directory
relPath, err := filepath.Rel(packagePath, path)
if err != nil {
return err
}

// Extract workflow name (remove .md extension)
workflowName := strings.TrimSuffix(filepath.Base(path), ".md")

// Add to list with relative path information
workflows = append(workflows, relPath)

if verbose {
fmt.Printf("Found workflow: %s (name: %s)\n", relPath, workflowName)
Comment on lines +286 to +293
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable workflowName is computed but never used in a meaningful way. It's only printed in verbose mode (line 293) but doesn't contribute to the function's output. Consider removing this variable or documenting why it's kept for debugging purposes only.

Suggested change
// Extract workflow name (remove .md extension)
workflowName := strings.TrimSuffix(filepath.Base(path), ".md")
// Add to list with relative path information
workflows = append(workflows, relPath)
if verbose {
fmt.Printf("Found workflow: %s (name: %s)\n", relPath, workflowName)
// Add to list with relative path information
workflows = append(workflows, relPath)
if verbose {
fmt.Printf("Found workflow: %s\n", relPath)

Copilot uses AI. Check for mistakes.
}

return nil
})

if err != nil {
return nil, fmt.Errorf("failed to scan package directory: %w", err)
}

packagesLog.Printf("Found %d workflows in package %s", len(workflows), repoSlug)
return workflows, nil
}

// findWorkflowInPackageForRepo searches for a workflow in installed packages
func findWorkflowInPackageForRepo(workflow *WorkflowSpec, verbose bool) ([]byte, *WorkflowSourceInfo, error) {

Expand Down
23 changes: 23 additions & 0 deletions pkg/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ func (w *WorkflowSpec) String() string {
return spec
}

// isRepoOnlySpec checks if a specification is repo-only (owner/repo[@version]) without workflow path
func isRepoOnlySpec(spec string) bool {
// URLs are not repo-only specs
if strings.HasPrefix(spec, "http://") || strings.HasPrefix(spec, "https://") {
return false
}

// Local paths are not repo-only specs
if strings.HasPrefix(spec, "./") {
return false
}

// Handle version first (anything after @)
parts := strings.SplitN(spec, "@", 2)
specWithoutVersion := parts[0]

// Split by slashes
slashParts := strings.Split(specWithoutVersion, "/")

// Exactly 2 parts means repo-only: owner/repo
return len(slashParts) == 2
}

// parseRepoSpec parses repository specification like "org/repo@version" or "org/repo@branch" or "org/repo@commit"
// Also supports GitHub URLs like "https://github.com/owner/repo[@version]"
func parseRepoSpec(repoSpec string) (*RepoSpec, error) {
Expand Down
Loading