diff --git a/.changeset/patch-github-enterprise-host.md b/.changeset/patch-github-enterprise-host.md new file mode 100644 index 0000000000..b3eb9616fe --- /dev/null +++ b/.changeset/patch-github-enterprise-host.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Normalize GitHub host detection and URL parsing so GitHub Enterprise deployments honor `GITHUB_SERVER_URL`, `GITHUB_ENTERPRISE_HOST`, `GITHUB_HOST`, and `GH_HOST`. Remote imports now use the normalized host when calling `git`/`gh`, and the parser handles enterprise-styled run/PR/file URLs. diff --git a/.changeset/patch-normalize-gh-host-detection.md b/.changeset/patch-normalize-gh-host-detection.md new file mode 100644 index 0000000000..c590158b3d --- /dev/null +++ b/.changeset/patch-normalize-gh-host-detection.md @@ -0,0 +1,4 @@ +--- +"gh-aw": patch +--- +Normalize GitHub host detection and URL parsing so GitHub Enterprise deployments honor GITHUB_SERVER_URL, GITHUB_ENTERPRISE_HOST, GITHUB_HOST, and GH_HOST when compiling workflows and CLI commands. diff --git a/.github/workflows/smoke-project.lock.yml b/.github/workflows/smoke-project.lock.yml index 4c183bfad3..c8f14dbee5 100644 --- a/.github/workflows/smoke-project.lock.yml +++ b/.github/workflows/smoke-project.lock.yml @@ -869,7 +869,7 @@ jobs: "type": "string", "sanitize": true, "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "pattern": "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" }, "start_date": { @@ -990,7 +990,7 @@ jobs: "type": "string", "sanitize": true, "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "pattern": "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" }, "pull_request": { diff --git a/.github/workflows/test-project-url-default.lock.yml b/.github/workflows/test-project-url-default.lock.yml index e85489a7f8..f838342ec4 100644 --- a/.github/workflows/test-project-url-default.lock.yml +++ b/.github/workflows/test-project-url-default.lock.yml @@ -633,7 +633,7 @@ jobs: "type": "string", "sanitize": true, "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "pattern": "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" }, "start_date": { @@ -725,7 +725,7 @@ jobs: "type": "string", "sanitize": true, "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "pattern": "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" }, "pull_request": { diff --git a/actions/setup/js/create_project_status_update.cjs b/actions/setup/js/create_project_status_update.cjs index e971340f86..f40f71ec49 100644 --- a/actions/setup/js/create_project_status_update.cjs +++ b/actions/setup/js/create_project_status_update.cjs @@ -59,7 +59,7 @@ function parseProjectUrl(projectUrl) { throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } - const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); + const match = projectUrl.match(/^https:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); if (!match) { throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 2fc17bcdcc..e2b4a78140 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -78,7 +78,7 @@ function parseProjectInput(projectUrl) { throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } - const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); + const urlMatch = projectUrl.match(/^https:\/\/[^/]+\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); if (!urlMatch) { throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } @@ -96,7 +96,7 @@ function parseProjectUrl(projectUrl) { throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } - const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); + const match = projectUrl.match(/^https:\/\/[^/]+\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); if (!match) { throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } diff --git a/pkg/cli/download_workflow.go b/pkg/cli/download_workflow.go index e9c51d531a..7196a0c82b 100644 --- a/pkg/cli/download_workflow.go +++ b/pkg/cli/download_workflow.go @@ -23,7 +23,8 @@ func resolveLatestReleaseViaGit(repo, currentRef string, allowMajor, verbose boo fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest release for %s via git ls-remote (current: %s, allow major: %v)", repo, currentRef, allowMajor))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // List all tags cmd := exec.Command("git", "ls-remote", "--tags", repoURL) @@ -102,7 +103,8 @@ func resolveLatestReleaseViaGit(repo, currentRef string, allowMajor, verbose boo func isBranchRefViaGit(repo, ref string) (bool, error) { downloadLog.Printf("Attempting git ls-remote to check if ref is branch: %s@%s", repo, ref) - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // List all branches and check if ref matches cmd := exec.Command("git", "ls-remote", "--heads", repoURL) @@ -167,7 +169,8 @@ func resolveBranchHeadViaGit(repo, branch string, verbose bool) (string, error) fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest commit for branch %s in %s via git ls-remote", branch, repo))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Get the SHA for the specific branch cmd := exec.Command("git", "ls-remote", repoURL, fmt.Sprintf("refs/heads/%s", branch)) @@ -236,7 +239,8 @@ func resolveDefaultBranchHeadViaGit(repo string, verbose bool) (string, error) { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching default branch for %s via git ls-remote", repo))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Get HEAD to find default branch cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD") @@ -326,7 +330,8 @@ func downloadWorkflowContentViaGit(repo, path, ref string, verbose bool) ([]byte downloadLog.Printf("Attempting git fallback for downloading workflow content: %s/%s@%s", repo, path, ref) // Use git archive to get the file content without cloning - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // git archive command: git archive --remote= cmd := exec.Command("git", "archive", "--remote="+repoURL, ref, path) @@ -366,7 +371,8 @@ func downloadWorkflowContentViaGitClone(repo, path, ref string, verbose bool) ([ } defer os.RemoveAll(tmpDir) - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHostForRepo(repo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Initialize git repository initCmd := exec.Command("git", "-C", tmpDir, "init") diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 8a0e1f9a8f..fbce963830 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -66,22 +66,27 @@ func findGitRootForPath(path string) (string, error) { // parseGitHubRepoSlugFromURL extracts owner/repo from a GitHub URL // Supports both HTTPS (https://github.com/owner/repo) and SSH (git@github.com:owner/repo) formats +// Also supports GitHub Enterprise URLs func parseGitHubRepoSlugFromURL(url string) string { gitLog.Printf("Parsing GitHub repo slug from URL: %s", url) // Remove .git suffix if present url = strings.TrimSuffix(url, ".git") - // Handle HTTPS URLs: https://github.com/owner/repo - if strings.HasPrefix(url, "https://github.com/") { - slug := strings.TrimPrefix(url, "https://github.com/") + githubHost := getGitHubHost() + githubHostWithoutScheme := strings.TrimPrefix(strings.TrimPrefix(githubHost, "https://"), "http://") + + // Handle HTTPS URLs: https://github.com/owner/repo or https://enterprise.github.com/owner/repo + if strings.HasPrefix(url, githubHost+"/") { + slug := strings.TrimPrefix(url, githubHost+"/") gitLog.Printf("Extracted slug from HTTPS URL: %s", slug) return slug } - // Handle SSH URLs: git@github.com:owner/repo - if strings.HasPrefix(url, "git@github.com:") { - slug := strings.TrimPrefix(url, "git@github.com:") + // Handle SSH URLs: git@github.com:owner/repo or git@enterprise.github.com:owner/repo + sshPrefix := "git@" + githubHostWithoutScheme + ":" + if strings.HasPrefix(url, sshPrefix) { + slug := strings.TrimPrefix(url, sshPrefix) gitLog.Printf("Extracted slug from SSH URL: %s", slug) return slug } diff --git a/pkg/cli/github.go b/pkg/cli/github.go index b90fe77a66..33dba0315e 100644 --- a/pkg/cli/github.go +++ b/pkg/cli/github.go @@ -4,6 +4,7 @@ import ( "os" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -28,7 +29,7 @@ func getGitHubHost() string { } } - defaultHost := "https://github.com" + defaultHost := string(constants.PublicGitHubHost) githubLog.Printf("No GitHub host environment variable set, using default: %s", defaultHost) return defaultHost } @@ -45,3 +46,18 @@ func normalizeGitHubHostURL(rawHostURL string) string { return normalized } + +// getGitHubHostForRepo returns the GitHub host URL for a specific repository. +// The gh-aw repository (github/gh-aw) always uses public GitHub (https://github.com) +// regardless of enterprise GitHub host settings, since gh-aw itself is only available +// on public GitHub. For all other repositories, it uses getGitHubHost(). +func getGitHubHostForRepo(repo string) string { + // The gh-aw repository is always on public GitHub + if repo == "github/gh-aw" || strings.HasPrefix(repo, "github/gh-aw/") { + githubLog.Print("Using public GitHub host for github/gh-aw repository") + return string(constants.PublicGitHubHost) + } + + // For all other repositories, use the configured GitHub host + return getGitHubHost() +} diff --git a/pkg/cli/pr_command.go b/pkg/cli/pr_command.go index dbe3233446..ee1f44c1a1 100644 --- a/pkg/cli/pr_command.go +++ b/pkg/cli/pr_command.go @@ -433,7 +433,8 @@ func createTransferPR(targetOwner, targetRepo string, prInfo *PRInfo, branchName // Add fork as remote if not already present remoteName := "fork" - forkRepoURL := fmt.Sprintf("https://github.com/%s/%s.git", forkOwner, forkRepo) + githubHost := getGitHubHost() + forkRepoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, forkOwner, forkRepo) // Check if fork remote exists checkRemoteCmd := exec.Command("git", "remote", "get-url", remoteName) diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index a5722519db..0731d3bfa2 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -111,10 +111,14 @@ func parseRepoSpec(repoSpec string) (*RepoSpec, error) { specLog.Printf("Version specified: %s", version) } + githubHost := getGitHubHost() + githubHostPrefix := githubHost + "/" + githubHostHTTPPrefix := "http://" + strings.TrimPrefix(githubHost, "https://") + "/" + // Check if this is a GitHub URL - if strings.HasPrefix(repo, "https://github.com/") || strings.HasPrefix(repo, "http://github.com/") { + if strings.HasPrefix(repo, githubHostPrefix) || strings.HasPrefix(repo, githubHostHTTPPrefix) { specLog.Print("Detected GitHub URL format") - // Parse GitHub URL: https://github.com/owner/repo + // Parse GitHub URL: https://github.com/owner/repo or https://enterprise.github.com/owner/repo repoURL, err := url.Parse(repo) if err != nil { specLog.Printf("Failed to parse GitHub URL: %v", err) @@ -125,7 +129,7 @@ func parseRepoSpec(repoSpec string) (*RepoSpec, error) { pathParts := strings.Split(strings.Trim(repoURL.Path, "/"), "/") if len(pathParts) != 2 || pathParts[0] == "" || pathParts[1] == "" { specLog.Printf("Invalid GitHub URL path parts: %v", pathParts) - return nil, fmt.Errorf("invalid GitHub URL: must be https://github.com/owner/repo. Example: https://github.com/github/gh-aw") + return nil, fmt.Errorf("invalid GitHub URL: must be %s/owner/repo. Example: %s/github/gh-aw", githubHost, githubHost) } repo = fmt.Sprintf("%s/%s", pathParts[0], pathParts[1]) diff --git a/pkg/cli/trial_command.go b/pkg/cli/trial_command.go index 9e57322b57..72655cbbdb 100644 --- a/pkg/cli/trial_command.go +++ b/pkg/cli/trial_command.go @@ -475,7 +475,8 @@ func RunWorkflowTrials(ctx context.Context, workflowSpecs []string, opts TrialOp } // Generate workflow run URL - workflowRunURL := fmt.Sprintf("https://github.com/%s/actions/runs/%s", hostRepoSlug, runID) + githubHost := getGitHubHost() + workflowRunURL := fmt.Sprintf("%s/%s/actions/runs/%s", githubHost, hostRepoSlug, runID) fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow run started with ID: %s (%s)", runID, workflowRunURL))) // Wait for workflow completion @@ -581,7 +582,8 @@ func RunWorkflowTrials(ctx context.Context, workflowSpecs []string, opts TrialOp if opts.DeleteHostRepo { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Host repository will be cleaned up")) } else { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Host repository preserved: https://github.com/%s", hostRepoSlug))) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Host repository preserved: %s/%s", githubHost, hostRepoSlug))) } }, UseStderr: true, @@ -606,7 +608,8 @@ func getCurrentGitHubUsername() (string, error) { // showTrialConfirmation displays a confirmation prompt to the user using parsed workflow specs func showTrialConfirmation(parsedSpecs []*WorkflowSpec, logicalRepoSlug, cloneRepoSlug, hostRepoSlug string, deleteHostRepo bool, forceDeleteHostRepo bool, autoMergePRs bool, repeatCount int, directTrialMode bool, engineOverride string) error { - hostRepoSlugURL := fmt.Sprintf("https://github.com/%s", hostRepoSlug) + githubHost := getGitHubHost() + hostRepoSlugURL := fmt.Sprintf("%s/%s", githubHost, hostRepoSlug) var sections []string diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 226cf831de..20c82a4411 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -271,7 +271,8 @@ func getLatestActionReleaseViaGit(repo, currentVersion string, allowMajor, verbo baseRepo := extractBaseRepo(repo) updateLog.Printf("Using base repository: %s for action: %s (git fallback)", baseRepo, repo) - repoURL := fmt.Sprintf("https://github.com/%s.git", baseRepo) + githubHost := getGitHubHostForRepo(baseRepo) + repoURL := fmt.Sprintf("%s/%s.git", githubHost, baseRepo) // List all tags cmd := exec.Command("git", "ls-remote", "--tags", repoURL) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 01b45a5b3b..83c903e469 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -308,6 +308,11 @@ const ( // DefaultMCPRegistryURL is the default MCP registry URL. const DefaultMCPRegistryURL URL = "https://api.mcp.github.com/v0.1" +// PublicGitHubHost is the public GitHub host URL. +// This is used as the default GitHub host and for the gh-aw repository itself, +// which is always hosted on public GitHub regardless of enterprise host settings. +const PublicGitHubHost URL = "https://github.com" + // GitHubCopilotMCPDomain is the domain for the hosted GitHub MCP server. // Used when github tool is configured with mode: remote. const GitHubCopilotMCPDomain = "api.githubcopilot.com" diff --git a/pkg/parser/github.go b/pkg/parser/github.go index 962e0d8edc..10bbd6c020 100644 --- a/pkg/parser/github.go +++ b/pkg/parser/github.go @@ -8,11 +8,64 @@ import ( "os/exec" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) var githubLog = logger.New("parser:github") +// GetGitHubHost returns the GitHub host URL from environment variables. +// Environment variables are checked in priority order for GitHub Enterprise support: +// 1. GITHUB_SERVER_URL - GitHub Actions standard (e.g., https://MYORG.ghe.com) +// 2. GITHUB_ENTERPRISE_HOST - GitHub Enterprise standard (e.g., MYORG.ghe.com) +// 3. GITHUB_HOST - GitHub Enterprise standard (e.g., MYORG.ghe.com) +// 4. GH_HOST - GitHub CLI standard (e.g., MYORG.ghe.com) +// 5. Defaults to https://github.com if none are set +// +// The function normalizes the URL by adding https:// if missing and removing trailing slashes. +func GetGitHubHost() string { + envVars := []string{"GITHUB_SERVER_URL", "GITHUB_ENTERPRISE_HOST", "GITHUB_HOST", "GH_HOST"} + + for _, envVar := range envVars { + if value := os.Getenv(envVar); value != "" { + githubLog.Printf("Resolved GitHub host from %s: %s", envVar, value) + return normalizeGitHubHostURL(value) + } + } + + defaultHost := string(constants.PublicGitHubHost) + githubLog.Printf("No GitHub host environment variable set, using default: %s", defaultHost) + return defaultHost +} + +// normalizeGitHubHostURL ensures the host URL has https:// scheme and no trailing slashes +func normalizeGitHubHostURL(rawHostURL string) string { + // Remove all trailing slashes + normalized := strings.TrimRight(rawHostURL, "/") + + // Add https:// scheme if no scheme is present + if !strings.HasPrefix(normalized, "https://") && !strings.HasPrefix(normalized, "http://") { + normalized = "https://" + normalized + } + + return normalized +} + +// GetGitHubHostForRepo returns the GitHub host URL for a specific repository. +// The gh-aw repository (github/gh-aw) always uses public GitHub (https://github.com) +// regardless of enterprise GitHub host settings, since gh-aw itself is only available +// on public GitHub. For all other repositories, it uses GetGitHubHost(). +func GetGitHubHostForRepo(owner, repo string) string { + // The gh-aw repository is always on public GitHub + if owner == "github" && repo == "gh-aw" { + githubLog.Print("Using public GitHub host for github/gh-aw repository") + return string(constants.PublicGitHubHost) + } + + // For all other repositories, use the configured GitHub host + return GetGitHubHost() +} + // GetGitHubToken attempts to get GitHub token from environment or gh CLI func GetGitHubToken() (string, error) { githubLog.Print("Getting GitHub token") diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index dd191380ef..7e0f89104f 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -306,7 +306,8 @@ func downloadIncludeFromWorkflowSpec(spec string, cache *ImportCache) (string, e func resolveRefToSHAViaGit(owner, repo, ref string) (string, error) { remoteLog.Printf("Attempting git ls-remote fallback for ref resolution: %s/%s@%s", owner, repo, ref) - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + githubHost := GetGitHubHostForRepo(owner, repo) + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // Try to resolve the ref using git ls-remote // Format: git ls-remote @@ -397,7 +398,8 @@ func downloadFileViaGit(owner, repo, path, ref string) ([]byte, error) { // Use git archive to get the file content without cloning // This works for public repositories without authentication - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + githubHost := GetGitHubHostForRepo(owner, repo) + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // git archive command: git archive --remote= cmd := exec.Command("git", "archive", "--remote="+repoURL, ref, path) @@ -432,7 +434,8 @@ func downloadFileViaGitClone(owner, repo, path, ref string) ([]byte, error) { } defer os.RemoveAll(tmpDir) - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + githubHost := GetGitHubHostForRepo(owner, repo) + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // Check if ref is a SHA (40 hex characters) isSHA := len(ref) == 40 && gitutil.IsHexString(ref) diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 04d4eb6cb8..2a587dd484 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -255,7 +255,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "update_project": { DefaultMax: 10, Fields: map[string]FieldValidation{ - "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, + "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, "content_type": {Type: "string", Enum: []string{"issue", "pull_request", "draft_issue"}}, "content_number": {IssueNumberOrTemporaryID: true}, "issue": {OptionalPositiveInteger: true}, // Legacy @@ -277,7 +277,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "create_project_status_update": { DefaultMax: 10, Fields: map[string]FieldValidation{ - "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, + "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://[^/]+/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, "body": {Required: true, Type: "string", Sanitize: true, MaxLength: 65536}, "status": {Type: "string", Enum: []string{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}}, "start_date": {Type: "string", Pattern: "^\\d{4}-\\d{2}-\\d{2}$", PatternError: "must be in YYYY-MM-DD format"},