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
15 changes: 13 additions & 2 deletions docs/src/content/docs/reference/spec-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ gh aw add githubnext/agentics@main # branch

## Workflow Specification (WorkflowSpec)

Format: `owner/repo/workflow-name[@version]` or `owner/repo/path/to/workflow.md[@version]`
Format: `owner/repo/workflow-name[@version]` or `owner/repo/path/to/workflow.md[@version]` or full GitHub URL

**Short form** (3 parts): Automatically adds `workflows/` prefix and `.md` extension
```bash
Expand All @@ -44,10 +44,20 @@ gh aw add owner/repo/workflows/ci-doctor.md@v1.0.0
gh aw add owner/repo/custom/path/workflow.md@main
```

**GitHub URL form**: Full GitHub URL to workflow file
```bash
gh aw add https://github.com/owner/repo/blob/main/workflows/ci-doctor.md
gh aw add https://github.com/owner/repo/blob/v1.0.0/custom/path/workflow.md
gh aw add https://github.com/owner/repo/tree/develop/workflows/helper.md
```

**Validation:**
- Minimum 3 parts (owner/repo/workflow-name)
- Minimum 3 parts (owner/repo/workflow-name) for spec format
- Explicit paths must end with `.md` extension
- Version optional (tag, branch, or commit SHA)
- GitHub URLs must be from github.com domain
- GitHub URLs must use /blob/, /tree/, or /raw/ format
- GitHub URLs automatically extract branch/tag/commit from the URL path

## Source Specification (SourceSpec)

Expand Down Expand Up @@ -91,6 +101,7 @@ owner/repo/workflow
gh aw add githubnext/agentics/ci-doctor # short form
gh aw add githubnext/agentics/ci-doctor@v1.0.0 # with version
gh aw add githubnext/agentics/workflows/ci-doctor.md@main # explicit path
gh aw add https://github.com/githubnext/agentics/blob/main/workflows/ci-doctor.md # GitHub URL
```

**Update workflow:**
Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ Examples:
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor
` + constants.CLIExtensionPrefix + ` add githubnext/agentics/ci-doctor@v1.0.0
` + 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:
- 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"
- Version can be tag, branch, or SHA

The -n flag allows you to specify a custom name for the workflow file (only applies to the first workflow when adding multiple).
Expand Down
68 changes: 68 additions & 0 deletions pkg/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"net/url"
"path/filepath"
"strings"
)
Expand Down Expand Up @@ -58,9 +59,76 @@ func parseRepoSpec(repoSpec string) (*RepoSpec, error) {
return spec, nil
}

// parseGitHubURL attempts to parse a GitHub URL and extract workflow specification components
// Supports URLs like:
// - https://github.com/owner/repo/blob/branch/path/to/workflow.md
// - https://github.com/owner/repo/blob/main/workflows/workflow.md
// - https://github.com/owner/repo/tree/branch/path/to/workflow.md
// - https://github.com/owner/repo/raw/branch/path/to/workflow.md
func parseGitHubURL(spec string) (*WorkflowSpec, error) {
// Parse the URL
parsedURL, err := url.Parse(spec)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}

// Must be a GitHub URL
if parsedURL.Host != "github.com" {
return nil, fmt.Errorf("URL must be from github.com")
}

// Parse the path: /owner/repo/{blob|tree|raw}/ref/path/to/file
pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/")

// Need at least: owner, repo, type (blob/tree/raw), ref, and filename
if len(pathParts) < 5 {
return nil, fmt.Errorf("invalid GitHub URL format: path too short")
}

owner := pathParts[0]
repo := pathParts[1]
urlType := pathParts[2] // blob, tree, or raw
ref := pathParts[3] // branch name, tag, or commit SHA
filePath := strings.Join(pathParts[4:], "/")

// Validate URL type
if urlType != "blob" && urlType != "tree" && urlType != "raw" {
return nil, fmt.Errorf("invalid GitHub URL format: expected /blob/, /tree/, or /raw/, got /%s/", urlType)
}

// Ensure the file path ends with .md
if !strings.HasSuffix(filePath, ".md") {
return nil, fmt.Errorf("GitHub URL must point to a .md file")
}

// Validate owner and repo
if owner == "" || repo == "" {
return nil, fmt.Errorf("invalid GitHub URL: owner and repo cannot be empty")
}

if !isValidGitHubIdentifier(owner) || !isValidGitHubIdentifier(repo) {
return nil, fmt.Errorf("invalid GitHub URL: '%s/%s' does not look like a valid GitHub repository", owner, repo)
}

return &WorkflowSpec{
RepoSpec: RepoSpec{
Repo: fmt.Sprintf("%s/%s", owner, repo),
Version: ref,
},
WorkflowPath: filePath,
WorkflowName: strings.TrimSuffix(filepath.Base(filePath), ".md"),
}, nil
}

