From 7c881f3e7015c00de2e47c41fb0bbc2e7fc8fdff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:12:34 +0000 Subject: [PATCH 1/3] Initial plan From cb1c80ef68f83b25f5cd95def7d801a3c645aec3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:19:07 +0000 Subject: [PATCH 2/3] Add GitHub URL parsing support for workflow specifications Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/content/docs/reference/spec-syntax.md | 15 +++- pkg/cli/add_command.go | 2 + pkg/cli/spec.go | 68 ++++++++++++++++++ pkg/cli/spec_test.go | 69 +++++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/spec-syntax.md b/docs/src/content/docs/reference/spec-syntax.md index d5b4b65cf14..956c9063ce7 100644 --- a/docs/src/content/docs/reference/spec-syntax.md +++ b/docs/src/content/docs/reference/spec-syntax.md @@ -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 @@ -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) @@ -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:** diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 0eb74a49ee6..b1e168dcc63 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -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). diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index f43c3e106aa..806c02b1322 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "net/url" "path/filepath" "strings" ) @@ -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] diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index 4aa0086e862..a00eedac952 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -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", From 912a1e7419a4ff605421324400f16c744d9d545a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:21:25 +0000 Subject: [PATCH 3/3] Add comprehensive tests for GitHub URL parsing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/spec_github_url_test.go | 103 ++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 pkg/cli/spec_github_url_test.go diff --git a/pkg/cli/spec_github_url_test.go b/pkg/cli/spec_github_url_test.go new file mode 100644 index 00000000000..638f8a1d8dc --- /dev/null +++ b/pkg/cli/spec_github_url_test.go @@ -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) + } + }) + } +}