diff --git a/cmd/publisher/commands/init.go b/cmd/publisher/commands/init.go index 92d6c8a72..22ceb711e 100644 --- a/cmd/publisher/commands/init.go +++ b/cmd/publisher/commands/init.go @@ -5,9 +5,12 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "net/url" "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -64,6 +67,9 @@ func InitCommand() error { model.CurrentSchemaURL, name, description, version, repoURL, repoSource, subfolder, packageType, packageIdentifier, version, envVars, ) + if server.Repository != nil { + server.Repository.ID = detectRepoID(repoSource, repoURL) + } // Write to file jsonData, err := json.MarshalIndent(server, "", " ") @@ -247,6 +253,83 @@ func buildGitHubServerName(repoURL, subfolder string) string { return fmt.Sprintf("io.github.%s/%s", owner, repo) } +// githubAPIBaseURL is a variable so the tests can point it at a stub server. +var githubAPIBaseURL = "https://api.github.com" + +// parseGitHubOwnerRepo pulls owner/repo out of a github.com web URL, which is +// the only shape `detectRepoURL` produces for GitHub (it rewrites the SSH form +// and strips `.git`). Anything else — a different host, a bare path, a URL with +// extra segments — returns ok=false rather than a guess. +func parseGitHubOwnerRepo(repoURL string) (owner, repo string, ok bool) { + u, err := url.Parse(repoURL) + if err != nil { + return "", "", false + } + if strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.") != "github.com" { + return "", "", false + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], strings.TrimSuffix(parts[1], ".git"), true +} + +// detectRepoID resolves the forge's own identifier for the repository, which +// `repository.id` exists to carry. +// +// A URL is not a stable pointer: renaming or transferring a GitHub repo leaves +// the registered `repository.url` resolving only through GitHub's redirect, +// which the REST API follows but GraphQL does not, so the source link rots for +// consumers. The numeric ID does not move (#1484). Recording it at init time +// costs one request and makes the entry re-resolvable later even if the URL has +// gone stale. +// +// Deliberately best effort: `init` is otherwise fully offline, so being off the +// network, rate limited, or pointed at a private repo leaves the field empty +// instead of failing the command. `GITHUB_TOKEN` is used when present, mostly +// so a rate-limited developer still gets the ID. +func detectRepoID(repoSource, repoURL string) string { + if repoSource != MethodGitHub { + return "" + } + owner, repo, ok := parseGitHubOwnerRepo(repoURL) + if !ok { + return "" + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext( + ctx, http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s", githubAPIBaseURL, owner, repo), nil, + ) + if err != nil { + return "" + } + req.Header.Set("Accept", "application/vnd.github+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + + var body struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.ID == 0 { + return "" + } + return strconv.FormatInt(body.ID, 10) +} + func detectDescription() string { // Try to get from package.json if data, err := os.ReadFile("package.json"); err == nil { diff --git a/cmd/publisher/commands/init_internal_test.go b/cmd/publisher/commands/init_internal_test.go new file mode 100644 index 000000000..0cd3490d6 --- /dev/null +++ b/cmd/publisher/commands/init_internal_test.go @@ -0,0 +1,77 @@ +package commands + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseGitHubOwnerRepo(t *testing.T) { + tests := []struct { + name string + repoURL string + owner string + repo string + ok bool + }{ + {name: "plain https URL", repoURL: "https://github.com/acme/weather", owner: "acme", repo: "weather", ok: true}, + {name: "www host", repoURL: "https://www.github.com/acme/weather", owner: "acme", repo: "weather", ok: true}, + {name: "trailing slash", repoURL: "https://github.com/acme/weather/", owner: "acme", repo: "weather", ok: true}, + {name: "dot-git suffix", repoURL: "https://github.com/acme/weather.git", owner: "acme", repo: "weather", ok: true}, + {name: "gitlab is not github", repoURL: "https://gitlab.com/acme/weather"}, + // A lookalike host must not be treated as GitHub — the ID we would fetch + // would belong to whatever that host decided to return. + {name: "lookalike host", repoURL: "https://github.com.evil.example/acme/weather"}, + {name: "deep path is not a repo root", repoURL: "https://github.com/acme/weather/tree/main/src"}, + {name: "owner only", repoURL: "https://github.com/acme"}, + {name: "empty", repoURL: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, ok := parseGitHubOwnerRepo(tt.repoURL) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.owner, owner) + assert.Equal(t, tt.repo, repo) + }) + } +} + +func TestDetectRepoID(t *testing.T) { + t.Run("records the numeric id GitHub reports", func(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"id": 123456789, "full_name": "acme/weather"}`)) + })) + defer srv.Close() + t.Setenv("GITHUB_TOKEN", "") + + originalBaseURL := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = originalBaseURL }() + + assert.Equal(t, "123456789", detectRepoID(MethodGitHub, "https://github.com/acme/weather")) + assert.Equal(t, "/repos/acme/weather", gotPath) + }) + + t.Run("stays empty rather than failing init when the lookup does not work", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + t.Setenv("GITHUB_TOKEN", "") + + originalBaseURL := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = originalBaseURL }() + + // 404 / private / rate limited. + assert.Empty(t, detectRepoID(MethodGitHub, "https://github.com/acme/weather")) + // Non-GitHub sources are never looked up, so no request is made at all. + assert.Empty(t, detectRepoID("gitlab", "https://gitlab.com/acme/weather")) + assert.Empty(t, detectRepoID(MethodGitHub, "")) + }) +}