// parseWorkflowSpec parses a workflow specification in the new format
// Format: owner/repo/workflows/workflow-name[@version] or owner/repo/workflow-name[@version]
// Also supports full GitHub URLs like https://github.com/owner/repo/blob/branch/path/to/workflow.md
func parseWorkflowSpec(spec string) (*WorkflowSpec, error) {
// Check if this is a GitHub URL
if strings.HasPrefix(spec, "http://") || strings.HasPrefix(spec, "https://") {
return parseGitHubURL(spec)
}

// Handle version first (anything after @)
parts := strings.SplitN(spec, "@", 2)
specWithoutVersion := parts[0]
Expand Down
103 changes: 103 additions & 0 deletions pkg/cli/spec_github_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cli

import (
"testing"
)

// TestParseGitHubURL tests the parseGitHubURL function directly
func TestParseGitHubURL(t *testing.T) {
tests := []struct {
name string
url string
wantRepo string
wantWorkflowPath string
wantWorkflowName string
wantVersion string
wantErr bool
errContains string
}{
{
name: "blob URL with main branch",
url: "https://github.com/githubnext/gh-aw-trial/blob/main/workflows/release-issue-linker.md",
wantRepo: "githubnext/gh-aw-trial",
wantWorkflowPath: "workflows/release-issue-linker.md",
wantWorkflowName: "release-issue-linker",
wantVersion: "main",
wantErr: false,
},
{
name: "tree URL with develop branch",
url: "https://github.com/owner/repo/tree/develop/custom/path/workflow.md",
wantRepo: "owner/repo",
wantWorkflowPath: "custom/path/workflow.md",
wantWorkflowName: "workflow",
wantVersion: "develop",
wantErr: false,
},
{
name: "raw URL with version tag",
url: "https://github.com/owner/repo/raw/v2.0.0/workflows/helper.md",
wantRepo: "owner/repo",
wantWorkflowPath: "workflows/helper.md",
wantWorkflowName: "helper",
wantVersion: "v2.0.0",
wantErr: false,
},
{
name: "invalid - non-github domain",
url: "https://gitlab.com/owner/repo/blob/main/workflows/test.md",
wantErr: true,
errContains: "must be from github.com",
},
{
name: "invalid - path too short",
url: "https://github.com/owner/repo/blob/main",
wantErr: true,
errContains: "path too short",
},
{
name: "invalid - wrong URL type",
url: "https://github.com/owner/repo/commits/main/workflows/test.md",
wantErr: true,
errContains: "expected /blob/, /tree/, or /raw/",
},
{
name: "invalid - missing .md extension",
url: "https://github.com/owner/repo/blob/main/workflows/test.txt",
wantErr: true,
errContains: "must point to a .md file",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
spec, err := parseGitHubURL(tt.url)

if tt.wantErr {
if err == nil {
t.Errorf("parseGitHubURL() expected error containing %q, got nil", tt.errContains)
return
}
return
}

if err != nil {
t.Errorf("parseGitHubURL() unexpected error: %v", err)
return
}

if spec.Repo != tt.wantRepo {
t.Errorf("parseGitHubURL() repo = %q, want %q", spec.Repo, tt.wantRepo)
}
if spec.WorkflowPath != tt.wantWorkflowPath {
t.Errorf("parseGitHubURL() workflowPath = %q, want %q", spec.WorkflowPath, tt.wantWorkflowPath)
}
if spec.WorkflowName != tt.wantWorkflowName {
t.Errorf("parseGitHubURL() workflowName = %q, want %q", spec.WorkflowName, tt.wantWorkflowName)
}
if spec.Version != tt.wantVersion {
t.Errorf("parseGitHubURL() version = %q, want %q", spec.Version, tt.wantVersion)
}
})
}
}
69 changes: 69 additions & 0 deletions pkg/cli/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,75 @@ func TestParseWorkflowSpec(t *testing.T) {
wantErr bool
errContains string
}{
{
name: "GitHub URL - blob with main branch",
spec: "https://github.com/githubnext/gh-aw-trial/blob/main/workflows/release-issue-linker.md",
wantRepo: "githubnext/gh-aw-trial",
wantWorkflowPath: "workflows/release-issue-linker.md",
wantWorkflowName: "release-issue-linker",
wantVersion: "main",
wantErr: false,
},
{
name: "GitHub URL - blob with version tag",
spec: "https://github.com/owner/repo/blob/v1.0.0/workflows/ci-doctor.md",
wantRepo: "owner/repo",
wantWorkflowPath: "workflows/ci-doctor.md",
wantWorkflowName: "ci-doctor",
wantVersion: "v1.0.0",
wantErr: false,
},
{
name: "GitHub URL - tree with branch",
spec: "https://github.com/owner/repo/tree/develop/custom/path/workflow.md",
wantRepo: "owner/repo",
wantWorkflowPath: "custom/path/workflow.md",
wantWorkflowName: "workflow",
wantVersion: "develop",
wantErr: false,
},
{
name: "GitHub URL - raw format",
spec: "https://github.com/owner/repo/raw/main/workflows/helper.md",
wantRepo: "owner/repo",
wantWorkflowPath: "workflows/helper.md",
wantWorkflowName: "helper",
wantVersion: "main",
wantErr: false,
},
{
name: "GitHub URL - commit SHA",
spec: "https://github.com/owner/repo/blob/abc123def456789012345678901234567890abcd/workflows/test.md",
wantRepo: "owner/repo",
wantWorkflowPath: "workflows/test.md",
wantWorkflowName: "test",
wantVersion: "abc123def456789012345678901234567890abcd",
wantErr: false,
},
{
name: "GitHub URL - invalid domain",
spec: "https://gitlab.com/owner/repo/blob/main/workflows/test.md",
wantErr: true,
errContains: "must be from github.com",
},
{
name: "GitHub URL - missing file extension",
spec: "https://github.com/owner/repo/blob/main/workflows/test",
wantErr: true,
errContains: "must point to a .md file",
},
{
name: "GitHub URL - invalid path (too short)",
spec: "https://github.com/owner/repo/blob/main",
wantErr: true,
errContains: "path too short",
},
{
name: "GitHub URL - invalid type",
spec: "https://github.com/owner/repo/commits/main/workflows/test.md",
wantErr: true,
errContains: "expected /blob/, /tree/, or /raw/",
},
{
name: "three-part spec with version",
spec: "owner/repo/workflow@v1.0.0",
Expand Down