From fb4348d123ff7f86560e20c4520ba04b63304a0d Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:31:10 +0700 Subject: [PATCH 01/21] replace REST API with HTML/Atom scraper, rename binary to github The tool now scrapes github.com HTML pages and Atom feeds instead of calling api.github.com. This removes the 60-req/hour ceiling and the need for any API key. Changes: - github/: full rewrite with parse.go (HTML+Atom), api.go (15 methods), ops.go (15 kit Handle registrations), domain.go (kit Domain), ids.go - cli/: slimmed down to NewApp() that delegates to the kit domain - cmd/github/: new main using kit.Main; drops old cmd/ghb/ - 17 tests pass with httptest servers, no network needed in CI - binary renamed from ghb to github; goreleaser and Dockerfile updated --- .goreleaser.yaml | 37 +-- Dockerfile | 8 +- README.md | 95 ++++-- cli/cmd_releases.go | 26 -- cli/cmd_repo.go | 36 --- cli/cmd_search.go | 36 --- cli/cmd_trending.go | 34 -- cli/cmd_user.go | 37 --- cli/errors.go | 15 - cli/output.go | 25 -- cli/root.go | 159 ++------- cli/version.go | 27 -- cmd/ghb/main.go | 27 -- cmd/github/main.go | 14 + github/api.go | 232 ++++++++++++++ github/domain.go | 58 ++++ github/github.go | 424 +++++++++--------------- github/github_test.go | 727 +++++++++++++++++++++++++++++------------- github/ids.go | 37 +++ github/ids_test.go | 40 +++ github/ops.go | 418 ++++++++++++++++++++++++ github/parse.go | 712 +++++++++++++++++++++++++++++++++++++++++ github/types.go | 212 ++++++------ go.mod | 15 +- go.sum | 54 +++- 25 files changed, 2422 insertions(+), 1083 deletions(-) delete mode 100644 cli/cmd_releases.go delete mode 100644 cli/cmd_repo.go delete mode 100644 cli/cmd_search.go delete mode 100644 cli/cmd_trending.go delete mode 100644 cli/cmd_user.go delete mode 100644 cli/errors.go delete mode 100644 cli/output.go delete mode 100644 cli/version.go delete mode 100644 cmd/ghb/main.go create mode 100644 cmd/github/main.go create mode 100644 github/api.go create mode 100644 github/domain.go create mode 100644 github/ids.go create mode 100644 github/ids_test.go create mode 100644 github/ops.go create mode 100644 github/parse.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b450097..b7072a2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,22 +3,22 @@ # entries for the package managers (Homebrew, Scoop). `git tag vX.Y.Z && git # push --tags` fans out to all of them through .github/workflows/release.yml. # -# Publish steps that push to a repository you do not own yet (the Homebrew tap, +# Publish steps that push to a repository we do not own yet (the Homebrew tap, # the Scoop bucket) self-disable when their token is absent. A release with no # extra secrets still produces every downloadable artifact and the container # image; each manager lights up the moment its repository and token exist. version: 2 -project_name: ghb +project_name: github-cli before: hooks: - go mod download builds: - - id: ghb - binary: ghb - main: ./cmd/ghb + - id: github + binary: github + main: ./cmd/github env: - CGO_ENABLED=0 flags: @@ -43,7 +43,7 @@ builds: archives: - id: default - name_template: "ghb_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" + name_template: "github_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" format_overrides: - goos: windows formats: [zip] @@ -53,12 +53,12 @@ archives: nfpms: - id: linux-packages - package_name: ghb + package_name: github-cli file_name_template: "{{ .ConventionalFileName }}" vendor: tamnd homepage: https://github.com/tamnd/github-cli maintainer: Duc-Tam Nguyen - description: Browse GitHub repositories, users and releases + description: "A command-line for GitHub: scrapes HTML, no API key needed." license: Apache-2.0 formats: - deb @@ -68,11 +68,11 @@ nfpms: section: utils contents: - src: ./LICENSE - dst: /usr/share/doc/ghb/LICENSE + dst: /usr/share/doc/github-cli/LICENSE dockers_v2: - images: - - ghcr.io/tamnd/ghb + - ghcr.io/tamnd/github tags: - "{{ .Version }}" - latest @@ -82,7 +82,7 @@ dockers_v2: - linux/arm64 labels: org.opencontainers.image.title: "{{ .ProjectName }}" - org.opencontainers.image.description: "Browse GitHub repositories, users and releases" + org.opencontainers.image.description: "A command-line for GitHub: scrapes HTML, no API key needed." org.opencontainers.image.url: "https://github.com/tamnd/github-cli" org.opencontainers.image.source: "https://github.com/tamnd/github-cli" org.opencontainers.image.version: "{{ .Version }}" @@ -90,14 +90,14 @@ dockers_v2: org.opencontainers.image.licenses: "Apache-2.0" homebrew_casks: - - name: ghb + - name: github-cli-tamnd repository: owner: tamnd name: homebrew-tap token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}' directory: Casks homepage: https://github.com/tamnd/github-cli - description: Browse GitHub repositories, users and releases + description: "A command-line for GitHub: scrapes HTML, no API key needed." skip_upload: '{{ if envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' commit_author: name: Duc-Tam Nguyen @@ -109,7 +109,7 @@ scoops: name: scoop-bucket token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}' homepage: https://github.com/tamnd/github-cli - description: Browse GitHub repositories, users and releases + description: "A command-line for GitHub: scrapes HTML, no API key needed." license: Apache-2.0 skip_upload: '{{ if envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' commit_author: @@ -155,15 +155,6 @@ changelog: - "^ci:" - Merge pull request - Merge branch - groups: - - title: Features - regexp: '^.*?feat(\(.+\))??!?:.+$' - order: 0 - - title: Fixes - regexp: '^.*?fix(\(.+\))??!?:.+$' - order: 1 - - title: Other - order: 999 release: github: diff --git a/Dockerfile b/Dockerfile index 1fe69d4..c7db790 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,10 @@ ARG TARGETPLATFORM # ca-certificates for HTTPS; tzdata for sane timestamps. RUN apk add --no-cache ca-certificates tzdata \ - && adduser -D -H -u 10001 ghb + && adduser -D -H -u 10001 github -COPY $TARGETPLATFORM/ghb /usr/bin/ghb +COPY $TARGETPLATFORM/github /usr/bin/github -USER ghb +USER github -ENTRYPOINT ["/usr/bin/ghb"] +ENTRYPOINT ["/usr/bin/github"] diff --git a/README.md b/README.md index 2992bd7..a6a1b52 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,90 @@ -# ghb +# github-cli -Browse GitHub repositories, users and releases +A command-line for GitHub that reads public data by scraping HTML pages and +Atom feeds. No API key required. No rate limit from the official REST API. -`ghb` is a single pure-Go binary. It speaks to github over plain -HTTPS, shapes the responses into clean records, and pipes into the rest of your -tools. No API key, nothing to run alongside it. +**Not affiliated with GitHub or Microsoft Corporation.** ## Install ```bash -go install github.com/tamnd/github-cli/cmd/ghb@latest +go install github.com/tamnd/github-cli/cmd/github@latest ``` -Or grab a prebuilt binary from the [releases](https://github.com/tamnd/github-cli/releases), or run -the container image: +Or grab a prebuilt binary from the +[releases](https://github.com/tamnd/github-cli/releases): ```bash -docker run --rm ghcr.io/tamnd/ghb:latest --help +# Linux/macOS +curl -sSL https://github.com/tamnd/github-cli/releases/latest/download/github_linux_amd64.tar.gz | tar xz +./github --help ``` -## Usage +Or run the container image: ```bash -ghb --help -ghb version +docker run --rm ghcr.io/tamnd/github:latest --help ``` -This is a fresh scaffold. The command tree starts with `version`; build out the -real commands in `cli/` on top of the `github` library package. +## Commands + +| Command | Description | Source | +|---------|-------------|--------| +| `github trending` | Top trending repositories | HTML | +| `github user ` | User profile | HTML | +| `github repos ` | User's public repositories | HTML | +| `github repo ` | Repository metadata | HTML | +| `github commits ` | Recent commits | Atom feed | +| `github releases ` | Releases | Atom feed | +| `github tags ` | Tags | Atom feed | +| `github issues ` | Issues | HTML | +| `github pulls ` | Pull requests | HTML | +| `github readme ` | README content | raw.githubusercontent.com | +| `github file ` | Any file | raw.githubusercontent.com | +| `github search ` | Search repositories | HTML | +| `github followers ` | User followers | HTML | +| `github following ` | Users followed by user | HTML | +| `github stars ` | Starred repositories | HTML | + +## Examples -## Development +```bash +# Trending Go repos today +github trending --lang go -``` -cmd/ghb/ thin main, wires cli.Root into fang -cli/ the cobra command tree -github/ the library: HTTP client and data models -docs/ tago documentation site -``` +# User profile as JSON +github user torvalds -o json -```bash -make build # ./bin/ghb -make test # go test ./... -make vet # go vet ./... +# Recent commits on main +github commits golang/go + +# List open issues +github issues golang/go + +# Search for HTTP libraries +github search "http client" + +# Fetch the README +github readme torvalds/linux ``` -## Releasing +## Output formats -Push a version tag and GitHub Actions runs GoReleaser, which builds the -archives, Linux packages, the multi-arch GHCR image, checksums, SBOMs, and a -cosign signature: +Every command supports `-o table|json|jsonl|csv|tsv|url` and `--fields`. ```bash -git tag v0.1.0 -git push --tags +github trending -o jsonl | jq '.full_name' +github repos torvalds --fields full_name,stars ``` -The Homebrew and Scoop steps self-disable until their tokens exist, so the first -release works with no extra secrets. +## Notes + +- HTML structure can change without notice. Parsers return empty strings on + missing fields rather than crashing. +- Search may return HTTP 429 from datacenter IPs. The binary exits with code 5 + when throttled. Add `--page 1` and wait a moment before retrying. +- The default pacing is 500 ms between requests. Use `--delay` to adjust. ## License -Apache-2.0. See [LICENSE](LICENSE). +Apache-2.0 diff --git a/cli/cmd_releases.go b/cli/cmd_releases.go deleted file mode 100644 index 19e895f..0000000 --- a/cli/cmd_releases.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func (a *App) releasesCmd() *cobra.Command { - return &cobra.Command{ - Use: "releases ", - Short: "List releases for a repository", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := splitOwnerRepo(args[0]) - if err != nil { - return codeError(exitUsage, err) - } - n := a.effectiveLimit(20) - a.progressf("fetching releases for %s/%s...", owner, repo) - releases, err := a.client.Releases(cmd.Context(), owner, repo, n) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(releases, len(releases)) - }, - } -} diff --git a/cli/cmd_repo.go b/cli/cmd_repo.go deleted file mode 100644 index cbbb04b..0000000 --- a/cli/cmd_repo.go +++ /dev/null @@ -1,36 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -func (a *App) repoCmd() *cobra.Command { - return &cobra.Command{ - Use: "repo ", - Short: "Show a single repository", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - owner, repo, err := splitOwnerRepo(args[0]) - if err != nil { - return codeError(exitUsage, err) - } - a.progressf("fetching repo %s/%s...", owner, repo) - r, err := a.client.GetRepo(cmd.Context(), owner, repo) - if err != nil { - return mapFetchErr(err) - } - return a.render(r) - }, - } -} - -func splitOwnerRepo(s string) (owner, repo string, err error) { - parts := strings.SplitN(s, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("argument must be owner/repo, got %q", s) - } - return parts[0], parts[1], nil -} diff --git a/cli/cmd_search.go b/cli/cmd_search.go deleted file mode 100644 index 639e6f9..0000000 --- a/cli/cmd_search.go +++ /dev/null @@ -1,36 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" - "github.com/tamnd/github-cli/github" -) - -func (a *App) searchCmd() *cobra.Command { - var ( - lang string - sort string - ) - cmd := &cobra.Command{ - Use: "search ", - Short: "Search GitHub repositories", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - n := a.effectiveLimit(20) - opts := github.SearchRepoOptions{ - Query: args[0], - Language: lang, - Sort: sort, - Limit: n, - } - a.progressf("searching repositories for %q...", args[0]) - repos, err := a.client.SearchRepos(cmd.Context(), opts) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(repos, len(repos)) - }, - } - cmd.Flags().StringVar(&lang, "lang", "", "language filter (e.g. Go, Python)") - cmd.Flags().StringVar(&sort, "sort", "stars", "sort order: stars|forks|updated|help-wanted-issues") - return cmd -} diff --git a/cli/cmd_trending.go b/cli/cmd_trending.go deleted file mode 100644 index b28af4d..0000000 --- a/cli/cmd_trending.go +++ /dev/null @@ -1,34 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" - "github.com/tamnd/github-cli/github" -) - -func (a *App) trendingCmd() *cobra.Command { - var ( - lang string - days int - ) - cmd := &cobra.Command{ - Use: "trending", - Short: "Show trending repositories (proxy via search API)", - RunE: func(cmd *cobra.Command, _ []string) error { - n := a.effectiveLimit(25) - opts := github.TrendingOptions{ - Language: lang, - Days: days, - Limit: n, - } - a.progressf("fetching trending repositories (last %d days)...", days) - repos, err := a.client.Trending(cmd.Context(), opts) - if err != nil { - return mapFetchErr(err) - } - return a.renderOrEmpty(repos, len(repos)) - }, - } - cmd.Flags().StringVar(&lang, "lang", "", "language filter (e.g. Go, Python)") - cmd.Flags().IntVar(&days, "days", 7, "time window in days (7, 30, or 365)") - return cmd -} diff --git a/cli/cmd_user.go b/cli/cmd_user.go deleted file mode 100644 index 28329ed..0000000 --- a/cli/cmd_user.go +++ /dev/null @@ -1,37 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -func (a *App) userCmd() *cobra.Command { - var repos bool - cmd := &cobra.Command{ - Use: "user ", - Short: "Show a GitHub user profile", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - a.progressf("fetching user %q...", username) - user, err := a.client.GetUser(cmd.Context(), username) - if err != nil { - return mapFetchErr(err) - } - if err := a.render(user); err != nil { - return err - } - if repos { - n := a.effectiveLimit(10) - a.progressf("fetching top %d repos for %q...", n, username) - repoList, err := a.client.UserRepos(cmd.Context(), username, n) - if err != nil { - return mapFetchErr(err) - } - return a.render(repoList) - } - return nil - }, - } - cmd.Flags().BoolVar(&repos, "repos", false, "also list the user's top repos") - return cmd -} diff --git a/cli/errors.go b/cli/errors.go deleted file mode 100644 index 7546fe4..0000000 --- a/cli/errors.go +++ /dev/null @@ -1,15 +0,0 @@ -package cli - -import ( - "errors" - - "github.com/tamnd/github-cli/github" -) - -func isNotFound(err error) bool { - return errors.Is(err, github.ErrNotFound) -} - -func isRateLimit(err error) bool { - return errors.Is(err, github.ErrRateLimit) -} diff --git a/cli/output.go b/cli/output.go deleted file mode 100644 index 06d10a2..0000000 --- a/cli/output.go +++ /dev/null @@ -1,25 +0,0 @@ -package cli - -import ( - "io" - - "github.com/tamnd/github-cli/pkg/render" -) - -// Format aliases so command code reads cleanly. -type Format = render.Format - -const ( - FormatTable = render.FormatTable - FormatJSON = render.FormatJSON - FormatJSONL = render.FormatJSONL - FormatCSV = render.FormatCSV - FormatTSV = render.FormatTSV - FormatURL = render.FormatURL - FormatRaw = render.FormatRaw -) - -// NewRenderer builds a renderer writing to w. -func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *render.Renderer { - return render.New(w, format, fields, noHeader, tmpl) -} diff --git a/cli/root.go b/cli/root.go index 015de9b..82be40f 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,12 +1,9 @@ -// Package cli builds the ghb command tree on top of the github library. +// Package cli assembles the github command tree from the github domain +// on top of the any-cli/kit framework. package cli import ( - "fmt" - "os" - - "github.com/mattn/go-isatty" - "github.com/spf13/cobra" + "github.com/tamnd/any-cli/kit" "github.com/tamnd/github-cli/github" ) @@ -17,139 +14,19 @@ var ( Date = "unknown" ) -// exit codes. -const ( - exitError = 1 - exitUsage = 2 - exitNoData = 3 -) - -// ExitError carries a process exit code up to main. -type ExitError struct { - Code int - Err error -} - -func (e *ExitError) Error() string { - if e.Err != nil { - return e.Err.Error() - } - return fmt.Sprintf("exit %d", e.Code) -} - -func (e *ExitError) Unwrap() error { return e.Err } - -func codeError(code int, err error) error { return &ExitError{Code: code, Err: err} } - -// App holds shared state threaded through every command. -type App struct { - client *github.Client - cfg github.Config - - output string - fields []string - noHeader bool - template string - limit int - quiet bool -} - -// Root builds the root command and its subtree. -func Root() *cobra.Command { - app := &App{cfg: github.DefaultConfig()} - - root := &cobra.Command{ - Use: "ghb", - Short: "Browse GitHub repositories, users and releases", - Long: `ghb reads public GitHub data through the GitHub REST API v3. -No authentication is required. Returns records as table, JSON, JSONL, -CSV, TSV, or URLs. - -ghb is an independent tool and is not affiliated with GitHub or Microsoft.`, - SilenceUsage: true, - SilenceErrors: true, - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - return app.setup() - }, - } - - pf := root.PersistentFlags() - pf.StringVarP(&app.output, "output", "o", "auto", "output: table|json|jsonl|csv|tsv|url|raw (auto=table on TTY, jsonl piped)") - pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include") - pf.BoolVar(&app.noHeader, "no-header", false, "omit the header row in table/csv/tsv") - pf.StringVar(&app.template, "template", "", "Go text/template applied per record") - pf.IntVarP(&app.limit, "limit", "n", 0, "limit number of records (0 = command default)") - pf.BoolVarP(&app.quiet, "quiet", "q", false, "suppress progress on stderr") - - pf.DurationVar(&app.cfg.Rate, "delay", app.cfg.Rate, "minimum spacing between requests") - pf.DurationVar(&app.cfg.Timeout, "timeout", app.cfg.Timeout, "per-request timeout") - pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx") - pf.StringVar(&app.cfg.UserAgent, "user-agent", app.cfg.UserAgent, "User-Agent sent with each request") - - root.AddCommand( - app.searchCmd(), - app.repoCmd(), - app.trendingCmd(), - app.userCmd(), - app.releasesCmd(), - newVersionCmd(), - ) - return root -} - -func (a *App) setup() error { - if a.output == "" || a.output == "auto" { - if isatty.IsTerminal(os.Stdout.Fd()) { - a.output = string(FormatTable) - } else { - a.output = string(FormatJSONL) - } - } - if !Format(a.output).Valid() { - return codeError(exitUsage, fmt.Errorf("unknown output format %q", a.output)) - } - a.client = github.NewClient(a.cfg) - return nil -} - -func (a *App) render(records any) error { - r := NewRenderer(os.Stdout, Format(a.output), a.fields, a.noHeader, a.template) - return r.Render(records) -} - -func (a *App) renderOrEmpty(records any, n int) error { - if err := a.render(records); err != nil { - return err - } - if n == 0 { - return codeError(exitNoData, nil) - } - return nil -} - -func (a *App) progressf(format string, args ...any) { - if a.quiet { - return - } - _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) -} - -func mapFetchErr(err error) error { - if err == nil { - return nil - } - if isNotFound(err) { - return codeError(exitNoData, err) - } - if isRateLimit(err) { - return codeError(exitError, err) - } - return codeError(exitError, err) -} - -func (a *App) effectiveLimit(def int) int { - if a.limit > 0 { - return a.limit - } - return def +// NewApp assembles the kit application from the github domain. The domain's +// Register installs the client factory and every operation, so the binary and a +// multi-domain host (ant) share one source of truth. kit.Run turns the App into +// the CLI, the serve surface, and the MCP tool surface. +// +// To add a command, declare it in github/ops.go with kit.Handle and it appears +// here automatically. Reach for app.AddCommand only for a verb that does not fit +// the emit-records shape. +func NewApp() *kit.App { + id := github.Domain{}.Info().Identity + id.Version = Version + + app := kit.New(id) + (github.Domain{}).Register(app) + return app } diff --git a/cli/version.go b/cli/version.go deleted file mode 100644 index bdcd7ab..0000000 --- a/cli/version.go +++ /dev/null @@ -1,27 +0,0 @@ -package cli - -import ( - "fmt" - "runtime" - - "github.com/spf13/cobra" -) - -func newVersionCmd() *cobra.Command { - var short bool - cmd := &cobra.Command{ - Use: "version", - Short: "Print version information", - RunE: func(c *cobra.Command, _ []string) error { - if short { - _, _ = fmt.Fprintln(c.OutOrStdout(), Version) - return nil - } - _, _ = fmt.Fprintf(c.OutOrStdout(), "ghb %s (commit %s, built %s, %s/%s, %s)\n", - Version, Commit, Date, runtime.GOOS, runtime.GOARCH, runtime.Version()) - return nil - }, - } - cmd.Flags().BoolVar(&short, "short", false, "print just the version number") - return cmd -} diff --git a/cmd/ghb/main.go b/cmd/ghb/main.go deleted file mode 100644 index 3ab444c..0000000 --- a/cmd/ghb/main.go +++ /dev/null @@ -1,27 +0,0 @@ -// Command ghb is a single-binary command line for github. -package main - -import ( - "context" - "os" - "os/signal" - "syscall" - - "github.com/charmbracelet/fang" - "github.com/tamnd/github-cli/cli" -) - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - root := cli.Root() - // fang gives styled help, errors, and shell completion for free; the command - // tree and its exit-code mapping stay in the cli package. - if err := fang.Execute(ctx, root, - fang.WithVersion(cli.Version), - fang.WithNotifySignal(os.Interrupt, syscall.SIGTERM), - ); err != nil { - os.Exit(1) - } -} diff --git a/cmd/github/main.go b/cmd/github/main.go new file mode 100644 index 0000000..6fba252 --- /dev/null +++ b/cmd/github/main.go @@ -0,0 +1,14 @@ +// Command github is a command-line for GitHub that scrapes HTML pages and +// Atom feeds. No API key or authentication is required. +// +// github is an independent tool and is not affiliated with GitHub or Microsoft. +package main + +import ( + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/github-cli/cli" +) + +func main() { + kit.Main(cli.NewApp()) +} diff --git a/github/api.go b/github/api.go new file mode 100644 index 0000000..5bba227 --- /dev/null +++ b/github/api.go @@ -0,0 +1,232 @@ +package github + +import ( + "context" + "fmt" + "time" +) + +// Trending returns trending repositories from github.com/trending. +// lang is optional (empty = all languages). +// since is "daily", "weekly", or "monthly" (default "daily"). +func (c *Client) Trending(ctx context.Context, lang, since string) ([]TrendingRepo, error) { + if since == "" { + since = "daily" + } + u := c.cfg.BaseURL + "/trending" + if lang != "" { + u += "/" + lang + } + if since != "daily" { + u += "?since=" + since + } + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseTrending(body), nil +} + +// GetUser fetches a GitHub user profile. +func (c *Client) GetUser(ctx context.Context, username string) (User, error) { + u := c.cfg.BaseURL + "/" + username + body, err := c.fetchHTML(ctx, u) + if err != nil { + return User{}, err + } + return ParseUser(body, username) +} + +// UserRepos lists a user's public repositories (30 per page). +func (c *Client) UserRepos(ctx context.Context, username string, page int) ([]Repo, error) { + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s?tab=repositories&page=%d", c.cfg.BaseURL, username, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseRepos(body, username), nil +} + +// GetRepo fetches a single repository's metadata. +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (Repo, error) { + u := c.cfg.BaseURL + "/" + owner + "/" + repo + body, err := c.fetchHTML(ctx, u) + if err != nil { + return Repo{}, err + } + return ParseRepo(body, owner, repo) +} + +// Commits lists commits from the Atom feed. +func (c *Client) Commits(ctx context.Context, owner, repo, branch string) ([]Commit, error) { + if branch == "" { + branch = "main" + } + u := fmt.Sprintf("%s/%s/%s/commits/%s.atom", c.cfg.BaseURL, owner, repo, branch) + body, err := c.fetchAtom(ctx, u) + if err != nil { + return nil, err + } + return ParseAtomCommits(body) +} + +// Releases lists releases from the Atom feed. +func (c *Client) Releases(ctx context.Context, owner, repo string) ([]Release, error) { + u := fmt.Sprintf("%s/%s/%s/releases.atom", c.cfg.BaseURL, owner, repo) + body, err := c.fetchAtom(ctx, u) + if err != nil { + return nil, err + } + return ParseAtomReleases(body) +} + +// Tags lists tags from the Atom feed. +func (c *Client) Tags(ctx context.Context, owner, repo string) ([]Tag, error) { + u := fmt.Sprintf("%s/%s/%s/tags.atom", c.cfg.BaseURL, owner, repo) + body, err := c.fetchAtom(ctx, u) + if err != nil { + return nil, err + } + return ParseAtomTags(body) +} + +// Issues lists issues from the HTML page. +// state is "open", "closed", or "all". +func (c *Client) Issues(ctx context.Context, owner, repo, state string, page int) ([]Issue, error) { + if state == "" { + state = "open" + } + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s/%s/issues?state=%s&page=%d", c.cfg.BaseURL, owner, repo, state, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseIssues(body, owner, repo, state), nil +} + +// Pulls lists pull requests from the HTML page. +func (c *Client) Pulls(ctx context.Context, owner, repo, state string, page int) ([]PullRequest, error) { + if state == "" { + state = "open" + } + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s/%s/pulls?state=%s&page=%d", c.cfg.BaseURL, owner, repo, state, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParsePulls(body, owner, repo, state), nil +} + +// Readme fetches the README of a repository. +// If branch is empty, it tries "main" then "master". +func (c *Client) Readme(ctx context.Context, owner, repo, branch string) (FileContent, error) { + branches := []string{branch} + if branch == "" { + branches = []string{"main", "master"} + } + filenames := []string{"README.md", "README.rst", "README"} + + for _, br := range branches { + for _, fn := range filenames { + u := fmt.Sprintf("%s/%s/%s/%s/%s", c.cfg.RawBaseURL, owner, repo, br, fn) + body, err := c.fetchRaw(ctx, u) + if err != nil { + continue + } + return FileContent{ + Path: fn, + Content: body, + URL: u, + }, nil + } + } + return FileContent{}, fmt.Errorf("README not found for %s/%s", owner, repo) +} + +// File fetches a file from raw.githubusercontent.com. +func (c *Client) File(ctx context.Context, owner, repo, branch, path string) (FileContent, error) { + if branch == "" { + branch = "main" + } + u := fmt.Sprintf("%s/%s/%s/%s/%s", c.cfg.RawBaseURL, owner, repo, branch, path) + body, err := c.fetchRaw(ctx, u) + if err != nil { + return FileContent{}, err + } + return FileContent{ + Path: path, + Content: body, + URL: u, + }, nil +} + +// Search searches repositories on github.com/search. +// GitHub may throttle search from datacenter IPs. +func (c *Client) Search(ctx context.Context, query string, page int) ([]SearchRepo, error) { + if page <= 0 { + page = 1 + } + // extra courtesy delay for search to avoid 429 + if page > 1 && c.cfg.SearchWait > 0 { + wait := c.cfg.SearchWait * time.Duration(page-1) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + } + u := fmt.Sprintf("%s/search?q=%s&type=repositories&p=%d", c.cfg.BaseURL, query, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseSearch(body), nil +} + +// Followers lists a user's followers. +func (c *Client) Followers(ctx context.Context, username string, page int) ([]User, error) { + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s?tab=followers&page=%d", c.cfg.BaseURL, username, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseFollowers(body), nil +} + +// Following lists users that a user follows. +func (c *Client) Following(ctx context.Context, username string, page int) ([]User, error) { + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s?tab=following&page=%d", c.cfg.BaseURL, username, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseFollowing(body), nil +} + +// Stars lists repositories starred by a user. +func (c *Client) Stars(ctx context.Context, username string, page int) ([]StarredRepo, error) { + if page <= 0 { + page = 1 + } + u := fmt.Sprintf("%s/%s?tab=stars&page=%d", c.cfg.BaseURL, username, page) + body, err := c.fetchHTML(ctx, u) + if err != nil { + return nil, err + } + return ParseStars(body), nil +} diff --git a/github/domain.go b/github/domain.go new file mode 100644 index 0000000..22000fb --- /dev/null +++ b/github/domain.go @@ -0,0 +1,58 @@ +package github + +import ( + "context" + + "github.com/tamnd/any-cli/kit" +) + +// init registers the github domain so a multi-domain host (ant) can load it +// with a blank import. +func init() { kit.Register(Domain{}) } + +// Domain is the GitHub scraper driver. It carries no state; the per-run client +// is built by the factory Register hands kit. +type Domain struct{} + +// Info describes the scheme, the hostnames a pasted link is matched against, and +// the identity reused for the binary's help and version. +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: "github", + Hosts: []string{"github.com", "raw.githubusercontent.com"}, + Identity: kit.Identity{ + Binary: "github", + Short: "A command-line for GitHub (scrapes HTML, no API key needed).", + Long: `github reads public GitHub data by scraping HTML pages and Atom feeds. +No API token is required. No rate limit from the official REST API. + +github is an independent tool and is not affiliated with GitHub or Microsoft.`, + Site: "https://github.com", + Repo: "https://github.com/tamnd/github-cli", + }, + } +} + +// Register installs the client factory and every operation onto app. +func (Domain) Register(app *kit.App) { + app.SetClient(newClientFromConfig) + RegisterOps(app) +} + +// newClientFromConfig builds a Client from the kit-resolved Config. +func newClientFromConfig(_ context.Context, cfg kit.Config) (any, error) { + c := DefaultConfig() + if cfg.UserAgent != "" { + c.UserAgent = cfg.UserAgent + } + if cfg.Rate > 0 { + c.Rate = cfg.Rate + } + if cfg.Retries > 0 { + c.Retries = cfg.Retries + } + if cfg.Timeout > 0 { + c.Timeout = cfg.Timeout + } + return NewClient(c), nil +} diff --git a/github/github.go b/github/github.go index 8fd347b..0560cea 100644 --- a/github/github.go +++ b/github/github.go @@ -1,349 +1,213 @@ -// Package github is the library behind the ghb command: the HTTP client, -// request shaping, and the typed data models for GitHub. +// Package github is the scraper library behind the github CLI. +// It reads public GitHub data from HTML pages, Atom feeds, and +// raw.githubusercontent.com. No API key or authentication is required. // -// The GitHub REST API v3 at https://api.github.com is open for public data -// with no authentication required. Unauthenticated access is rate-limited to -// 60 requests per hour per IP address. The client paces to 1 request per second -// to stay safely within that budget. +// github is an independent tool and is not affiliated with GitHub or Microsoft. package github import ( "context" - "encoding/json" - "errors" "fmt" "io" "net/http" - "net/url" "strconv" "sync" "time" ) -// DefaultUserAgent identifies the client to the GitHub API. -const DefaultUserAgent = "ghb/0.1.0 (+https://github.com/tamnd/github-cli)" - -// ErrNotFound is returned when the API responds with HTTP 404. -var ErrNotFound = errors.New("not found") +const ( + defaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + defaultRawUA = "go-http-client/1.1" + defaultRate = 500 * time.Millisecond + defaultTimeout = 30 * time.Second + defaultRetries = 3 +) -// ErrRateLimit is returned when the unauthenticated rate limit (60 req/hr) is exhausted. -var ErrRateLimit = errors.New("GitHub rate limit reached (60 req/hr for unauthenticated); retry after a minute") +// ErrRateLimit is returned when GitHub returns HTTP 429 on search. +var ErrRateLimit = fmt.Errorf("rate limited by GitHub search; try again later") // Config holds constructor parameters for Client. type Config struct { - BaseURL string // default: "https://api.github.com" - UserAgent string // default: DefaultUserAgent - Rate time.Duration // default: 1s - Retries int // default: 2 - Timeout time.Duration // default: 30s + BaseURL string // "https://github.com" + RawBaseURL string // "https://raw.githubusercontent.com" + UserAgent string + Rate time.Duration + Retries int + Timeout time.Duration + SearchWait time.Duration // extra delay before search pages; default 3s } // DefaultConfig returns sensible defaults. func DefaultConfig() Config { return Config{ - BaseURL: "https://api.github.com", - UserAgent: DefaultUserAgent, - Rate: 1 * time.Second, - Retries: 2, - Timeout: 30 * time.Second, + BaseURL: "https://github.com", + RawBaseURL: "https://raw.githubusercontent.com", + UserAgent: defaultUA, + Rate: defaultRate, + Retries: defaultRetries, + Timeout: defaultTimeout, + SearchWait: 3 * time.Second, } } -// Client talks to the GitHub REST API v3. +// Client scrapes public GitHub data. type Client struct { - httpClient *http.Client - baseURL string - userAgent string - rate time.Duration - retries int - mu sync.Mutex - last time.Time + cfg Config + http *http.Client + mu sync.Mutex + last time.Time } -// NewClient returns a Client with the given config. +// NewClient creates a Client with the given Config. func NewClient(cfg Config) *Client { return &Client{ - httpClient: &http.Client{Timeout: cfg.Timeout}, - baseURL: cfg.BaseURL, - userAgent: cfg.UserAgent, - rate: cfg.Rate, - retries: cfg.Retries, + cfg: cfg, + http: &http.Client{Timeout: cfg.Timeout}, } } -// get fetches a URL with pacing and retries. -func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) { - var lastErr error - for attempt := 0; attempt <= c.retries; attempt++ { - if attempt > 0 { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(backoff(attempt)): - } - } - body, retry, err := c.do(ctx, rawURL) - if err == nil { - return body, nil - } - lastErr = err - if !retry { - return nil, err - } +// fetchHTML fetches a GitHub HTML page with browser-like headers. +func (c *Client) fetchHTML(ctx context.Context, u string) (string, error) { + headers := map[string]string{ + "User-Agent": c.cfg.UserAgent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", } - return nil, fmt.Errorf("get %s: %w", rawURL, lastErr) -} - -func (c *Client) do(ctx context.Context, rawURL string) ([]byte, bool, error) { - c.pace() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + b, status, err := c.do(ctx, u, headers) if err != nil { - return nil, false, err - } - req.Header.Set("User-Agent", c.userAgent) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, true, err - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode == http.StatusForbidden { - if resp.Header.Get("X-RateLimit-Remaining") == "0" { - return nil, false, ErrRateLimit - } - return nil, false, fmt.Errorf("http 403") + return "", err } - if resp.StatusCode == http.StatusNotFound { - return nil, false, ErrNotFound + if status == http.StatusNotFound { + return "", fmt.Errorf("not found: %s", u) } - if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { - return nil, true, fmt.Errorf("http %d", resp.StatusCode) + if status == http.StatusTooManyRequests { + return "", ErrRateLimit } - if resp.StatusCode != http.StatusOK { - return nil, false, fmt.Errorf("http %d", resp.StatusCode) + if status != http.StatusOK { + return "", fmt.Errorf("http %d: %s", status, u) } - b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) - if err != nil { - return nil, true, err - } - return b, false, nil + return string(b), nil } -func (c *Client) pace() { - c.mu.Lock() - defer c.mu.Unlock() - if c.rate <= 0 { - return - } - if wait := c.rate - time.Since(c.last); wait > 0 { - time.Sleep(wait) - } - c.last = time.Now() -} - -func backoff(attempt int) time.Duration { - d := time.Duration(attempt) * 500 * time.Millisecond - if d > 5*time.Second { - d = 5 * time.Second +// fetchAtom fetches an Atom feed. +func (c *Client) fetchAtom(ctx context.Context, u string) (string, error) { + headers := map[string]string{ + "User-Agent": c.cfg.UserAgent, + "Accept": "application/atom+xml, application/xml;q=0.9, */*;q=0.8", } - return d -} - -func (c *Client) getJSON(ctx context.Context, rawURL string, v any) error { - body, err := c.get(ctx, rawURL) + b, status, err := c.do(ctx, u, headers) if err != nil { - return err - } - if err := json.Unmarshal(body, v); err != nil { - return fmt.Errorf("decode %s: %w", rawURL, err) - } - return nil -} - -// ─── API methods ────────────────────────────────────────────────────────────── - -// SearchRepoOptions controls repository search. -type SearchRepoOptions struct { - Query string // raw keyword query - Language string // language filter (empty = all) - Sort string // stars|forks|updated|help-wanted-issues - Limit int // max records to return -} - -// SearchRepos searches repositories. -func (c *Client) SearchRepos(ctx context.Context, opts SearchRepoOptions) ([]Repo, error) { - q := opts.Query - if opts.Language != "" { - q += " language:" + opts.Language - } - sort := opts.Sort - if sort == "" { - sort = "stars" - } - limit := opts.Limit - if limit <= 0 { - limit = 20 - } - perPage := limit - if perPage > 100 { - perPage = 100 - } - - params := url.Values{} - params.Set("q", q) - params.Set("sort", sort) - params.Set("order", "desc") - params.Set("per_page", strconv.Itoa(perPage)) - params.Set("page", "1") - - rawURL := c.baseURL + "/search/repositories?" + params.Encode() - var resp searchReposResp - if err := c.getJSON(ctx, rawURL, &resp); err != nil { - return nil, err + return "", err } - - out := make([]Repo, 0, len(resp.Items)) - for i, w := range resp.Items { - if i >= limit { - break - } - out = append(out, wireRepoToRepo(w, i+1)) + if status == http.StatusNotFound { + return "", fmt.Errorf("not found: %s", u) } - return out, nil -} - -// GetRepo fetches a single repository. -func (c *Client) GetRepo(ctx context.Context, owner, repo string) (Repo, error) { - rawURL := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, - url.PathEscape(owner), url.PathEscape(repo)) - var w wireRepo - if err := c.getJSON(ctx, rawURL, &w); err != nil { - return Repo{}, err + if status != http.StatusOK { + return "", fmt.Errorf("http %d: %s", status, u) } - return wireRepoToRepo(w, 1), nil + return string(b), nil } -// TrendingOptions controls the trending proxy query. -type TrendingOptions struct { - Language string // language filter (empty = all) - Days int // 7|30|365 - Limit int // max records -} - -// Trending returns repos created after a cutoff date sorted by stars. -func (c *Client) Trending(ctx context.Context, opts TrendingOptions) ([]Repo, error) { - days := opts.Days - if days <= 0 { - days = 7 +// fetchRaw fetches raw content from raw.githubusercontent.com. +func (c *Client) fetchRaw(ctx context.Context, u string) (string, error) { + headers := map[string]string{ + "User-Agent": defaultRawUA, } - cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02") - q := "stars:>10 created:>" + cutoff - if opts.Language != "" { - q += " language:" + opts.Language + b, status, err := c.do(ctx, u, headers) + if err != nil { + return "", err } - limit := opts.Limit - if limit <= 0 { - limit = 25 + if status == http.StatusNotFound { + return "", fmt.Errorf("not found: %s", u) } - perPage := limit - if perPage > 100 { - perPage = 100 - } - - params := url.Values{} - params.Set("q", q) - params.Set("sort", "stars") - params.Set("order", "desc") - params.Set("per_page", strconv.Itoa(perPage)) - params.Set("page", "1") - - rawURL := c.baseURL + "/search/repositories?" + params.Encode() - var resp searchReposResp - if err := c.getJSON(ctx, rawURL, &resp); err != nil { - return nil, err + if status != http.StatusOK { + return "", fmt.Errorf("http %d: %s", status, u) } + return string(b), nil +} - out := make([]Repo, 0, len(resp.Items)) - for i, w := range resp.Items { - if i >= limit { - break +// do is the low-level sender: paces requests, then retries on 429/5xx. +// Returns (body, statusCode, error). +func (c *Client) do(ctx context.Context, u string, headers map[string]string) ([]byte, int, error) { + var lastErr error + var lastStatus int + for attempt := 0; attempt <= c.cfg.Retries; attempt++ { + if attempt > 0 { + wait := time.Duration(1< 100 { - perPage = 100 - } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + req.Header.Set(k, v) + } - params := url.Values{} - params.Set("sort", "pushed") - params.Set("per_page", strconv.Itoa(perPage)) + resp, err := c.http.Do(req) + if err != nil { + lastErr = err + continue + } - rawURL := fmt.Sprintf("%s/users/%s/repos?%s", - c.baseURL, url.PathEscape(username), params.Encode()) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + lastStatus = resp.StatusCode + continue + } - var items []wireRepo - if err := c.getJSON(ctx, rawURL, &items); err != nil { - return nil, err - } + // handle Retry-After on 429 + if resp.StatusCode == http.StatusTooManyRequests { + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, parseErr := strconv.Atoi(ra); parseErr == nil && secs > 0 { + select { + case <-ctx.Done(): + return nil, resp.StatusCode, ctx.Err() + case <-time.After(time.Duration(secs) * time.Second): + } + } + } + lastStatus = resp.StatusCode + lastErr = fmt.Errorf("http %d", resp.StatusCode) + continue + } - out := make([]Repo, 0, len(items)) - for i, w := range items { - if i >= limit { - break + // retry on 5xx + if resp.StatusCode >= 500 { + lastStatus = resp.StatusCode + lastErr = fmt.Errorf("http %d", resp.StatusCode) + continue } - out = append(out, wireRepoToRepo(w, i+1)) - } - return out, nil -} -// Releases returns releases for a repo, most recent first. -func (c *Client) Releases(ctx context.Context, owner, repo string, limit int) ([]Release, error) { - if limit <= 0 { - limit = 20 + return body, resp.StatusCode, nil } - perPage := limit - if perPage > 100 { - perPage = 100 + if lastErr != nil { + return nil, lastStatus, fmt.Errorf("fetch %s: %w (after %d retries)", u, lastErr, c.cfg.Retries) } + return nil, lastStatus, fmt.Errorf("fetch %s: http %d (after %d retries)", u, lastStatus, c.cfg.Retries) +} - params := url.Values{} - params.Set("per_page", strconv.Itoa(perPage)) - - rawURL := fmt.Sprintf("%s/repos/%s/%s/releases?%s", - c.baseURL, url.PathEscape(owner), url.PathEscape(repo), params.Encode()) - - var items []wireRelease - if err := c.getJSON(ctx, rawURL, &items); err != nil { - return nil, err +// pace enforces the minimum gap between outbound requests. +func (c *Client) pace(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cfg.Rate <= 0 { + return } - - out := make([]Release, 0, len(items)) - for i, w := range items { - if i >= limit { - break + if wait := c.cfg.Rate - time.Since(c.last); wait > 0 { + select { + case <-ctx.Done(): + case <-time.After(wait): } - out = append(out, wireReleaseToRelease(w, i+1)) } - return out, nil + c.last = time.Now() } diff --git a/github/github_test.go b/github/github_test.go index 760acfa..224235c 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -2,7 +2,6 @@ package github import ( "context" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -10,302 +9,580 @@ import ( "time" ) -func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { - t.Helper() - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - cfg := DefaultConfig() - cfg.BaseURL = srv.URL - cfg.Rate = 0 - return NewClient(cfg), srv +// ── parser unit tests ──────────────────────────────────────────────────────── + +const trendingFixture = ` + + + + + +` + +func TestParseTrending(t *testing.T) { + repos := ParseTrending(trendingFixture) + if len(repos) != 2 { + t.Fatalf("want 2 repos, got %d", len(repos)) + } + r := repos[0] + if r.Rank != 1 { + t.Errorf("rank: want 1, got %d", r.Rank) + } + if r.FullName != "golang/go" { + t.Errorf("full_name: want golang/go, got %q", r.FullName) + } + if r.Stars != 123456 { + t.Errorf("stars: want 123456, got %d", r.Stars) + } + if r.Language != "Go" { + t.Errorf("language: want Go, got %q", r.Language) + } + if r.PeriodStars != 42 { + t.Errorf("period_stars: want 42, got %d", r.PeriodStars) + } + if r.URL != "https://github.com/golang/go" { + t.Errorf("url: got %q", r.URL) + } + + r2 := repos[1] + if r2.Rank != 2 { + t.Errorf("rank[1]: want 2, got %d", r2.Rank) + } + if r2.FullName != "kubernetes/kubernetes" { + t.Errorf("full_name[1]: got %q", r2.FullName) + } } -func TestGetSendsHeaders(t *testing.T) { - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("User-Agent") == "" { - t.Error("request carried no User-Agent") - } - if r.Header.Get("Accept") != "application/vnd.github.v3+json" { - t.Errorf("Accept header = %q, want application/vnd.github.v3+json", r.Header.Get("Accept")) - } - _, _ = w.Write([]byte(`"hello"`)) - }) +const userFixture = ` + + +Linus Torvalds + +@linux +kernel.org + + 230000 + + + 0 + + + 6 + + +` - body, err := c.get(context.Background(), c.baseURL) +func TestParseUser(t *testing.T) { + u, err := ParseUser(userFixture, "torvalds") if err != nil { t.Fatal(err) } - if string(body) != `"hello"` { - t.Errorf("body = %q", body) + if u.Login != "torvalds" { + t.Errorf("login: want torvalds, got %q", u.Login) + } + if u.Name != "Linus Torvalds" { + t.Errorf("name: want 'Linus Torvalds', got %q", u.Name) + } + if u.URL != "https://github.com/torvalds" { + t.Errorf("url: got %q", u.URL) } } -func TestGetRetriesOn503(t *testing.T) { - var hits int - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - hits++ - if hits < 3 { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - _, _ = w.Write([]byte(`"recovered"`)) - }) - c.retries = 5 +const reposFixture = ` + + + + +` - start := time.Now() - body, err := c.get(context.Background(), c.baseURL) - if err != nil { - t.Fatal(err) +func TestParseRepos(t *testing.T) { + repos := ParseRepos(reposFixture, "torvalds") + if len(repos) != 2 { + t.Fatalf("want 2 repos, got %d", len(repos)) + } + r := repos[0] + if r.FullName != "torvalds/linux" { + t.Errorf("full_name: want torvalds/linux, got %q", r.FullName) } - if string(body) != `"recovered"` { - t.Errorf("body = %q after retries", body) + if r.Description != "Linux kernel source tree" { + t.Errorf("description: got %q", r.Description) } - if hits != 3 { - t.Errorf("server saw %d hits, want 3", hits) + if r.Language != "C" { + t.Errorf("language: want C, got %q", r.Language) } - if time.Since(start) < 500*time.Millisecond { - t.Error("retries did not back off") + if r.Stars != 182000 { + t.Errorf("stars: want 182000, got %d", r.Stars) } } -func TestSearchRepos(t *testing.T) { - desc := "A great project" - lang := "Go" - license := "MIT" - resp := searchReposResp{ - TotalCount: 1, - Items: []wireRepo{ - { - ID: 1, - FullName: "owner/repo", - Description: &desc, - HTMLURL: "https://github.com/owner/repo", - Stars: 5000, - Forks: 1200, - Language: &lang, - License: &struct { - SPDXID string `json:"spdx_id"` - }{SPDXID: license}, - PushedAt: "2024-06-01T12:00:00Z", - }, - }, - } - - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, "/search/repositories") { - t.Errorf("unexpected path: %s", r.URL.Path) - } - _ = json.NewEncoder(w).Encode(resp) - }) +const atomCommitsFixture = ` + + + tag:github.com,2008:Grit::Commit/abc1234567890abcdef + Fix memory leak in parser + 2024-01-15T12:00:00Z + Jane Doejane@example.com + + + + tag:github.com,2008:Grit::Commit/def9876543210fedcba + Add test coverage for edge cases + 2024-01-14T08:00:00Z + John Smithjohn@example.com + + +` - repos, err := c.SearchRepos(context.Background(), SearchRepoOptions{ - Query: "great project", - Limit: 5, - }) +func TestParseAtomCommits(t *testing.T) { + commits, err := ParseAtomCommits(atomCommitsFixture) if err != nil { t.Fatal(err) } - if len(repos) != 1 { - t.Fatalf("got %d repos, want 1", len(repos)) + if len(commits) != 2 { + t.Fatalf("want 2 commits, got %d", len(commits)) } - r := repos[0] - if r.FullName != "owner/repo" { - t.Errorf("FullName = %q", r.FullName) + c := commits[0] + if c.SHA != "abc1234" { + t.Errorf("sha: want abc1234, got %q", c.SHA) } - if r.Stars != 5000 { - t.Errorf("Stars = %d, want 5000", r.Stars) + if c.Message != "Fix memory leak in parser" { + t.Errorf("message: got %q", c.Message) } - if r.License != "MIT" { - t.Errorf("License = %q, want MIT", r.License) + if c.Author != "Jane Doe" { + t.Errorf("author: got %q", c.Author) } - if r.URL != "https://github.com/owner/repo" { - t.Errorf("URL = %q", r.URL) + if c.Date != "2024-01-15T12:00:00Z" { + t.Errorf("date: got %q", c.Date) } - if r.Rank != 1 { - t.Errorf("Rank = %d, want 1", r.Rank) + if c.URL != "https://github.com/foo/bar/commit/abc1234567890abcdef" { + t.Errorf("url: got %q", c.URL) + } + + c2 := commits[1] + if c2.Author != "John Smith" { + t.Errorf("author[1]: got %q", c2.Author) } } -func TestGetRepo(t *testing.T) { - desc := "Linux kernel source tree" - lang := "C" - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/repos/torvalds/linux" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - _ = json.NewEncoder(w).Encode(wireRepo{ - ID: 1234, - FullName: "torvalds/linux", - Description: &desc, - HTMLURL: "https://github.com/torvalds/linux", - Stars: 220000, - Forks: 64000, - Language: &lang, - PushedAt: "2024-06-01T00:00:00Z", - }) - }) - - repo, err := c.GetRepo(context.Background(), "torvalds", "linux") +const atomReleasesFixture = ` + + + tag:github.com,2008:Repository/12345:v1.23.0 + Go 1.23.0 + 2024-08-13T18:00:00Z + 2024-08-13T18:00:00Z + golang + + + + tag:github.com,2008:Repository/12345:v1.22.5 + Go 1.22.5 + 2024-07-01T18:00:00Z + 2024-07-01T18:00:00Z + golang + + +` + +func TestParseAtomReleases(t *testing.T) { + releases, err := ParseAtomReleases(atomReleasesFixture) if err != nil { t.Fatal(err) } - if repo.FullName != "torvalds/linux" { - t.Errorf("FullName = %q", repo.FullName) + if len(releases) != 2 { + t.Fatalf("want 2 releases, got %d", len(releases)) + } + r := releases[0] + if r.Tag != "v1.23.0" { + t.Errorf("tag: want v1.23.0, got %q", r.Tag) } - if repo.Stars != 220000 { - t.Errorf("Stars = %d, want 220000", repo.Stars) + if r.Name != "Go 1.23.0" { + t.Errorf("name: got %q", r.Name) } - if repo.Language != "C" { - t.Errorf("Language = %q, want C", repo.Language) + if r.Author != "golang" { + t.Errorf("author: got %q", r.Author) } - if repo.License != "" { - t.Errorf("License = %q, want empty", repo.License) + if r.Published != "2024-08-13T18:00:00Z" { + t.Errorf("published: got %q", r.Published) } } -func TestReleases(t *testing.T) { - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/repos/golang/go/releases" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - _ = json.NewEncoder(w).Encode([]wireRelease{ - { - TagName: "go1.22.0", - Name: "Go 1.22", - Prerelease: false, - Draft: false, - CreatedAt: "2024-02-06T00:00:00Z", - HTMLURL: "https://github.com/golang/go/releases/tag/go1.22.0", - }, - { - TagName: "go1.22rc1", - Name: "Go 1.22 RC1", - Prerelease: true, - Draft: false, - CreatedAt: "2024-01-23T00:00:00Z", - HTMLURL: "https://github.com/golang/go/releases/tag/go1.22rc1", - }, - }) - }) - - releases, err := c.Releases(context.Background(), "golang", "go", 5) +const atomTagsFixture = ` + + + v1.23.0 + 2024-08-13T18:00:00Z + + + + v1.22.5 + 2024-07-01T18:00:00Z + + +` + +func TestParseAtomTags(t *testing.T) { + tags, err := ParseAtomTags(atomTagsFixture) if err != nil { t.Fatal(err) } - if len(releases) != 2 { - t.Fatalf("got %d releases, want 2", len(releases)) + if len(tags) != 2 { + t.Fatalf("want 2 tags, got %d", len(tags)) + } + if tags[0].Name != "v1.23.0" { + t.Errorf("name: want v1.23.0, got %q", tags[0].Name) } - if releases[0].TagName != "go1.22.0" { - t.Errorf("TagName = %q", releases[0].TagName) + if tags[0].Updated != "2024-08-13T18:00:00Z" { + t.Errorf("updated: got %q", tags[0].Updated) } - if releases[0].Rank != 1 { - t.Errorf("Rank = %d, want 1", releases[0].Rank) + if tags[1].Name != "v1.22.5" { + t.Errorf("name[1]: got %q", tags[1].Name) } - if releases[1].Prerelease != true { - t.Errorf("Prerelease = %v, want true", releases[1].Prerelease) +} + +const issuesFixture = ` + + +
+ Fix crash on empty input + Jan 15 + opened by janedoe + bug +
+
+ Add support for generics + Jan 10 + opened by johnsmith + enhancement + 5 comments +
+ +` + +func TestParseIssues(t *testing.T) { + issues := ParseIssues(issuesFixture, "golang", "go", "open") + if len(issues) < 1 { + t.Fatalf("want at least 1 issue, got %d", len(issues)) + } + iss := issues[0] + if iss.Number != 42 { + t.Errorf("number: want 42, got %d", iss.Number) } - if releases[0].URL != "https://github.com/golang/go/releases/tag/go1.22.0" { - t.Errorf("URL = %q", releases[0].URL) + if !strings.Contains(iss.Title, "crash") && !strings.Contains(iss.Title, "empty") { + t.Errorf("title should mention crash or empty, got %q", iss.Title) } } -func TestGetUser(t *testing.T) { - name := "Linus Torvalds" - company := "Linux Foundation" - location := "Portland, OR" - bio := "Creator of Linux" - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/torvalds" { - t.Errorf("unexpected path: %s", r.URL.Path) +const searchFixture = ` + + + +

The Go programming language

+Go +112,000 stars +Jan 15 + +

Empowering everyone to build reliable and efficient software.

+Rust +90,000 stars +Jan 14 + +` + +func TestParseSearch(t *testing.T) { + results := ParseSearch(searchFixture) + if len(results) < 1 { + t.Fatalf("want at least 1 result, got %d", len(results)) + } + r := results[0] + if r.FullName != "golang/go" { + t.Errorf("full_name: want golang/go, got %q", r.FullName) + } + if r.Stars != 112000 { + t.Errorf("stars: want 112000, got %d", r.Stars) + } +} + +const followersFixture = ` + + +janedoe +Jane Doe +johnsmith +John Smith + +` + +func TestParseFollowers(t *testing.T) { + users := ParseFollowers(followersFixture) + if len(users) < 2 { + t.Fatalf("want at least 2 followers, got %d", len(users)) + } + if users[0].Login != "janedoe" { + t.Errorf("login: want janedoe, got %q", users[0].Login) + } + if users[1].Login != "johnsmith" { + t.Errorf("login[1]: want johnsmith, got %q", users[1].Login) + } +} + +const starsFixture = ` + + +
+

golang/go

+

The Go programming language

+ Go + 123,456 +
+
+

rust-lang/rust

+

Empowering everyone to build reliable and efficient software.

+ Rust + 90,000 +
+ +` + +func TestParseStars(t *testing.T) { + repos := ParseStars(starsFixture) + if len(repos) < 2 { + t.Fatalf("want at least 2 starred repos, got %d", len(repos)) + } + r := repos[0] + if r.FullName != "golang/go" { + t.Errorf("full_name: want golang/go, got %q", r.FullName) + } + if r.Language != "Go" { + t.Errorf("language: want Go, got %q", r.Language) + } +} + +// ── cleanInt tests ─────────────────────────────────────────────────────────── + +func TestCleanInt(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"12,345", 12345}, + {"1,234,567", 1234567}, + {"3.2k", 3200}, + {"5k", 5000}, + {"0", 0}, + {"", 0}, + {"abc", 0}, + {"100", 100}, + } + for _, tc := range cases { + got := cleanInt(tc.in) + if got != tc.want { + t.Errorf("cleanInt(%q): want %d, got %d", tc.in, tc.want, got) + } + } +} + +// ── HTTP integration tests ─────────────────────────────────────────────────── + +func TestClientTrending(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/trending") { + http.NotFound(w, r) + return } - _ = json.NewEncoder(w).Encode(wireUser{ - Login: "torvalds", - Name: &name, - Company: &company, - Location: &location, - Bio: &bio, - PublicRepos: 10, - Followers: 200000, - HTMLURL: "https://github.com/torvalds", - }) - }) - - user, err := c.GetUser(context.Background(), "torvalds") + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(trendingFixture)) + })) + defer ts.Close() + + cfg := DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 0 // no pacing in tests + c := NewClient(cfg) + + repos, err := c.Trending(context.Background(), "", "daily") if err != nil { t.Fatal(err) } - if user.Login != "torvalds" { - t.Errorf("Login = %q", user.Login) + if len(repos) != 2 { + t.Fatalf("want 2 repos, got %d", len(repos)) } - if user.Name != "Linus Torvalds" { - t.Errorf("Name = %q", user.Name) - } - if user.Followers != 200000 { - t.Errorf("Followers = %d, want 200000", user.Followers) - } - if user.URL != "https://github.com/torvalds" { - t.Errorf("URL = %q, want https://github.com/torvalds", user.URL) + if repos[0].FullName != "golang/go" { + t.Errorf("full_name: got %q", repos[0].FullName) } } -func TestGetUserNullableName(t *testing.T) { - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(wireUser{ - Login: "nobody", - Name: nil, - PublicRepos: 0, - Followers: 0, - HTMLURL: "https://github.com/nobody", - }) - }) - - user, err := c.GetUser(context.Background(), "nobody") +func TestClientRetry429(t *testing.T) { + attempt := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt++ + if attempt == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(trendingFixture)) + })) + defer ts.Close() + + cfg := DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 0 + cfg.Timeout = 5 * time.Second + c := NewClient(cfg) + + repos, err := c.Trending(context.Background(), "", "daily") if err != nil { t.Fatal(err) } - if user.Name != "" { - t.Errorf("Name = %q, want empty for nil", user.Name) + if len(repos) != 2 { + t.Errorf("want 2 repos after retry, got %d", len(repos)) } - if user.URL != "https://github.com/nobody" { - t.Errorf("URL = %q", user.URL) + if attempt < 2 { + t.Errorf("want at least 2 attempts, got %d", attempt) } } -func TestTrendingQueryContainsCutoff(t *testing.T) { - var gotQuery string - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.Query().Get("q") - sort := r.URL.Query().Get("sort") - if sort != "stars" { - t.Errorf("sort = %q, want stars", sort) +func TestClientRateLimit(t *testing.T) { + times := make([]time.Time, 0, 3) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + times = append(times, time.Now()) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(trendingFixture)) + })) + defer ts.Close() + + cfg := DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 100 * time.Millisecond + c := NewClient(cfg) + + ctx := context.Background() + for i := 0; i < 3; i++ { + _, err := c.Trending(ctx, "", "daily") + if err != nil { + t.Fatal(err) } - _ = json.NewEncoder(w).Encode(searchReposResp{}) - }) + } - _, err := c.Trending(context.Background(), TrendingOptions{Days: 7, Limit: 5}) + if len(times) < 3 { + t.Fatalf("want 3 requests, got %d", len(times)) + } + for i := 1; i < len(times); i++ { + gap := times[i].Sub(times[i-1]) + if gap < 90*time.Millisecond { + t.Errorf("gap between request %d and %d: %v < 90ms (rate not enforced)", i-1, i, gap) + } + } +} + +func TestClientAtomCommits(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, ".atom") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/atom+xml") + _, _ = w.Write([]byte(atomCommitsFixture)) + })) + defer ts.Close() + + cfg := DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 0 + c := NewClient(cfg) + + commits, err := c.Commits(context.Background(), "foo", "bar", "main") if err != nil { t.Fatal(err) } - if !strings.Contains(gotQuery, "created:>") { - t.Errorf("query %q missing created:>", gotQuery) + if len(commits) != 2 { + t.Fatalf("want 2 commits, got %d", len(commits)) } - if !strings.Contains(gotQuery, "stars:>10") { - t.Errorf("query %q missing stars:>10", gotQuery) + if commits[0].SHA != "abc1234" { + t.Errorf("sha: want abc1234, got %q", commits[0].SHA) } } -func TestRateLimitError(t *testing.T) { - c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-RateLimit-Remaining", "0") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`)) - }) +func TestClientReadme(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "README.md") { + _, _ = w.Write([]byte("# Hello\n\nThis is the README.")) + return + } + http.NotFound(w, r) + })) + defer ts.Close() + + cfg := DefaultConfig() + cfg.RawBaseURL = ts.URL + cfg.Rate = 0 + c := NewClient(cfg) - _, err := c.get(context.Background(), c.baseURL) - if err == nil { - t.Fatal("expected error, got nil") + fc, err := c.Readme(context.Background(), "owner", "repo", "main") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(fc.Content, "Hello") { + t.Errorf("content should contain Hello, got %q", fc.Content) } - if err != ErrRateLimit { - t.Errorf("err = %v, want ErrRateLimit", err) + if fc.Path != "README.md" { + t.Errorf("path: want README.md, got %q", fc.Path) } } diff --git a/github/ids.go b/github/ids.go new file mode 100644 index 0000000..c4af9ea --- /dev/null +++ b/github/ids.go @@ -0,0 +1,37 @@ +package github + +import ( + "fmt" + "path" + "strings" +) + +// ParseRepoSlug splits an "owner/repo" string into its two parts. +// Returns an error if the string is empty, has no slash, or has more than +// one slash (e.g. "owner/repo/extra"). +func ParseRepoSlug(slug string) (owner, repo string, err error) { + slug = strings.TrimSpace(slug) + if slug == "" { + return "", "", fmt.Errorf("repo slug is empty") + } + parts := strings.SplitN(slug, "/", 3) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid repo %q: must be owner/repo", slug) + } + return parts[0], parts[1], nil +} + +// repoURL returns the canonical GitHub HTML URL for a repo. +func repoURL(owner, repo string) string { + return "https://github.com/" + owner + "/" + repo +} + +// userURL returns the canonical GitHub HTML URL for a user. +func userURL(username string) string { + return "https://github.com/" + username +} + +// lastPathSegment returns the last non-empty segment of a URL path. +func lastPathSegment(u string) string { + return path.Base(u) +} diff --git a/github/ids_test.go b/github/ids_test.go new file mode 100644 index 0000000..bb370c1 --- /dev/null +++ b/github/ids_test.go @@ -0,0 +1,40 @@ +package github + +import "testing" + +func TestParseRepoSlug(t *testing.T) { + cases := []struct { + in string + wantOwner string + wantRepo string + wantErr bool + }{ + {"golang/go", "golang", "go", false}, + {"torvalds/linux", "torvalds", "linux", false}, + {"owner/repo-name", "owner", "repo-name", false}, + {"", "", "", true}, + {"noslash", "", "", true}, + {"too/many/slashes", "", "", true}, + {"/noleadingslash", "", "", true}, + {"trailingslash/", "", "", true}, + } + for _, tc := range cases { + owner, repo, err := ParseRepoSlug(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("ParseRepoSlug(%q): want error, got owner=%q repo=%q", tc.in, owner, repo) + } + continue + } + if err != nil { + t.Errorf("ParseRepoSlug(%q): unexpected error: %v", tc.in, err) + continue + } + if owner != tc.wantOwner { + t.Errorf("ParseRepoSlug(%q): owner want %q, got %q", tc.in, tc.wantOwner, owner) + } + if repo != tc.wantRepo { + t.Errorf("ParseRepoSlug(%q): repo want %q, got %q", tc.in, tc.wantRepo, repo) + } + } +} diff --git a/github/ops.go b/github/ops.go new file mode 100644 index 0000000..317a59a --- /dev/null +++ b/github/ops.go @@ -0,0 +1,418 @@ +package github + +import ( + "context" + + "github.com/tamnd/any-cli/kit" +) + +// RegisterOps installs all 15 GitHub operations onto app. +func RegisterOps(app *kit.App) { + + // trending: top trending repositories + kit.Handle(app, kit.OpMeta{ + Name: "trending", + Group: "read", + List: true, + Summary: "List trending GitHub repositories", + Long: "Fetches the github.com/trending page and returns repository cards.", + }, func(ctx context.Context, in trendingIn, emit func(TrendingRepo) error) error { + results, err := in.Client.Trending(ctx, in.Lang, in.Since) + if err != nil { + return err + } + for _, r := range results { + if err := emit(r); err != nil { + return err + } + } + return nil + }) + + // user: GitHub user profile + kit.Handle(app, kit.OpMeta{ + Name: "user", + Group: "read", + Single: true, + Summary: "Show a GitHub user profile", + Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, + }, func(ctx context.Context, in userIn, emit func(User) error) error { + u, err := in.Client.GetUser(ctx, in.Username) + if err != nil { + return err + } + return emit(u) + }) + + // repos: list a user's public repos + kit.Handle(app, kit.OpMeta{ + Name: "repos", + Group: "read", + List: true, + Summary: "List a user's public repositories", + Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, + }, func(ctx context.Context, in reposIn, emit func(Repo) error) error { + results, err := in.Client.UserRepos(ctx, in.Username, in.Page) + if err != nil { + return err + } + for _, r := range results { + if err := emit(r); err != nil { + return err + } + } + return nil + }) + + // repo: single repository metadata + kit.Handle(app, kit.OpMeta{ + Name: "repo", + Group: "read", + Single: true, + Summary: "Show a repository's metadata", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in repoIn, emit func(Repo) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + r, err := in.Client.GetRepo(ctx, owner, repoName) + if err != nil { + return err + } + return emit(r) + }) + + // commits: commits from the Atom feed + kit.Handle(app, kit.OpMeta{ + Name: "commits", + Group: "read", + List: true, + Summary: "List recent commits (from Atom feed)", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in commitsIn, emit func(Commit) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + results, err := in.Client.Commits(ctx, owner, repoName, in.Branch) + if err != nil { + return err + } + for _, c := range results { + if err := emit(c); err != nil { + return err + } + } + return nil + }) + + // releases: releases from the Atom feed + kit.Handle(app, kit.OpMeta{ + Name: "releases", + Group: "read", + List: true, + Summary: "List releases (from Atom feed)", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in releasesIn, emit func(Release) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + results, err := in.Client.Releases(ctx, owner, repoName) + if err != nil { + return err + } + for _, r := range results { + if err := emit(r); err != nil { + return err + } + } + return nil + }) + + // tags: tags from the Atom feed + kit.Handle(app, kit.OpMeta{ + Name: "tags", + Group: "read", + List: true, + Summary: "List tags (from Atom feed)", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in tagsIn, emit func(Tag) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + results, err := in.Client.Tags(ctx, owner, repoName) + if err != nil { + return err + } + for _, t := range results { + if err := emit(t); err != nil { + return err + } + } + return nil + }) + + // issues: open issues list + kit.Handle(app, kit.OpMeta{ + Name: "issues", + Group: "read", + List: true, + Summary: "List issues for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in issuesIn, emit func(Issue) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + results, err := in.Client.Issues(ctx, owner, repoName, in.State, in.Page) + if err != nil { + return err + } + for _, iss := range results { + if err := emit(iss); err != nil { + return err + } + } + return nil + }) + + // pulls: pull requests list + kit.Handle(app, kit.OpMeta{ + Name: "pulls", + Group: "read", + List: true, + Summary: "List pull requests for a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in pullsIn, emit func(PullRequest) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + results, err := in.Client.Pulls(ctx, owner, repoName, in.State, in.Page) + if err != nil { + return err + } + for _, pr := range results { + if err := emit(pr); err != nil { + return err + } + } + return nil + }) + + // readme: fetch README content + kit.Handle(app, kit.OpMeta{ + Name: "readme", + Group: "read", + Single: true, + Summary: "Fetch the README of a repository", + Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, + }, func(ctx context.Context, in readmeIn, emit func(FileContent) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + fc, err := in.Client.Readme(ctx, owner, repoName, in.Branch) + if err != nil { + return err + } + return emit(fc) + }) + + // file: fetch any file from a repo + kit.Handle(app, kit.OpMeta{ + Name: "file", + Group: "read", + Single: true, + Summary: "Fetch a file from a repository", + Args: []kit.Arg{ + {Name: "repo", Help: "owner/repo slug"}, + {Name: "path", Help: "file path in the repository"}, + }, + }, func(ctx context.Context, in fileIn, emit func(FileContent) error) error { + owner, repoName, err := ParseRepoSlug(in.Repo) + if err != nil { + return err + } + fc, err := in.Client.File(ctx, owner, repoName, in.Branch, in.Path) + if err != nil { + return err + } + return emit(fc) + }) + + // search: search repositories + kit.Handle(app, kit.OpMeta{ + Name: "search", + Group: "read", + List: true, + Summary: "Search GitHub repositories", + Long: "Scrapes github.com/search. May be rate-limited from datacenter IPs (exit 5).", + Args: []kit.Arg{{Name: "query", Help: "search query"}}, + }, func(ctx context.Context, in searchIn, emit func(SearchRepo) error) error { + results, err := in.Client.Search(ctx, in.Query, in.Page) + if err != nil { + return err + } + for _, r := range results { + if err := emit(r); err != nil { + return err + } + } + return nil + }) + + // followers: list a user's followers + kit.Handle(app, kit.OpMeta{ + Name: "followers", + Group: "read", + List: true, + Summary: "List a user's followers", + Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, + }, func(ctx context.Context, in followersIn, emit func(User) error) error { + results, err := in.Client.Followers(ctx, in.Username, in.Page) + if err != nil { + return err + } + for _, u := range results { + if err := emit(u); err != nil { + return err + } + } + return nil + }) + + // following: list users that a user follows + kit.Handle(app, kit.OpMeta{ + Name: "following", + Group: "read", + List: true, + Summary: "List users that a user follows", + Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, + }, func(ctx context.Context, in followingIn, emit func(User) error) error { + results, err := in.Client.Following(ctx, in.Username, in.Page) + if err != nil { + return err + } + for _, u := range results { + if err := emit(u); err != nil { + return err + } + } + return nil + }) + + // stars: list starred repositories for a user + kit.Handle(app, kit.OpMeta{ + Name: "stars", + Group: "read", + List: true, + Summary: "List repositories starred by a user", + Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, + }, func(ctx context.Context, in starsIn, emit func(StarredRepo) error) error { + results, err := in.Client.Stars(ctx, in.Username, in.Page) + if err != nil { + return err + } + for _, r := range results { + if err := emit(r); err != nil { + return err + } + } + return nil + }) +} + +// ── input structs ────────────────────────────────────────────────────────── + +type trendingIn struct { + Client *Client `kit:"inject"` + Lang string `kit:"flag" help:"language filter (e.g. go, python, c++)"` + Since string `kit:"flag" help:"time window: daily, weekly, monthly" default:"daily"` +} + +type userIn struct { + Client *Client `kit:"inject"` + Username string `kit:"arg" help:"GitHub username"` +} + +type reposIn struct { + Client *Client `kit:"inject"` + Username string `kit:"arg" help:"GitHub username"` + Page int `kit:"flag" help:"page number (30 repos per page)" default:"1"` +} + +type repoIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` +} + +type commitsIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` + Branch string `kit:"flag" help:"branch name" default:"main"` +} + +type releasesIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` +} + +type tagsIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` +} + +type issuesIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` + State string `kit:"flag" help:"open, closed, or all" default:"open"` + Page int `kit:"flag" help:"page number" default:"1"` +} + +type pullsIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` + State string `kit:"flag" help:"open, closed, or all" default:"open"` + Page int `kit:"flag" help:"page number" default:"1"` +} + +type readmeIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` + Branch string `kit:"flag" help:"branch name (default: try main then master)"` +} + +type fileIn struct { + Client *Client `kit:"inject"` + Repo string `kit:"arg" help:"owner/repo slug"` + Path string `kit:"arg" help:"file path in the repository"` + Branch string `kit:"flag" help:"branch name" default:"main"` +} + +type searchIn struct { + Client *Client `kit:"inject"` + Query string `kit:"arg" help:"search query"` + Page int `kit:"flag" help:"page number" default:"1"` +} + +type followersIn struct { + Client *Client `kit:"inject"` + Username string `kit:"arg" help:"GitHub username"` + Page int `kit:"flag" help:"page number" default:"1"` +} + +type followingIn struct { + Client *Client `kit:"inject"` + Username string `kit:"arg" help:"GitHub username"` + Page int `kit:"flag" help:"page number" default:"1"` +} + +type starsIn struct { + Client *Client `kit:"inject"` + Username string `kit:"arg" help:"GitHub username"` + Page int `kit:"flag" help:"page number" default:"1"` +} diff --git a/github/parse.go b/github/parse.go new file mode 100644 index 0000000..848a80c --- /dev/null +++ b/github/parse.go @@ -0,0 +1,712 @@ +package github + +import ( + "encoding/xml" + "html" + "regexp" + "strconv" + "strings" +) + +// ── Atom wire types ────────────────────────────────────────────────────────── + +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + Entries []atomEntry `xml:"entry"` +} + +type atomEntry struct { + ID string `xml:"id"` + Title string `xml:"title"` + Published string `xml:"published"` + Updated string `xml:"updated"` + Author struct { + Name string `xml:"name"` + Email string `xml:"email"` + } `xml:"author"` + Link struct { + Href string `xml:"href,attr"` + } `xml:"link"` + Content string `xml:"content"` +} + +// ── compile-time regexes ───────────────────────────────────────────────────── + +// Trending +var ( + reTrendingArticle = regexp.MustCompile(`(?s)]*class="[^"]*Box-row[^"]*"[^>]*>(.*?)`) + reTrendingLink = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"`) + reTrendingDesc = regexp.MustCompile(`(?s)]*class="[^"]*col-9[^"]*"[^>]*>(.*?)

`) + reTrendingLang = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>\s*([^<]+?)\s*<`) + reTrendingStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*(?:<[^>]+>)*\s*([0-9,]+)`) + reTrendingForks = regexp.MustCompile(`href="[^"]+/network/members"[^>]*>\s*(?:<[^>]+>)*\s*([0-9,]+)`) + reTrendingPeriod = regexp.MustCompile(`([0-9,]+)\s+stars?\s+(?:today|this week|this month)`) +) + +// User profile +var ( + reUserName = regexp.MustCompile(`(?:itemprop="name"|class="[^"]*p-name[^"]*")[^>]*>\s*([^<\n]+?)\s*<`) + reUserBio = regexp.MustCompile(`class="[^"]*p-note[^"]*"[^>]*>\s*]*>\s*(.*?)\s*`) + reUserBioAlt = regexp.MustCompile(`class="[^"]*p-note[^"]*"[^>]*>([^<]+)<`) + reUserCompany = regexp.MustCompile(`class="[^"]*p-org[^"]*"[^>]*>(?:<[^>]+>)*\s*([^<\n]+?)\s*(?:]*>(?:[^<]*<[^/][^>]*>)*\s*([^<]+?)\s*<`) + reUserEmail = regexp.MustCompile(`class="[^"]*u-email[^"]*"[^>]*>([^<]+)<`) + reUserBlog = regexp.MustCompile(`href="(https?://[^"]+)"[^>]*rel="nofollow me"`) + reUserFollowers = regexp.MustCompile(`tab=followers"[^>]*>\s*]*>([\d,k]+)<`) + reUserFollowing = regexp.MustCompile(`tab=following"[^>]*>\s*]*>([\d,k]+)<`) + reUserRepos = regexp.MustCompile(`tab=repositories"[^>]*>\s*]*>([\d,k]+)<`) +) + +// Repo list (repos tab) — each repo in an
  • block +var ( + reRepoItem = regexp.MustCompile(`(?s)]*itemprop="owns"[^>]*>(.*?)
  • `) + reRepoItemLink = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"`) + reRepoItemDesc = regexp.MustCompile(`(?s)itemprop="description"[^>]*>\s*(.*?)\s*]*>([^<]+)<`) + reRepoItemStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*([0-9,k]+)`) + reRepoItemForks = regexp.MustCompile(`href="[^"]+/network/members"[^>]*>\s*([0-9,k]+)`) + reRepoItemDate = regexp.MustCompile(`]+datetime="([^"]+)"`) +) + +// Single repo page +var ( + reRepoDesc = regexp.MustCompile(`(?s)class="[^"]*f4 my-3[^"]*"[^>]*>\s*(.*?)\s*

    `) + reRepoDescAlt = regexp.MustCompile(`(?s)class="[^"]*about-description[^"]*"[^>]*>\s*(.*?)\s*

    `) + reRepoLang = regexp.MustCompile(`class="[^"]*color-fg-default[^"]*"\s+itemprop="programmingLanguage"[^>]*>([^<]+)<`) + reRepoLangAlt = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`) + reRepoStars = regexp.MustCompile(`href="/[^/]+/[^/]+/stargazers[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) + reRepoForks = regexp.MustCompile(`href="/[^/]+/[^/]+/forks[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) + reRepoTopics = regexp.MustCompile(`class="[^"]*topic-tag[^"]*"[^>]*>\s*([^<]+?)\s*<`) + reRepoLicense = regexp.MustCompile(`/blob/[^"]*LICENSE[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([^<]+?)\s*<`) + reRepoBranch = regexp.MustCompile(`data-menu-button[^>]*>\s*(?:<[^>]+>)*\s*([^\s<]+)\s*(?:<|$)`) + reRepoFork = regexp.MustCompile(`Forked from`) + reRepoArchive = regexp.MustCompile(`(?i)archived`) + reRepoIssues = regexp.MustCompile(`href="/[^/]+/[^/]+/issues"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) + reRepoWatchers = regexp.MustCompile(`href="/[^/]+/[^/]+/watchers[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) +) + +// Issues +var ( + reIssueBlock = regexp.MustCompile(`(?s)id="issue_(\d+)"[^>]*>(.*?)(?:id="issue_\d+"|\s*\s*)`) + reIssueTitle = regexp.MustCompile(`class="Link--primary[^"]*"[^>]*href="([^"]+)"[^>]*>\s*\n?\s*([^<\n]+)`) + reIssueDate = regexp.MustCompile(`]+datetime="([^"]+)"`) + reIssueAuthor = regexp.MustCompile(`opened by\s*]*>([^<]+)<`) + reIssueLabel = regexp.MustCompile(`class="[^"]*IssueLabel[^"]*"[^>]*>([^<]+)<`) + reIssueComments = regexp.MustCompile(`(\d+)\s+comment`) +) + +// Pull requests +var ( + rePRTitle = regexp.MustCompile(`class="Link--primary[^"]*"[^>]*href="(/[^/]+/[^/]+/pull/(\d+))"[^>]*>\s*\n?\s*([^<\n]+)`) +) + +// Search +var ( + reSearchName = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"[^>]*class="v-align-middle`) + reSearchNameB = regexp.MustCompile(`class="v-align-middle[^"]*"[^>]*href="/([^/"]+/[^/"]+)"`) + reSearchDesc = regexp.MustCompile(`(?s)]*class="[^"]*mb-1[^"]*"[^>]*>\s*(.*?)\s*

    `) + reSearchStars = regexp.MustCompile(`([0-9,]+)\s+stars?`) + reSearchLang = regexp.MustCompile(`(?s)]*class="[^"]*search-match[^"]*"[^>]*>([^<]+)<`) + reSearchDate = regexp.MustCompile(`]+datetime="([^"]+)"`) +) + +// Followers/Following +var ( + reFollowerLogin = regexp.MustCompile(`data-hovercard-type="user"[^>]*href="/([^"]+)"`) + reFollowerName = regexp.MustCompile(`class="[^"]*Link--secondary[^"]*"[^>]*>([^<]+)<`) +) + +// Stars tab +var ( + reStarName = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"[^>]*class="[^"]*Link--primary`) + reStarNameB = regexp.MustCompile(`class="[^"]*Link--primary[^"]*"[^>]*href="/([^/"]+/[^/"]+)"`) + reStarDesc = regexp.MustCompile(`(?s)]*class="[^"]*col-9[^"]*"[^>]*>(.*?)

    `) + reStarLang = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`) + reStarStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*([0-9,]+)`) +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +// cleanInt parses a comma-formatted or k-suffixed integer string. +// "12,345" → 12345; "3.2k" → 3200; returns 0 on failure. +func cleanInt(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + // handle k suffix + if strings.HasSuffix(s, "k") || strings.HasSuffix(s, "K") { + f, err := strconv.ParseFloat(strings.ReplaceAll(s[:len(s)-1], ",", ""), 64) + if err != nil { + return 0 + } + return int(f * 1000) + } + s = strings.ReplaceAll(s, ",", "") + n, _ := strconv.Atoi(s) + return n +} + +// cleanStr strips HTML tags, decodes HTML entities, and trims whitespace. +func cleanStr(s string) string { + // strip tags + reTag := regexp.MustCompile(`<[^>]+>`) + s = reTag.ReplaceAllString(s, " ") + // decode entities + s = html.UnescapeString(s) + // collapse whitespace + reWS := regexp.MustCompile(`\s+`) + s = reWS.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +// extractSHA extracts the commit SHA from a GitHub Atom entry ID. +// IDs look like: tag:github.com,2008:Grit::Commit/abc1234567890 +func extractSHA(id string) string { + if idx := strings.LastIndex(id, "/"); idx >= 0 { + return id[idx+1:] + } + return id +} + +// first returns the first capture group match, or "". +func first(re *regexp.Regexp, s string) string { + m := re.FindStringSubmatch(s) + if m == nil || len(m) < 2 { + return "" + } + return cleanStr(m[1]) +} + +// ── ParseTrending ──────────────────────────────────────────────────────────── + +// ParseTrending parses the github.com/trending HTML page. +func ParseTrending(body string) []TrendingRepo { + articles := reTrendingArticle.FindAllStringSubmatch(body, -1) + out := make([]TrendingRepo, 0, len(articles)) + for i, m := range articles { + block := m[1] + + fullName := first(reTrendingLink, block) + if fullName == "" { + continue + } + + desc := "" + if dm := reTrendingDesc.FindStringSubmatch(block); dm != nil { + desc = cleanStr(dm[1]) + } + + lang := first(reTrendingLang, block) + stars := 0 + if sm := reTrendingStars.FindStringSubmatch(block); sm != nil { + stars = cleanInt(sm[1]) + } + forks := 0 + if fm := reTrendingForks.FindStringSubmatch(block); fm != nil { + forks = cleanInt(fm[1]) + } + period := 0 + if pm := reTrendingPeriod.FindStringSubmatch(block); pm != nil { + period = cleanInt(pm[1]) + } + + out = append(out, TrendingRepo{ + Rank: i + 1, + FullName: fullName, + Description: desc, + Language: lang, + Stars: stars, + Forks: forks, + PeriodStars: period, + URL: "https://github.com/" + fullName, + }) + } + return out +} + +// ── ParseUser ──────────────────────────────────────────────────────────────── + +// ParseUser parses a github.com/{username} profile page. +func ParseUser(body, username string) (User, error) { + name := first(reUserName, body) + + bio := "" + if bm := reUserBio.FindStringSubmatch(body); bm != nil { + bio = cleanStr(bm[1]) + } else if bm2 := reUserBioAlt.FindStringSubmatch(body); bm2 != nil { + bio = cleanStr(bm2[1]) + } + + company := first(reUserCompany, body) + location := first(reUserLocation, body) + email := first(reUserEmail, body) + blog := first(reUserBlog, body) + + followers := 0 + if fm := reUserFollowers.FindStringSubmatch(body); fm != nil { + followers = cleanInt(fm[1]) + } + following := 0 + if fm := reUserFollowing.FindStringSubmatch(body); fm != nil { + following = cleanInt(fm[1]) + } + repos := 0 + if rm := reUserRepos.FindStringSubmatch(body); rm != nil { + repos = cleanInt(rm[1]) + } + + return User{ + Login: username, + Name: name, + Bio: bio, + Company: company, + Location: location, + Email: email, + Blog: blog, + Followers: followers, + Following: following, + Repos: repos, + URL: userURL(username), + }, nil +} + +// ── ParseRepos ─────────────────────────────────────────────────────────────── + +// ParseRepos parses the github.com/{username}?tab=repositories HTML page. +func ParseRepos(body, username string) []Repo { + items := reRepoItem.FindAllStringSubmatch(body, -1) + out := make([]Repo, 0, len(items)) + for _, m := range items { + block := m[1] + + fullName := first(reRepoItemLink, block) + if fullName == "" { + continue + } + + desc := "" + if dm := reRepoItemDesc.FindStringSubmatch(block); dm != nil { + desc = cleanStr(dm[1]) + } + + lang := first(reRepoItemLang, block) + stars := 0 + if sm := reRepoItemStars.FindStringSubmatch(block); sm != nil { + stars = cleanInt(sm[1]) + } + forks := 0 + if fm := reRepoItemForks.FindStringSubmatch(block); fm != nil { + forks = cleanInt(fm[1]) + } + pushedAt := first(reRepoItemDate, block) + + out = append(out, Repo{ + FullName: fullName, + Description: desc, + Language: lang, + Stars: stars, + Forks: forks, + PushedAt: pushedAt, + URL: "https://github.com/" + fullName, + }) + } + return out +} + +// ── ParseRepo ──────────────────────────────────────────────────────────────── + +// ParseRepo parses the github.com/{owner}/{repo} page. +func ParseRepo(body, owner, repo string) (Repo, error) { + fullName := owner + "/" + repo + + desc := "" + if dm := reRepoDesc.FindStringSubmatch(body); dm != nil { + desc = cleanStr(dm[1]) + } else if dm2 := reRepoDescAlt.FindStringSubmatch(body); dm2 != nil { + desc = cleanStr(dm2[1]) + } + + lang := "" + if lm := reRepoLang.FindStringSubmatch(body); lm != nil { + lang = cleanStr(lm[1]) + } else if lm2 := reRepoLangAlt.FindStringSubmatch(body); lm2 != nil { + lang = cleanStr(lm2[1]) + } + + stars := 0 + if sm := reRepoStars.FindStringSubmatch(body); sm != nil { + stars = cleanInt(sm[1]) + } + forks := 0 + if fm := reRepoForks.FindStringSubmatch(body); fm != nil { + forks = cleanInt(fm[1]) + } + openIssues := 0 + if im := reRepoIssues.FindStringSubmatch(body); im != nil { + openIssues = cleanInt(im[1]) + } + watchers := 0 + if wm := reRepoWatchers.FindStringSubmatch(body); wm != nil { + watchers = cleanInt(wm[1]) + } + + // topics + topicMatches := reRepoTopics.FindAllStringSubmatch(body, -1) + topics := make([]string, 0, len(topicMatches)) + for _, tm := range topicMatches { + t := cleanStr(tm[1]) + if t != "" { + topics = append(topics, t) + } + } + + license := first(reRepoLicense, body) + branch := first(reRepoBranch, body) + if branch == "" { + branch = "main" + } + + isFork := reRepoFork.MatchString(body) + isArchived := reRepoArchive.MatchString(body) + + return Repo{ + FullName: fullName, + Description: desc, + Language: lang, + Stars: stars, + Forks: forks, + Watchers: watchers, + OpenIssues: openIssues, + DefaultBranch: branch, + License: license, + Topics: topics, + Fork: isFork, + Archived: isArchived, + URL: repoURL(owner, repo), + }, nil +} + +// ── Atom feeds ─────────────────────────────────────────────────────────────── + +// ParseAtomCommits parses the /commits/{branch}.atom feed. +func ParseAtomCommits(body string) ([]Commit, error) { + var feed atomFeed + if err := xml.Unmarshal([]byte(body), &feed); err != nil { + return nil, err + } + out := make([]Commit, 0, len(feed.Entries)) + for _, e := range feed.Entries { + sha := extractSHA(e.ID) + if len(sha) > 7 { + sha = sha[:7] + } + out = append(out, Commit{ + SHA: sha, + Message: strings.TrimSpace(e.Title), + Author: strings.TrimSpace(e.Author.Name), + Date: e.Published, + URL: e.Link.Href, + }) + } + return out, nil +} + +// ParseAtomReleases parses the /releases.atom feed. +func ParseAtomReleases(body string) ([]Release, error) { + var feed atomFeed + if err := xml.Unmarshal([]byte(body), &feed); err != nil { + return nil, err + } + out := make([]Release, 0, len(feed.Entries)) + for _, e := range feed.Entries { + tag := lastPathSegment(e.Link.Href) + out = append(out, Release{ + Tag: tag, + Name: strings.TrimSpace(e.Title), + Author: strings.TrimSpace(e.Author.Name), + Published: e.Published, + URL: e.Link.Href, + }) + } + return out, nil +} + +// ParseAtomTags parses the /tags.atom feed. +func ParseAtomTags(body string) ([]Tag, error) { + var feed atomFeed + if err := xml.Unmarshal([]byte(body), &feed); err != nil { + return nil, err + } + out := make([]Tag, 0, len(feed.Entries)) + for _, e := range feed.Entries { + out = append(out, Tag{ + Name: strings.TrimSpace(e.Title), + Updated: e.Updated, + URL: e.Link.Href, + }) + } + return out, nil +} + +// ── ParseIssues ────────────────────────────────────────────────────────────── + +// ParseIssues parses the /{owner}/{repo}/issues HTML page. +func ParseIssues(body, owner, repo, state string) []Issue { + // find all issue-N id blocks + matches := reIssueBlock.FindAllStringSubmatch(body, -1) + out := make([]Issue, 0, len(matches)) + for _, m := range matches { + numStr := m[1] + block := m[2] + num, _ := strconv.Atoi(numStr) + if num == 0 { + continue + } + + title := "" + issueURL := "" + if tm := reIssueTitle.FindStringSubmatch(block); tm != nil { + issueURL = "https://github.com" + tm[1] + title = cleanStr(tm[2]) + } + + createdAt := first(reIssueDate, block) + author := first(reIssueAuthor, block) + + labelMatches := reIssueLabel.FindAllStringSubmatch(block, -1) + labels := make([]string, 0, len(labelMatches)) + for _, lm := range labelMatches { + labels = append(labels, cleanStr(lm[1])) + } + + comments := 0 + if cm := reIssueComments.FindStringSubmatch(block); cm != nil { + comments, _ = strconv.Atoi(cm[1]) + } + + if issueURL == "" { + issueURL = "https://github.com/" + owner + "/" + repo + "/issues/" + numStr + } + + out = append(out, Issue{ + Number: num, + Title: title, + State: state, + Author: author, + Comments: comments, + Labels: strings.Join(labels, ", "), + CreatedAt: createdAt, + URL: issueURL, + }) + } + return out +} + +// ── ParsePulls ─────────────────────────────────────────────────────────────── + +// ParsePulls parses the /{owner}/{repo}/pulls HTML page. +// The PR list uses the same HTML structure as issues with a different URL path. +func ParsePulls(body, owner, repo, state string) []PullRequest { + // use the same issue_N id structure + matches := reIssueBlock.FindAllStringSubmatch(body, -1) + out := make([]PullRequest, 0, len(matches)) + for _, m := range matches { + numStr := m[1] + block := m[2] + num, _ := strconv.Atoi(numStr) + if num == 0 { + continue + } + + title := "" + prURL := "" + prNum := num + if tm := rePRTitle.FindStringSubmatch(block); tm != nil { + prURL = "https://github.com" + tm[1] + n, _ := strconv.Atoi(tm[2]) + if n > 0 { + prNum = n + } + title = cleanStr(tm[3]) + } else if tm2 := reIssueTitle.FindStringSubmatch(block); tm2 != nil { + prURL = "https://github.com" + tm2[1] + title = cleanStr(tm2[2]) + } + + createdAt := first(reIssueDate, block) + author := first(reIssueAuthor, block) + + comments := 0 + if cm := reIssueComments.FindStringSubmatch(block); cm != nil { + comments, _ = strconv.Atoi(cm[1]) + } + + if prURL == "" { + prURL = "https://github.com/" + owner + "/" + repo + "/pull/" + numStr + } + + out = append(out, PullRequest{ + Number: prNum, + Title: title, + State: state, + Author: author, + Comments: comments, + CreatedAt: createdAt, + URL: prURL, + }) + } + return out +} + +// ── ParseSearch ────────────────────────────────────────────────────────────── + +// ParseSearch parses the github.com/search?type=repositories results page. +func ParseSearch(body string) []SearchRepo { + // find all result items by looking for full_name links + nameMatches := reSearchName.FindAllStringSubmatch(body, -1) + if len(nameMatches) == 0 { + nameMatches = reSearchNameB.FindAllStringSubmatch(body, -1) + } + + out := make([]SearchRepo, 0, len(nameMatches)) + // split body on each result card anchor to get per-card blocks + // Use a simpler approach: find all v-align-middle hrefs + reCard := regexp.MustCompile(`(?s)class="v-align-middle[^"]*"[^>]*href="/([^/"]+/[^/"]+)"[^>]*>.*?(?:class="v-align-middle|$)`) + _ = reCard + + for i, nm := range nameMatches { + fullName := nm[1] + // carve out a block around this match to extract nearby metadata + idx := strings.Index(body, nm[0]) + block := "" + if idx >= 0 { + end := idx + 2000 + if end > len(body) { + end = len(body) + } + block = body[idx:end] + } + + desc := "" + if dm := reSearchDesc.FindStringSubmatch(block); dm != nil { + desc = cleanStr(dm[1]) + } + stars := 0 + if sm := reSearchStars.FindStringSubmatch(block); sm != nil { + stars = cleanInt(sm[1]) + } + lang := first(reSearchLang, block) + updatedAt := first(reSearchDate, block) + + out = append(out, SearchRepo{ + Rank: i + 1, + FullName: fullName, + Description: desc, + Language: lang, + Stars: stars, + UpdatedAt: updatedAt, + URL: "https://github.com/" + fullName, + }) + } + return out +} + +// ── ParseFollowers / ParseFollowing ───────────────────────────────────────── + +// ParseFollowers parses the ?tab=followers HTML page. +func ParseFollowers(body string) []User { + return parseUserGrid(body) +} + +// ParseFollowing parses the ?tab=following HTML page. +func ParseFollowing(body string) []User { + return parseUserGrid(body) +} + +func parseUserGrid(body string) []User { + loginMatches := reFollowerLogin.FindAllStringSubmatch(body, -1) + out := make([]User, 0, len(loginMatches)) + seen := map[string]bool{} + for _, lm := range loginMatches { + login := cleanStr(lm[1]) + if login == "" || seen[login] { + continue + } + // skip orgs and special pages + if strings.Contains(login, "/") || strings.HasPrefix(login, "?") { + continue + } + seen[login] = true + + // carve out a block near this login to look for display name + idx := strings.Index(body, lm[0]) + name := "" + if idx >= 0 { + end := idx + 500 + if end > len(body) { + end = len(body) + } + block := body[idx:end] + name = first(reFollowerName, block) + } + + out = append(out, User{ + Login: login, + Name: name, + URL: userURL(login), + }) + } + return out +} + +// ── ParseStars ─────────────────────────────────────────────────────────────── + +// ParseStars parses the ?tab=stars HTML page. +func ParseStars(body string) []StarredRepo { + nameMatches := reStarName.FindAllStringSubmatch(body, -1) + if len(nameMatches) == 0 { + nameMatches = reStarNameB.FindAllStringSubmatch(body, -1) + } + out := make([]StarredRepo, 0, len(nameMatches)) + seen := map[string]bool{} + for _, nm := range nameMatches { + fullName := nm[1] + if fullName == "" || seen[fullName] { + continue + } + if !strings.Contains(fullName, "/") { + continue + } + seen[fullName] = true + + idx := strings.Index(body, nm[0]) + block := "" + if idx >= 0 { + end := idx + 1000 + if end > len(body) { + end = len(body) + } + block = body[idx:end] + } + + desc := "" + if dm := reStarDesc.FindStringSubmatch(block); dm != nil { + desc = cleanStr(dm[1]) + } + lang := first(reStarLang, block) + stars := 0 + if sm := reStarStars.FindStringSubmatch(block); sm != nil { + stars = cleanInt(sm[1]) + } + + out = append(out, StarredRepo{ + FullName: fullName, + Description: desc, + Language: lang, + Stars: stars, + URL: "https://github.com/" + fullName, + }) + } + return out +} diff --git a/github/types.go b/github/types.go index f45ce98..a8579f1 100644 --- a/github/types.go +++ b/github/types.go @@ -1,130 +1,130 @@ +// Package github is the scraper library behind the github CLI. +// It reads public GitHub data from HTML pages, Atom feeds, and +// raw.githubusercontent.com. No API key or authentication is required. +// +// github is an independent tool and is not affiliated with GitHub or Microsoft. package github -import "fmt" - -// Repo is the record emitted for repository commands. -type Repo struct { - Rank int `json:"rank"` - FullName string `json:"full_name"` - Description string `json:"description"` - Language string `json:"language"` - Stars int `json:"stars"` - Forks int `json:"forks"` - License string `json:"license"` - PushedAt string `json:"pushed_at"` - URL string `json:"url"` +// TrendingRepo is one entry from the GitHub trending page. +type TrendingRepo struct { + Rank int `json:"rank" table:"Rank,right"` + FullName string `json:"full_name" table:"Repo"` + Description string `json:"description" table:"Description"` + Language string `json:"language" table:"Lang"` + Stars int `json:"stars" table:"Stars,right"` + Forks int `json:"forks" table:"Forks,right"` + PeriodStars int `json:"period_stars" table:"New Stars,right"` + URL string `json:"url" table:"-" kit:"url"` } -// User is the record emitted for user commands. +// User is a GitHub user profile record. +// It is also used for the followers and following listings; +// counts are 0 on listing pages where they are not shown. type User struct { - Login string `json:"login"` - Name string `json:"name"` - Company string `json:"company"` - Location string `json:"location"` - Followers int `json:"followers"` - Repos int `json:"repos"` - Bio string `json:"bio"` - URL string `json:"url"` + Login string `json:"login" table:"Login"` + Name string `json:"name" table:"Name"` + Bio string `json:"bio" table:"-"` + Company string `json:"company" table:"Company"` + Location string `json:"location" table:"Location"` + Email string `json:"email" table:"-"` + Blog string `json:"blog" table:"-"` + Followers int `json:"followers" table:"Followers,right"` + Following int `json:"following" table:"Following,right"` + Repos int `json:"repos" table:"Repos,right"` + URL string `json:"url" table:"-" kit:"url"` } -// Release is the record emitted for the releases command. -type Release struct { - Rank int `json:"rank"` - TagName string `json:"tag_name"` - Name string `json:"name"` - Prerelease bool `json:"prerelease"` - CreatedAt string `json:"created_at"` - URL string `json:"url"` +// Repo is a repository record used by both repo (single) and repos (list). +type Repo struct { + FullName string `json:"full_name" table:"Repo"` + Description string `json:"description" table:"Description"` + Language string `json:"language" table:"Lang"` + Stars int `json:"stars" table:"Stars,right"` + Forks int `json:"forks" table:"Forks,right"` + Watchers int `json:"watchers" table:"-"` + OpenIssues int `json:"open_issues" table:"Issues,right"` + DefaultBranch string `json:"default_branch" table:"-"` + License string `json:"license" table:"License"` + Topics []string `json:"topics" table:"-"` + Fork bool `json:"fork" table:"-"` + Archived bool `json:"archived" table:"-"` + PushedAt string `json:"pushed_at" table:"Pushed"` + CreatedAt string `json:"created_at" table:"-"` + UpdatedAt string `json:"updated_at" table:"-"` + URL string `json:"url" table:"-" kit:"url"` } -// ─── wire types from GitHub REST API ───────────────────────────────────────── - -type wireRepo struct { - ID int `json:"id"` - FullName string `json:"full_name"` - Description *string `json:"description"` - HTMLURL string `json:"html_url"` - Stars int `json:"stargazers_count"` - Forks int `json:"forks_count"` - Language *string `json:"language"` - License *struct { - SPDXID string `json:"spdx_id"` - } `json:"license"` - PushedAt string `json:"pushed_at"` +// Commit is one entry from the commits Atom feed. +type Commit struct { + SHA string `json:"sha" table:"SHA"` + Message string `json:"message" table:"Message"` + Author string `json:"author" table:"Author"` + Date string `json:"date" table:"Date"` + URL string `json:"url" table:"-" kit:"url"` } -type wireUser struct { - Login string `json:"login"` - Name *string `json:"name"` - Company *string `json:"company"` - Location *string `json:"location"` - Bio *string `json:"bio"` - PublicRepos int `json:"public_repos"` - Followers int `json:"followers"` - HTMLURL string `json:"html_url"` +// Release is one entry from the releases Atom feed. +type Release struct { + Tag string `json:"tag" table:"Tag"` + Name string `json:"name" table:"Name"` + Author string `json:"author" table:"Author"` + Published string `json:"published" table:"Published"` + URL string `json:"url" table:"-" kit:"url"` } -type wireRelease struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - CreatedAt string `json:"created_at"` - HTMLURL string `json:"html_url"` +// Tag is one entry from the tags Atom feed. +type Tag struct { + Name string `json:"name" table:"Tag"` + Updated string `json:"updated" table:"Updated"` + URL string `json:"url" table:"-" kit:"url"` } -type searchReposResp struct { - TotalCount int `json:"total_count"` - Items []wireRepo `json:"items"` +// Issue is one issue row scraped from the issues HTML page. +type Issue struct { + Number int `json:"number" table:"#,right"` + Title string `json:"title" table:"Title"` + State string `json:"state" table:"State"` + Author string `json:"author" table:"Author"` + Comments int `json:"comments" table:"Comments,right"` + Labels string `json:"labels" table:"Labels"` + CreatedAt string `json:"created_at" table:"Created"` + URL string `json:"url" table:"-" kit:"url"` } -// ─── converters ────────────────────────────────────────────────────────────── - -func deref(s *string) string { - if s == nil { - return "" - } - return *s +// PullRequest is one PR row scraped from the pulls HTML page. +type PullRequest struct { + Number int `json:"number" table:"#,right"` + Title string `json:"title" table:"Title"` + State string `json:"state" table:"State"` + Author string `json:"author" table:"Author"` + Comments int `json:"comments" table:"Comments,right"` + CreatedAt string `json:"created_at" table:"Created"` + URL string `json:"url" table:"-" kit:"url"` } -func wireRepoToRepo(w wireRepo, rank int) Repo { - lic := "" - if w.License != nil { - lic = w.License.SPDXID - } - return Repo{ - Rank: rank, - FullName: w.FullName, - Description: deref(w.Description), - Language: deref(w.Language), - Stars: w.Stars, - Forks: w.Forks, - License: lic, - PushedAt: w.PushedAt, - URL: w.HTMLURL, - } +// SearchRepo is one repository card from the search results page. +type SearchRepo struct { + Rank int `json:"rank" table:"Rank,right"` + FullName string `json:"full_name" table:"Repo"` + Description string `json:"description" table:"Description"` + Language string `json:"language" table:"Lang"` + Stars int `json:"stars" table:"Stars,right"` + UpdatedAt string `json:"updated_at" table:"Updated"` + URL string `json:"url" table:"-" kit:"url"` } -func wireUserToUser(w wireUser) User { - return User{ - Login: w.Login, - Name: deref(w.Name), - Company: deref(w.Company), - Location: deref(w.Location), - Followers: w.Followers, - Repos: w.PublicRepos, - Bio: deref(w.Bio), - URL: fmt.Sprintf("https://github.com/%s", w.Login), - } +// StarredRepo is one repository card from the stars tab. +type StarredRepo struct { + FullName string `json:"full_name" table:"Repo"` + Description string `json:"description" table:"Description"` + Language string `json:"language" table:"Lang"` + Stars int `json:"stars" table:"Stars,right"` + URL string `json:"url" table:"-" kit:"url"` } -func wireReleaseToRelease(w wireRelease, rank int) Release { - return Release{ - Rank: rank, - TagName: w.TagName, - Name: w.Name, - Prerelease: w.Prerelease, - CreatedAt: w.CreatedAt, - URL: w.HTMLURL, - } +// FileContent is the result of the readme and file commands. +type FileContent struct { + Path string `json:"path" table:"Path"` + Content string `json:"content" table:"-"` + URL string `json:"url" table:"-" kit:"url"` } diff --git a/go.mod b/go.mod index 667b111..fdb0055 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.26 require ( github.com/charmbracelet/fang v1.0.0 - github.com/mattn/go-isatty v0.0.22 github.com/spf13/cobra v1.10.2 + github.com/tamnd/any-cli v0.4.0 ) require ( @@ -20,18 +20,27 @@ require ( github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.24.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect ) diff --git a/go.sum b/go.sum index e7d4564..280027e 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,14 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -47,8 +55,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -58,17 +70,51 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tamnd/any-cli v0.4.0 h1:ngyRJBvjZ2X1iBlwlmDLvY2S9aQWlDjVE7CiOwxtt5Y= +github.com/tamnd/any-cli v0.4.0/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= From b37197ef9257cf3194c09b75634515353451ed35 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:48:16 +0700 Subject: [PATCH 02/21] chore: upgrade GitHub Actions to latest versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node.js 20 is being deprecated in the Actions runtime. actions/checkout → v7.0.0 browser-actions/setup-chrome → v2.1.2 golangci/golangci-lint-action → v9.2.1 goreleaser/goreleaser-action → v7.2.2 docker/setup-qemu-action → v4.1.0 docker/setup-buildx-action → v4.1.0 docker/login-action → v4.2.0 sigstore/cosign-installer → v4.1.2 anchore/sbom-action → v0.24.0 --- .github/workflows/docs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a9d2589..8530157 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,14 +26,14 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.0 with: submodules: true # Sitemap lastmod comes from the latest content commit. fetch-depth: 0 - name: Checkout tago - uses: actions/checkout@v6.0.2 + uses: actions/checkout@v7.0.0 with: repository: tamnd/tago path: .tago-src @@ -107,7 +107,7 @@ jobs: group: cloudflare-pages-github-cli cancel-in-progress: true steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.0 with: fetch-depth: 1 sparse-checkout: scripts/ From 901392069bdb94ad3b1cfe82593e0c6ea57f91e8 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:42:00 +0700 Subject: [PATCH 03/21] gh: keyless core, repository reader, and the nine search types The foundation of the rewrite: URI parsing, the surface table, the per-field merge, the repository page decoder, and every search type that answers without a session. Search is the widest surface here. Nine of the ten types return real results to an anonymous client. The tenth is code search, which answers 200 with an empty result set, so it exits 7 with a message saying why rather than reporting no matches. Three things the live probes settled: The sidebarAbout sections block is mostly booleans meaning "this box is on the page", with only releases and usedBy arriving as objects. A typed struct there loses the whole block the first time a member is true instead of {}, so each member is decoded on its own. There is no keyless language histogram. /graphs/languages 301s back to the repository page, show_partial and the other guesses 404, and the sidebar language bar is a loading skeleton on a cold fetch. The primary language comes from a repository search instead, one hop, recorded in _via so nobody mistakes it for a byte count. Tree entry ids use owner/name@ref/path with a slash. A colon there put the filename inside the ref and produced tree URLs nobody could follow. --- gh/base.go | 254 +++++++++++++ gh/client.go | 506 ++++++++++++++++++++++++++ gh/errors.go | 121 +++++++ gh/gh.go | 55 +++ gh/live_test.go | 263 ++++++++++++++ gh/merge.go | 262 ++++++++++++++ gh/repo.go | 519 +++++++++++++++++++++++++++ gh/search.go | 812 ++++++++++++++++++++++++++++++++++++++++++ gh/surface.go | 198 ++++++++++ gh/types.go | 761 +++++++++++++++++++++++++++++++++++++++ gh/uri.go | 580 ++++++++++++++++++++++++++++++ go.mod | 9 +- go.sum | 12 + pkg/page/compact.go | 68 ++++ pkg/page/dom.go | 285 +++++++++++++++ pkg/page/page.go | 252 +++++++++++++ pkg/page/scan.go | 157 ++++++++ pkg/page/selectors.go | 172 +++++++++ 18 files changed, 5282 insertions(+), 4 deletions(-) create mode 100644 gh/base.go create mode 100644 gh/client.go create mode 100644 gh/errors.go create mode 100644 gh/gh.go create mode 100644 gh/live_test.go create mode 100644 gh/merge.go create mode 100644 gh/repo.go create mode 100644 gh/search.go create mode 100644 gh/surface.go create mode 100644 gh/types.go create mode 100644 gh/uri.go create mode 100644 pkg/page/compact.go create mode 100644 pkg/page/dom.go create mode 100644 pkg/page/page.go create mode 100644 pkg/page/scan.go create mode 100644 pkg/page/selectors.go diff --git a/gh/base.go b/gh/base.go new file mode 100644 index 0000000..6ac0744 --- /dev/null +++ b/gh/base.go @@ -0,0 +1,254 @@ +package gh + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + "sync" + "time" +) + +// base.go holds what every record has in common and the guard that keeps the +// records honest. +// +// The guard is decodeExtra. Every decoder runs it, and it puts anything the +// struct did not claim into Extra. The scenario suite then asserts Extra is +// empty. The effect is that the day GitHub adds a field, a test fails and +// names it, instead of the field being silently dropped for a year. + +// Base is embedded in every record. Kind and ID are the identity, URI and URL +// are the two addresses, Sources records where the fields came from, and Extra +// is the data-loss guard. +type Base struct { + Kind string `json:"kind" table:"kind"` + ID string `json:"id" table:"id" kit:"id"` + URI string `json:"uri,omitempty" table:"-"` + URL string `json:"url,omitempty" table:"-"` + Sources []string `json:"sources,omitempty" table:"-"` + Extra json.RawMessage `json:"extra,omitempty" table:"-"` +} + +// setIdentity fills Kind, ID, URI, and URL from a kind and an id. Every +// constructor calls it, so no record can exist with a URI that disagrees with +// its id. +func (b *Base) setIdentity(kind, id string) { + b.Kind = kind + b.ID = id + b.URI = URI(kind, id) + if u, err := Locate(kind, id); err == nil { + b.URL = u + } +} + +// addSource records a URL a field came from. Duplicates are dropped and the +// order is the order they were read, which makes the field useful for +// debugging a merge as well as for provenance. +func (b *Base) addSource(urls ...string) { + for _, u := range urls { + if u == "" { + continue + } + if !contains(b.Sources, u) { + b.Sources = append(b.Sources, u) + } + } +} + +// addExtra files a block of unmodelled keys under the name of the payload block +// they came from. Namespacing matters: "twelve unknown keys" is not actionable, +// "twelve unknown keys in sidebarAbout" is. +func (b *Base) addExtra(name string, raw json.RawMessage) { + if len(raw) == 0 { + return + } + m := map[string]json.RawMessage{} + if len(b.Extra) > 0 { + if err := json.Unmarshal(b.Extra, &m); err != nil { + return + } + } + m[name] = raw + out, err := json.Marshal(m) + if err != nil { + return + } + b.Extra = out +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +// Actor is a person or an organization as it appears inside another record: on +// a commit, an issue, a release. It is deliberately small. The full account is +// a separate read, and inlining it would turn one request into hundreds. +// Both id forms are kept. The numeric one is what avatar URLs and the older +// links use, the base64 global one is what Relay results carry, and a joiner +// downstream will have one or the other and not both. +type Actor struct { + Login string `json:"login" table:"login"` + Name string `json:"name,omitempty" table:"name"` + Type string `json:"type,omitempty" table:"-"` + NodeID string `json:"node_id,omitempty" table:"-"` + DatabaseID *int `json:"database_id,omitempty" table:"-"` + AvatarURL string `json:"avatar_url,omitempty" table:"-"` + URL string `json:"url,omitempty" table:"-"` + URI string `json:"uri,omitempty" table:"-"` +} + +// actor builds an Actor from a login, filling the derived fields. An empty +// login gives an empty Actor rather than one with a URL to nowhere. +func actor(login string) Actor { + if login == "" { + return Actor{} + } + return Actor{ + Login: login, + URL: BaseURL + "/" + login, + URI: URI(KindUser, login), + } +} + +// --- the data-loss guard --- + +// decodeExtra returns the keys of raw that v did not claim, minus the keys in +// skip. It is what stands between this tool and silently dropping a field +// GitHub added last Tuesday. +// +// skip lists are explicit, short, and commented one entry at a time. A key +// dropped without a reason is a bug waiting to be found by someone six months +// from now, so the convention is that every skip list entry says why. +func decodeExtra(raw json.RawMessage, v any, skip ...string) json.RawMessage { + if len(raw) == 0 { + return nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + for _, k := range claimedKeys(reflect.TypeOf(v)) { + delete(m, k) + } + for _, k := range skip { + if strings.HasSuffix(k, "*") { + prefix := strings.TrimSuffix(k, "*") + for key := range m { + if strings.HasPrefix(key, prefix) { + delete(m, key) + } + } + continue + } + delete(m, k) + } + // Drop the keys whose value is null or an empty container. A key GitHub + // sends as null carries no information, and reporting it as unmodelled + // data would make Extra noisy enough that nobody would read it. + for k, val := range m { + if isEmptyJSON(val) { + delete(m, k) + } + } + if len(m) == 0 { + return nil + } + out, err := json.Marshal(m) + if err != nil { + return nil + } + return out +} + +func isEmptyJSON(v json.RawMessage) bool { + s := strings.TrimSpace(string(v)) + return s == "" || s == "null" || s == "{}" || s == "[]" || s == `""` +} + +// claimedKeys walks a struct's json tags, following embedded structs, and +// returns every key the type would decode. +var claimedCache sync.Map // reflect.Type -> []string + +func claimedKeys(t reflect.Type) []string { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil + } + if v, ok := claimedCache.Load(t); ok { + return v.([]string) + } + seen := map[string]bool{} + var walk func(reflect.Type) + walk = func(t reflect.Type) { + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + tag := f.Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + if name == "-" { + continue + } + if f.Anonymous && name == "" { + ft := f.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + walk(ft) + continue + } + } + if name == "" { + name = f.Name + } + seen[name] = true + } + } + walk(t) + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + claimedCache.Store(t, keys) + return keys +} + +// --- small shared helpers --- + +// parseTime accepts the three time formats GitHub uses across its surfaces: +// RFC 3339 with a zone, RFC 3339 in UTC with a Z, and the datetime attribute +// on a element, which is the same thing. +func parseTime(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + u := t.UTC() + return &u + } + } + return nil +} + +func intp(v int) *int { return &v } + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} diff --git a/gh/client.go b/gh/client.go new file mode 100644 index 0000000..9f28442 --- /dev/null +++ b/gh/client.go @@ -0,0 +1,506 @@ +package gh + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/tamnd/any-cli/kit/errs" +) + +// Client reads github.com. It is safe for concurrent use: the pacer and the +// cache are synchronised, so a crawl running many workers still produces one +// polite stream of requests rather than one stream per worker. +// +// There is no Token field. That is not an oversight, it is the design: see the +// package comment, and see TestNoAuth, which fails the build if a credential +// ever appears in this package. +type Client struct { + HTTP *http.Client + UserAgent string + + Rate time.Duration // the minimum gap between requests, shared by every worker + Retries int + Workers int + + CacheDir string + NoCache bool + CacheTTL time.Duration + + // Deep makes a record read follow the extra requests that fill in fields + // the primary surface omits. Off by default because it multiplies requests. + Deep bool + + // Verbose writes one line per request to stderr: the URL, the surface, the + // status, and the byte count. It is the fastest way to see what a command + // actually costs. + Verbose bool + + mu sync.Mutex + last time.Time + + sem chan struct{} +} + +// NewClient returns a client configured from cfg, falling back to Defaults for +// anything unset. +func NewClient(cfg Config) *Client { + if cfg.Rate <= 0 { + cfg.Rate = Defaults.Rate + } + if cfg.Retries <= 0 { + cfg.Retries = Defaults.Retries + } + if cfg.Workers <= 0 { + cfg.Workers = Defaults.Workers + } + if cfg.Timeout <= 0 { + cfg.Timeout = Defaults.Timeout + } + if cfg.CacheTTL <= 0 { + cfg.CacheTTL = 15 * time.Minute + } + if cfg.CacheDir == "" { + cfg.CacheDir = DefaultCacheDir() + } + c := &Client{ + HTTP: &http.Client{Timeout: cfg.Timeout}, + UserAgent: cfg.UserAgent, + Rate: cfg.Rate, + Retries: cfg.Retries, + Workers: cfg.Workers, + CacheDir: cfg.CacheDir, + NoCache: cfg.NoCache, + CacheTTL: cfg.CacheTTL, + Deep: cfg.Deep, + sem: make(chan struct{}, cfg.Workers), + } + if c.UserAgent == "" { + c.UserAgent = UserAgentBase + "/dev (+https://github.com/tamnd/github-cli)" + } + return c +} + +// DefaultCacheDir is $XDG_CACHE_HOME/github-cli, or the platform equivalent. +func DefaultCacheDir() string { + base, err := os.UserCacheDir() + if err != nil { + return "" + } + return filepath.Join(base, "github-cli") +} + +// Surface names one of the eight ways this tool reads github.com. It decides +// the request headers and how a non-200 is read, which is why it travels with +// every request instead of being guessed from the URL. +type Surface int + +const ( + // SurfaceHTML is a plain page fetch. Every route serves one. + SurfaceHTML Surface = iota + // SurfaceRouteJSON asks a React route for its props with Accept: json. + SurfaceRouteJSON + // SurfaceXHR sets X-Requested-With, which unlocks the fragments the front + // end fetches for itself: refs, contributor statistics, hovercards. + SurfaceXHR + // SurfaceSearch is /search?q=&type=, which answers JSON for the asking. + SurfaceSearch + // SurfaceFeed is an .atom endpoint. + SurfaceFeed + // SurfaceRaw is raw.githubusercontent.com and codeload: bytes, no page. + SurfaceRaw + // SurfaceGit is the git smart protocol at /{owner}/{repo}.git/info/refs. + SurfaceGit +) + +func (s Surface) String() string { + return [...]string{"html", "route-json", "xhr", "search", "feed", "raw", "git"}[s] +} + +// Response is one completed exchange. FinalURL differs from URL when GitHub +// redirected, which is how a renamed repository is detected without a second +// request. +type Response struct { + Body []byte + Status int + Header http.Header + URL string + FinalURL string + Surface Surface +} + +// Get fetches a URL on a surface. Every request in the package goes through +// here, so pacing, caching, retry, and error classification each have exactly +// one home. +func (c *Client) Get(ctx context.Context, rawURL string, s Surface) (*Response, error) { + if hit, ok := c.cacheGet(rawURL, s); ok { + c.trace(hit, true) + return hit, nil + } + var last error + for attempt := 0; attempt <= c.Retries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt, last)): + } + } + resp, retry, err := c.do(ctx, rawURL, s) + if err == nil { + c.cachePut(rawURL, s, resp) + c.trace(resp, false) + return resp, nil + } + last = err + if !retry { + return nil, err + } + } + return nil, last +} + +// GetJSON fetches and decodes in one step. +func (c *Client) GetJSON(ctx context.Context, rawURL string, s Surface, v any) (*Response, error) { + resp, err := c.Get(ctx, rawURL, s) + if err != nil { + return nil, err + } + if v != nil { + if err := json.Unmarshal(resp.Body, v); err != nil { + return resp, errs.New(errs.KindNetwork, "%s: %v", shortURL(rawURL), err) + } + } + return resp, nil +} + +// GetHTML fetches a page. +func (c *Client) GetHTML(ctx context.Context, rawURL string) (*Response, error) { + return c.Get(ctx, rawURL, SurfaceHTML) +} + +// Stream opens a body without buffering, retrying, or caching. Release assets +// and repository archives go through here: a tarball does not belong in memory +// and does not belong in the cache. The caller closes the reader. +func (c *Client) Stream(ctx context.Context, rawURL string) (io.ReadCloser, http.Header, error) { + if err := c.acquire(ctx); err != nil { + return nil, nil, err + } + defer c.release() + c.pace(ctx) + req, err := c.newRequest(ctx, rawURL, SurfaceRaw) + if err != nil { + return nil, nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, wrapNetwork(rawURL, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + _ = resp.Body.Close() + return nil, nil, statusError(rawURL, resp.StatusCode, b) + } + return resp.Body, resp.Header, nil +} + +func (c *Client) do(ctx context.Context, rawURL string, s Surface) (resp *Response, retry bool, err error) { + if err := c.acquire(ctx); err != nil { + return nil, false, err + } + defer c.release() + c.pace(ctx) + + req, err := c.newRequest(ctx, rawURL, s) + if err != nil { + return nil, false, err + } + hr, err := c.HTTP.Do(req) + if err != nil { + return nil, true, wrapNetwork(rawURL, err) + } + defer func() { _ = hr.Body.Close() }() + + b, err := io.ReadAll(hr.Body) + if err != nil { + return nil, true, wrapNetwork(rawURL, err) + } + out := &Response{Body: b, Status: hr.StatusCode, Header: hr.Header, URL: rawURL, Surface: s} + if hr.Request != nil && hr.Request.URL != nil { + out.FinalURL = hr.Request.URL.String() + } + switch { + case hr.StatusCode >= 200 && hr.StatusCode < 300: + return out, false, nil + case hr.StatusCode == http.StatusNotAcceptable, hr.StatusCode == http.StatusGone: + // Not failures. 406 means "this route does not serve JSON" and 410 + // means "there is no JSON here at all". Both are answers about which + // surface to use, so they come back as a response for the caller to + // route on rather than as an error. + return out, false, nil + case hr.StatusCode == http.StatusAccepted: + // A statistic GitHub is still computing. Only the contributor graph + // does this, and it is the caller's job to poll. + return out, false, nil + } + err = statusError(rawURL, hr.StatusCode, b) + return nil, retryableStatus(hr.StatusCode), err +} + +func (c *Client) newRequest(ctx context.Context, rawURL string, s Surface) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, errs.Usage("bad url %q: %v", rawURL, err) + } + req.Header.Set("User-Agent", c.UserAgent) + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + switch s { + case SurfaceRouteJSON: + // The one header that turns a React route into its own props. + req.Header.Set("Accept", "application/json") + case SurfaceXHR: + // The front end's own fragment requests carry this, and several routes + // answer JSON only when they see it. + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + case SurfaceSearch: + req.Header.Set("Accept", "application/json") + case SurfaceFeed: + req.Header.Set("Accept", "application/atom+xml, application/xml;q=0.9") + case SurfaceRaw: + req.Header.Set("Accept", "*/*") + case SurfaceGit: + req.Header.Set("Accept", "application/x-git-upload-pack-advertisement") + default: + req.Header.Set("Accept", "text/html,application/xhtml+xml") + } + return req, nil +} + +// acquire and release bound the number of requests in flight. The semaphore is +// on the client rather than on the caller so that a crawl at depth three does +// not get three times the concurrency because it has three levels. +func (c *Client) acquire(ctx context.Context) error { + select { + case c.sem <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *Client) release() { <-c.sem } + +// pace holds the lock across the sleep on purpose. The point is that N workers +// produce one paced stream, not N paced streams. +func (c *Client) pace(ctx context.Context) { + if c.Rate <= 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if wait := c.Rate - time.Since(c.last); wait > 0 { + select { + case <-ctx.Done(): + case <-time.After(wait): + } + } + c.last = time.Now() +} + +func (c *Client) trace(r *Response, cached bool) { + if !c.Verbose || r == nil { + return + } + tag := "" + if cached { + tag = " (cached)" + } + fmt.Fprintf(os.Stderr, "%-10s %3d %7d %s%s\n", r.Surface, r.Status, len(r.Body), shortURL(r.URL), tag) +} + +func retryableStatus(status int) bool { + return status == http.StatusTooManyRequests || status >= 500 +} + +// backoff is exponential with full jitter and a thirty second ceiling. +func backoff(attempt int, _ error) time.Duration { + d := time.Duration(1< 30*time.Second { + d = 30 * time.Second + } + return d/2 + time.Duration(rand.Int64N(int64(d/2)+1)) +} + +// Poll waits for a statistic GitHub computes in the background. Only the +// contributor graph needs it: the first request kicks off the job and answers +// 202 with an empty body, and the answer arrives some seconds later. +// +// Observed twice in a row several seconds apart on a cold repository, so one +// retry is not enough. Eight attempts over a sixty second budget, and never a +// silent empty list: "no contributors" and "not computed yet" are different +// answers and conflating them would make an empty result look authoritative. +func (c *Client) Poll(ctx context.Context, rawURL string, s Surface) (*Response, error) { + wait := time.Second + deadline := time.Now().Add(60 * time.Second) + for attempt := 1; attempt <= 8; attempt++ { + resp, err := c.Get(ctx, rawURL, s) + if err != nil { + return nil, err + } + if resp.Status != http.StatusAccepted && len(resp.Body) > 0 { + return resp, nil + } + if attempt == 1 { + fmt.Fprintf(os.Stderr, "github: %s is being computed, waiting\n", shortURL(rawURL)) + } + // A 202 must never be cached: the whole point is that the answer is + // not ready yet and the next request is what makes it ready. + c.cacheDrop(rawURL, s) + if time.Now().After(deadline) { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + if wait < 30*time.Second { + wait *= 2 + } + } + return nil, errs.Unsupported("%s: github is still computing this statistic, try again shortly", shortURL(rawURL)) +} + +// --- URL building --- + +// Page builds a github.com URL from path segments, escaping each one. +func Page(parts ...string) string { + esc := make([]string, 0, len(parts)) + for _, p := range parts { + esc = append(esc, p) + } + return BaseURL + "/" + strings.Join(esc, "/") +} + +// query appends parameters, skipping empty values so optional filters can be +// passed straight through. +func query(base string, kv ...string) string { + v := url.Values{} + for i := 0; i+1 < len(kv); i += 2 { + if kv[i+1] != "" { + v.Add(kv[i], kv[i+1]) + } + } + if len(v) == 0 { + return base + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + v.Encode() +} + +// --- the on-disk response cache --- + +// The cache key includes the surface, because /golang/go answers with wholly +// different bytes depending on the Accept header and a shared key would serve +// HTML to a JSON decoder. +func (c *Client) cacheKey(rawURL string, s Surface) string { + sum := sha256.Sum256([]byte(s.String() + " " + rawURL)) + return hex.EncodeToString(sum[:]) +} + +func (c *Client) cachePath(rawURL string, s Surface) string { + key := c.cacheKey(rawURL, s) + return filepath.Join(c.CacheDir, key[:2], key) +} + +func (c *Client) cacheGet(rawURL string, s Surface) (*Response, bool) { + if c.NoCache || c.CacheDir == "" { + return nil, false + } + path := c.cachePath(rawURL, s) + st, err := os.Stat(path) + if err != nil { + return nil, false + } + if ttl := c.ttlFor(rawURL); ttl > 0 && time.Since(st.ModTime()) > ttl { + return nil, false + } + b, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var ent cacheEntry + if err := json.Unmarshal(b, &ent); err != nil { + return nil, false + } + return &Response{Body: ent.Body, Status: ent.Status, Header: ent.Header, + URL: rawURL, FinalURL: ent.FinalURL, Surface: s}, true +} + +func (c *Client) cachePut(rawURL string, s Surface, resp *Response) { + if c.NoCache || c.CacheDir == "" || resp == nil || resp.Status == http.StatusAccepted { + return + } + path := c.cachePath(rawURL, s) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + b, err := json.Marshal(cacheEntry{Body: resp.Body, Status: resp.Status, Header: resp.Header, FinalURL: resp.FinalURL}) + if err != nil { + return + } + // Write through a temp file so a killed process cannot leave a half entry + // that later decodes as valid. + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return + } + _ = os.Rename(tmp, path) +} + +func (c *Client) cacheDrop(rawURL string, s Surface) { + if c.CacheDir == "" { + return + } + _ = os.Remove(c.cachePath(rawURL, s)) +} + +type cacheEntry struct { + Body []byte `json:"body"` + Status int `json:"status"` + Header http.Header `json:"header,omitempty"` + FinalURL string `json:"finalUrl,omitempty"` +} + +// ttlFor gives an immutable document an unlimited life. Anything pinned to a +// full object name cannot change, so re-fetching it is pure waste. +func (c *Client) ttlFor(rawURL string) time.Duration { + if looksImmutable(rawURL) { + return 0 + } + return c.CacheTTL +} + +func looksImmutable(rawURL string) bool { + for _, seg := range strings.Split(rawURL, "/") { + if len(seg) == 40 && isSHA(seg) { + return true + } + } + return false +} diff --git a/gh/errors.go b/gh/errors.go new file mode 100644 index 0000000..cacd4db --- /dev/null +++ b/gh/errors.go @@ -0,0 +1,121 @@ +package gh + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + + "github.com/tamnd/any-cli/kit/errs" +) + +// errors.go is the one place an HTTP status becomes a program outcome. The CLI +// exit code, the HTTP response under `github serve`, and the MCP error object +// all read the kind decided here, so a 404 means the same thing on every +// surface. +// +// Two of GitHub's status codes are routing decisions rather than failures and +// are handled by the caller, not here: +// +// 406 this route does not serve JSON; ask for HTML instead +// 410 there is no JSON at this address at all +// +// Both mean "wrong surface", which is a thing the client can fix by trying the +// other one. Turning them into errors here would hide that. + +// statusError classifies a non-2xx response. +func statusError(rawURL string, status int, body []byte) error { + where := shortURL(rawURL) + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + // This tool sends no credential, so a 401 or a 403 means the page is not + // public. Saying "pass a token" would be wrong: there is no token to + // pass. Saying what is actually true is more useful. + if isRateLimitBody(body) { + return errs.RateLimited("%s: github is throttling anonymous reads, try again shortly", where) + } + return errs.NeedAuth("%s: not public, and this tool reads only public pages (use gh for the rest)", where) + case status == http.StatusNotFound: + return errs.NotFound("%s: not found", where) + case status == http.StatusGone: + return errs.NotFound("%s: gone", where) + case status == http.StatusTooManyRequests: + return errs.RateLimited("%s: rate limited", where) + case status == http.StatusUnavailableForLegalReasons: + return errs.Unsupported("%s: unavailable for legal reasons (DMCA)", where) + case status == http.StatusBadRequest: + return errs.Usage("%s: bad request", where) + case status >= 500: + return errs.New(errs.KindNetwork, "%s: server error %d", where, status) + default: + return errs.New(errs.KindGeneric, "%s: http %d", where, status) + } +} + +// isRateLimitBody spots the throttle page GitHub serves as a 403 when a client +// asks for too much too fast. It is the same status as a private repository +// and the body is the only way to tell them apart. +func isRateLimitBody(body []byte) bool { + s := strings.ToLower(string(body)) + if len(s) > 4096 { + s = s[:4096] + } + return strings.Contains(s, "rate limit") || strings.Contains(s, "abuse detection") || + strings.Contains(s, "too many requests") +} + +// wrapNetwork turns a transport failure into the network kind, keeping the +// distinction between "the name does not resolve" and "the page said no", +// because those two send a person to very different places. +func wrapNetwork(rawURL string, err error) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return errs.New(errs.KindNetwork, "%s: cannot resolve %s", shortURL(rawURL), dnsErr.Name) + } + return errs.New(errs.KindNetwork, "%s: %v", shortURL(rawURL), err) +} + +// shortURL trims the scheme and the host so an error message reads as a path. +// The host is the same for every message in this tool, so printing it in every +// message is noise. +func shortURL(raw string) string { + s := strings.TrimPrefix(raw, "https://") + s = strings.TrimPrefix(s, "http://") + if i := strings.Index(s, "?"); i > 0 { + s = s[:i] + } + return s +} + +// notPublic is the message for the surfaces that exist but need a session: +// code search, traffic, clones, referrers. It names what would be needed rather +// than being vague, because a vague "unsupported" wastes an afternoon. +func notPublic(what, why string) error { + return errs.Unsupported("%s is not available without a session: %s", what, why) +} + +// usageBadID rejects a malformed identifier before a request goes out. Showing +// the expected shape saves the round trip and the 404 that would follow it. +func usageBadID(kind, got, want string) error { + return errs.Usage("%q is not a %s, expected %s", got, kind, want) +} + +// structureChanged is the loud failure from doc 02 section 7: the page came +// back fine but carried none of the blocks the decoder knows. That is different +// from a missing optional field, and it must not return an empty record with a +// zero exit code. +func structureChanged(what string) error { + return errs.New(errs.KindNetwork, + "%s: the page structure changed, none of the expected data was there (run `github page %s` to see what arrived)", + what, what) +} + +// noJSONHere is what a 410 means. It is separated out so the message can say +// the useful half: the data is reachable, just on a different surface. +func noJSONHere(rawURL string) error { + return errs.Unsupported("%s serves no JSON; this is a page-only route", shortURL(rawURL)) +} diff --git a/gh/gh.go b/gh/gh.go new file mode 100644 index 0000000..17dc6f6 --- /dev/null +++ b/gh/gh.go @@ -0,0 +1,55 @@ +// Package gh reads github.com without a token. +// +// Every byte this package fetches is a byte a logged-out browser would get: +// public HTML, the JSON those pages ship inside themselves, the JSON their own +// front end asks for, Atom feeds, and the git smart protocol. There is no REST +// client here and there never will be one. The unauthenticated REST API allows +// sixty requests an hour, which is not enough to read one organization, and the +// pages are behind a CDN, which makes them faster than the API even when the +// API would work. +// +// The consequence is a read-only tool. It cannot see a private repository and +// it cannot write anything. For that, use the official gh. +package gh + +import "time" + +// The hosts. All five are public and none of them accept a credential from us. +const ( + BaseURL = "https://github.com" + RawURL = "https://raw.githubusercontent.com" + CodeLoad = "https://codeload.github.com" + GistRaw = "https://gist.githubusercontent.com" + AvatarURL = "https://avatars.githubusercontent.com" + OpenGraph = "https://opengraph.githubassets.com" +) + +// UserAgentBase is the honest half of the User-Agent. The version is appended +// at runtime. It is deliberately not configurable: making it configurable would +// be making impersonation a feature, and a tool that reads only public pages +// has no reason to hide. +const UserAgentBase = "github-cli" + +// Defaults are the pacing numbers every command starts from. GitHub publishes +// no rate limit for the pages, so these are chosen to be quieter than a person +// browsing with a few tabs open: eight requests a second across four workers. +var Defaults = Config{ + Rate: 125 * time.Millisecond, + Retries: 4, + Workers: 4, + Timeout: 30 * time.Second, +} + +// Config is the resolved per-run configuration. It carries no credential field +// because there is no credential. +type Config struct { + UserAgent string + Rate time.Duration + Retries int + Workers int + Timeout time.Duration + CacheDir string + NoCache bool + CacheTTL time.Duration + Deep bool +} diff --git a/gh/live_test.go b/gh/live_test.go new file mode 100644 index 0000000..3a464ab --- /dev/null +++ b/gh/live_test.go @@ -0,0 +1,263 @@ +package gh + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +// live_test.go talks to github.com. It is skipped unless GITHUB_LIVE=1, so the +// normal test run stays offline and deterministic, and this is what you reach +// for when you want to know whether a surface still looks the way the spec says +// it does. +// +// `make fixtures` runs these with recording on, which is how the offline +// scenario suite gets its data. + +func liveClient(t *testing.T) *Client { + t.Helper() + if os.Getenv("GITHUB_LIVE") != "1" { + t.Skip("set GITHUB_LIVE=1 to run against github.com") + } + cfg := Defaults + cfg.CacheDir = t.TempDir() + return NewClient(cfg) +} + +func TestLiveRepo(t *testing.T) { + c := liveClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + r, err := c.Repo(ctx, "gohugoio/hugo", RepoOptions{}) + if err != nil { + t.Fatal(err) + } + if r.Owner != "gohugoio" || r.Name != "hugo" { + t.Fatalf("identity: %+v", r.Base) + } + // Each of these comes from a different block of the page, so between them + // they say the whole merge worked and not just one decoder. + if r.DefaultBranch == "" { + t.Error("default branch missing, codeViewLayoutRoute did not decode") + } + if r.Stars == nil { + t.Error("stars missing, sidebarAbout did not decode") + } + if r.HeadSHA == "" { + t.Error("head sha missing, codeViewRepoRoute did not decode") + } + if len(r.Tree) == 0 { + t.Error("tree empty") + } + if r.License == "" { + t.Error("licence missing, the octicon-law selector stopped matching") + } + if r.Language == "" { + t.Error("language missing, both the language bar and the search fallback came up empty") + } + if len(r.Extra) > 0 { + // Not a failure by itself, but it is how a new upstream field announces + // itself, so it gets printed. + t.Logf("unmodelled keys: %s", string(r.Extra)) + } + out, _ := json.MarshalIndent(r, "", " ") + t.Logf("%s", out) +} + +// TestLiveSearch walks every search type that works without a session. It is +// one test rather than nine because the value is in the comparison: when one +// type changes shape and the other eight do not, the failure says so. +func TestLiveSearch(t *testing.T) { + c := liveClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // Each case asserts the one field that proves the decoder ran rather than + // just that a result came back. + cases := []struct { + typ string + query string + run func(context.Context, string) (int, string, error) + }{ + {SearchRepos, "hugo", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchRepositories(ctx, q, 5, func(r Repo) error { + n++ + if r.Owner == "" || r.Name == "" { + bad = "identity empty" + } + if r.Stars == nil { + bad = "stars nil for " + r.ID + } + logExtra(t, "repo "+r.ID, r.Extra) + return nil + }) + return n, bad, err + }}, + {SearchIssues, "repo:golang/go generics", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchIssuesAndPulls(ctx, q, SearchIssues, 5, func(th Thread) error { + n++ + if th.Number == 0 || th.Repo == "" { + bad = "identity empty" + } + if th.Author.Login == "" { + bad = "author empty, author_name moved" + } + logExtra(t, "issue "+th.ID, th.Extra) + return nil + }) + return n, bad, err + }}, + {SearchPulls, "repo:golang/go generics", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchIssuesAndPulls(ctx, q, SearchPulls, 5, func(th Thread) error { + n++ + if th.Kind != KindPR { + bad = th.ID + " is not classified as a pull request" + } + return nil + }) + return n, bad, err + }}, + {SearchUsers, "torvalds", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchAccounts(ctx, q, 5, func(a Account) error { + n++ + if a.Login == "" { + bad = "login empty" + } + logExtra(t, "user "+a.ID, a.Extra) + return nil + }) + return n, bad, err + }}, + {SearchCommits, "repo:golang/go fix", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchCommitsBy(ctx, q, 5, func(cm Commit) error { + n++ + if cm.SHA == "" { + bad = "sha empty" + } + if cm.Subject == "" { + bad = "subject empty for " + cm.SHA + } + logExtra(t, "commit "+cm.SHA, cm.Extra) + return nil + }) + return n, bad, err + }}, + {SearchDiscussions, "hugo", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchDiscussionsBy(ctx, q, 5, func(d Discussion) error { + n++ + if d.Number == 0 { + bad = "number zero" + } + // hl_title arrives entity-escaped here and nowhere else, so a + // stray / means stripHL stopped unescaping. + if strings.Contains(d.Title, "&#") { + bad = "title still escaped: " + d.Title + } + logExtra(t, "discussion "+d.ID, d.Extra) + return nil + }) + return n, bad, err + }}, + {SearchTopics, "go", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchTopicsBy(ctx, q, 5, func(tp Topic) error { + n++ + if tp.Name == "" { + bad = "name empty" + } + logExtra(t, "topic "+tp.ID, tp.Extra) + return nil + }) + return n, bad, err + }}, + {SearchPackages, "hugo", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchPackagesBy(ctx, q, 5, func(pk Package) error { + n++ + if pk.Name == "" { + bad = "name empty" + } + if pk.Type == "" { + bad = "type empty for " + pk.Name + } + logExtra(t, "package "+pk.ID, pk.Extra) + return nil + }) + return n, bad, err + }}, + {SearchWikis, "hugo", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchWikisBy(ctx, q, 5, func(w WikiPage) error { + n++ + if w.Repo == "" { + bad = "repo empty" + } + logExtra(t, "wiki "+w.ID, w.Extra) + return nil + }) + return n, bad, err + }}, + {SearchMarket, "lint", func(ctx context.Context, q string) (int, string, error) { + n, bad := 0, "" + err := c.SearchMarketplace(ctx, q, 5, func(a Action) error { + n++ + if a.Slug == "" { + bad = "slug empty" + } + logExtra(t, "action "+a.ID, a.Extra) + return nil + }) + return n, bad, err + }}, + } + + for _, tc := range cases { + t.Run(tc.typ, func(t *testing.T) { + n, bad, err := tc.run(ctx, tc.query) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatalf("no results for %q, the type or the envelope changed", tc.query) + } + if bad != "" { + t.Error(bad) + } + }) + } +} + +// TestLiveCodeSearchStaysRefused guards the one search type that answers 200 +// with nothing. If GitHub ever opens it up this test fails, which is the +// notification to go implement it. +func TestLiveCodeSearchStaysRefused(t *testing.T) { + c := liveClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var env searchEnvelope + if _, err := c.GetJSON(ctx, searchURL("func main", SearchCode, 1), SurfaceSearch, &env); err != nil { + t.Fatal(err) + } + if len(env.Payload.Results) > 0 { + t.Errorf("code search returned %d results without a session, it can be implemented now", + len(env.Payload.Results)) + } +} + +func logExtra(t *testing.T, what string, extra json.RawMessage) { + t.Helper() + if len(extra) > 0 { + t.Logf("%s unmodelled: %s", what, string(extra)) + } +} diff --git a/gh/merge.go b/gh/merge.go new file mode 100644 index 0000000..8fd6ab1 --- /dev/null +++ b/gh/merge.go @@ -0,0 +1,262 @@ +package gh + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// merge.go combines the several surfaces that answer for one record. +// +// The order is explicit and lives at the call site, one ordered list per record +// type, because a merge that cannot say which surface won is a merge that +// cannot be debugged. What lives here is only the per-field mechanics that all +// of them share: +// +// - a source that did not return a field never clears it, so merging is +// driven by a populated check and never by struct assignment, +// - Sources accumulates in merge order, so provenance is the merge history, +// - when two surfaces give different non-empty values for one field, the +// later one wins and the disagreement is recorded in Extra["_conflict"]. +// +// That last rule is the interesting one. The scenario suite asserts _conflict +// is empty, so two surfaces disagreeing becomes a named test failure and gets +// looked at, instead of being quietly averaged into a number nobody can trace. + +// mergeInto copies every populated field of src into dst, returning the fields +// where the two disagreed. dst and src must be pointers to the same struct +// type. +// +// Booleans are a known soft spot: a false bool is indistinguishable from an +// unset one, so a false never overwrites a true. Every bool in the model is +// phrased so that false is the safe default (IsFork, IsArchived, HasWiki), which +// makes that the right behaviour rather than a compromise. +func mergeInto(dst, src any) map[string][]string { + dv := reflect.ValueOf(dst) + sv := reflect.ValueOf(src) + if dv.Kind() != reflect.Pointer || sv.Kind() != reflect.Pointer { + return nil + } + if dv.Type() != sv.Type() { + return nil + } + conflicts := map[string][]string{} + mergeStruct(dv.Elem(), sv.Elem(), "", conflicts) + if len(conflicts) == 0 { + return nil + } + return conflicts +} + +func mergeStruct(dst, src reflect.Value, prefix string, conflicts map[string][]string) { + t := dst.Type() + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + d, s := dst.Field(i), src.Field(i) + + // Base is the envelope, not data. Its fields have their own merge rules + // and are handled by mergeBase. + if f.Anonymous && f.Type == reflect.TypeOf(Base{}) { + mergeBase(d.Addr().Interface().(*Base), s.Addr().Interface().(*Base)) + continue + } + // Any other embedded struct is part of the record: Thread inside Issue, + // Repo inside Trending, Account inside Org. + if f.Anonymous && f.Type.Kind() == reflect.Struct { + mergeStruct(d, s, prefix, conflicts) + continue + } + + name := jsonName(f) + if name == "-" { + continue + } + if prefix != "" { + name = prefix + "." + name + } + if !populated(s) { + continue + } + if populated(d) && !reflect.DeepEqual(d.Interface(), s.Interface()) { + conflicts[name] = []string{display(d), display(s)} + } + d.Set(s) + } +} + +// mergeBase keeps the identity of the first source and unions the provenance. +// A later surface never renames a record: if the id changed, the merge was +// between two different things and the caller made a mistake. +func mergeBase(dst, src *Base) { + if dst.Kind == "" { + dst.Kind = src.Kind + } + if dst.ID == "" { + dst.ID = src.ID + } + if dst.URI == "" { + dst.URI = src.URI + } + if dst.URL == "" { + dst.URL = src.URL + } + dst.addSource(src.Sources...) + dst.Extra = mergeExtra(dst.Extra, src.Extra) +} + +// mergeExtra unions two unmodelled-key sets. Both are keys nobody claimed, so +// there is nothing smarter to do than keep them all; a key present in both +// keeps the later value, matching the field rule above. +func mergeExtra(dst, src json.RawMessage) json.RawMessage { + if len(src) == 0 { + return dst + } + if len(dst) == 0 { + return src + } + var a, b map[string]json.RawMessage + if json.Unmarshal(dst, &a) != nil || json.Unmarshal(src, &b) != nil { + return dst + } + for k, v := range b { + a[k] = v + } + out, err := json.Marshal(a) + if err != nil { + return dst + } + return out +} + +// recordConflicts writes the disagreements into Extra["_conflict"]. It is a +// reserved key and the suite asserts it is absent, which is the whole point: +// this is a tripwire, not a feature. +func recordConflicts(b *Base, conflicts map[string][]string) { + if len(conflicts) == 0 { + return + } + m := map[string]json.RawMessage{} + if len(b.Extra) > 0 { + _ = json.Unmarshal(b.Extra, &m) + } + existing := map[string][]string{} + if raw, ok := m["_conflict"]; ok { + _ = json.Unmarshal(raw, &existing) + } + for k, v := range conflicts { + existing[k] = v + } + raw, err := json.Marshal(existing) + if err != nil { + return + } + m["_conflict"] = raw + out, err := json.Marshal(m) + if err != nil { + return + } + b.Extra = out +} + +// recordVia notes which extraction tier produced a field. It is populated only +// under --verbose, and it is what tells you that a field which used to arrive +// from a JSON payload is now arriving from a class selector, which is the early +// warning that something moved. +func recordVia(b *Base, field, tier string) { + m := map[string]json.RawMessage{} + if len(b.Extra) > 0 { + _ = json.Unmarshal(b.Extra, &m) + } + via := map[string]string{} + if raw, ok := m["_via"]; ok { + _ = json.Unmarshal(raw, &via) + } + via[field] = tier + raw, err := json.Marshal(via) + if err != nil { + return + } + m["_via"] = raw + out, err := json.Marshal(m) + if err != nil { + return + } + b.Extra = out +} + +// populated is the "did this surface actually say something" test. Absent is +// not zero anywhere in this tool, and this function is where that rule is +// enforced for the merge. +func populated(v reflect.Value) bool { + switch v.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice: + return !v.IsNil() && (v.Kind() != reflect.Slice && v.Kind() != reflect.Map || v.Len() > 0) + case reflect.String: + return v.String() != "" + case reflect.Bool: + return v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() != 0 + case reflect.Float32, reflect.Float64: + return v.Float() != 0 + case reflect.Struct: + return !v.IsZero() + default: + return !v.IsZero() + } +} + +func jsonName(f reflect.StructField) string { + tag := f.Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + if name == "" { + return f.Name + } + return name +} + +// display renders a value for a conflict entry. It stays short: the point is to +// let a human see which two surfaces disagreed, not to reproduce the payload. +func display(v reflect.Value) string { + if v.Kind() == reflect.Pointer && !v.IsNil() { + v = v.Elem() + } + switch v.Kind() { + case reflect.String: + return truncate(v.String(), 120) + case reflect.Slice, reflect.Map, reflect.Struct: + raw, err := json.Marshal(v.Interface()) + if err != nil { + return fmt.Sprint(v.Interface()) + } + return truncate(string(raw), 200) + default: + return fmt.Sprint(v.Interface()) + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// sortedKeys keeps merge and conflict output deterministic. Map order is random +// in Go, and a record whose field order changes between runs makes every diff +// of two outputs useless. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/gh/repo.go b/gh/repo.go new file mode 100644 index 0000000..e47c724 --- /dev/null +++ b/gh/repo.go @@ -0,0 +1,519 @@ +package gh + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "golang.org/x/net/html" + + "github.com/tamnd/github-cli/pkg/page" +) + +// repo.go reads a repository. +// +// It reads HTML rather than the JSON route, and that is not laziness. The JSON +// route returns the file tree and the ids and nothing else; description, +// homepage, topics, stars, watchers, forks, licence, and the release and tag +// counts live in payload.sidebarAbout, which only ever ships inside the HTML +// document. One request gets all of it. +// +// Merge order, later sources overwriting earlier ones field by field and only +// where they actually said something: +// +// 1. codeViewLayoutRoute.repo ids and structure +// 2. sidebarAbout counts and metadata +// 3. codeViewRepoRoute head SHA, tree, commit count, README +// 4. the DOM licence and the language bar +// 5. deferred fragments languages and dependents, --deep only + +// RepoOptions controls how much a repository read costs. +type RepoOptions struct { + // Deep runs the fragment fetches: the language histogram and the dependent + // count. Four to six requests instead of one. + Deep bool + // Readme keeps the rendered README, which is most of the response body on a + // well-documented repository. + Readme bool +} + +// Repo reads one repository. id is owner/name. +func (c *Client) Repo(ctx context.Context, id string, opts RepoOptions) (*Repo, error) { + owner, name, ok := SplitRepo(id) + if !ok { + return nil, usageBadID("repository", id, "owner/name") + } + res, err := c.GetHTML(ctx, repoURL(id)) + if err != nil { + return nil, err + } + p := page.Extract(res.FinalURL, res.Body) + + r := &Repo{Owner: owner, Name: name} + r.setIdentity(KindRepo, id) + r.addSource(res.FinalURL) + if p.Canonical != "" { + // Renames 301, and the payload then reports the new name while the + // caller asked for the old one. The canonical link is the authority. + r.URL = p.Canonical + } + + found := false + if raw, ok := p.Route("codeViewLayoutRoute"); ok { + found = r.readLayoutRoute(raw) || found + } + if raw, ok := p.Payload["sidebarAbout"]; ok { + found = r.readSidebar(raw) || found + } + if raw, ok := p.Route("codeViewRepoRoute"); ok { + found = r.readRepoRoute(raw, opts.Readme) || found + } + if !found { + return nil, structureChanged(id) + } + r.readDOM(p) + + // The language is worth a second request. It is one of the fields people + // most expect on a repository record, the page carries it only in a bar + // that is a loading skeleton on a cold fetch, and search answers it in one + // hop. This runs only when the page did not already say. + if r.Language == "" { + if lang, color, err := c.searchLanguage(ctx, id); err == nil && lang != "" { + r.Language = lang + r.LanguageColor = color + recordVia(&r.Base, "language", "search") + } + } + + if opts.Deep { + if err := c.deepenRepo(ctx, r); err != nil { + return nil, err + } + } + return r, nil +} + +// --- payload blocks --- + +type layoutRepo struct { + ID *int `json:"id"` + Name string `json:"name"` + OwnerLogin string `json:"ownerLogin"` + DefaultBranch string `json:"defaultBranch"` + CreatedAt string `json:"createdAt"` + IsFork bool `json:"isFork"` + IsEmpty bool `json:"isEmpty"` + Private bool `json:"private"` + IsOrgOwned bool `json:"isOrgOwned"` + OwnerAvatar string `json:"ownerAvatar"` +} + +func (r *Repo) readLayoutRoute(raw json.RawMessage) bool { + var v struct { + Repo json.RawMessage `json:"repo"` + } + if err := json.Unmarshal(raw, &v); err != nil || len(v.Repo) == 0 { + return false + } + var lr layoutRepo + if err := json.Unmarshal(v.Repo, &lr); err != nil { + return false + } + r.DatabaseID = lr.ID + if lr.Name != "" { + r.Name = lr.Name + } + if lr.OwnerLogin != "" { + r.Owner = lr.OwnerLogin + } + r.DefaultBranch = lr.DefaultBranch + r.CreatedAt = parseTime(lr.CreatedAt) + r.IsFork = lr.IsFork + r.IsEmpty = lr.IsEmpty + r.IsPrivate = lr.Private + r.IsOrgOwned = lr.IsOrgOwned + r.OwnerAvatarURL = lr.OwnerAvatar + + r.addExtra("codeViewLayoutRoute.repo", decodeExtra(v.Repo, &lr, + // The inverse of private, and both always arrive. + "public", + // Viewer permissions, uniformly false without a session. + "currentUserCanPush", "currentUserCanFork", "currentUserIsOwner", + )) + return true +} + +type sidebarAbout struct { + Description string `json:"description"` + Website string `json:"website"` + StargazerCount *int `json:"stargazerCount"` + WatcherCount *int `json:"watcherCount"` + ForksCount *int `json:"forksCount"` + StargazersPath string `json:"stargazersPath"` + ForkNetworkPath string `json:"forkNetworkPath"` + ActivityPath string `json:"activityPath"` + OwnerLogin string `json:"ownerLogin"` + RepoName string `json:"repoName"` + IsOrg bool `json:"isOrg"` + HasCitation bool `json:"hasCitation"` + + Topics []struct { + Name string `json:"name"` + } `json:"topics"` + + // Sections is deliberately untyped. Most of its members are a plain bool + // meaning "this box is on the page", but a few are objects carrying the + // counts, and which is which changes with what the repository has. A typed + // struct here loses the whole block the moment one member arrives as true + // instead of {}, so each member is decoded on its own below and a member + // that does not fit costs only that member. + Sections map[string]json.RawMessage `json:"sections"` +} + +// releasesSection and usedBySection are the two members that carry numbers. +type releasesSection struct { + ReleaseCount *int `json:"releaseCount"` + TagCount *int `json:"tagCount"` +} + +type usedBySection struct { + DependentsCount *int `json:"dependentsCount"` +} + +// section decodes one member of sidebarAbout.sections, and returns false for +// the bool form rather than treating it as a failure. +func section[T any](m map[string]json.RawMessage, name string) (T, bool) { + var out T + raw, ok := m[name] + if !ok || len(raw) == 0 || raw[0] != '{' { + return out, false + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, false + } + return out, true +} + +func (r *Repo) readSidebar(raw json.RawMessage) bool { + var sa sidebarAbout + if err := json.Unmarshal(raw, &sa); err != nil { + return false + } + r.Description = sa.Description + r.Homepage = sa.Website + r.Stars = sa.StargazerCount + r.Watchers = sa.WatcherCount + r.Forks = sa.ForksCount + r.StargazersPath = sa.StargazersPath + r.ForkNetworkPath = sa.ForkNetworkPath + r.ActivityPath = sa.ActivityPath + r.HasCitation = sa.HasCitation + r.IsOrgOwned = r.IsOrgOwned || sa.IsOrg + if rel, ok := section[releasesSection](sa.Sections, "releases"); ok { + r.ReleaseCount = rel.ReleaseCount + r.TagCount = rel.TagCount + } + if used, ok := section[usedBySection](sa.Sections, "usedBy"); ok && used.DependentsCount != nil { + r.DependentCount = used.DependentsCount + } + for _, t := range sa.Topics { + if t.Name != "" { + r.Topics = append(r.Topics, t.Name) + } + } + if langs := decodeLanguages(sa.Sections["languages"]); len(langs) > 0 { + r.Languages = langs + r.Language = topLanguage(langs) + } + + r.addExtra("sidebarAbout", decodeExtra(raw, &sa, + // The description again with emoji shortcodes expanded. The raw form is + // what the record carries. + "formattedDescription", + // UI state and viewer permissions. + "showInsights", "canEditMetadata", "canEditTopics", + // Routes to pages with nothing public on them. + "reportPath", "customPropertiesPath", "watchersPath", + )) + return true +} + +type repoRoute struct { + Path string `json:"path"` + RefInfo struct { + Name string `json:"name"` + RefType string `json:"refType"` + CurrentOid string `json:"currentOid"` + } `json:"refInfo"` + Tree struct { + Items []treeItem `json:"items"` + TotalCount *int `json:"totalCount"` + } `json:"tree"` + Overview struct { + CommitCount string `json:"commitCount"` + OverviewFiles []struct { + DisplayName string `json:"displayName"` + Path string `json:"path"` + PreferredFileType string `json:"preferredFileType"` + RichText string `json:"richText"` + } `json:"overviewFiles"` + } `json:"overview"` +} + +type treeItem struct { + Name string `json:"name"` + Path string `json:"path"` + ContentType string `json:"contentType"` +} + +func (r *Repo) readRepoRoute(raw json.RawMessage, keepReadme bool) bool { + var rr repoRoute + if err := json.Unmarshal(raw, &rr); err != nil { + return false + } + r.HeadSHA = rr.RefInfo.CurrentOid + if r.DefaultBranch == "" { + r.DefaultBranch = rr.RefInfo.Name + } + r.FileCount = rr.Tree.TotalCount + + ref := firstNonEmpty(rr.RefInfo.Name, r.DefaultBranch) + for _, it := range rr.Tree.Items { + r.Tree = append(r.Tree, newTreeEntry(r.ID, ref, it)) + } + + // "8,112" in one locale and "8.112" in another, for the same number. Both + // forms are kept so that the ambiguity stays visible. + if n, display, ok := page.ParseCompactCount(rr.Overview.CommitCount); ok { + r.CommitCount = intp(n) + r.CommitCountDisplay = display + } + + for _, f := range rr.Overview.OverviewFiles { + if f.PreferredFileType != "readme" { + continue + } + r.ReadmePath = f.Path + if keepReadme { + r.ReadmeHTML = f.RichText + } + break + } + + r.addExtra("codeViewRepoRoute", decodeExtra(raw, &rr, + // Chrome: which panels the front end opens, which buttons it draws. + "banners", "codeButton", "popovers", "treeExpanded", "symbolsExpanded", + "isOverview", "showBranchInfobar", "userNameDisplayConfiguration", + // Copilot entitlement, which is a property of the viewer, not the repo. + "copilot*", + )) + return true +} + +// newTreeEntry builds one entry. The id form is owner/name@ref/path with a +// slash, not a colon: Locate splits on the first slash after the ref, so a colon +// there lands the whole filename inside the ref and produces a URL nobody can +// follow. +func newTreeEntry(repo, ref string, it treeItem) TreeEntry { + e := TreeEntry{Repo: repo, Ref: ref, Name: it.Name, Path: it.Path, Type: it.ContentType} + kind := KindFile + if it.ContentType == "directory" { + kind = KindTree + } + e.setIdentity(kind, repo+"@"+ref+"/"+it.Path) + return e +} + +// decodeLanguages reads the language histogram out of sections.languages. The +// block is lazily populated and is an empty object on a cold page, which is why +// there is a language-bar fallback below. +func decodeLanguages(raw json.RawMessage) map[string]int64 { + if len(raw) == 0 { + return nil + } + var v struct { + Languages []struct { + Name string `json:"name"` + Size *int64 `json:"size"` + Bytes *int64 `json:"bytes"` + } `json:"languages"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return nil + } + out := map[string]int64{} + for _, l := range v.Languages { + switch { + case l.Size != nil: + out[l.Name] = *l.Size + case l.Bytes != nil: + out[l.Name] = *l.Bytes + } + } + if len(out) == 0 { + return nil + } + return out +} + +func topLanguage(langs map[string]int64) string { + best, bestN := "", int64(-1) + for _, k := range sortedKeys(langs) { + if langs[k] > bestN { + best, bestN = k, langs[k] + } + } + return best +} + +// --- the DOM pass --- + +// readDOM picks up the two fields that exist nowhere in any payload: the +// licence and the language bar. Both are class selectors, both are in the +// bottom tier of the preference ladder, and both degrade to absent rather than +// to wrong. +func (r *Repo) readDOM(p *page.Page) { + doc := p.Doc() + if doc == nil { + return + } + if r.SocialImageURL == "" { + r.SocialImageURL = p.MetaContent("og:image") + } + if lic := licenseFrom(doc); lic != "" { + r.License = lic + } + if len(r.Languages) == 0 { + if langs, pct := languageBar(doc); len(langs) > 0 { + r.Languages = langs + r.Language = topLanguage(langs) + if pct { + // Percentages are not byte counts and the record must not + // pretend otherwise. + recordVia(&r.Base, "languages", "bar") + } + } + } + if r.Description == "" { + r.Description = descriptionFromOG(p.MetaContent("og:title"), r.ID) + } +} + +// licenseFrom reads the About-sidebar licence link. This is the single most +// fragile extraction in the tool: the anchor is found by the icon inside it, +// because the icon outlives the anchor's classes. When it stops matching, the +// field is absent, which is a truthful answer, rather than empty, which would +// be a claim. +func licenseFrom(doc *html.Node) string { + a := page.Find(doc, page.LicenseLink) + if a == nil { + return "" + } + text := page.Text(a) + text = strings.TrimSuffix(text, " license") + text = strings.TrimSuffix(text, " License") + return strings.TrimSpace(text) +} + +// languageBar reads the coloured bar under the About box. The second return +// says the values are percentages times one hundred rather than byte counts, +// which the caller then records so that nobody aggregates the two together. +func languageBar(doc *html.Node) (map[string]int64, bool) { + links := page.FindAll(doc, page.LanguageBarItem) + if len(links) == 0 { + return nil, false + } + out := map[string]int64{} + for _, a := range links { + nameNode := page.Find(a, page.LanguageBarName) + if nameNode == nil { + continue + } + name := page.Text(nameNode) + rest := strings.TrimSpace(strings.TrimPrefix(page.Text(a), name)) + pct, err := strconv.ParseFloat(strings.TrimSuffix(rest, "%"), 64) + if err != nil || name == "" { + continue + } + out[name] = int64(pct * 100) + } + if len(out) == 0 { + return nil, false + } + return out, true +} + +// descriptionFromOG recovers a description from the Open Graph title, which on +// a repository reads "GitHub - owner/name: the description". This looks like a +// hack until the sidebar has not loaded, which happens, and then it is the only +// description on the page. +func descriptionFromOG(title, id string) string { + marker := id + ": " + i := strings.Index(title, marker) + if i < 0 { + return "" + } + return strings.TrimSpace(title[i+len(marker):]) +} + +// --- the deep pass --- + +// deepenRepo runs the extra fetch --deep opts into: the dependent count off the +// dependency graph. The failure is soft. A dependency graph that is disabled is +// a fact about the repository, not an error in the read. +func (c *Client) deepenRepo(ctx context.Context, r *Repo) error { + if n, err := c.dependents(ctx, r.ID); err == nil && n != nil { + r.DependentCount = n + } + r.addSource(repoSubURL(r.ID, "network/dependents")) + return nil +} + +// searchLanguage asks repository search for the repository by name, because the +// search result carries the primary language and its colour and the repository +// page does not. +// +// The obvious place to look is /{owner}/{repo}/graphs/languages, and it is a +// dead end: it 301s back to the repository page for an anonymous client, and +// none of show_partial, /languages, or /graphs/languages-data exist. The +// language bar in the sidebar is the other source, and on a cold page it is a +// skeleton with no /search?l= links in it at all. So the histogram with real +// byte counts has no keyless source, and the language name does, one search away. +func (c *Client) searchLanguage(ctx context.Context, id string) (lang, color string, err error) { + owner, name, ok := SplitRepo(id) + if !ok { + return "", "", usageBadID("repository", id, "owner/name") + } + q := "repo:" + owner + "/" + name + err = c.SearchRepositories(ctx, q, 5, func(r Repo) error { + if r.ID == id && r.Language != "" { + lang, color = r.Language, r.LanguageColor + } + return nil + }) + if err != nil { + return "", "", err + } + return lang, color, nil +} + +// dependents reads the count off the /network/dependents heading. Prose, marked +// fragile, and absent when the graph is off or private. +func (c *Client) dependents(ctx context.Context, id string) (*int, error) { + res, err := c.GetHTML(ctx, repoSubURL(id, "network/dependents")) + if err != nil { + return nil, err + } + p := page.Extract(res.FinalURL, res.Body) + doc := p.Doc() + if doc == nil { + return nil, nil + } + for _, a := range page.FindAll(doc, page.Sel{Tag: "a", Attr: "href", AttrContains: "dependent_type=REPOSITORY"}) { + if n, _, ok := page.CountIn(page.Text(a)); ok { + return intp(n), nil + } + } + return nil, nil +} diff --git a/gh/search.go b/gh/search.go new file mode 100644 index 0000000..198e759 --- /dev/null +++ b/gh/search.go @@ -0,0 +1,812 @@ +package gh + +import ( + "context" + "encoding/json" + "html" + "strconv" + "strings" + + "github.com/tamnd/any-cli/kit/errs" +) + +// search.go is the widest surface this tool has. Ten search types answer JSON +// to an anonymous Accept header, and nine of them return real results. Code +// search is the tenth: it answers 200 with an empty result set, because it +// needs a session, and this file says so out loud rather than returning zero +// hits and letting the caller conclude the query was wrong. +// +// Search results are decoded into the same record types a full read produces. +// A caller doing `github repos --org golang` gets Repo records, thinner but the +// same type, so --fields works the same and the output pipes into `github get` +// for the full read. A record from a listing is a real record with fewer +// fields, never a different type. + +// The site's own names for the search types. They are not guessable (issues but +// pullrequests, registrypackages but wikis) which is exactly why they are +// constants. +const ( + SearchRepos = "repositories" + SearchIssues = "issues" + SearchPulls = "pullrequests" + SearchUsers = "users" + SearchCommits = "commits" + SearchDiscussions = "discussions" + SearchTopics = "topics" + SearchPackages = "registrypackages" + SearchWikis = "wikis" + SearchMarket = "marketplace" + SearchCode = "code" +) + +// SearchTypes is every type in the order the commands present them. +var SearchTypes = []string{ + SearchRepos, SearchIssues, SearchPulls, SearchUsers, SearchCommits, + SearchDiscussions, SearchTopics, SearchPackages, SearchWikis, SearchMarket, +} + +// searchEnvelope is the shape every search type shares. +type searchEnvelope struct { + Payload struct { + Results []json.RawMessage `json:"results"` + Type string `json:"type"` + Page int `json:"page"` + PageCount int `json:"page_count"` + ResultCount int `json:"result_count"` + Errors []string `json:"errors"` + // WarnLimitedResults is set when GitHub capped the result set, which it + // does silently otherwise. + WarnLimitedResults bool `json:"warn_limited_results"` + } `json:"payload"` +} + +// searchFetch turns one search type into a pager. The numbered ?p=N form is the +// only pagination search has, and page_count is authoritative, so the walk stops +// on the count rather than on a short page. +func searchFetch[T any](c *Client, query, typ string, decode func(json.RawMessage) (T, bool)) fetchPage[T] { + return func(ctx context.Context, token string) ([]T, string, error) { + n := pageToken(token) + var env searchEnvelope + res, err := c.GetJSON(ctx, searchURL(query, typ, n), SurfaceSearch, &env) + if err != nil { + return nil, "", err + } + if len(env.Payload.Errors) > 0 { + return nil, "", errs.Usage("search: %s", strings.Join(env.Payload.Errors, "; ")) + } + out := make([]T, 0, len(env.Payload.Results)) + for _, raw := range env.Payload.Results { + rec, ok := decode(raw) + if !ok { + continue + } + if b := baseOf(&rec); b != nil { + b.addSource(res.FinalURL) + } + out = append(out, rec) + } + next := "" + if n < env.Payload.PageCount { + next = strconv.Itoa(n + 1) + } + return out, next, nil + } +} + +// baseOf reaches the embedded Base of a record so the pager can stamp the +// source URL without every decoder repeating it. +func baseOf(v any) *Base { + type based interface{ base() *Base } + if b, ok := v.(based); ok { + return b.base() + } + return nil +} + +func (b *Base) base() *Base { return b } + +// --- the nine working types --- + +// SearchRepositories streams repository records for a query. +func (c *Client) SearchRepositories(ctx context.Context, query string, limit int, emit func(Repo) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchRepos, decodeSearchRepo), emit) +} + +// SearchIssuesAndPulls streams thread records. typ is SearchIssues or +// SearchPulls: the result shape is identical and only the qualifier differs, +// which is why one decoder serves both. +func (c *Client) SearchIssuesAndPulls(ctx context.Context, query, typ string, limit int, emit func(Thread) error) error { + return paginate(ctx, limit, searchFetch(c, query, typ, decodeSearchThread), emit) +} + +// SearchAccounts streams user records. +func (c *Client) SearchAccounts(ctx context.Context, query string, limit int, emit func(Account) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchUsers, decodeSearchAccount), emit) +} + +// SearchCommitsBy streams commit records. Commit search is the only source for +// signature and verification state on a keyless surface. +func (c *Client) SearchCommitsBy(ctx context.Context, query string, limit int, emit func(Commit) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchCommits, decodeSearchCommit), emit) +} + +// SearchDiscussionsBy streams discussion records. +func (c *Client) SearchDiscussionsBy(ctx context.Context, query string, limit int, emit func(Discussion) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchDiscussions, decodeSearchDiscussion), emit) +} + +// SearchTopicsBy streams topic records. +func (c *Client) SearchTopicsBy(ctx context.Context, query string, limit int, emit func(Topic) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchTopics, decodeSearchTopic), emit) +} + +// SearchPackagesBy streams package records. Search is the only source for +// packages, so these records are complete rather than thin. +func (c *Client) SearchPackagesBy(ctx context.Context, query string, limit int, emit func(Package) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchPackages, decodeSearchPackage), emit) +} + +// SearchWikisBy streams wiki page records. +func (c *Client) SearchWikisBy(ctx context.Context, query string, limit int, emit func(WikiPage) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchWikis, decodeSearchWiki), emit) +} + +// SearchMarketplace streams action and app listings. +func (c *Client) SearchMarketplace(ctx context.Context, query string, limit int, emit func(Action) error) error { + return paginate(ctx, limit, searchFetch(c, query, SearchMarket, decodeSearchAction), emit) +} + +// SearchCodeBy is the honest gap. The route answers 200 with zero results +// without a session, which is the worst possible failure mode: it looks like +// the query matched nothing. +func (c *Client) SearchCodeBy(context.Context, string, int, func(File) error) error { + return notPublic("code search", "the route answers 200 with an empty result set to an anonymous client, which is indistinguishable from no matches") +} + +// --- decoders --- + +type searchRepoResult struct { + ID string `json:"id"` + Archived bool `json:"archived"` + Color string `json:"color"` + Followers *int `json:"followers"` + HasFundingFile bool `json:"has_funding_file"` + HLName string `json:"hl_name"` + HLTruncDescription string `json:"hl_trunc_description"` + Language string `json:"language"` + Mirror bool `json:"mirror"` + OwnedByOrganization bool `json:"owned_by_organization"` + Public bool `json:"public"` + Sponsorable bool `json:"sponsorable"` + Topics []string `json:"topics"` + Type string `json:"type"` + HelpWanted *int `json:"help_wanted_issues_count"` + GoodFirstIssue *int `json:"good_first_issue_issues_count"` + Repo repoNest `json:"repo"` +} + +// repoNest is the doubly-wrapped repository reference search uses everywhere: +// {"repo":{"repository":{...}}}. Modelling it once keeps six decoders from each +// spelling it out. +type repoNest struct { + Repository struct { + ID *int `json:"id"` + Name string `json:"name"` + OwnerLogin string `json:"owner_login"` + OwnerID *int `json:"owner_id"` + UpdatedAt string `json:"updated_at"` + HasIssues bool `json:"has_issues"` + } `json:"repository"` +} + +func (r repoNest) id() string { + if r.Repository.OwnerLogin == "" || r.Repository.Name == "" { + return "" + } + return r.Repository.OwnerLogin + "/" + r.Repository.Name +} + +func decodeSearchRepo(raw json.RawMessage) (Repo, bool) { + var s searchRepoResult + if err := json.Unmarshal(raw, &s); err != nil { + return Repo{}, false + } + id := s.Repo.id() + if id == "" { + id = stripHL(s.HLName) + } + owner, name, ok := SplitRepo(id) + if !ok { + return Repo{}, false + } + r := Repo{Owner: owner, Name: name} + r.setIdentity(KindRepo, id) + r.DatabaseID = s.Repo.Repository.ID + r.Description = stripHL(s.HLTruncDescription) + if r.Description != s.HLTruncDescription { + r.DescriptionHighlight = s.HLTruncDescription + } + r.Language = s.Language + r.LanguageColor = s.Color + r.Stars = s.Followers + r.Topics = s.Topics + r.IsArchived = s.Archived + r.IsMirror = s.Mirror + r.IsOrgOwned = s.OwnedByOrganization + r.IsPrivate = !s.Public + r.Visibility = strings.ToLower(s.Type) + r.Sponsorable = s.Sponsorable + r.HasFunding = s.HasFundingFile + r.HelpWantedIssues = s.HelpWanted + r.GoodFirstIssues = s.GoodFirstIssue + r.UpdatedAt = parseTime(s.Repo.Repository.UpdatedAt) + + r.addExtra("search", decodeExtra(raw, &s, + // Viewer state, always false for an anonymous read. + "starred_by_current_user", "followed_by_current_user", "is_current_user", + )) + return r, true +} + +type searchThreadResult struct { + AuthorName string `json:"author_name"` + AuthorAvatarURL string `json:"author_avatar_url"` + ID string `json:"id"` + Repo repoNest `json:"repo"` + Labels []string `json:"labels"` + NumComments *int `json:"num_comments"` + Number int `json:"number"` + State string `json:"state"` + StateReason *string `json:"state_reason"` + HLTitle string `json:"hl_title"` + HLText string `json:"hl_text"` + Created string `json:"created"` + ReviewableState *string `json:"reviewable_state"` + Merged *bool `json:"merged"` + Issue struct { + Issue struct { + PullRequestID *int `json:"pull_request_id"` + } `json:"issue"` + } `json:"issue"` +} + +// decodeSearchThread produces a Thread. The caller decides whether it wanted +// issues or pull requests, and pull_request_id says which one this actually is, +// so a query that mixes them still classifies each result correctly. +func decodeSearchThread(raw json.RawMessage) (Thread, bool) { + var s searchThreadResult + if err := json.Unmarshal(raw, &s); err != nil { + return Thread{}, false + } + repo := s.Repo.id() + if repo == "" || s.Number == 0 { + return Thread{}, false + } + kind := KindIssue + if s.Issue.Issue.PullRequestID != nil { + kind = KindPR + } + t := Thread{Repo: repo, Number: s.Number} + t.setIdentity(kind, repo+"#"+strconv.Itoa(s.Number)) + t.Title = stripHL(s.HLTitle) + if t.Title != s.HLTitle { + t.TitleHighlight = s.HLTitle + } + t.Body = stripHL(s.HLText) + t.State = s.State + if s.StateReason != nil { + t.StateReason = *s.StateReason + } + t.CommentCount = s.NumComments + t.CreatedAt = parseTime(s.Created) + t.Author = actor(s.AuthorName) + t.Author.AvatarURL = s.AuthorAvatarURL + for _, l := range s.Labels { + t.Labels = append(t.Labels, Label{Name: l}) + } + if n, err := strconv.Atoi(s.ID); err == nil { + t.DatabaseID = intp(n) + } + + t.addExtra("search", decodeExtra(raw, &s)) + return t, true +} + +type searchUserResult struct { + AvatarURL string `json:"avatar_url"` + HLLogin string `json:"hl_login"` + HLName string `json:"hl_name"` + HLProfileBio string `json:"hl_profile_bio"` + Followers *int `json:"followers"` + ID string `json:"id"` + Location string `json:"location"` + Login string `json:"login"` + DisplayLogin string `json:"display_login"` + Name string `json:"name"` + ProfileBio string `json:"profile_bio"` + Sponsorable bool `json:"sponsorable"` + Repos *int `json:"repos"` +} + +func decodeSearchAccount(raw json.RawMessage) (Account, bool) { + var s searchUserResult + if err := json.Unmarshal(raw, &s); err != nil { + return Account{}, false + } + login := firstNonEmpty(s.Login, stripHL(s.HLLogin)) + if login == "" { + return Account{}, false + } + a := Account{Login: login, Type: "User"} + a.setIdentity(KindUser, login) + a.Name = s.Name + a.Bio = s.ProfileBio + a.Location = s.Location + a.AvatarURL = s.AvatarURL + a.Followers = s.Followers + a.RepoCount = s.Repos + a.Sponsorable = s.Sponsorable + if n, err := strconv.Atoi(s.ID); err == nil { + a.DatabaseID = intp(n) + } + + a.addExtra("search", decodeExtra(raw, &s, + // Viewer state. + "followed_by_current_user", "is_current_user", + )) + return a, true +} + +type searchCommitResult struct { + ID string `json:"id"` + SHA string `json:"sha"` + AuthorDate string `json:"author_date"` + HLSubject string `json:"hl_subject"` + HLBody string `json:"hl_body"` + Message string `json:"message"` + Repository repoNest `json:"repository"` + VerificationStatus string `json:"verification_status"` + VerificationReason string `json:"signature_verification_reason"` + SignedByGitHub bool `json:"signed_by_github"` + HasSignature bool `json:"has_signature"` + KeyExpired bool `json:"key_expired"` + KeyID string `json:"key_id"` + ChecksState string `json:"checks_header_state"` + ChecksSummary string `json:"checks_status_summary"` + + // The plural is not a typo. Commit search reports co-authors, so this is a + // list where a single author field would lose the trailer. + Authors []struct { + Login string `json:"login"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + } `json:"authors"` + + // Committer is present only when it differs from the author, which on + // golang/go means gopherbot on every commit and on most repositories means + // nothing at all. + Committer *struct { + Login string `json:"login"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + } `json:"committer"` + + IssueReferences []struct { + ID *int `json:"id"` + Title string `json:"title"` + State string `json:"state"` + IsPullRequest bool `json:"is_pull_request"` + Permalink string `json:"permalink"` + Merged bool `json:"merged"` + } `json:"issue_references"` +} + +func decodeSearchCommit(raw json.RawMessage) (Commit, bool) { + var s searchCommitResult + if err := json.Unmarshal(raw, &s); err != nil { + return Commit{}, false + } + repo := s.Repository.id() + if repo == "" || s.SHA == "" { + return Commit{}, false + } + c := Commit{Repo: repo, SHA: s.SHA} + c.setIdentity(KindCommit, repo+"@"+s.SHA) + subject, body, _ := strings.Cut(s.Message, "\n\n") + c.Subject = strings.TrimSpace(subject) + c.Body = strings.TrimSpace(body) + // hl_subject arrives wrapped in an anchor rather than as plain text, which + // is the one place search returns markup instead of a highlighted string. + if hl := stripTags(s.HLSubject); hl != "" && c.Subject == "" { + c.Subject = hl + } + c.SubjectHighlight = s.HLSubject + c.AuthoredAt = parseTime(s.AuthorDate) + c.Verification = s.VerificationStatus + c.VerificationReason = s.VerificationReason + c.SignedByGitHub = s.SignedByGitHub + c.HasSignature = s.HasSignature + c.KeyID = s.KeyID + c.KeyExpired = s.KeyExpired + c.StatusRollup = s.ChecksState + c.StatusSummary = s.ChecksSummary + for _, a := range s.Authors { + act := actor(a.Login) + act.Name = a.DisplayName + act.AvatarURL = a.AvatarURL + c.Authors = append(c.Authors, act) + } + if s.Committer != nil { + act := actor(s.Committer.Login) + act.Name = s.Committer.DisplayName + act.AvatarURL = s.Committer.AvatarURL + c.Committer = &act + } + for _, r := range s.IssueReferences { + c.IssueRefs = append(c.IssueRefs, ThreadRef{ + DatabaseID: r.ID, + Title: r.Title, + State: r.State, + IsPullRequest: r.IsPullRequest, + Merged: r.Merged, + URL: r.Permalink, + }) + } + + c.addExtra("search", decodeExtra(raw, &s, + // A link to GitHub's own docs about signature verification. + "help_url", + // The whole CI rollup, which is a repository-shaped blob three levels + // deep and belongs to `github checks`, not to a commit listing. + "status_check_rollup", + // Prose assembled from authors and committer_attribution for a + // tooltip, and the flag that generated it. + "commit_author_tooltip", "committer_attribution", + // Viewer state. + "is_viewer", + )) + return c, true +} + +type searchDiscussionResult struct { + Body string `json:"body"` + Created string `json:"created"` + Updated string `json:"updated"` + HLText string `json:"hl_text"` + HLTitle string `json:"hl_title"` + ID string `json:"id"` + NumComments *int `json:"num_comments"` + Number int `json:"number"` + Repo repoNest `json:"repo"` + Title string `json:"title"` + URL string `json:"url"` + UserAvatarURL string `json:"user_avatar_url"` + UserID *int `json:"user_id"` + UserLogin string `json:"user_login"` +} + +func decodeSearchDiscussion(raw json.RawMessage) (Discussion, bool) { + var s searchDiscussionResult + if err := json.Unmarshal(raw, &s); err != nil { + return Discussion{}, false + } + repo := s.Repo.id() + if repo == "" || s.Number == 0 { + return Discussion{}, false + } + var d Discussion + d.Repo = repo + d.Number = s.Number + d.setIdentity(KindDiscussion, repo+"#"+strconv.Itoa(s.Number)) + d.Title = firstNonEmpty(s.Title, stripHL(s.HLTitle)) + d.TitleHighlight = s.HLTitle + d.Body = s.Body + d.CommentCount = s.NumComments + d.CreatedAt = parseTime(s.Created) + d.UpdatedAt = parseTime(s.Updated) + d.Author = actor(s.UserLogin) + d.Author.AvatarURL = s.UserAvatarURL + d.Author.DatabaseID = s.UserID + if n, err := strconv.Atoi(s.ID); err == nil { + d.DatabaseID = intp(n) + } + + d.addExtra("search", decodeExtra(raw, &s)) + return d, true +} + +type searchTopicResult struct { + ID string `json:"id"` + Name string `json:"name"` + ShortDescription string `json:"short_description"` + DisplayName string `json:"display_name"` + Released string `json:"released"` + WikipediaURL string `json:"wikipedia_url"` + URL string `json:"url"` + GitHubURL string `json:"github_url"` + LogoURL string `json:"logo_url"` + Featured bool `json:"featured"` + Curated bool `json:"curated"` + StargazerCount *int `json:"stargazer_count"` + AppliedCount *int `json:"applied_count"` + HLDisplayName string `json:"hl_display_name"` + HLShortDescription string `json:"hl_short_description"` + CreatedBy string `json:"created_by"` + Aliases []string `json:"aliases"` + Related []string `json:"related"` +} + +func decodeSearchTopic(raw json.RawMessage) (Topic, bool) { + var s searchTopicResult + if err := json.Unmarshal(raw, &s); err != nil || s.Name == "" { + return Topic{}, false + } + t := Topic{Name: s.Name} + t.setIdentity(KindTopic, s.Name) + t.DisplayName = s.DisplayName + t.ShortDescription = s.ShortDescription + t.Released = s.Released + t.WikipediaURL = s.WikipediaURL + t.GitHubURL = s.GitHubURL + t.LogoURL = s.LogoURL + t.Featured = s.Featured + t.Curated = s.Curated + t.StargazerCount = s.StargazerCount + t.AppliedCount = s.AppliedCount + t.CreatedBy = s.CreatedBy + t.Aliases = s.Aliases + t.Related = s.Related + + t.addExtra("search", decodeExtra(raw, &s, + // True when logo_url is set, which the record already carries. + "has_logo_url", + // A moderation flag with no public meaning. + "flagged", + // The highlight map repeats hl_display_name and hl_short_description in + // a nested shape, and both are already modelled. + "highlights", + // A ceiling marker on repository_count, kept out because the count it + // qualifies is not one this record claims. + "repository_count", "repository_count_over_max_fetch_limit", + // Viewer state. + "starred_by_current_user", + )) + return t, true +} + +type searchPackageResult struct { + ID string `json:"id"` + Color string `json:"color"` + Downloads *int `json:"downloads"` + Name string `json:"name"` + PackageURL string `json:"package_url"` + PackageType string `json:"package_type"` + Public bool `json:"public"` + Summary string `json:"summary"` + Topics []string `json:"topics"` + UpdatedAt string `json:"updated_at"` + Repo struct { + Name string `json:"name"` + OwnerLogin string `json:"owner_login"` + } `json:"repo"` + Source struct { + PackageType string `json:"package_type"` + } `json:"source"` +} + +func decodeSearchPackage(raw json.RawMessage) (Package, bool) { + var s searchPackageResult + if err := json.Unmarshal(raw, &s); err != nil || s.Name == "" { + return Package{}, false + } + p := Package{Name: s.Name} + if s.Repo.OwnerLogin != "" && s.Repo.Name != "" { + p.Repo = s.Repo.OwnerLogin + "/" + s.Repo.Name + } + p.setIdentity(KindPackage, firstNonEmpty(p.Repo+"/"+s.Name, s.Name)) + if s.PackageURL != "" { + p.URL = BaseURL + s.PackageURL + } + // package_type is null at the top level and populated inside source, which + // looks like an oversight upstream but is consistent enough to rely on. + p.Type = firstNonEmpty(s.PackageType, s.Source.PackageType) + p.Summary = s.Summary + p.Downloads = s.Downloads + p.Topics = s.Topics + p.UpdatedAt = parseTime(s.UpdatedAt) + + p.addExtra("search", decodeExtra(raw, &s, + // The language colour of the source repository, which belongs to the + // repository record and not to the package. + "color", + // The whole source blob: registry internals, ids, and a version list + // that `github package` reads properly. + "source", + )) + return p, true +} + +type searchWikiResult struct { + Body string `json:"body"` + Filename string `json:"filename"` + Format string `json:"format"` + HLBody string `json:"hl_body"` + HLTitle string `json:"hl_title"` + ID string `json:"id"` + Path string `json:"path"` + Public bool `json:"public"` + Repo repoNest `json:"repo"` + RepoID *int `json:"repo_id"` + Title string `json:"title"` + UpdatedAt string `json:"updated_at"` +} + +func decodeSearchWiki(raw json.RawMessage) (WikiPage, bool) { + var s searchWikiResult + if err := json.Unmarshal(raw, &s); err != nil { + return WikiPage{}, false + } + repo := s.Repo.id() + if repo == "" { + return WikiPage{}, false + } + w := WikiPage{Repo: repo, Path: s.Path, Format: s.Format} + w.Title = firstNonEmpty(s.Title, stripHL(s.HLTitle)) + w.setIdentity(KindWiki, repo+"/"+firstNonEmpty(s.Path, s.Filename)) + w.Body = s.Body + w.UpdatedAt = parseTime(s.UpdatedAt) + + w.addExtra("search", decodeExtra(raw, &s)) + return w, true +} + +// searchActionResult covers both listing shapes at once. A repository action +// fills repository_action and a marketplace app fills marketplace_listing, and +// the two never overlap, so decoding both and taking whichever arrived is +// simpler and more honest than branching on type. +type searchActionResult struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Free bool `json:"free"` + PrimaryCategory string `json:"primary_category"` + SecondaryCategory string `json:"secondary_category"` + IsVerifiedOwner bool `json:"is_verified_owner"` + Slug string `json:"slug"` + OwnerLogin string `json:"owner_login"` + ResourcePath string `json:"resource_path"` + Description string `json:"description"` + ShortDescription string `json:"short_description"` + FullDescription string `json:"full_description"` + Stars *int `json:"stars"` + DependentsCount *int `json:"dependents_count"` + InstallationCount *int `json:"installation_count"` + ListingLogoURL string `json:"listing_logo_url"` + State string `json:"state"` + Recommended bool `json:"recommended"` + + RepositoryAction struct { + RepositoryAction struct { + ID *int `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Description string `json:"description"` + IconName string `json:"icon_name"` + Color string `json:"color"` + Featured bool `json:"featured"` + RepositoryID *int `json:"repository_id"` + Slug string `json:"slug"` + } `json:"repository_action"` + } `json:"repository_action"` + + MarketplaceListing struct { + Listing struct { + ID *int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ShortDescription string `json:"short_description"` + FullDescription string `json:"full_description"` + ExtendedDescription string `json:"extended_description"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + TOSURL string `json:"tos_url"` + CompanyURL string `json:"company_url"` + SupportURL string `json:"support_url"` + DocumentationURL string `json:"documentation_url"` + PricingURL string `json:"pricing_url"` + ByGitHub bool `json:"by_github"` + ListableType string `json:"listable_type"` + ListableID *int `json:"listable_id"` + } `json:"listing"` + } `json:"marketplace_listing"` +} + +func decodeSearchAction(raw json.RawMessage) (Action, bool) { + var s searchActionResult + if err := json.Unmarshal(raw, &s); err != nil { + return Action{}, false + } + ra := s.RepositoryAction.RepositoryAction + ml := s.MarketplaceListing.Listing + + slug := firstNonEmpty(s.Slug, ra.Slug, ml.Slug) + if slug == "" { + return Action{}, false + } + a := Action{Owner: s.OwnerLogin, Slug: slug} + a.Name = firstNonEmpty(s.Name, ra.Name, ml.Name) + a.setIdentity(KindAction, slug) + if s.ResourcePath != "" { + a.URL = BaseURL + s.ResourcePath + } + a.Description = firstNonEmpty(s.Description, ra.Description) + a.ShortDescription = firstNonEmpty(s.ShortDescription, ml.ShortDescription) + a.FullDescription = firstNonEmpty(s.FullDescription, ml.FullDescription) + a.ExtendedDescription = ml.ExtendedDescription + a.Type = s.Type + a.PrimaryCategory = s.PrimaryCategory + a.SecondaryCategory = s.SecondaryCategory + a.IsFree = s.Free + a.IsVerifiedOwner = s.IsVerifiedOwner + a.IsRecommended = s.Recommended + a.State = s.State + a.Stars = s.Stars + a.DependentCount = s.DependentsCount + a.InstallationCount = s.InstallationCount + a.LogoURL = s.ListingLogoURL + + a.Path = ra.Path + a.IconName = ra.IconName + a.IconColor = ra.Color + a.RepositoryID = ra.RepositoryID + a.IsFeatured = ra.Featured + + a.ListingID = ml.ID + a.CompanyURL = ml.CompanyURL + a.DocumentationURL = ml.DocumentationURL + a.SupportURL = ml.SupportURL + a.PrivacyPolicyURL = ml.PrivacyPolicyURL + a.TermsURL = ml.TOSURL + a.PricingURL = ml.PricingURL + a.ByGitHub = ml.ByGitHub + + a.addExtra("search", decodeExtra(raw, &s, + // Repeats name and description with markers, both already modelled. + "highlights", + // The listing's own icon markup, which is a whole inline SVG document + // and is a rendering concern rather than a fact about the action. + "icon_svg", + )) + return a, true +} + +// --- highlight handling --- + +// stripHL turns a hl_ field into plain text. Search wraps matches in and +// entity-escapes the rest, so this is a fixed transform and not an HTML parse: +// nothing else ever appears in those fields. +func stripHL(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "", "") + s = strings.ReplaceAll(s, "", "") + return html.UnescapeString(s) +} + +// stripTags is the wider hammer, needed for hl_subject on commit search, which +// arrives as a whole anchor element rather than as a highlighted string. +func stripTags(s string) string { + var b strings.Builder + depth := 0 + for _, r := range s { + switch { + case r == '<': + depth++ + case r == '>' && depth > 0: + depth-- + case depth == 0: + b.WriteRune(r) + } + } + return strings.TrimSpace(html.UnescapeString(b.String())) +} diff --git a/gh/surface.go b/gh/surface.go new file mode 100644 index 0000000..8a0c0d3 --- /dev/null +++ b/gh/surface.go @@ -0,0 +1,198 @@ +package gh + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +// surface.go is the route-to-surface table from the spec, written down as data +// rather than scattered through the decoders, plus the four pagers every +// listing in this tool is built from. +// +// The table is not decoration. `github surfaces` prints it, which means the +// answer to "can this tool read X" is a command rather than a reading of the +// source, and a route that is not in the table is Unsupported with exit code 7 +// instead of a guess. + +// RouteInfo is one row: what the route is, which surface answers it best, and +// what to fall back to when that surface declines. +type RouteInfo struct { + Route string `json:"route" table:"route"` + Primary string `json:"primary" table:"primary"` + Fallback string `json:"fallback,omitempty" table:"fallback"` + Note string `json:"note,omitempty" table:"note,truncate"` +} + +// Routes is the whole index. Surface names are the ones in doc 01: html, +// route-json, xhr, search, feed, raw, git, embedded, ld-json. +var Routes = []RouteInfo{ + {"/{owner}/{repo}", "embedded", "route-json", "sidebarAbout lives only in the HTML payload"}, + {"/{owner}/{repo}/tree/{ref}/{path}", "route-json", "embedded", ""}, + {"/{owner}/{repo}/blob/{ref}/{path}", "route-json", "raw", "metadata from the route, bytes from raw"}, + {"/{owner}/{repo}/branches", "route-json", "xhr", ""}, + {"/{owner}/{repo}/refs", "xhr", "git", "names only, 6 KB against 588 KB"}, + {"/{owner}/{repo}/commits/{ref}", "route-json", "feed", ""}, + {"/{owner}/{repo}/commit/{sha}", "route-json", "raw", "the diff comes from .patch"}, + {"/{owner}/{repo}/compare/{a}...{b}", "route-json", "raw", ""}, + {"/{owner}/{repo}/issues/{n}", "embedded", "ld-json", "Relay preloaded queries"}, + {"/{owner}/{repo}/pull/{n}", "embedded", "raw", "Relay, plus .diff and .patch"}, + {"/{owner}/{repo}/discussions/{n}", "embedded", "", "Relay"}, + {"/{owner}/{repo}/issues", "search", "", "type=issues with a repo: qualifier"}, + {"/{owner}/{repo}/pulls", "search", "", "type=pullrequests"}, + {"/{owner}/{repo}/releases", "feed", "html", "releases.atom, then a page each for assets"}, + {"/{owner}/{repo}/releases/tag/{tag}", "html", "feed", "download counts exist nowhere else"}, + {"/{owner}/{repo}/tags", "feed", "git", "the feed is recent, git is complete"}, + {"/{owner}/{repo}/graphs/contributors", "xhr", "", "answers 202 while it computes, so it polls"}, + {"/{owner}/{repo}/wiki", "feed", "html", ""}, + {"/{owner}/{repo}.git/info/refs", "git", "", "every ref and its SHA in one request"}, + {"/{login}", "html", "ld-json", "microdata and microformats, no payload at all"}, + {"/{org}", "html", "xhr", "two deferred fragments under --deep"}, + {"/{login}?tab=repositories", "search", "html", "user: qualifier"}, + {"/{login}?tab=stars", "html", "", ""}, + {"/{login}.atom", "feed", "", ""}, + {"/users/{login}/hovercard", "xhr", "html", ""}, + {"/users/{login}/contributions", "xhr", "", "returns an HTML fragment, not JSON"}, + {"/orgs/{org}/people", "html", "", ""}, + {"/search", "search", "", "ten types, code is the one that needs a token"}, + {"/trending", "html", "", "no JSON equivalent exists, tokened or not"}, + {"/topics/{slug}", "html", "search", ""}, + {"gist.github.com/{id}", "html", "raw", ""}, + {"raw.githubusercontent.com/...", "raw", "", ""}, + {"codeload.github.com/...", "raw", "", ""}, +} + +// --- URL builders --- +// +// Every request in this tool goes through one of these. Building URLs in one +// place is what makes the escape hatch (`github url`) tell the truth about what +// the tool would actually fetch. + +func repoURL(repo string) string { return BaseURL + "/" + repo } + +func repoSubURL(repo, sub string) string { return BaseURL + "/" + repo + "/" + sub } + +func treeURL(repo, ref, path string) string { + u := BaseURL + "/" + repo + "/tree/" + ref + if path != "" { + u += "/" + path + } + return u +} + +func blobURL(repo, ref, path string) string { + return BaseURL + "/" + repo + "/blob/" + ref + "/" + path +} + +func rawURL(repo, ref, path string) string { + return RawURL + "/" + repo + "/" + ref + "/" + path +} + +func commitURL(repo, sha string) string { return BaseURL + "/" + repo + "/commit/" + sha } + +func threadURL(repo, segment string, number int) string { + return BaseURL + "/" + repo + "/" + segment + "/" + strconv.Itoa(number) +} + +func feedURL(path string) string { return BaseURL + "/" + strings.TrimPrefix(path, "/") } + +func accountURL(login string) string { return BaseURL + "/" + login } + +// gitRefsURL is the smart-protocol advertisement: every ref and its object id, +// in one request, with no page limit and no login. +func gitRefsURL(repo string) string { + return BaseURL + "/" + repo + ".git/info/refs?service=git-upload-pack" +} + +// searchURL builds a search request. Type is the site's own name for the type: +// repositories, issues, pullrequests, discussions, users, commits, registrypackages, +// wikis, topics, marketplace. +func searchURL(query, typ string, page int) string { + v := url.Values{} + v.Set("q", query) + if typ != "" { + v.Set("type", typ) + } + if page > 1 { + v.Set("p", strconv.Itoa(page)) + } + return BaseURL + "/search?" + v.Encode() +} + +// --- the pagers --- + +// A pager answers one question: given where we are, what is the next batch and +// where does that leave us. Four shapes cover every listing github.com has. +// +// search numbered pages, ?p=N, stops on a short page +// cursor Relay endCursor, stops when hasNextPage is false +// rails a rel="next" anchor in the markup, stops when it is absent +// none one response, and that is the whole list +// +// They share one driver so that --limit, cancellation, and the "stop asking for +// more once the caller has enough" rule are written once. + +// fetchPage returns one batch and the token for the next batch. An empty next +// token ends the walk. +type fetchPage[T any] func(ctx context.Context, token string) (batch []T, next string, err error) + +// paginate walks a listing, handing each record to emit as it arrives. +// +// Records are emitted as they decode, not collected and returned, because a +// listing of every repository in an organization should start printing on the +// first page rather than after the last one. +// +// limit <= 0 means no limit. The walk stops the moment the limit is reached, so +// asking for five records off a thousand-record listing costs one request. +func paginate[T any](ctx context.Context, limit int, fetch fetchPage[T], emit func(T) error) error { + token := "" + seen := 0 + for { + if err := ctx.Err(); err != nil { + return wrapNetwork("", err) + } + batch, next, err := fetch(ctx, token) + if err != nil { + return err + } + for _, rec := range batch { + if err := emit(rec); err != nil { + return err + } + seen++ + if limit > 0 && seen >= limit { + return nil + } + } + // A page that returned nothing ends the walk even when the surface + // still claims a next token. Trusting the token alone is how a paginator + // spins forever against a route that has started answering empty. + if next == "" || next == token || len(batch) == 0 { + return nil + } + token = next + } +} + +// pageToken and cursorToken are the two token encodings. Keeping them as helper +// functions rather than inline strconv calls means the "which page am I on" +// logic reads the same in all nine listings that use it. + +func pageToken(token string) int { + if token == "" { + return 1 + } + n, err := strconv.Atoi(token) + if err != nil || n < 1 { + return 1 + } + return n +} + +func nextPageToken(current int, gotFull bool) string { + if !gotFull { + return "" + } + return strconv.Itoa(current + 1) +} diff --git a/gh/types.go b/gh/types.go new file mode 100644 index 0000000..243aa97 --- /dev/null +++ b/gh/types.go @@ -0,0 +1,761 @@ +package gh + +import "time" + +// types.go is the record model: twenty-five types that between them describe +// everything this tool can read off github.com without a token. +// +// Three conventions run through all of them. +// +// Counts are *int. nil means the surface did not carry it, 0 means the surface +// said zero. Collapsing those two is how aggregates end up quietly wrong. +// +// Timestamps are *time.Time and come from a datetime attribute or an ISO +// string. Rendered relative text ("3 days ago") is never parsed. +// +// Where a surface offers only a rendered count, the record carries both the +// parsed integer and the original string, because "8,112" and "8.112" are the +// same number in different locales and throwing away the original hides that. + +// --- repository --- + +// Repo is the centre of the model. A read from a page fills most of it, a read +// from a search result fills a thinner but honest subset, and Sources says +// which happened. +type Repo struct { + Base + + Owner string `json:"owner" table:"owner"` + Name string `json:"name" table:"name"` + + Description string `json:"description,omitempty" table:"description,truncate"` + DescriptionHighlight string `json:"description_highlight,omitempty" table:"-"` + Homepage string `json:"homepage,omitempty" table:"-"` + Topics []string `json:"topics,omitempty" table:"topics"` + + DatabaseID *int `json:"database_id,omitempty" table:"-"` + NodeID string `json:"node_id,omitempty" table:"-"` + + DefaultBranch string `json:"default_branch,omitempty" table:"branch"` + HeadSHA string `json:"head_sha,omitempty" table:"-"` + + Language string `json:"language,omitempty" table:"language"` + LanguageColor string `json:"language_color,omitempty" table:"-"` + Languages map[string]int64 `json:"languages,omitempty" table:"-"` + + Stars *int `json:"stars,omitempty" table:"stars"` + StarsDisplay string `json:"stars_display,omitempty" table:"-"` + Forks *int `json:"forks,omitempty" table:"forks"` + Watchers *int `json:"watchers,omitempty" table:"watchers"` + + OpenIssues *int `json:"open_issues,omitempty" table:"issues"` + GoodFirstIssues *int `json:"good_first_issues,omitempty" table:"-"` + HelpWantedIssues *int `json:"help_wanted_issues,omitempty" table:"-"` + + CommitCount *int `json:"commit_count,omitempty" table:"-"` + CommitCountDisplay string `json:"commit_count_display,omitempty" table:"-"` + ReleaseCount *int `json:"release_count,omitempty" table:"-"` + TagCount *int `json:"tag_count,omitempty" table:"-"` + FileCount *int `json:"file_count,omitempty" table:"-"` + DependentCount *int `json:"dependent_count,omitempty" table:"-"` + + // License comes from one sidebar anchor and from nowhere else on any + // keyless surface. See page.LicenseLink. + License string `json:"license,omitempty" table:"license"` + + IsFork bool `json:"is_fork" table:"-"` + ForkOf string `json:"fork_of,omitempty" table:"-"` + IsArchived bool `json:"is_archived" table:"-"` + IsMirror bool `json:"is_mirror" table:"-"` + IsTemplate bool `json:"is_template" table:"-"` + IsEmpty bool `json:"is_empty" table:"-"` + IsPrivate bool `json:"is_private" table:"-"` + IsOrgOwned bool `json:"is_org_owned" table:"-"` + Visibility string `json:"visibility,omitempty" table:"-"` + + Sponsorable bool `json:"sponsorable" table:"-"` + HasFunding bool `json:"has_funding" table:"-"` + HasCitation bool `json:"has_citation" table:"-"` + HasDiscussions bool `json:"has_discussions" table:"-"` + HasWiki bool `json:"has_wiki" table:"-"` + HasPages bool `json:"has_pages" table:"-"` + + CreatedAt *time.Time `json:"created_at,omitempty" table:"created,time"` + PushedAt *time.Time `json:"pushed_at,omitempty" table:"pushed,time"` + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"updated,time"` + + OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"-"` + SocialImageURL string `json:"social_image_url,omitempty" table:"-"` + + ReadmePath string `json:"readme_path,omitempty" table:"-"` + ReadmeHTML string `json:"readme_html,omitempty" table:"-"` + ReadmeText string `json:"readme_text,omitempty" table:"-"` + + Tree []TreeEntry `json:"tree,omitempty" table:"-"` + + StargazersPath string `json:"stargazers_path,omitempty" table:"-"` + ForkNetworkPath string `json:"fork_network_path,omitempty" table:"-"` + ActivityPath string `json:"activity_path,omitempty" table:"-"` +} + +// --- trees and files --- + +// TreeEntry is one row of a directory listing. Size and SHA are absent from the +// tree route and cost one request each, which is what `--sizes` opts into. +type TreeEntry struct { + Base + + Repo string `json:"repo" table:"-"` + Ref string `json:"ref" table:"-"` + + Name string `json:"name" table:"name"` + Path string `json:"path" table:"path"` + // Type is contentType verbatim: file, directory, symlink_file, + // symlink_directory, submodule. + Type string `json:"type" table:"type"` + + Size *int64 `json:"size,omitempty" table:"size"` + SHA string `json:"sha,omitempty" table:"-"` +} + +// File is a blob. The interesting part is Symbols: GitHub runs a symbol +// extractor over every blob it renders and ships the result in the route +// payload, and there is no unauthenticated REST equivalent anywhere. +type File struct { + Base + + Repo string `json:"repo" table:"-"` + Ref string `json:"ref" table:"-"` + Path string `json:"path" table:"path"` + + Size *int64 `json:"size,omitempty" table:"size"` + Lines *int `json:"lines,omitempty" table:"lines"` + Language string `json:"language,omitempty" table:"language"` + IsBinary bool `json:"is_binary" table:"-"` + IsLFS bool `json:"is_lfs" table:"-"` + IsGenerated bool `json:"is_generated" table:"-"` + + RawURL string `json:"raw_url" table:"-"` + + Content string `json:"content,omitempty" table:"-"` + RawLines []string `json:"raw_lines,omitempty" table:"-"` + RichText string `json:"rich_text,omitempty" table:"-"` + + TOC []Heading `json:"toc,omitempty" table:"-"` + Symbols []Symbol `json:"symbols,omitempty" table:"-"` + // SymbolsStatus is ok, timed_out, or not_analyzed. An empty symbol list + // with not_analyzed means the language is unsupported, which is a different + // fact from a file that genuinely has no symbols, and the caller should not + // have to guess which one it got. + SymbolsStatus string `json:"symbols_status,omitempty" table:"-"` +} + +// Heading is one entry of a rendered markdown table of contents. +type Heading struct { + Level int `json:"level" table:"level"` + Text string `json:"text" table:"text"` + Anchor string `json:"anchor" table:"anchor"` +} + +// Symbol is one extracted definition, with byte offsets into the blob. +type Symbol struct { + Name string `json:"name" table:"name"` + Kind string `json:"kind" table:"kind"` + FullyQualifiedName string `json:"fqn,omitempty" table:"-"` + IdentStart int `json:"ident_start" table:"-"` + IdentEnd int `json:"ident_end" table:"-"` + ExtentStart int `json:"extent_start" table:"-"` + ExtentEnd int `json:"extent_end" table:"-"` +} + +// --- accounts --- + +// Account is a user or an organization. Profiles carry no JSON payload at all, +// so every field here comes from microdata, a microformat class, a stable data +// attribute, or a counted link. That makes accounts the most selector-dependent +// records in the tool, and the reason every field has a golden pinning it. +type Account struct { + Base + + Login string `json:"login" table:"login"` + Name string `json:"name,omitempty" table:"name"` + // Type is User or Organization, decided by which blocks the page carries + // rather than guessed from the login. + Type string `json:"type" table:"type"` + + Bio string `json:"bio,omitempty" table:"bio,truncate"` + Company string `json:"company,omitempty" table:"company"` + Location string `json:"location,omitempty" table:"location"` + Website string `json:"website,omitempty" table:"-"` + Email string `json:"email,omitempty" table:"-"` + Pronouns string `json:"pronouns,omitempty" table:"-"` + SocialLinks []string `json:"social_links,omitempty" table:"-"` + + DatabaseID *int `json:"database_id,omitempty" table:"-"` + NodeID string `json:"node_id,omitempty" table:"-"` + AvatarURL string `json:"avatar_url,omitempty" table:"-"` + + Followers *int `json:"followers,omitempty" table:"followers"` + FollowersDisplay string `json:"followers_display,omitempty" table:"-"` + Following *int `json:"following,omitempty" table:"following"` + Starred *int `json:"starred,omitempty" table:"-"` + RepoCount *int `json:"repo_count,omitempty" table:"repos"` + + CreatedAt *time.Time `json:"created_at,omitempty" table:"joined,time"` + + Sponsorable bool `json:"sponsorable" table:"-"` + IsVerified bool `json:"is_verified" table:"-"` + IsHireable bool `json:"is_hireable" table:"-"` + + ReadmeHTML string `json:"readme_html,omitempty" table:"-"` + ReadmeText string `json:"readme_text,omitempty" table:"-"` + + PinnedRepos []string `json:"pinned_repos,omitempty" table:"-"` + Organizations []string `json:"organizations,omitempty" table:"-"` + Achievements []string `json:"achievements,omitempty" table:"-"` + + SocialImageURL string `json:"social_image_url,omitempty" table:"-"` +} + +// Org is an Account plus the five things only an organization page has. +// TopLanguages and TopTopics are deferred fragments and arrive only with +// --deep. +type Org struct { + Account + + VerifiedDomains []string `json:"verified_domains,omitempty" table:"-"` + MemberCount *int `json:"member_count,omitempty" table:"members"` + TopLanguages map[string]int `json:"top_languages,omitempty" table:"-"` + TopTopics []string `json:"top_topics,omitempty" table:"-"` + IsEnterprise bool `json:"is_enterprise" table:"-"` +} + +// --- threads --- + +// Thread is what issues, pull requests, and discussions have in common, which +// is most of it: their pages share a Relay payload shape. +type Thread struct { + Base + + Repo string `json:"repo" table:"repo"` + Number int `json:"number" table:"number"` + + Title string `json:"title" table:"title,truncate"` + TitleHighlight string `json:"title_highlight,omitempty" table:"-"` + TitleHTML string `json:"title_html,omitempty" table:"-"` + + State string `json:"state" table:"state"` + StateReason string `json:"state_reason,omitempty" table:"-"` + + Body string `json:"body,omitempty" table:"-"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + + Author Actor `json:"author" table:"author"` + + Labels []Label `json:"labels,omitempty" table:"labels"` + Milestone *Milestone `json:"milestone,omitempty" table:"-"` + Assignees []Actor `json:"assignees,omitempty" table:"-"` + Reactions []Reaction `json:"reactions,omitempty" table:"-"` + + CommentCount *int `json:"comment_count,omitempty" table:"comments"` + + Locked bool `json:"locked" table:"-"` + IsPinned bool `json:"is_pinned" table:"-"` + + CreatedAt *time.Time `json:"created_at,omitempty" table:"created,time"` + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"updated,time"` + ClosedAt *time.Time `json:"closed_at,omitempty" table:"-"` + + AuthorAssociation string `json:"author_association,omitempty" table:"-"` + + NodeID string `json:"node_id,omitempty" table:"-"` + DatabaseID *int `json:"database_id,omitempty" table:"-"` +} + +// Issue adds the tracking relationships GitHub keeps between issues and the +// work that closes them. +type Issue struct { + Thread + + IssueType string `json:"issue_type,omitempty" table:"-"` + SubIssueTotal *int `json:"sub_issue_total,omitempty" table:"-"` + SubIssueDone *int `json:"sub_issue_done,omitempty" table:"-"` + DuplicateOf string `json:"duplicate_of,omitempty" table:"-"` + LinkedPRs []string `json:"linked_prs,omitempty" table:"-"` + ClosedByPRs []string `json:"closed_by_prs,omitempty" table:"-"` + ProjectItems []string `json:"project_items,omitempty" table:"-"` +} + +// PullRequest adds the diff and the merge state. +type PullRequest struct { + Thread + + BaseRef string `json:"base_ref,omitempty" table:"base"` + HeadRef string `json:"head_ref,omitempty" table:"head"` + BaseOID string `json:"base_oid,omitempty" table:"-"` + HeadOID string `json:"head_oid,omitempty" table:"-"` + + Merged bool `json:"merged" table:"-"` + MergedAt *time.Time `json:"merged_at,omitempty" table:"merged,time"` + MergedBy *Actor `json:"merged_by,omitempty" table:"-"` + Mergeable string `json:"mergeable,omitempty" table:"-"` + IsDraft bool `json:"is_draft" table:"-"` + + Additions *int `json:"additions,omitempty" table:"+"` + // The render tag grammar uses "-" to mean "skip this column", and "-" is + // also the natural header for deletions. The grammar wins: deletions is + // hidden by default and shown with --fields deletions. This is deliberate, + // please do not "fix" it. + Deletions *int `json:"deletions,omitempty" table:"-"` + ChangedFiles *int `json:"changed_files,omitempty" table:"files"` + CommitCount *int `json:"commit_count,omitempty" table:"-"` + + ReviewDecision string `json:"review_decision,omitempty" table:"review"` + ReviewRequests []Actor `json:"review_requests,omitempty" table:"-"` + ClosesIssues []string `json:"closes_issues,omitempty" table:"-"` +} + +// Discussion adds the answer, which is the thing discussions have that issues +// do not. +type Discussion struct { + Thread + + Category string `json:"category,omitempty" table:"category"` + IsAnswered bool `json:"is_answered" table:"answered"` + AnswerChosenAt *time.Time `json:"answer_chosen_at,omitempty" table:"-"` + AnswerAuthor *Actor `json:"answer_author,omitempty" table:"-"` + Upvotes *int `json:"upvotes,omitempty" table:"upvotes"` +} + +// Label is a thread label. +type Label struct { + Name string `json:"name" table:"name"` + Color string `json:"color,omitempty" table:"color"` + Description string `json:"description,omitempty" table:"description,truncate"` + URL string `json:"url,omitempty" table:"url,url"` + NodeID string `json:"node_id,omitempty" table:"-"` +} + +// Milestone is a thread milestone. +type Milestone struct { + Title string `json:"title" table:"title"` + Number *int `json:"number,omitempty" table:"number"` + Closed bool `json:"closed" table:"closed"` + DueOn *time.Time `json:"due_on,omitempty" table:"due,time"` + ClosedAt *time.Time `json:"closed_at,omitempty" table:"-"` + Progress *float64 `json:"progress,omitempty" table:"progress"` + URL string `json:"url,omitempty" table:"url,url"` +} + +// Reaction is one emoji group. Content is the GraphQL enum: THUMBS_UP, +// THUMBS_DOWN, LAUGH, HOORAY, CONFUSED, HEART, ROCKET, EYES. All eight always +// arrive, most with a zero count, and the decoder drops the zeroes so an +// unreacted thread has an empty list rather than eight noisy nothings. +type Reaction struct { + Content string `json:"content" table:"content"` + Count int `json:"count" table:"count"` +} + +// TimelineItem is one event on a thread. Type is the GraphQL __typename, +// lower-snake-cased. +// +// An unrecognised typename does not get dropped: Type is set, the common fields +// are filled, the whole node goes into Extra, and the fixture suite fails and +// names it. That is the entire strategy for union drift. +type TimelineItem struct { + Base + + Thread string `json:"thread" table:"-"` + Type string `json:"type" table:"type"` + Cursor string `json:"cursor,omitempty" table:"-"` + + Actor *Actor `json:"actor,omitempty" table:"actor"` + CreatedAt *time.Time `json:"created_at,omitempty" table:"created,time"` + + Body string `json:"body,omitempty" table:"body,truncate"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + + Label *Label `json:"label,omitempty" table:"-"` + Milestone *Milestone `json:"milestone,omitempty" table:"-"` + Assignee *Actor `json:"assignee,omitempty" table:"-"` + FromTitle string `json:"from_title,omitempty" table:"-"` + ToTitle string `json:"to_title,omitempty" table:"-"` + Commit string `json:"commit,omitempty" table:"-"` + Source string `json:"source,omitempty" table:"-"` + Reactions []Reaction `json:"reactions,omitempty" table:"-"` + + Minimized bool `json:"minimized" table:"-"` + MinimizedReason string `json:"minimized_reason,omitempty" table:"-"` + CreatedViaEmail bool `json:"created_via_email" table:"-"` + LastEditedAt *time.Time `json:"last_edited_at,omitempty" table:"-"` +} + +// --- commits --- + +// Commit is one commit. Authors is a list because co-authored commits are +// common and the payload already ships an array; Committer is separate and only +// differs from the author when the surface says it does. +type Commit struct { + Base + + Repo string `json:"repo" table:"-"` + SHA string `json:"sha" table:"sha"` + + Subject string `json:"subject" table:"subject,truncate"` + SubjectHighlight string `json:"subject_highlight,omitempty" table:"-"` + Body string `json:"body,omitempty" table:"-"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + + Authors []Actor `json:"authors,omitempty" table:"authors"` + Committer *Actor `json:"committer,omitempty" table:"-"` + Pusher *Actor `json:"pusher,omitempty" table:"-"` + + AuthoredAt *time.Time `json:"authored_at,omitempty" table:"authored,time"` + CommittedAt *time.Time `json:"committed_at,omitempty" table:"-"` + PushedAt *time.Time `json:"pushed_at,omitempty" table:"-"` + + // DateGroup is the calendar-day heading the commit list grouped this commit + // under. It is kept because it is the only place the surface tells you what + // timezone it grouped in. + DateGroup string `json:"date_group,omitempty" table:"-"` + + Verification string `json:"verification,omitempty" table:"-"` + // VerificationReason is the why behind Verification: "unsigned", + // "valid", "expired_key", and so on. Verification alone says a commit is + // unverified without saying whether that is because nobody signed it or + // because the signature failed, which are very different facts. + VerificationReason string `json:"verification_reason,omitempty" table:"-"` + SignedByGitHub bool `json:"signed_by_github" table:"-"` + HasSignature bool `json:"has_signature" table:"-"` + KeyID string `json:"key_id,omitempty" table:"-"` + KeyExpired bool `json:"key_expired" table:"-"` + + StatusRollup string `json:"status_rollup,omitempty" table:"status"` + StatusSummary string `json:"status_summary,omitempty" table:"-"` + CommentCount *int `json:"comment_count,omitempty" table:"-"` + + // IssueRefs are the issues and pull requests this commit's message closes + // or mentions, already resolved by GitHub. This is the commit-to-thread + // edge of the graph, handed over for free, and it is the reason commit + // search is worth reading even when you already have the commit. + IssueRefs []ThreadRef `json:"issue_refs,omitempty" table:"-"` + + Parents []string `json:"parents,omitempty" table:"-"` + Additions *int `json:"additions,omitempty" table:"-"` + Deletions *int `json:"deletions,omitempty" table:"-"` + Files []FileChange `json:"files,omitempty" table:"-"` +} + +// ThreadRef is a pointer to an issue or a pull request from somewhere else. It +// is not a Thread: it carries only what the referring surface knew, and the +// caller resolves it with `github get` when it wants the rest. +type ThreadRef struct { + DatabaseID *int `json:"database_id,omitempty" table:"id"` + Title string `json:"title,omitempty" table:"title,truncate"` + State string `json:"state,omitempty" table:"state"` + IsPullRequest bool `json:"is_pull_request" table:"-"` + Merged bool `json:"merged" table:"-"` + URL string `json:"url,omitempty" table:"url"` +} + +// FileChange is one file in a commit or a diff. +type FileChange struct { + Path string `json:"path" table:"path"` + PrevPath string `json:"prev_path,omitempty" table:"-"` + Status string `json:"status" table:"status"` + Additions *int `json:"additions,omitempty" table:"+"` + // Hidden by the same tag-grammar collision as PullRequest.Deletions. + Deletions *int `json:"deletions,omitempty" table:"-"` + IsBinary bool `json:"is_binary" table:"-"` +} + +// --- refs and releases --- + +// GitRef is a branch or a tag. It is not called Ref because Ident already owns +// the word "reference" in this package, and a git ref and a parsed URI are very +// different things to confuse in a stack trace. +// +// Three surfaces carry refs and each is incomplete differently: the branches +// page has authors and dates but a truncated list, the refs XHR has every name +// and nothing else, and the git protocol has every name with its SHA. The +// commands pick per question, which is why `github refs --names-only` is 6 KB +// where `github refs` is 588 KB. +type GitRef struct { + Base + + Repo string `json:"repo" table:"-"` + Name string `json:"name" table:"name"` + Type string `json:"type" table:"type"` + SHA string `json:"sha,omitempty" table:"sha"` + + // PeeledSHA is set for annotated tags, from the ^{} entry in the git + // protocol advertisement. + PeeledSHA string `json:"peeled_sha,omitempty" table:"-"` + IsDefault bool `json:"is_default" table:"default"` + Protected bool `json:"protected" table:"protected"` + + Author *Actor `json:"author,omitempty" table:"author"` + AuthoredAt *time.Time `json:"authored_at,omitempty" table:"authored,time"` +} + +// Release is one published release. Assets and download counts exist only on +// the per-release HTML page, so the feed-driven listing leaves Assets nil and +// `--assets` opts into one request per release. +type Release struct { + Base + + Repo string `json:"repo" table:"-"` + Tag string `json:"tag" table:"tag"` + + Title string `json:"title,omitempty" table:"title,truncate"` + Body string `json:"body,omitempty" table:"-"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + + Author *Actor `json:"author,omitempty" table:"author"` + + PublishedAt *time.Time `json:"published_at,omitempty" table:"published,time"` + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"-"` + + IsPrerelease bool `json:"is_prerelease" table:"pre"` + IsLatest bool `json:"is_latest" table:"latest"` + IsDraft bool `json:"is_draft" table:"-"` + + CommitSHA string `json:"commit_sha,omitempty" table:"-"` + + Assets []Asset `json:"assets,omitempty" table:"-"` + TarballURL string `json:"tarball_url,omitempty" table:"-"` + ZipballURL string `json:"zipball_url,omitempty" table:"-"` + + // RepoDatabaseID comes free from the Atom , which is + // tag:github.com,2008:Repository/11180687/v0.164.0. That is how a release + // read from a feed joins to a repository record without a second fetch. + RepoDatabaseID *int `json:"repo_database_id,omitempty" table:"-"` +} + +// Asset is one release download. +type Asset struct { + Name string `json:"name" table:"name"` + Size *int64 `json:"size,omitempty" table:"size"` + SizeDisplay string `json:"size_display,omitempty" table:"-"` + DownloadCount *int `json:"download_count,omitempty" table:"downloads"` + URL string `json:"url" table:"url,url"` + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"-"` + ContentType string `json:"content_type,omitempty" table:"-"` +} + +// --- the long tail --- + +// Topic is a curated or uncurated topic. The search result carries most of it; +// the long description, the logo, the creator, the release year, the Wikipedia +// link, and the aliases need the topic page. +type Topic struct { + Base + + Name string `json:"name" table:"name"` + DisplayName string `json:"display_name,omitempty" table:"display"` + + ShortDescription string `json:"short_description,omitempty" table:"description,truncate"` + Description string `json:"description,omitempty" table:"-"` + DescriptionHTML string `json:"description_html,omitempty" table:"-"` + + LogoURL string `json:"logo_url,omitempty" table:"-"` + WikipediaURL string `json:"wikipedia_url,omitempty" table:"-"` + GitHubURL string `json:"github_url,omitempty" table:"-"` + CreatedBy string `json:"created_by,omitempty" table:"-"` + Released string `json:"released,omitempty" table:"released"` + Aliases []string `json:"aliases,omitempty" table:"-"` + Related []string `json:"related,omitempty" table:"-"` + + StargazerCount *int `json:"stargazer_count,omitempty" table:"stars"` + AppliedCount *int `json:"applied_count,omitempty" table:"repos"` + + Featured bool `json:"featured" table:"-"` + Curated bool `json:"curated" table:"-"` +} + +// Package is a published package. Search is the only source, which means the +// record is complete the moment it is read. +type Package struct { + Base + + Repo string `json:"repo,omitempty" table:"repo"` + Name string `json:"name" table:"name"` + Type string `json:"type" table:"type"` + + Summary string `json:"summary,omitempty" table:"summary,truncate"` + Downloads *int `json:"downloads,omitempty" table:"downloads"` + Topics []string `json:"topics,omitempty" table:"-"` + Source string `json:"source,omitempty" table:"-"` + + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"updated,time"` +} + +// WikiPage is one page of a repository wiki. +type WikiPage struct { + Base + + Repo string `json:"repo" table:"repo"` + Title string `json:"title" table:"title"` + Path string `json:"path" table:"path"` + Format string `json:"format,omitempty" table:"-"` + + Body string `json:"body,omitempty" table:"-"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"updated,time"` + Author *Actor `json:"author,omitempty" table:"author"` +} + +// Gist is a gist and its files. +type Gist struct { + Base + + Owner string `json:"owner,omitempty" table:"owner"` + Description string `json:"description,omitempty" table:"description,truncate"` + + IsPublic bool `json:"is_public" table:"public"` + FileCount *int `json:"file_count,omitempty" table:"files"` + Forks *int `json:"forks,omitempty" table:"forks"` + Stars *int `json:"stars,omitempty" table:"stars"` + Revisions *int `json:"revisions,omitempty" table:"-"` + + Files []GistFile `json:"files,omitempty" table:"-"` + + CreatedAt *time.Time `json:"created_at,omitempty" table:"created,time"` + UpdatedAt *time.Time `json:"updated_at,omitempty" table:"-"` +} + +// GistFile is one file in a gist. +type GistFile struct { + Name string `json:"name" table:"name"` + Language string `json:"language,omitempty" table:"language"` + Size *int64 `json:"size,omitempty" table:"size"` + RawURL string `json:"raw_url" table:"-"` + Content string `json:"content,omitempty" table:"-"` +} + +// Action is a marketplace listing. The type covers both actions and apps, and +// Type says which. +type Action struct { + Base + + Name string `json:"name" table:"name"` + Slug string `json:"slug" table:"-"` + Owner string `json:"owner,omitempty" table:"owner"` + Description string `json:"description,omitempty" table:"description,truncate"` + + // ShortDescription is the one-line blurb on the listing card, which is a + // different string from Description on an app and the same one on a + // repository action. Both are kept rather than picked between. + ShortDescription string `json:"short_description,omitempty" table:"-"` + FullDescription string `json:"full_description,omitempty" table:"-"` + ExtendedDescription string `json:"extended_description,omitempty" table:"-"` + + Type string `json:"type,omitempty" table:"type"` + PrimaryCategory string `json:"primary_category,omitempty" table:"category"` + SecondaryCategory string `json:"secondary_category,omitempty" table:"-"` + Highlights []string `json:"highlights,omitempty" table:"-"` + + // A repository action and a marketplace app are both listings and the + // search results are interleaved, but only one of these two groups is ever + // populated for a given record. Which group is filled in is itself the + // answer to "what kind of thing is this". + Path string `json:"path,omitempty" table:"-"` + RepositoryID *int `json:"repository_id,omitempty" table:"-"` + IconName string `json:"icon_name,omitempty" table:"-"` + IconColor string `json:"icon_color,omitempty" table:"-"` + + ListingID *int `json:"listing_id,omitempty" table:"-"` + LogoURL string `json:"logo_url,omitempty" table:"-"` + InstallationCount *int `json:"installation_count,omitempty" table:"installs"` + State string `json:"state,omitempty" table:"state"` + CompanyURL string `json:"company_url,omitempty" table:"-"` + DocumentationURL string `json:"documentation_url,omitempty" table:"-"` + SupportURL string `json:"support_url,omitempty" table:"-"` + PrivacyPolicyURL string `json:"privacy_policy_url,omitempty" table:"-"` + TermsURL string `json:"terms_url,omitempty" table:"-"` + PricingURL string `json:"pricing_url,omitempty" table:"-"` + + Stars *int `json:"stars,omitempty" table:"stars"` + DependentCount *int `json:"dependent_count,omitempty" table:"used_by"` + + IsFree bool `json:"is_free" table:"-"` + IsVerifiedOwner bool `json:"is_verified_owner" table:"verified"` + IsFeatured bool `json:"is_featured" table:"-"` + IsRecommended bool `json:"is_recommended" table:"-"` + ByGitHub bool `json:"by_github" table:"-"` +} + +// Trending embeds Repo because a trending entry is a repository with three +// extra facts. Embedding is what makes `github trending -o url | xargs -n1 +// github get` work with no special case anywhere. +type Trending struct { + Repo + + StarsInPeriod *int `json:"stars_in_period,omitempty" table:"period_stars"` + Period string `json:"period" table:"period"` + BuiltBy []Actor `json:"built_by,omitempty" table:"-"` + Rank int `json:"rank" table:"rank"` +} + +// --- contributions --- + +// Contributor is one person's contribution statistics for a repository. Weeks +// arrives with the response so it is kept by default, and it is never a table +// column because a hundred weeks is not a column. +type Contributor struct { + Base + + Repo string `json:"repo" table:"repo"` + Login string `json:"login" table:"login"` + + Commits *int `json:"commits,omitempty" table:"commits"` + Additions *int `json:"additions,omitempty" table:"+"` + // Hidden by the tag-grammar collision, as everywhere else. + Deletions *int `json:"deletions,omitempty" table:"-"` + + // FirstWeek and LastWeek are derived by trimming the leading and trailing + // zero weeks, which turns a six-hundred-element array into two dates a + // table can show. + FirstWeek *time.Time `json:"first_week,omitempty" table:"first,time"` + LastWeek *time.Time `json:"last_week,omitempty" table:"last,time"` + + Weeks []ContributorWeek `json:"weeks,omitempty" table:"-"` + + AvatarURL string `json:"avatar_url,omitempty" table:"-"` + DatabaseID *int `json:"database_id,omitempty" table:"-"` +} + +// ContributorWeek is one week of one contributor's statistics. +type ContributorWeek struct { + Week time.Time `json:"week" table:"week,time"` + Additions int `json:"additions" table:"+"` + Deletions int `json:"deletions" table:"-"` + Commits int `json:"commits" table:"commits"` +} + +// ContributionDay is one square of a profile contribution graph. +type ContributionDay struct { + Base + + Login string `json:"login" table:"login"` + Date time.Time `json:"date" table:"date,time"` + Count int `json:"count" table:"count"` + Level int `json:"level" table:"level"` +} + +// Event is one entry of an activity feed. Type is derived from the entry id, +// which encodes the event class, rather than from the title text, which is +// prose and is localised. +type Event struct { + Base + + Actor Actor `json:"actor" table:"actor"` + Type string `json:"type" table:"type"` + Repo string `json:"repo,omitempty" table:"repo"` + + Title string `json:"title,omitempty" table:"title,truncate"` + BodyHTML string `json:"body_html,omitempty" table:"-"` + Target string `json:"target,omitempty" table:"-"` + At *time.Time `json:"at,omitempty" table:"at,time"` +} diff --git a/gh/uri.go b/gh/uri.go new file mode 100644 index 0000000..5e80805 --- /dev/null +++ b/gh/uri.go @@ -0,0 +1,580 @@ +package gh + +import ( + "net/url" + "strings" + + "github.com/tamnd/any-cli/kit/errs" +) + +// uri.go is the whole identity scheme. Everything downstream, the graph, the +// RDF subjects, the cache keys, the `github get` dispatch, resolves through +// here, so an id that parses wrong is a bug that shows up everywhere at once. +// +// The grammar has three separators and each one means exactly one thing: +// +// / separates a namespace from a name, and a repository from a path +// # introduces a thread number +// @ introduces a git revision +// +// A path may contain slashes but never a `#` or an `@` in a position that +// matters, because the revision always comes immediately after the repository +// and the number is always last. So parsing splits on `#`, then on the first +// `@` after the second `/`, and the rest is a path. That is the entire trick. + +// The kinds. Twenty are addressable as github:// URIs; compare is a recognised +// route that names a range rather than a thing, and is here because people +// paste compare URLs. +const ( + KindRepo = "repo" + KindUser = "user" + KindOrg = "org" + KindIssue = "issue" + KindPR = "pr" + KindDiscussion = "discussion" + KindCommit = "commit" + KindBranch = "branch" + KindTag = "tag" + KindRelease = "release" + KindFile = "file" + KindTree = "tree" + KindLabel = "label" + KindMilestone = "milestone" + KindTopic = "topic" + KindGist = "gist" + KindPackage = "package" + KindAction = "action" + KindWiki = "wiki" + KindAdvisory = "advisory" + KindCompare = "compare" +) + +// Scheme is the URI scheme this package mints and dereferences. +const Scheme = "github" + +// Ident is a parsed reference: what kind of thing, its canonical id, and the +// fragment the URL carried. The fragment never changes the kind. A link to +// #issuecomment-66046293 is still a link to the issue, and a link to #L10-L20 +// is still a link to the file, so the anchor is recorded and set aside. +type Ident struct { + Kind string `json:"kind" table:"kind"` + ID string `json:"id" table:"id"` + Anchor string `json:"anchor,omitempty" table:"anchor"` + URI string `json:"uri" table:"uri"` + URL string `json:"url" table:"url,url"` +} + +// reserved lists the top-level github.com paths that are site routes rather +// than accounts. Without it, `github get https://github.com/topics/go` would +// classify topics as a user, which is the kind of wrong answer that only shows +// up in someone else's script. +var reserved = map[string]bool{ + "about": true, "advisories": true, "apps": true, "collections": true, + "contact": true, "customer-stories": true, "dashboard": true, "enterprise": true, + "events": true, "explore": true, "features": true, "issues": true, "join": true, + "login": true, "logout": true, "marketplace": true, "new": true, "notifications": true, + "orgs": true, "pricing": true, "pulls": true, "search": true, "security": true, + "settings": true, "site": true, "sponsors": true, "stars": true, "topics": true, + "trending": true, "readme": true, "codespaces": true, "sessions": true, +} + +// Classify turns anything a person might paste into a kind and an id. It does +// no I/O, and it never fails on a well-formed github.com URL. +// +// Two of its answers are guesses and both are documented as such. A bare word +// is a user, because a pure function cannot tell a user from an organization +// without asking. A bare owner/name is a repository. `github get` reads the +// page and returns a record whose Kind is the truth; classification is a +// routing hint, not an answer. +func Classify(input string) (kind, id string, err error) { + r, err := Parse(input) + if err != nil { + return "", "", err + } + return r.Kind, r.ID, nil +} + +// Parse is Classify with the fragment and the derived forms kept. +func Parse(input string) (Ident, error) { + s := strings.TrimSpace(input) + if s == "" { + return Ident{}, errs.Usage("empty reference") + } + var anchor string + switch { + case strings.HasPrefix(s, Scheme+"://"): + kind, id, a, err := parseURI(s) + if err != nil { + return Ident{}, err + } + return finish(kind, id, a) + case strings.Contains(s, "://"): + kind, id, a, err := parseURL(s) + if err != nil { + return Ident{}, err + } + return finish(kind, id, a) + } + // A bare reference. Strip a fragment the same way a URL would, so + // golang/go#1#issuecomment-1 and a pasted anchor both behave. + if i := strings.Index(s, "#"); i >= 0 { + if j := strings.Index(s[i+1:], "#"); j >= 0 { + anchor = s[i+1+j+1:] + s = s[:i+1+j] + } + } + s = strings.TrimPrefix(s, "/") + s = strings.TrimSuffix(s, "/") + kind, id, err := classifyBare(s) + if err != nil { + return Ident{}, err + } + return finish(kind, id, anchor) +} + +func finish(kind, id, anchor string) (Ident, error) { + u, err := Locate(kind, id) + if err != nil { + return Ident{}, err + } + return Ident{Kind: kind, ID: id, Anchor: anchor, URI: URI(kind, id), URL: u}, nil +} + +// URI renders the github:// form. It is a string join and not a url.URL, +// because ids contain `#` and `@` on purpose and url.URL would escape them. +func URI(kind, id string) string { return Scheme + "://" + kind + "/" + id } + +func parseURI(s string) (kind, id, anchor string, err error) { + rest := strings.TrimPrefix(s, Scheme+"://") + if i := strings.LastIndex(rest, "#issuecomment-"); i >= 0 { + anchor, rest = rest[i+1:], rest[:i] + } + kind, id, ok := strings.Cut(rest, "/") + if !ok || kind == "" || id == "" { + return "", "", "", errs.Usage("%q is not a %s:// URI", s, Scheme) + } + if !knownKind(kind) { + return "", "", "", errs.Usage("%q is not a kind this tool knows", kind) + } + return kind, strings.TrimSuffix(id, "/"), anchor, nil +} + +func knownKind(k string) bool { + switch k { + case KindRepo, KindUser, KindOrg, KindIssue, KindPR, KindDiscussion, KindCommit, + KindBranch, KindTag, KindRelease, KindFile, KindTree, KindLabel, KindMilestone, + KindTopic, KindGist, KindPackage, KindAction, KindWiki, KindAdvisory, KindCompare: + return true + } + return false +} + +// parseURL handles every github.com host that serves content, plus the two +// static hosts. Query strings are dropped: ?tab=repositories names a tab on a +// profile, not a different profile. +func parseURL(raw string) (kind, id, anchor string, err error) { + u, perr := url.Parse(raw) + if perr != nil { + return "", "", "", errs.Usage("%q is not a URL: %v", raw, perr) + } + host := strings.ToLower(u.Host) + host = strings.TrimPrefix(host, "www.") + path := strings.Trim(u.Path, "/") + anchor = u.Fragment + + switch host { + case "raw.githubusercontent.com": + // /{owner}/{repo}/{ref}/{path...} + p := strings.Split(path, "/") + if len(p) < 4 { + return "", "", "", errs.Usage("%q is not a raw file URL", raw) + } + return KindFile, p[0] + "/" + p[1] + "@" + p[2] + "/" + strings.Join(p[3:], "/"), anchor, nil + case "gist.github.com", "gist.githubusercontent.com": + p := strings.Split(path, "/") + if len(p) == 0 || p[0] == "" { + return "", "", "", errs.Usage("%q names no gist", raw) + } + // A gist URL is either /{id} or /{owner}/{id}. The id is the last + // segment that looks like one. + return KindGist, p[len(p)-1], anchor, nil + case "github.com", "codeload.github.com": + return classifyPath(path, anchor, raw) + default: + return "", "", "", errs.Usage("%q is not a github.com URL", raw) + } +} + +func classifyPath(path, anchor, raw string) (kind, id, a string, err error) { + if path == "" { + return "", "", "", errs.Usage("%q names no resource", raw) + } + p := strings.Split(path, "/") + + // Site routes first, so a repository named "topics" cannot shadow one. + switch p[0] { + case "topics": + if len(p) >= 2 { + return KindTopic, p[1], anchor, nil + } + case "marketplace": + if len(p) >= 3 && p[1] == "actions" { + return KindAction, p[2], anchor, nil + } + case "advisories": + if len(p) >= 2 { + return KindAdvisory, p[1], anchor, nil + } + case "orgs": + if len(p) >= 2 { + return KindOrg, p[1], anchor, nil + } + } + if reserved[p[0]] { + return "", "", "", errs.Usage("%q is a github.com page, not a resource this tool reads", raw) + } + if len(p) == 1 { + return KindUser, p[0], anchor, nil + } + owner, name := p[0], strings.TrimSuffix(p[1], ".git") + repo := owner + "/" + name + if len(p) == 2 { + return KindRepo, repo, anchor, nil + } + + rest := p[2:] + switch rest[0] { + case "issues": + if len(rest) >= 2 && isNumber(rest[1]) { + return KindIssue, repo + "#" + rest[1], anchor, nil + } + return KindRepo, repo, anchor, nil + case "pull", "pulls": + if len(rest) >= 2 && isNumber(rest[1]) { + return KindPR, repo + "#" + rest[1], anchor, nil + } + return KindRepo, repo, anchor, nil + case "discussions": + if len(rest) >= 2 && isNumber(rest[1]) { + return KindDiscussion, repo + "#" + rest[1], anchor, nil + } + return KindRepo, repo, anchor, nil + case "commit": + if len(rest) >= 2 { + return KindCommit, repo + "@" + rest[1], anchor, nil + } + case "commits": + if len(rest) >= 2 { + return KindBranch, repo + "@" + rest[1], anchor, nil + } + return KindRepo, repo, anchor, nil + case "tree": + if len(rest) == 2 { + return KindBranch, repo + "@" + rest[1], anchor, nil + } + if len(rest) > 2 { + return KindTree, repo + "@" + rest[1] + "/" + strings.Join(rest[2:], "/"), anchor, nil + } + case "blob", "raw", "blame": + if len(rest) >= 3 { + return KindFile, repo + "@" + rest[1] + "/" + strings.Join(rest[2:], "/"), anchor, nil + } + case "releases": + if len(rest) >= 3 && rest[1] == "tag" { + return KindRelease, repo + "@" + strings.Join(rest[2:], "/"), anchor, nil + } + if len(rest) >= 3 && rest[1] == "download" { + return KindRelease, repo + "@" + rest[2], anchor, nil + } + return KindRepo, repo, anchor, nil + case "labels": + if len(rest) >= 2 { + name, _ := url.PathUnescape(strings.Join(rest[1:], "/")) + return KindLabel, repo + "/" + name, anchor, nil + } + case "milestone": + if len(rest) >= 2 { + return KindMilestone, repo + "/" + rest[1], anchor, nil + } + case "wiki": + if len(rest) >= 2 { + return KindWiki, repo + "/" + strings.Join(rest[1:], "/"), anchor, nil + } + return KindWiki, repo + "/Home", anchor, nil + case "pkgs": + // /{owner}/{repo}/pkgs/{type}/{name} + if len(rest) >= 3 { + return KindPackage, repo + "/" + rest[len(rest)-1], anchor, nil + } + case "compare": + if len(rest) >= 2 { + return KindCompare, repo + "@" + strings.Join(rest[1:], "/"), anchor, nil + } + case "archive": + if len(rest) >= 2 { + ref := strings.TrimSuffix(strings.TrimSuffix(rest[len(rest)-1], ".zip"), ".tar.gz") + return KindBranch, repo + "@" + ref, anchor, nil + } + } + // Every other repository tab (actions, settings, network, graphs, stargazers) + // is a view of the repository, so that is what it resolves to. + return KindRepo, repo, anchor, nil +} + +// classifyBare reads the compact forms people type: owner/name, owner/name#12, +// owner/name@sha, owner/name@ref/path, and a bare login. +func classifyBare(s string) (kind, id string, err error) { + if s == "" { + return "", "", errs.Usage("empty reference") + } + if strings.HasPrefix(strings.ToUpper(s), "GHSA-") { + return KindAdvisory, s, nil + } + if base, num, ok := strings.Cut(s, "#"); ok { + if !isNumber(num) { + return "", "", errs.Usage("%q: the part after # must be a number", s) + } + if strings.Count(base, "/") != 1 { + return "", "", errs.Usage("%q: a thread reference looks like owner/name#123", s) + } + // Bare owner/name#N is an issue, which is the same guess github.com + // makes: /issues/N redirects to /pull/N when N is a pull request. + return KindIssue, base + "#" + num, nil + } + if i := strings.Index(s, "@"); i >= 0 && strings.Count(s[:i], "/") == 1 { + repo, rev := s[:i], s[i+1:] + if rev == "" { + return "", "", errs.Usage("%q: nothing after @", s) + } + if r, path, ok := strings.Cut(rev, "/"); ok { + return KindFile, repo + "@" + r + "/" + path, nil + } + if isSHA(rev) { + return KindCommit, repo + "@" + rev, nil + } + // A short ref with no path is a branch by default. `github tag` and + // `github release` name their own kind and override this. + return KindBranch, repo + "@" + rev, nil + } + switch strings.Count(s, "/") { + case 0: + if reserved[s] { + return "", "", errs.Usage("%q is a github.com page, not an account", s) + } + return KindUser, s, nil + case 1: + return KindRepo, s, nil + default: + // owner/name/something. Ambiguous between wiki, label, milestone, and + // package, so it goes to the one whose ids are numeric when it is + // numeric and to a file path otherwise. + p := strings.SplitN(s, "/", 3) + if isNumber(p[2]) { + return KindMilestone, s, nil + } + return KindWiki, s, nil + } +} + +// Locate turns a kind and id back into the canonical github.com URL. +// Locate(Classify(u)) is the canonical form of u, which is what makes -o url +// safe to pipe back into the tool. +func Locate(kind, id string) (string, error) { + if id == "" { + return "", errs.Usage("%s with no id", kind) + } + switch kind { + case KindRepo: + return BaseURL + "/" + id, nil + case KindUser, KindOrg: + return BaseURL + "/" + id, nil + case KindIssue, KindPR, KindDiscussion: + repo, num, ok := strings.Cut(id, "#") + if !ok { + return "", errs.Usage("%s id %q is missing its number", kind, id) + } + seg := map[string]string{KindIssue: "issues", KindPR: "pull", KindDiscussion: "discussions"}[kind] + return BaseURL + "/" + repo + "/" + seg + "/" + num, nil + case KindCommit: + repo, sha, ok := cutRev(id) + if !ok { + return "", errs.Usage("commit id %q is missing its sha", id) + } + return BaseURL + "/" + repo + "/commit/" + sha, nil + case KindBranch: + repo, ref, ok := cutRev(id) + if !ok { + return "", errs.Usage("branch id %q is missing its ref", id) + } + return BaseURL + "/" + repo + "/tree/" + ref, nil + case KindTag, KindRelease: + repo, tag, ok := cutRev(id) + if !ok { + return "", errs.Usage("%s id %q is missing its tag", kind, id) + } + return BaseURL + "/" + repo + "/releases/tag/" + tag, nil + case KindFile, KindTree: + repo, ref, path, ok := SplitPathID(id) + if !ok { + return "", errs.Usage("%s id %q is not owner/name@ref/path", kind, id) + } + seg := "blob" + if kind == KindTree { + seg = "tree" + } + if path == "" { + return BaseURL + "/" + repo + "/tree/" + ref, nil + } + return BaseURL + "/" + repo + "/" + seg + "/" + ref + "/" + path, nil + case KindLabel: + repo, name, ok := cutRepoRest(id) + if !ok { + return "", errs.Usage("label id %q is not owner/name/label", id) + } + return BaseURL + "/" + repo + "/labels/" + url.PathEscape(name), nil + case KindMilestone: + repo, num, ok := cutRepoRest(id) + if !ok { + return "", errs.Usage("milestone id %q is not owner/name/number", id) + } + return BaseURL + "/" + repo + "/milestone/" + num, nil + case KindWiki: + repo, page, ok := cutRepoRest(id) + if !ok { + return "", errs.Usage("wiki id %q is not owner/name/page", id) + } + return BaseURL + "/" + repo + "/wiki/" + page, nil + case KindPackage: + repo, name, ok := cutRepoRest(id) + if !ok { + return "", errs.Usage("package id %q is not owner/name/package", id) + } + return BaseURL + "/" + repo + "/pkgs/container/" + name, nil + case KindTopic: + return BaseURL + "/topics/" + id, nil + case KindAction: + return BaseURL + "/marketplace/actions/" + id, nil + case KindAdvisory: + return BaseURL + "/advisories/" + id, nil + case KindGist: + return "https://gist.github.com/" + id, nil + case KindCompare: + repo, rng, ok := cutRev(id) + if !ok { + return "", errs.Usage("compare id %q is missing its range", id) + } + return BaseURL + "/" + repo + "/compare/" + rng, nil + } + return "", errs.Usage("%q is not a kind this tool knows", kind) +} + +// cutRev splits owner/name@rev. It looks for the `@` after the second slash so +// that an owner with an `@` in it, which github.com does not allow but a +// hand-written id might contain, cannot confuse it. +func cutRev(id string) (repo, rev string, ok bool) { + i := strings.Index(id, "@") + if i <= 0 || i == len(id)-1 { + return "", "", false + } + return id[:i], id[i+1:], true +} + +// SplitPathID splits owner/name@ref/path/to/file into its three parts. The ref +// runs to the next slash, which means a branch with a slash in its name +// (feature/x) parses as ref "feature" and path "x/...". That is a real +// ambiguity in GitHub's own URLs and nothing here can resolve it; a caller who +// knows better passes --ref. +func SplitPathID(id string) (repo, ref, path string, ok bool) { + repo, rest, ok := cutRev(id) + if !ok { + return "", "", "", false + } + ref, path, _ = strings.Cut(rest, "/") + if ref == "" { + return "", "", "", false + } + return repo, ref, path, true +} + +// cutRepoRest splits owner/name/rest, keeping any slashes in rest. +func cutRepoRest(id string) (repo, rest string, ok bool) { + p := strings.SplitN(id, "/", 3) + if len(p) != 3 || p[0] == "" || p[1] == "" || p[2] == "" { + return "", "", false + } + return p[0] + "/" + p[1], p[2], true +} + +// SplitThreadID splits owner/name#123. +func SplitThreadID(id string) (repo string, num string, ok bool) { + repo, num, ok = strings.Cut(id, "#") + if !ok || repo == "" || !isNumber(num) { + return "", "", false + } + return repo, num, true +} + +// SplitRepo splits owner/name. +func SplitRepo(id string) (owner, name string, ok bool) { + owner, name, ok = strings.Cut(id, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return "", "", false + } + return owner, name, true +} + +// RepoOf returns the repository an id belongs to, for the kinds whose id +// carries one. This is what makes `github tree ` work. +func RepoOf(kind, id string) (string, bool) { + switch kind { + case KindRepo: + if _, _, ok := SplitRepo(id); ok { + return id, true + } + case KindIssue, KindPR, KindDiscussion: + if repo, _, ok := SplitThreadID(id); ok { + return repo, true + } + case KindCommit, KindBranch, KindTag, KindRelease, KindCompare: + if repo, _, ok := cutRev(id); ok { + return repo, true + } + case KindFile, KindTree: + if repo, _, _, ok := SplitPathID(id); ok { + return repo, true + } + case KindLabel, KindMilestone, KindWiki, KindPackage: + if repo, _, ok := cutRepoRest(id); ok { + return repo, true + } + } + return "", false +} + +func isNumber(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// isSHA reports whether a revision looks like an object name rather than a +// branch. Seven is git's own abbreviation floor, and a seven-character branch +// name made only of hex digits (`decade`, `facade` are six) is rare enough that +// this is the right default and --ref is the override. +func isSHA(s string) bool { + if len(s) < 7 || len(s) > 40 { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} diff --git a/go.mod b/go.mod index fdb0055..eba50f1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( github.com/charmbracelet/fang v1.0.0 github.com/spf13/cobra v1.10.2 - github.com/tamnd/any-cli v0.4.0 + github.com/tamnd/any-cli v0.4.4 ) require ( @@ -36,9 +36,10 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 280027e..3919846 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tamnd/any-cli v0.4.0 h1:ngyRJBvjZ2X1iBlwlmDLvY2S9aQWlDjVE7CiOwxtt5Y= github.com/tamnd/any-cli v0.4.0/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= +github.com/tamnd/any-cli v0.4.4 h1:mOo3JJ7M3ZWQtOocYVtMEN7Zhfc3ogVWcVrbarlt9jE= +github.com/tamnd/any-cli v0.4.4/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -79,14 +81,24 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/page/compact.go b/pkg/page/compact.go new file mode 100644 index 0000000..bc3ff0e --- /dev/null +++ b/pkg/page/compact.go @@ -0,0 +1,68 @@ +package page + +import ( + "strconv" + "strings" +) + +// compact.go parses the numbers GitHub renders for people rather than for +// programs: 313k followers, 1.2k stars, 8,112 commits. +// +// The rule elsewhere in this tool is never to parse a rendered number when a +// raw one exists, and it holds: sidebarAbout.stargazerCount is an integer and +// is always preferred. These functions are for the pages where the rendered +// form is the only form there is. + +// ParseCompactCount reads a rendered count and returns the integer plus the +// original string. Both are kept because the grouping separator depends on the +// locale GitHub infers, so "8,112" and "8.112" are the same number, and +// throwing away the original would make that ambiguity invisible. +// +// Returns ok=false rather than zero when the string is not a number at all. +// Absent is not zero: a parser that returns 0 on failure is a bug that ships +// quietly and is discovered a year later in someone's aggregate. +func ParseCompactCount(s string) (n int, display string, ok bool) { + display = strings.TrimSpace(s) + t := strings.ToLower(display) + t = strings.TrimSpace(strings.NewReplacer(" ", " ", "+", "").Replace(t)) + if t == "" { + return 0, display, false + } + // A suffix multiplier turns the rest into a float: 1.2k is 1200. + mult := 1 + switch { + case strings.HasSuffix(t, "k"): + mult, t = 1_000, strings.TrimSuffix(t, "k") + case strings.HasSuffix(t, "m"): + mult, t = 1_000_000, strings.TrimSuffix(t, "m") + case strings.HasSuffix(t, "b"): + mult, t = 1_000_000_000, strings.TrimSuffix(t, "b") + } + t = strings.TrimSpace(t) + if mult > 1 { + f, err := strconv.ParseFloat(strings.Replace(t, ",", ".", 1), 64) + if err != nil { + return 0, display, false + } + return int(f * float64(mult)), display, true + } + // No suffix, so any separator is a thousands separator. GitHub uses a + // comma, a period, or a thin space depending on the inferred locale, and + // all three mean the same thing here. + t = strings.NewReplacer(",", "", ".", "", " ", "", " ", "", "'", "").Replace(t) + v, err := strconv.Atoi(t) + if err != nil { + return 0, display, false + } + return v, display, true +} + +// CountIn parses the first token of a rendered label, which is how the profile +// counters read: "313k followers", "1.2k following". +func CountIn(s string) (int, string, bool) { + fields := strings.Fields(strings.TrimSpace(s)) + if len(fields) == 0 { + return 0, "", false + } + return ParseCompactCount(fields[0]) +} diff --git a/pkg/page/dom.go b/pkg/page/dom.go new file mode 100644 index 0000000..117fd02 --- /dev/null +++ b/pkg/page/dom.go @@ -0,0 +1,285 @@ +package page + +import ( + "bytes" + "strings" + + "golang.org/x/net/html" +) + +// dom.go is the parsed-document half of the extractor, and the matcher it +// needs. The matcher handles tag name, id, class membership, attribute +// presence, attribute value, prefix and suffix on an attribute, and one level +// of descendant. That is every selector in selectors.go and nothing more, +// which is the reason there is no CSS selector dependency here. + +// Doc parses the page once and caches the tree. Most reads never call it: the +// React pages carry their data as JSON and the scanner alone is enough. +func (p *Page) Doc() *html.Node { + if p.doc != nil || len(p.HTML) == 0 { + return p.doc + } + n, err := html.Parse(bytes.NewReader(p.HTML)) + if err != nil { + return nil + } + p.doc = n + return p.doc +} + +// Sel is one selector. A zero field is "do not care", so a selector that only +// sets Class matches on class alone. +type Sel struct { + Tag string + ID string + Class string // one class, matched against the whitespace-separated list + Attr string // attribute that must be present + AttrValue string // and, if set, must equal this + AttrPrefix string + AttrSuffix string + AttrContains string + // HasDescendantClass requires a descendant element carrying this class. + // It exists for the licence link, which is identified by the icon inside + // it because the icon changes less often than the anchor's own classes. + HasDescendantClass string +} + +// Match reports whether n satisfies every field the selector set. +func (s Sel) Match(n *html.Node) bool { + if n.Type != html.ElementNode { + return false + } + if s.Tag != "" && n.Data != s.Tag { + return false + } + if s.ID != "" && Attr(n, "id") != s.ID { + return false + } + if s.Class != "" && !HasClass(n, s.Class) { + return false + } + if s.Attr != "" { + v, ok := lookupAttr(n, s.Attr) + if !ok { + return false + } + if s.AttrValue != "" && v != s.AttrValue { + return false + } + if s.AttrPrefix != "" && !strings.HasPrefix(v, s.AttrPrefix) { + return false + } + if s.AttrSuffix != "" && !strings.HasSuffix(v, s.AttrSuffix) { + return false + } + if s.AttrContains != "" && !strings.Contains(v, s.AttrContains) { + return false + } + } + if s.HasDescendantClass != "" && Find(n, Sel{Class: s.HasDescendantClass}) == nil { + return false + } + return true +} + +// Attr reads one attribute, empty when it is absent. +func Attr(n *html.Node, name string) string { + v, _ := lookupAttr(n, name) + return v +} + +func lookupAttr(n *html.Node, name string) (string, bool) { + for _, a := range n.Attr { + if a.Key == name { + return a.Val, true + } + } + return "", false +} + +// HasClass matches one class in the whitespace-separated list, never a +// substring. `Box-row` must not match `Box-row-hover`. +func HasClass(n *html.Node, want string) bool { + v, ok := lookupAttr(n, "class") + if !ok { + return false + } + for _, c := range strings.Fields(v) { + if c == want { + return true + } + } + return false +} + +// Find returns the first matching element, or nil. +func Find(root *html.Node, s Sel) *html.Node { + var found *html.Node + Walk(root, func(n *html.Node) bool { + if s.Match(n) { + found = n + return false + } + return true + }) + return found +} + +// FindAll returns every matching element in document order. +func FindAll(root *html.Node, s Sel) []*html.Node { + var out []*html.Node + Walk(root, func(n *html.Node) bool { + if s.Match(n) { + out = append(out, n) + } + return true + }) + return out +} + +// Walk visits every node depth-first. Returning false from fn stops the +// traversal of that subtree and, once found is set, the search as a whole. +func Walk(n *html.Node, fn func(*html.Node) bool) { + if n == nil { + return + } + if !fn(n) { + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + Walk(c, fn) + } +} + +// Text returns the concatenated, whitespace-collapsed text of a subtree. It is +// the last-resort extractor and every caller of it is marked as such. +func Text(n *html.Node) string { + if n == nil { + return "" + } + var b strings.Builder + Walk(n, func(x *html.Node) bool { + if x.Type == html.TextNode { + b.WriteString(x.Data) + } + return true + }) + return collapse(b.String()) +} + +func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } + +// RelTime returns the datetime attribute of the first +// descendant. The element's own text is never read: it is localised and +// relative, and parsing it would be a whole class of bug for no gain. +func RelTime(n *html.Node) string { + rt := Find(n, Sel{Tag: "relative-time"}) + if rt == nil { + rt = Find(n, Sel{Tag: "time-ago"}) + } + if rt == nil { + return "" + } + return Attr(rt, "datetime") +} + +// readDOM is steps 5 to 8 of the extraction: meta tags, canonical, microdata, +// and the deferred fragments. It runs on every page because all four are cheap +// once the tree exists and all four are useful on both planes. +func (p *Page) readDOM() { + doc := p.Doc() + if doc == nil { + return + } + p.Meta = map[string]string{} + p.Microdata = map[string][]string{} + + Walk(doc, func(n *html.Node) bool { + if n.Type != html.ElementNode { + return true + } + switch n.Data { + case "meta": + key := firstAttr(n, "property", "name") + content := Attr(n, "content") + if content == "" { + return true + } + if strings.HasPrefix(key, "og:") || strings.HasPrefix(key, "twitter:") || + key == "description" || key == "octolytics-dimension-user_login" || + key == "octolytics-dimension-repository_id" || key == "route-pattern" { + p.Meta[key] = content + } + case "link": + if Attr(n, "rel") == "canonical" { + p.Canonical = Attr(n, "href") + } + case "include-fragment", "turbo-frame": + src := Attr(n, "src") + // In-product messaging is growth tooling, not content, and it is on + // nearly every page. + if src != "" && !strings.Contains(src, "/in-product-messaging") { + p.Fragments = appendUnique(p.Fragments, src) + } + case "title": + if p.Title == "" { + p.Title = Text(n) + } + } + if v, ok := lookupAttr(n, "itemprop"); ok { + // itemprop can name several properties at once, as in + // itemprop="name codeRepository" on the repositories tab. + for _, name := range strings.Fields(v) { + p.Microdata[name] = append(p.Microdata[name], itemValue(n)) + } + } + return true + }) + if len(p.Meta) == 0 { + p.Meta = nil + } + if len(p.Microdata) == 0 { + p.Microdata = nil + } +} + +// itemValue reads a microdata property the way the specification says to: the +// attribute that carries the machine-readable form when there is one, and the +// element text otherwise. +func itemValue(n *html.Node) string { + switch n.Data { + case "meta": + return Attr(n, "content") + case "a", "area", "link": + return Attr(n, "href") + case "img", "audio", "embed", "iframe", "source", "video": + return Attr(n, "src") + case "time": + if v := Attr(n, "datetime"); v != "" { + return v + } + case "data": + if v := Attr(n, "value"); v != "" { + return v + } + } + return Text(n) +} + +func firstAttr(n *html.Node, names ...string) string { + for _, name := range names { + if v, ok := lookupAttr(n, name); ok { + return v + } + } + return "" +} + +func appendUnique(ss []string, s string) []string { + for _, x := range ss { + if x == s { + return ss + } + } + return append(ss, s) +} diff --git a/pkg/page/page.go b/pkg/page/page.go new file mode 100644 index 0000000..193c981 --- /dev/null +++ b/pkg/page/page.go @@ -0,0 +1,252 @@ +// Package page turns a github.com HTML document into structured data. +// +// github.com is two applications sharing a domain. The React half ships its +// route props as JSON inside a script tag, and the Rails half ships schema.org +// microdata. Both are structured, which means most of what looks like scraping +// is really JSON decoding with an HTML document as the envelope. +// +// Pulling text out of rendered markup is the third choice here, not the first. +// Where it is unavoidable, the selector lives in selectors.go with the date it +// was last checked against a live page, so a break is a one-file diff. +package page + +import ( + "encoding/json" + "strings" + + "golang.org/x/net/html" +) + +// Plane says which of the two applications rendered a page. It is worth +// knowing because it decides where the data is: React pages carry an app +// payload and Rails pages carry microdata. +type Plane string + +const ( + PlaneReact Plane = "react" + PlaneRails Plane = "rails" +) + +// Page is the whole HTML plane of one document in one struct. Every +// page-derived record is built from a Page and never from a raw string, which +// is what makes `github page` possible: it prints this, and every record is a +// projection of it. +type Page struct { + URL string `json:"url"` + Canonical string `json:"canonical,omitempty"` + Title string `json:"title,omitempty"` + Plane Plane `json:"plane"` + + // Payload is the React route props, the union of every route object the + // server sent for this page. + Payload map[string]json.RawMessage `json:"payload,omitempty"` + + // Queries holds the Relay results the server preloaded, keyed by query + // name. This is a GraphQL response without a GraphQL token, and on issue + // and pull request pages it is where everything lives. + Queries map[string]json.RawMessage `json:"queries,omitempty"` + + // StructuredData is payload.structured_data, GitHub's own schema.org view + // of the page. Its url field is wrong, see Canonical. + StructuredData json.RawMessage `json:"structured_data,omitempty"` + + // LinkedData is every ` is a byte scan, and the content between them is JSON that never +// contains `` because GitHub escapes it before it goes out. +// +// This matters for cost. A repository page is 300 KB and an issue page is 340 +// KB, and the app payload is the only part of either that most reads need. The +// scanner allocates once per block and never builds a node tree. + +type jsonBlock struct { + dataTarget string + id string + body []byte +} + +var ( + scriptOpen = []byte("") + typeJSON = []byte(`type="application/json"`) + typeLDJSON = []byte(`type="application/ld+json"`) +) + +// scanJSONScripts returns every ") + if j < 0 { + return []byte("{}") + } + return []byte(strings.TrimSpace(rest[:j])) +} + +// Head issues a HEAD and returns the response headers. It exists for one +// question, "how big is this file", which is worth asking without downloading +// the answer. +func (c *Client) Head(ctx context.Context, url string) (http.Header, error) { + if err := c.acquire(ctx); err != nil { + return nil, err + } + defer c.release() + c.pace(ctx) + + req, err := c.newRequest(ctx, url, SurfaceRaw) + if err != nil { + return nil, err + } + req.Method = http.MethodHead + res, err := c.HTTP.Do(req) + if err != nil { + return nil, wrapNetwork(url, err) + } + _ = res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, statusError(url, res.StatusCode, nil) + } + return res.Header, nil +} diff --git a/gh/live_test.go b/gh/live_test.go index 551eb20..231f214 100644 --- a/gh/live_test.go +++ b/gh/live_test.go @@ -505,6 +505,117 @@ func TestLiveSearch(t *testing.T) { } } +// TestLiveContents covers the tree route, the blob route, and raw bytes. The +// three are one test because the interesting question is whether the metadata +// and the bytes still agree with each other. +func TestLiveContents(t *testing.T) { + c := liveClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + t.Run("tree", func(t *testing.T) { + var got []TreeEntry + err := c.Tree(ctx, "cli/cli", "pkg", TreeOptions{}, func(e TreeEntry) error { + got = append(got, e) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(got) < 5 { + t.Fatalf("only %d entries under pkg/", len(got)) + } + e := got[0] + if e.Name == "" || !strings.HasPrefix(e.Path, "pkg/") { + t.Errorf("path is not repository-relative: %+v", e) + } + if e.Type == "" { + t.Error("contentType missing") + } + // An empty ref means HEAD in the URL and a resolved SHA on the record. + if len(e.Ref) != 40 { + t.Errorf("ref %q is not a resolved commit", e.Ref) + } + logExtra(t, "tree", e.Extra) + }) + + t.Run("tree_recursive", func(t *testing.T) { + n := 0 + err := c.Tree(ctx, "cli/cli", "pkg/iostreams", TreeOptions{Recursive: true, Limit: 12}, func(TreeEntry) error { + n++ + return nil + }) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("recursive walk emitted nothing") + } + }) + + t.Run("blob", func(t *testing.T) { + f, err := c.Blob(ctx, "cli/cli", "pkg/iostreams/iostreams.go", BlobOptions{}) + if err != nil { + t.Fatal(err) + } + if f.Language != "Go" { + t.Errorf("language %q, the blob layout route stopped decoding", f.Language) + } + if f.Lines == nil || *f.Lines < 100 { + t.Errorf("line count %v for a 13 KB file", f.Lines) + } + // GitHub's symbol analyser answers null about half the time and the + // same list a second later, on both surfaces, with any headers. Blob + // retries twice, and past that the honest report is "unavailable" + // rather than a hard failure here. not_analyzed for a Go file would be + // a real change and does fail. + switch f.SymbolsStatus { + case "ok": + if len(f.Symbols) == 0 { + t.Fatal("status ok with no symbols") + } + s := f.Symbols[0] + if s.Name == "" || s.Kind == "" || s.ExtentEnd == 0 { + t.Errorf("symbol is half empty: %+v", s) + } + case "unavailable", "timed_out": + t.Logf("symbols %s after three tries, the analyser was cold", f.SymbolsStatus) + default: + t.Errorf("symbols status %q for a Go file", f.SymbolsStatus) + } + logExtra(t, "blob", f.Extra) + }) + + t.Run("blob_markdown", func(t *testing.T) { + f, err := c.Blob(ctx, "cli/cli", "README.md", BlobOptions{Content: true}) + if err != nil { + t.Fatal(err) + } + if len(f.TOC) == 0 { + t.Error("no table of contents on a rendered markdown file") + } + if f.RichText == "" { + t.Error("no rendered html") + } + if !strings.Contains(f.Content, "gh") { + t.Errorf("content does not look like the readme: %.60q", f.Content) + } + if f.Via["content"] != "raw" { + t.Errorf("content provenance is %q", f.Via["content"]) + } + }) + + t.Run("raw", func(t *testing.T) { + b, err := c.Raw(ctx, "cli/cli", "trunk", "go.mod") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(b), "module ") { + t.Errorf("go.mod does not start with a module line: %.40q", b) + } + }) +} + // TestLiveCodeSearchStaysRefused guards the one search type that answers 200 // with nothing. If GitHub ever opens it up this test fails, which is the // notification to go implement it. diff --git a/gh/types.go b/gh/types.go index d1062be..df88b17 100644 --- a/gh/types.go +++ b/gh/types.go @@ -128,12 +128,17 @@ type File struct { Ref string `json:"ref" table:"-"` Path string `json:"path" table:"path"` - Size *int64 `json:"size,omitempty" table:"size"` - Lines *int `json:"lines,omitempty" table:"lines"` - Language string `json:"language,omitempty" table:"language"` - IsBinary bool `json:"is_binary" table:"-"` - IsLFS bool `json:"is_lfs" table:"-"` - IsGenerated bool `json:"is_generated" table:"-"` + Size *int64 `json:"size,omitempty" table:"size"` + // SizeDisplay is what the page shows, "13.3 KB". The page has no byte + // count anywhere, so an exact Size costs a request to raw and is filled + // only when the bytes were fetched anyway. + SizeDisplay string `json:"size_display,omitempty" table:"-"` + Lines *int `json:"lines,omitempty" table:"lines"` + Language string `json:"language,omitempty" table:"language"` + IsBinary bool `json:"is_binary" table:"-"` + IsLFS bool `json:"is_lfs" table:"-"` + IsGenerated bool `json:"is_generated" table:"-"` + IsTruncated bool `json:"is_truncated" table:"-"` RawURL string `json:"raw_url" table:"-"` @@ -143,10 +148,11 @@ type File struct { TOC []Heading `json:"toc,omitempty" table:"-"` Symbols []Symbol `json:"symbols,omitempty" table:"-"` - // SymbolsStatus is ok, timed_out, or not_analyzed. An empty symbol list - // with not_analyzed means the language is unsupported, which is a different - // fact from a file that genuinely has no symbols, and the caller should not - // have to guess which one it got. + // SymbolsStatus is ok, timed_out, not_analyzed, or unavailable. An empty + // symbol list with not_analyzed means the language is unsupported, which is + // a different fact from a file that genuinely has no symbols, and + // unavailable means GitHub's analyser had not finished when we asked. The + // caller should not have to guess which one it got. SymbolsStatus string `json:"symbols_status,omitempty" table:"-"` } From dca357d8c010c865409ed36a4d77c2570fc4ca07 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:46:17 +0700 Subject: [PATCH 07/21] gh: commits, refs, releases, and ranges Six surfaces answer history questions and each is incomplete in a different way, so the choice is written down per function: route JSON for the commit list and for one commit, the git upload-pack advertisement for a complete ref list, the release pages for the labels the feed has no room for, and the plain-text patch mailbox for a range. The compare page turned out not to be a JSON route at all. It answers 227 KB of Rails HTML with no react-app payload on it, while the same range with a .patch suffix is a git-format-patch stream carrying every commit's author, date, subject and diff in a format GitHub does not own and cannot restyle. CompareRefs reads that instead. Two release findings worth recording. Assets are lazy behind an include-fragment, so the 238 KB release page alone never sees them. And download counts are gone for a logged-out client: the fragment publishes a sha256 digest where the count used to be, which is a better trade than it looks. page.Find was returning the last match rather than the first. Walk only stops the subtree it is told to stop, so the result kept being overwritten, and on a template that renders a dialog repeating the content's own tags that quietly answered with the dialog. It has a test now, and so does Sel. --- gh/commit.go | 1308 +++++++++++++++++++++++++++++++++ gh/commit_test.go | 217 ++++++ gh/live_test.go | 392 ++++++++++ gh/types.go | 57 +- pkg/gitproto/gitproto.go | 161 ++++ pkg/gitproto/gitproto_test.go | 84 +++ pkg/page/dom.go | 7 + pkg/page/dom_test.go | 104 +++ 8 files changed, 2324 insertions(+), 6 deletions(-) create mode 100644 gh/commit.go create mode 100644 gh/commit_test.go create mode 100644 pkg/gitproto/gitproto.go create mode 100644 pkg/gitproto/gitproto_test.go create mode 100644 pkg/page/dom_test.go diff --git a/gh/commit.go b/gh/commit.go new file mode 100644 index 0000000..705f621 --- /dev/null +++ b/gh/commit.go @@ -0,0 +1,1308 @@ +package gh + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" + + "github.com/tamnd/github-cli/pkg/gitproto" + "github.com/tamnd/github-cli/pkg/page" +) + +// commit.go reads history: commits, branches, tags, refs, releases, and the +// range between two of them. +// +// Six surfaces answer here and each is incomplete in its own way, so the choice +// is written down per function rather than left to a reader to reconstruct: +// +// commits list route JSON, grouped by day, 35 a page, opaque cursor +// one commit route JSON, with the whole diff inline as diffEntryData +// branches route JSON, rich but capped, and it admits the cap +// every ref the git advertisement, complete, one request, no paging +// releases Rails pages, 10 a page, with the labels the feed lacks +// a range the plain-text patch mailbox, which no styling can break +// +// The one thing nothing here carries is signature verification. It exists on +// exactly one keyless surface, commit search, which is why VerifyCommits reads +// search rather than any of the above. + +// --- commit listings --- + +// CommitOptions controls a commit walk. Every field maps to a query parameter +// the route already understands, so filtering happens on GitHub's side and not +// after a full download. +type CommitOptions struct { + // Ref is a branch, a tag, or a SHA. Empty means the default branch. + Ref string + // Path limits history to one file or directory. + Path string + // Author is a login, not an email. + Author string + // Since and Until are dates, YYYY-MM-DD. + Since string + Until string + // Limit stops the walk. Zero means every commit, which on a large + // repository is thousands of requests, so callers should set it. + Limit int +} + +// Commits streams a repository's history, newest first. +// +// The route groups commits under calendar-day headings and this flattens them, +// keeping the heading on each record as DateGroup. That heading is the only +// place the surface says which timezone it grouped in, so throwing it away +// would make an off-by-one-day question unanswerable. +func (c *Client) Commits(ctx context.Context, repo string, opts CommitOptions, emit func(Commit) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + ref := opts.Ref + if ref == "" { + ref = c.defaultRef(ctx, repo) + } + base := repoSubURL(repo, "commits/"+ref) + if opts.Path != "" { + base += "/" + strings.Trim(opts.Path, "/") + } + kv := []string{} + for _, p := range [][2]string{ + {"author", opts.Author}, {"since", opts.Since}, {"until", opts.Until}, + } { + if p[1] != "" { + kv = append(kv, p[0], p[1]) + } + } + + fetch := func(ctx context.Context, token string) ([]Commit, string, error) { + u := base + args := kv + if token != "" { + // The cursor is " " and goes back verbatim. + args = append(append([]string{}, kv...), "after", token) + } + if len(args) > 0 { + u = query(base, args...) + } + var env struct { + Payload struct { + CommitGroups []struct { + Title string `json:"title"` + Commits []json.RawMessage `json:"commits"` + } `json:"commitGroups"` + Filters struct { + Pagination struct { + EndCursor string `json:"endCursor"` + HasNextPage bool `json:"hasNextPage"` + } `json:"pagination"` + } `json:"filters"` + RefInfo struct { + CurrentOid string `json:"currentOid"` + } `json:"refInfo"` + } `json:"payload"` + } + res, err := c.GetJSON(ctx, u, SurfaceRouteJSON, &env) + if err != nil { + return nil, "", err + } + var out []Commit + for _, g := range env.Payload.CommitGroups { + for _, raw := range g.Commits { + cm, err := decodeCommitNode(repo, raw) + if err != nil { + return nil, "", err + } + cm.DateGroup = g.Title + cm.addSource(res.FinalURL) + out = append(out, *cm) + } + } + next := "" + if env.Payload.Filters.Pagination.HasNextPage { + next = env.Payload.Filters.Pagination.EndCursor + } + return out, next, nil + } + return paginate(ctx, opts.Limit, fetch, emit) +} + +// commitNode is the commit shape the list route and the single-commit route +// share. They really are the same object; the single-commit route adds parents +// and the two Relay ids on top. +type commitNode struct { + Oid string `json:"oid"` + URL string `json:"url"` + AuthoredDate string `json:"authoredDate"` + CommittedDate string `json:"committedDate"` + PushedDate string `json:"pushedDate"` + ShortMessage string `json:"shortMessage"` + ShortMessageMarkdown string `json:"shortMessageMarkdown"` + ShortMessageMarkdownLink string `json:"shortMessageMarkdownLink"` + BodyMessageHTML string `json:"bodyMessageHtml"` + Authors []struct { + Login string `json:"login"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + Path string `json:"path"` + } `json:"authors"` + CommitterAttribution bool `json:"committerAttribution"` + Committer *struct { + Login string `json:"login"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + Path string `json:"path"` + ID *int `json:"id"` + IsGitHub bool `json:"isGitHub"` + } `json:"committer"` + Pusher *struct { + Login string `json:"login"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatarUrl"` + } `json:"pusher"` + Parents []string `json:"parents"` + GlobalRelayID string `json:"globalRelayId"` +} + +func decodeCommitNode(repo string, raw json.RawMessage) (*Commit, error) { + var v commitNode + if err := json.Unmarshal(raw, &v); err != nil { + return nil, badPayload(repo, err) + } + cm := &Commit{Repo: repo, SHA: v.Oid, NodeID: v.GlobalRelayID} + cm.setIdentity(KindCommit, repo+"@"+v.Oid) + cm.URL = commitURL(repo, v.Oid) + + // shortMessage is null as often as not and the markdown field carries the + // subject with issue links already resolved. Stripping the tags back to + // text is what makes a commit subject readable in a table, and the markup + // is kept because it is where the issue references live. + cm.Subject = firstNonEmpty(v.ShortMessage, stripTags(v.ShortMessageMarkdown)) + cm.BodyHTML = v.BodyMessageHTML + cm.Body = stripTags(v.BodyMessageHTML) + cm.AuthoredAt = parseTime(v.AuthoredDate) + cm.CommittedAt = parseTime(v.CommittedDate) + cm.PushedAt = parseTime(v.PushedDate) + cm.Parents = v.Parents + + for _, a := range v.Authors { + act := actor(a.Login) + act.Name = a.DisplayName + act.AvatarURL = a.AvatarURL + cm.Authors = append(cm.Authors, act) + } + if v.Committer != nil && v.Committer.Login != "" { + act := actor(v.Committer.Login) + act.Name = v.Committer.DisplayName + act.AvatarURL = v.Committer.AvatarURL + act.DatabaseID = v.Committer.ID + cm.Committer = &act + // web-flow is GitHub's own committer identity on a squash or a merge + // made through the web UI. It is not a person and calling it one makes + // every "who committed this" query wrong on half of a busy repository. + cm.SignedByGitHub = v.Committer.IsGitHub + } + if v.Pusher != nil && v.Pusher.Login != "" { + act := actor(v.Pusher.Login) + act.Name = v.Pusher.DisplayName + act.AvatarURL = v.Pusher.AvatarURL + cm.Pusher = &act + } + cm.IssueRefs = threadRefsIn(v.ShortMessageMarkdown) + + cm.addExtra("commit", decodeExtra(raw, &v, + // Both are the parent and the commit again under older names, kept by + // the front end for a diff widget that predates oid and parents. + "sha1", "sha2", + // True when the committer differs from the author, which the record + // already shows by having both. + "committerAttribution", + )) + return cm, nil +} + +// threadRefsIn pulls the issue and pull request links GitHub already resolved +// inside a commit subject. This is the commit-to-thread edge of the graph +// handed over for free, and it is why the markdown field is worth keeping. +func threadRefsIn(markup string) []ThreadRef { + if markup == "" { + return nil + } + doc, err := html.Parse(strings.NewReader(markup)) + if err != nil { + return nil + } + var out []ThreadRef + for _, n := range page.FindAll(doc, page.Sel{Tag: "a", Class: "issue-link"}) { + ref := ThreadRef{ + URL: page.Attr(n, "href"), + IsPullRequest: page.Attr(n, "data-hovercard-type") == "pull_request", + } + if id, err := strconv.Atoi(page.Attr(n, "data-id")); err == nil { + ref.DatabaseID = &id + } + out = append(out, ref) + } + return out +} + +// --- one commit --- + +// CommitInfoOptions controls how much of a commit's diff comes back. +type CommitInfoOptions struct { + // Files fills the per-file change list from the inline diff. The route + // ships the whole thing whether we decode it or not, so this costs parsing + // and memory rather than a request. + Files bool + // Patch fetches the .patch form as well, one extra request, for a caller + // that wants to apply the change rather than describe it. + Patch bool +} + +// CommitInfo reads one commit. sha may be a full SHA, an abbreviation, a +// branch, or a tag: the route resolves all four, and the record reports what it +// resolved to. +func (c *Client) CommitInfo(ctx context.Context, repo, sha string, opts CommitInfoOptions) (*Commit, error) { + if _, _, ok := SplitRepo(repo); !ok { + return nil, usageBadID("repository", repo, "owner/name") + } + if sha == "" { + return nil, usageBadID("commit", sha, "a sha, a branch, or a tag") + } + var env struct { + Payload struct { + Commit json.RawMessage `json:"commit"` + HeaderInfo struct { + Additions int `json:"additions"` + Deletions int `json:"deletions"` + FilesChanged int `json:"filesChanged"` + } `json:"headerInfo"` + DiffEntryData []json.RawMessage `json:"diffEntryData"` + MoreDiffsToLoad bool `json:"moreDiffsToLoad"` + } `json:"payload"` + } + url := commitURL(repo, sha) + res, err := c.GetJSON(ctx, url, SurfaceRouteJSON, &env) + if err != nil { + return nil, err + } + if len(env.Payload.Commit) == 0 { + return nil, structureChanged(repo + "@" + sha) + } + cm, err := decodeCommitNode(repo, env.Payload.Commit) + if err != nil { + return nil, err + } + cm.addSource(res.FinalURL) + cm.Additions = intp(env.Payload.HeaderInfo.Additions) + cm.Deletions = intp(env.Payload.HeaderInfo.Deletions) + + if opts.Files { + for _, raw := range env.Payload.DiffEntryData { + fc, err := decodeDiffEntry(raw) + if err != nil { + return nil, err + } + cm.Files = append(cm.Files, fc) + } + // A commit that touches hundreds of files ships the first few and + // defers the rest. Saying so beats handing back a short list that + // looks complete. + if env.Payload.MoreDiffsToLoad && len(cm.Files) < env.Payload.HeaderInfo.FilesChanged { + recordVia(&cm.Base, "files", "partial") + } + } + if opts.Patch { + text, err := c.Patch(ctx, url) + if err != nil { + return nil, err + } + cm.Body = firstNonEmpty(cm.Body, patchBody(text)) + cm.addSource(url + ".patch") + } + return cm, nil +} + +// diffEntry is one file in the inline diff. The line arrays are skipped: this +// package models the change, not the rendering of it, and `github patch` hands +// back the real thing for a caller that wants lines. +type diffEntry struct { + Path string `json:"path"` + Status string `json:"status"` + LinesAdded *int `json:"linesAdded"` + LinesDeleted *int `json:"linesDeleted"` + IsBinary bool `json:"isBinary"` + IsTooBig bool `json:"isTooBig"` + IsSubmodule bool `json:"isSubmodule"` + OldTreeEntry *struct { + Path string `json:"path"` + IsGenerated bool `json:"isGenerated"` + } `json:"oldTreeEntry"` + NewTreeEntry *struct { + Path string `json:"path"` + IsGenerated bool `json:"isGenerated"` + } `json:"newTreeEntry"` +} + +func decodeDiffEntry(raw json.RawMessage) (FileChange, error) { + var v diffEntry + if err := json.Unmarshal(raw, &v); err != nil { + return FileChange{}, badPayload("diff entry", err) + } + fc := FileChange{ + Path: v.Path, + Status: strings.ToLower(v.Status), + Additions: v.LinesAdded, + Deletions: v.LinesDeleted, + IsBinary: v.IsBinary, + } + // A rename ships both entries with different paths. Nothing else in the + // payload says "renamed", so the two paths are the evidence. + if v.OldTreeEntry != nil && v.NewTreeEntry != nil && v.OldTreeEntry.Path != v.NewTreeEntry.Path { + fc.PrevPath = v.OldTreeEntry.Path + } + return fc, nil +} + +// VerifyCommits fills in signature state, which no commit route carries. +// +// Commit search is the only keyless surface that reports it, so this asks +// search for the exact SHAs and merges what comes back. It is a separate +// function rather than a flag because it is a different request against a +// different index, and a caller should see that in the code they wrote. +func (c *Client) VerifyCommits(ctx context.Context, repo string, commits []*Commit) error { + for _, cm := range commits { + if cm.SHA == "" { + continue + } + q := "repo:" + repo + " hash:" + cm.SHA + err := c.SearchCommitsBy(ctx, q, 1, func(found Commit) error { + if found.SHA != cm.SHA { + return nil + } + cm.Verification = found.Verification + cm.VerificationReason = found.VerificationReason + cm.HasSignature = found.HasSignature + cm.KeyID = found.KeyID + cm.KeyExpired = found.KeyExpired + recordVia(&cm.Base, "verification", "search") + return nil + }) + if err != nil { + return err + } + } + return nil +} + +// --- refs --- + +// RefOptions controls a ref listing. +type RefOptions struct { + // Complete reads the git advertisement instead of the branches page: every + // ref in one request, with SHAs, and no cap. It costs the author and date + // the page carries, because the protocol does not have them. + Complete bool + // Pulls includes refs/pull/*, which github.com advertises for every pull + // request ever opened. On a busy repository that is most of the response. + Pulls bool + Limit int +} + +// Branches lists branches. Without Complete this is the branches route, which +// carries the last author and the last authored date and is capped by GitHub; +// with it, this is the git advertisement, which is complete and carries SHAs. +// +// Neither is strictly better and the record says which one answered, so a +// consumer that finds Author empty knows why. +func (c *Client) Branches(ctx context.Context, repo string, opts RefOptions, emit func(GitRef) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + if opts.Complete { + return c.advertisedRefs(ctx, repo, opts, KindBranch, emit) + } + base := repoSubURL(repo, "branches") + fetch := func(ctx context.Context, token string) ([]GitRef, string, error) { + n := pageToken(token) + u := base + if n > 1 { + u = query(base, "page", strconv.Itoa(n)) + } + var env struct { + Payload struct { + Branches struct { + Default *branchNode `json:"default"` + Active []branchNode `json:"active"` + } `json:"branches"` + HasMore struct { + Active bool `json:"active"` + } `json:"hasMore"` + } `json:"payload"` + } + res, err := c.GetJSON(ctx, u, SurfaceRouteJSON, &env) + if err != nil { + return nil, "", err + } + var out []GitRef + // The default branch is served separately from the active list and is + // not repeated inside it, so it is prepended once, on the first page. + if n == 1 && env.Payload.Branches.Default != nil { + out = append(out, env.Payload.Branches.Default.toRef(repo, res.FinalURL)) + } + for _, b := range env.Payload.Branches.Active { + out = append(out, b.toRef(repo, res.FinalURL)) + } + next := "" + if env.Payload.HasMore.Active { + next = strconv.Itoa(n + 1) + } + return out, next, nil + } + return paginate(ctx, opts.Limit, fetch, emit) +} + +type branchNode struct { + Name string `json:"name"` + IsDefault bool `json:"isDefault"` + Path string `json:"path"` + Protected bool `json:"protectedByBranchProtections"` + AuthoredDate string `json:"authoredDate"` + Author *struct { + Login string `json:"login"` + Name string `json:"name"` + AvatarURL string `json:"avatarUrl"` + } `json:"author"` +} + +func (b branchNode) toRef(repo, source string) GitRef { + r := GitRef{ + Repo: repo, + Name: b.Name, + Type: "branch", + IsDefault: b.IsDefault, + Protected: b.Protected, + AuthoredAt: parseTime(b.AuthoredDate), + } + r.setIdentity(KindBranch, repo+"@"+b.Name) + if b.Author != nil && b.Author.Login != "" { + a := actor(b.Author.Login) + a.Name = b.Author.Name + a.AvatarURL = b.Author.AvatarURL + r.Author = &a + } + r.addSource(source) + return r +} + +// Tags lists tags. The git advertisement is the default here rather than an +// opt-in, because the tags feed gives ten and the tags page gives ten at a +// time, and a repository with four hundred tags is the normal case. +func (c *Client) Tags(ctx context.Context, repo string, opts RefOptions, emit func(GitRef) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + return c.advertisedRefs(ctx, repo, opts, KindTag, emit) +} + +// Refs lists every ref of every kind in one request. +func (c *Client) Refs(ctx context.Context, repo string, opts RefOptions, emit func(GitRef) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + return c.advertisedRefs(ctx, repo, opts, "", emit) +} + +// advertisedRefs reads the git smart-protocol advertisement and emits the kinds +// asked for. kind is KindBranch, KindTag, or empty for everything. +// +// The whole advertisement arrives in one response, so there is no paging and no +// cursor here, and Limit is applied by walking rather than by asking for less. +func (c *Client) advertisedRefs(ctx context.Context, repo string, opts RefOptions, kind string, emit func(GitRef) error) error { + url := gitRefsURL(repo) + res, err := c.Get(ctx, url, SurfaceGit) + if err != nil { + return err + } + ad, err := gitproto.Parse(res.Body) + if err != nil { + return badPayload(repo, err) + } + + sent := 0 + send := func(r GitRef) error { + r.Repo = repo + r.addSource(url) + if err := emit(r); err != nil { + return err + } + sent++ + return nil + } + done := func() bool { return opts.Limit > 0 && sent >= opts.Limit } + + if kind == "" || kind == KindBranch { + for _, b := range ad.Branches() { + if done() { + return nil + } + r := GitRef{Name: b.Name, Type: "branch", SHA: b.SHA, IsDefault: b.Name == ad.DefaultBranch} + r.setIdentity(KindBranch, repo+"@"+b.Name) + if err := send(r); err != nil { + return err + } + } + } + if kind == "" || kind == KindTag { + for _, t := range ad.Tags() { + if done() { + return nil + } + r := GitRef{Name: t.Name, Type: "tag", SHA: t.SHA, PeeledSHA: t.Peeled} + r.setIdentity(KindTag, repo+"@"+t.Name) + if err := send(r); err != nil { + return err + } + } + } + if opts.Pulls && kind == "" { + for _, p := range ad.PullHeads() { + if done() { + return nil + } + r := GitRef{Name: "pull/" + p.Name, Type: "pull", SHA: p.SHA} + r.setIdentity(KindBranch, repo+"@refs/pull/"+p.Name) + if err := send(r); err != nil { + return err + } + } + } + return nil +} + +// DefaultBranch asks the git advertisement which branch HEAD points at. It is +// the authoritative answer, where the route JSON's refInfo is whichever ref the +// URL happened to resolve to. +func (c *Client) DefaultBranch(ctx context.Context, repo string) (string, error) { + res, err := c.Get(ctx, gitRefsURL(repo), SurfaceGit) + if err != nil { + return "", err + } + ad, err := gitproto.Parse(res.Body) + if err != nil { + return "", badPayload(repo, err) + } + if ad.DefaultBranch == "" { + return "", structureChanged(repo + " HEAD") + } + return ad.DefaultBranch, nil +} + +// --- releases --- + +// ReleaseOptions controls a release listing. +type ReleaseOptions struct { + // Assets fetches the lazy asset fragment for each release, one extra + // request each. Without it a release record has no downloads. + Assets bool + // Body keeps the rendered release notes, which are most of the bytes on a + // project that writes a changelog. + Body bool + Limit int +} + +// Releases streams a repository's releases, newest first. +// +// The list comes from the HTML pages rather than releases.atom, which is the +// opposite of what you would expect from a feed-shaped problem. The feed gives +// ten entries and does not page, so it cannot answer "every release"; the pages +// give ten at a time with a rel="next" and carry the Latest and Pre-release +// labels the feed has no room for. +func (c *Client) Releases(ctx context.Context, repo string, opts ReleaseOptions, emit func(Release) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + base := repoSubURL(repo, "releases") + fetch := func(ctx context.Context, token string) ([]Release, string, error) { + n := pageToken(token) + u := base + if n > 1 { + u = query(base, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + p := page.Extract(res.FinalURL, res.Body) + doc := p.Doc() + if doc == nil { + return nil, "", structureChanged(repo + " releases") + } + var out []Release + for _, sec := range page.FindAll(doc, releaseSection) { + rel := decodeReleaseSection(repo, sec, opts.Body) + if rel.Tag == "" { + continue + } + rel.addSource(res.FinalURL) + out = append(out, rel) + } + next := "" + if len(out) > 0 && page.Find(doc, nextPageLink) != nil { + next = strconv.Itoa(n + 1) + } + return out, next, nil + } + wrapped := emit + if opts.Assets { + wrapped = func(rel Release) error { + if assets, err := c.releaseAssets(ctx, repo, rel.Tag); err == nil { + rel.Assets = assets + rel.addSource(assetsFragmentURL(repo, rel.Tag)) + } + return emit(rel) + } + } + return paginate(ctx, opts.Limit, fetch, wrapped) +} + +// Release reads one release by tag. tag may also be "latest", which github.com +// redirects to whatever that is today. +// +// The per-tag page is not the list page with nine releases removed. It is a +// different template with a different shape, so it gets its own decoder rather +// than a selector that limps along on both. What it gains over a list entry is +// the commit the tag points at; what it lacks is nothing. +func (c *Client) Release(ctx context.Context, repo, tag string, opts ReleaseOptions) (*Release, error) { + if _, _, ok := SplitRepo(repo); !ok { + return nil, usageBadID("repository", repo, "owner/name") + } + if tag == "" { + return nil, usageBadID("release", tag, "a tag name") + } + sub := "releases/tag/" + tag + if tag == "latest" { + sub = "releases/latest" + } + url := repoSubURL(repo, sub) + res, err := c.GetHTML(ctx, url) + if err != nil { + return nil, err + } + p := page.Extract(res.FinalURL, res.Body) + doc := p.Doc() + if doc == nil { + return nil, structureChanged(repo + "@" + tag) + } + rel, ok := decodeReleasePage(repo, doc) + if !ok { + return nil, structureChanged(repo + "@" + tag) + } + if rel.Tag == "" { + rel.Tag = tag + rel.setIdentity(KindRelease, repo+"@"+tag) + } + rel.addSource(res.FinalURL) + tag = rel.Tag + if !opts.Body { + rel.Body, rel.BodyHTML = "", "" + } + if opts.Assets { + assets, err := c.releaseAssets(ctx, repo, rel.Tag) + if err != nil { + return nil, err + } + rel.Assets = assets + rel.addSource(assetsFragmentURL(repo, rel.Tag)) + } + return &rel, nil +} + +// releaseSection anchors on the id GitHub gives every release box, +// release-{tag}. It survives the styling churn that renames every class around +// it, because the anchor is what the in-page release menu links to. +var releaseSection = page.Sel{Tag: "section", Attr: "id", AttrPrefix: "release-"} + +var nextPageLink = page.Sel{Tag: "a", Attr: "rel", AttrValue: "next"} + +func decodeReleaseSection(repo string, sec *html.Node, keepBody bool) Release { + rel := Release{Repo: repo} + for _, a := range page.FindAll(sec, page.Sel{Tag: "a", Attr: "href", AttrContains: "/releases/tag/"}) { + href := page.Attr(a, "href") + if i := strings.LastIndex(href, "/releases/tag/"); i >= 0 { + rel.Tag = strings.TrimPrefix(href[i:], "/releases/tag/") + rel.Title = firstNonEmpty(rel.Title, page.Text(a)) + break + } + } + if rel.Tag == "" { + return rel + } + rel.setIdentity(KindRelease, repo+"@"+rel.Tag) + + // The labels are the whole reason this reads the page and not the feed. + for _, l := range page.FindAll(sec, page.Sel{Class: "Label"}) { + switch page.Text(l) { + case "Latest": + rel.IsLatest = true + case "Pre-release": + rel.IsPrerelease = true + case "Draft": + rel.IsDraft = true + } + } + if a := page.Find(sec, page.Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "user"}); a != nil { + author := actor(strings.TrimPrefix(page.Attr(a, "href"), "/")) + rel.Author = &author + } + if t := page.Find(sec, page.RelTimeEl); t != nil { + rel.PublishedAt = parseTime(page.Attr(t, "datetime")) + } + if body := page.Find(sec, page.Sel{Class: "markdown-body"}); body != nil && keepBody { + rel.BodyHTML = page.OuterHTML(body) + rel.Body = page.Text(body) + } + for _, a := range page.FindAll(sec, page.Sel{Tag: "a", Attr: "href", AttrContains: "/archive/refs/tags/"}) { + href := page.Attr(a, "href") + switch { + case strings.HasSuffix(href, ".tar.gz"): + rel.TarballURL = absoluteURL(href) + case strings.HasSuffix(href, ".zip"): + rel.ZipballURL = absoluteURL(href) + } + } + return rel +} + +// primaryContent is the div GitHub marks with data-hpc, which is its own +// "hero primary content" flag. Anchoring on it keeps the header, the sidebar, +// and half a dozen dialogs out of every selector below, which matters on a +// template where a search overlay also has an h1 and the repository header also +// has something with class Label. +var primaryContent = page.Sel{Tag: "div", Attr: "data-hpc"} + +// decodeReleasePage reads the single-release template. +// +// The false return is a real answer: a tag that has no release, which happens +// on every repository that tags more often than it publishes, renders a page +// that looks fine and has no release box on it. +func decodeReleasePage(repo string, doc *html.Node) (Release, bool) { + root := page.Find(doc, primaryContent) + if root == nil { + root = doc + } + box := page.Find(root, page.Sel{Tag: "div", Class: "Box"}) + if box == nil { + return Release{}, false + } + rel := Release{Repo: repo} + // The breadcrumb above the box is the only place the page states the tag + // as a tag rather than as a heading somebody typed. + for _, a := range page.FindAll(root, page.Sel{Tag: "a", Attr: "href", AttrContains: "/releases/tag/"}) { + href := page.Attr(a, "href") + if i := strings.LastIndex(href, "/releases/tag/"); i >= 0 { + rel.Tag = strings.TrimPrefix(href[i:], "/releases/tag/") + break + } + } + if rel.Tag == "" { + if a := page.Find(root, page.Sel{Tag: "a", Attr: "href", AttrContains: "/tree/"}); a != nil { + href := page.Attr(a, "href") + rel.Tag = href[strings.LastIndex(href, "/tree/")+len("/tree/"):] + } + } + if rel.Tag == "" { + return Release{}, false + } + rel.setIdentity(KindRelease, repo+"@"+rel.Tag) + + // The first h1 inside the box is the release name. The later ones belong + // to the tag-picker dialog, which is nested in the same box. + if h := page.Find(box, page.Sel{Tag: "h1"}); h != nil { + rel.Title = strings.TrimSpace(page.Text(h)) + } + for _, l := range page.FindAll(root, page.Sel{Class: "Label"}) { + switch strings.TrimSpace(page.Text(l)) { + case "Latest": + rel.IsLatest = true + case "Pre-release": + rel.IsPrerelease = true + case "Draft": + rel.IsDraft = true + } + } + // The publisher is the bold link in the byline row, which is a user on a + // hand-cut release and /apps/something when a workflow cut it. Reading the + // first user hovercard instead would pick a contributor avatar from the + // footer, which is a different person and a wrong answer. + if a := page.Find(root, page.Sel{Tag: "a", Class: "text-bold"}); a != nil { + href := strings.TrimPrefix(page.Attr(a, "href"), "/") + // A release cut by a workflow links to /apps/github-actions, which is + // not a login and would give a profile URL that 404s. The app name is + // the useful half and the type says why it has no profile. + if name, ok := strings.CutPrefix(href, "apps/"); ok { + rel.Author = &Actor{Login: name, Type: "Bot", URL: BaseURL + "/apps/" + name} + } else if href != "" { + author := actor(href) + rel.Author = &author + } + } + if t := page.Find(root, page.RelTimeEl); t != nil { + rel.PublishedAt = parseTime(page.Attr(t, "datetime")) + } + if a := page.Find(root, page.Sel{Tag: "a", Attr: "href", AttrContains: "/commit/"}); a != nil { + href := page.Attr(a, "href") + if sha := href[strings.LastIndex(href, "/commit/")+len("/commit/"):]; len(sha) == 40 && isHex(sha) { + rel.CommitSHA = sha + } + } + if body := page.Find(root, page.Sel{Class: "markdown-body"}); body != nil { + rel.BodyHTML = page.OuterHTML(body) + rel.Body = page.Text(body) + } + for _, a := range page.FindAll(root, page.Sel{Tag: "a", Attr: "href", AttrContains: "/archive/refs/tags/"}) { + href := page.Attr(a, "href") + switch { + case strings.HasSuffix(href, ".tar.gz"): + rel.TarballURL = absoluteURL(href) + case strings.HasSuffix(href, ".zip"): + rel.ZipballURL = absoluteURL(href) + } + } + return rel, true +} + +func assetsFragmentURL(repo, tag string) string { + return repoSubURL(repo, "releases/expanded_assets/"+tag) +} + +// releaseAssets reads the deferred asset fragment. +// +// The release page ships an include-fragment where the asset table should be, +// so a page fetch alone never sees the downloads no matter how large it is. +// The fragment itself is small and is the only place the sha256 digests exist. +func (c *Client) releaseAssets(ctx context.Context, repo, tag string) ([]Asset, error) { + res, err := c.Get(ctx, assetsFragmentURL(repo, tag), SurfaceXHR) + if err != nil { + return nil, err + } + doc, err := html.Parse(strings.NewReader(string(res.Body))) + if err != nil { + return nil, badPayload(repo+"@"+tag, err) + } + var out []Asset + for _, row := range page.FindAll(doc, page.Sel{Tag: "li", Class: "Box-row"}) { + a := Asset{} + if link := page.Find(row, page.Sel{Tag: "a", Attr: "href", AttrContains: "/releases/download/"}); link != nil { + href := page.Attr(link, "href") + a.URL = absoluteURL(href) + a.Name = href[strings.LastIndex(href, "/")+1:] + } + if link := page.Find(row, page.Sel{Tag: "a", Attr: "href", AttrContains: "/archive/refs/tags/"}); link != nil && a.URL == "" { + href := page.Attr(link, "href") + a.URL = absoluteURL(href) + a.Name = href[strings.LastIndex(href, "/")+1:] + } + if a.Name == "" { + continue + } + for _, span := range page.FindAll(row, page.Sel{Tag: "span", Class: "Truncate-text"}) { + switch text := page.Text(span); { + case strings.HasPrefix(text, "sha256:"): + a.Digest = text + case a.Label == "" && text != "": + // The first truncated span in the row is the label, and the + // second half of the same pair is the empty overflow tail. + a.Label = text + } + } + if t := page.Find(row, page.RelTimeEl); t != nil { + a.UpdatedAt = parseTime(page.Attr(t, "datetime")) + } + // The size is the one bare span in the row with no class of its own, + // so it is found by shape: the last span whose text reads like a size. + for _, span := range page.FindAll(row, page.Sel{Tag: "span"}) { + if s := page.Text(span); looksLikeSize(s) { + a.SizeDisplay = s + } + } + out = append(out, a) + } + return out, nil +} + +// looksLikeSize matches "13.1 MB" and "742 Bytes" and not much else. It is +// deliberately narrow: a false positive here would put a random span's text in +// the size column. +func looksLikeSize(s string) bool { + n, unit, ok := strings.Cut(s, " ") + if !ok { + return false + } + switch unit { + case "Bytes", "KB", "MB", "GB", "TB": + default: + return false + } + _, err := strconv.ParseFloat(n, 64) + return err == nil +} + +func absoluteURL(href string) string { + if strings.HasPrefix(href, "http") { + return href + } + return BaseURL + href +} + +// --- ranges --- + +// CompareOptions controls a range read. +type CompareOptions struct { + // Files parses the per-file changes out of the patch. Free, since the + // patch is already downloaded. + Files bool + // Patch keeps the raw stream on the record. + Patch bool +} + +// CompareRefs reads the range between two refs. +// +// It reads the plain-text patch mailbox, not the compare page. The page has no +// JSON payload of any kind, it is more than twice the size, and every field on +// it is a class name away from breaking. git-format-patch output is a format +// GitHub does not own and cannot restyle, and it carries every commit with its +// author, date, subject, and diff. +// +// The trade is that the mailbox has no logins, only names and emails, so the +// authors on these commits have Name set and Login empty. That is honest: the +// patch really does not say who the GitHub user was. +func (c *Client) CompareRefs(ctx context.Context, repo, base, head string, opts CompareOptions) (*Compare, error) { + if _, _, ok := SplitRepo(repo); !ok { + return nil, usageBadID("repository", repo, "owner/name") + } + if base == "" || head == "" { + return nil, usageBadID("range", base+"..."+head, "base...head") + } + rng := base + "..." + head + url := repoSubURL(repo, "compare/"+rng) + text, err := c.Patch(ctx, url) + if err != nil { + return nil, err + } + + cmp := &Compare{Repo: repo, BaseRef: base, HeadRef: head, PatchURL: url + ".patch", DiffURL: url + ".diff"} + cmp.setIdentity(KindCompare, repo+"@"+rng) + cmp.addSource(cmp.PatchURL) + if opts.Patch { + cmp.Patch = text + } + for _, part := range splitMailbox(text) { + cm := commitFromPatch(repo, part) + if cm == nil { + continue + } + cm.addSource(cmp.PatchURL) + if opts.Files { + cm.Files = filesInPatch(part) + } + cmp.Commits = append(cmp.Commits, *cm) + } + cmp.CommitCount = len(cmp.Commits) + if opts.Files { + cmp.Files = filesInPatch(text) + cmp.FileCount = len(cmp.Files) + for _, f := range cmp.Files { + if f.Additions != nil { + cmp.Additions += *f.Additions + } + if f.Deletions != nil { + cmp.Deletions += *f.Deletions + } + } + } + return cmp, nil +} + +// Patch returns the git patch for a commit, a pull request, or a range. url is +// any github.com URL naming one of the three; the .patch suffix is appended. +// +// This is the cheapest complete view of a change on the whole site: no +// negotiation, no payload, no page, and no token. +func (c *Client) Patch(ctx context.Context, url string) (string, error) { + res, err := c.Get(ctx, strings.TrimSuffix(url, "/")+".patch", SurfaceRaw) + if err != nil { + return "", err + } + return string(res.Body), nil +} + +// Diff returns the unified diff, which is the patch without the commit +// metadata. On a wide range it is a third of the size. +func (c *Client) Diff(ctx context.Context, url string) (string, error) { + res, err := c.Get(ctx, strings.TrimSuffix(url, "/")+".diff", SurfaceRaw) + if err != nil { + return "", err + } + return string(res.Body), nil +} + +// --- patch parsing --- + +// splitMailbox cuts a git-format-patch stream into one string per commit. +// +// A commit starts at a line reading "From <40 hex> Mon Sep 17 00:00:00 2001", +// which is git's fixed magic date and not a real one. Matching the whole shape +// rather than just "From " is what keeps a diff line reading "From " in a +// changed file from splitting the mailbox in half. +func splitMailbox(text string) []string { + var out []string + var cur []string + for _, line := range strings.Split(text, "\n") { + if isMailboxHeader(line) { + if len(cur) > 0 { + out = append(out, strings.Join(cur, "\n")) + } + cur = cur[:0] + } + if len(cur) > 0 || isMailboxHeader(line) { + cur = append(cur, line) + } + } + if len(cur) > 0 { + out = append(out, strings.Join(cur, "\n")) + } + return out +} + +func isMailboxHeader(line string) bool { + rest, ok := strings.CutPrefix(line, "From ") + if !ok { + return false + } + sha, tail, ok := strings.Cut(rest, " ") + return ok && len(sha) == 40 && isHex(sha) && tail == "Mon Sep 17 00:00:00 2001" +} + +func isHex(s string) bool { + for _, r := range s { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +// commitFromPatch reads the mail headers off one mailbox entry. +func commitFromPatch(repo, part string) *Commit { + head, body, _ := strings.Cut(part, "\n\n") + lines := strings.Split(head, "\n") + if len(lines) == 0 { + return nil + } + sha := "" + if rest, ok := strings.CutPrefix(lines[0], "From "); ok { + sha, _, _ = strings.Cut(rest, " ") + } + if len(sha) != 40 { + return nil + } + cm := &Commit{Repo: repo, SHA: sha} + cm.setIdentity(KindCommit, repo+"@"+sha) + cm.URL = commitURL(repo, sha) + + for i, line := range lines[1:] { + switch { + case strings.HasPrefix(line, "From: "): + name, email := splitAddress(strings.TrimPrefix(line, "From: ")) + a := Actor{Name: name} + if login, ok := loginFromNoreply(email); ok { + a = actor(login) + a.Name = name + } + cm.Authors = append(cm.Authors, a) + case strings.HasPrefix(line, "Date: "): + cm.AuthoredAt = parseMailDate(strings.TrimPrefix(line, "Date: ")) + case strings.HasPrefix(line, "Subject: "): + // git wraps a long subject onto continuation lines that begin with + // whitespace, and the [PATCH n/m] prefix is git's, not the author's. + subject := strings.TrimPrefix(line, "Subject: ") + for _, cont := range lines[i+2:] { + if !strings.HasPrefix(cont, " ") && !strings.HasPrefix(cont, "\t") { + break + } + subject += " " + strings.TrimSpace(cont) + } + cm.Subject = stripPatchPrefix(subject) + } + } + // The message body is everything before the diffstat separator. + if msg, _, ok := strings.Cut(body, "\n---\n"); ok { + cm.Body = strings.TrimSpace(msg) + } + return cm +} + +// splitAddress cuts `Name ` into its two halves. +func splitAddress(s string) (name, email string) { + s = strings.TrimSpace(s) + i := strings.LastIndex(s, "<") + if i < 0 || !strings.HasSuffix(s, ">") { + return s, "" + } + return strings.TrimSpace(s[:i]), s[i+1 : len(s)-1] +} + +// loginFromNoreply recovers a GitHub login from the noreply address GitHub +// hands out, 12345+octocat@users.noreply.github.com or the older +// octocat@users.noreply.github.com. It is the only place a patch carries a +// login, and it is worth taking when it is there. +func loginFromNoreply(email string) (string, bool) { + local, host, ok := strings.Cut(email, "@") + if !ok || host != "users.noreply.github.com" { + return "", false + } + if _, login, ok := strings.Cut(local, "+"); ok { + return login, login != "" + } + return local, local != "" +} + +// parseMailDate reads the RFC 2822 date git writes, "Thu, 7 Nov 2024 14:39:11 +// -0700". Every other record in this package carries RFC 3339, and normalising +// here is what keeps one date format in the output instead of six in the input. +// +// The day is not zero-padded in git's output and RFC1123Z requires that it is, +// so both layouts are tried. +func parseMailDate(s string) *time.Time { + s = strings.TrimSpace(s) + for _, layout := range []string{time.RFC1123Z, "Mon, 2 Jan 2006 15:04:05 -0700"} { + if t, err := time.Parse(layout, s); err == nil { + u := t.UTC() + return &u + } + } + return nil +} + +func stripPatchPrefix(subject string) string { + s := strings.TrimSpace(subject) + if !strings.HasPrefix(s, "[PATCH") { + return s + } + if i := strings.Index(s, "]"); i >= 0 { + return strings.TrimSpace(s[i+1:]) + } + return s +} + +// patchBody returns the message body of a single-commit patch. +func patchBody(text string) string { + parts := splitMailbox(text) + if len(parts) == 0 { + return "" + } + cm := commitFromPatch("", parts[0]) + if cm == nil { + return "" + } + return cm.Body +} + +// filesInPatch reads the per-file changes out of a unified diff by counting the +// + and - lines. git's own diffstat is in the mailbox header, but only for a +// single commit, and a range has no combined one, so counting is the only way +// to get the same number for both. +// +// Three lines in a patch start with a + or a - and are not content: the two +// file headers, and the "-- " that ends a mailbox entry before the git version +// string. Miscounting those is how a two-line change becomes a three-line one. +func filesInPatch(text string) []FileChange { + var out []FileChange + var cur *FileChange + add, del := 0, 0 + inBody := false + flush := func() { + if cur == nil { + return + } + cur.Additions = intp(add) + cur.Deletions = intp(del) + out = append(out, *cur) + cur, add, del, inBody = nil, 0, 0, false + } + for _, line := range strings.Split(text, "\n") { + switch { + case strings.HasPrefix(line, "diff --git "): + flush() + cur = &FileChange{Path: pathFromDiffHeader(line), Status: "modified"} + case cur == nil: + case line == "--" || line == "-- ": + // The mailbox signature separator. Everything after it belongs to + // the mail, not to the diff. + flush() + case strings.HasPrefix(line, "new file mode"): + cur.Status = "added" + case strings.HasPrefix(line, "deleted file mode"): + cur.Status = "removed" + case strings.HasPrefix(line, "rename from "): + cur.PrevPath = strings.TrimPrefix(line, "rename from ") + cur.Status = "renamed" + case strings.HasPrefix(line, "rename to "): + cur.Path = strings.TrimPrefix(line, "rename to ") + case strings.HasPrefix(line, "Binary files "): + cur.IsBinary = true + case strings.HasPrefix(line, "--- "): + // The old-side header. It is also the authority on the path when + // the new side is /dev/null, which is what a deletion looks like. + if p, ok := strings.CutPrefix(line, "--- a/"); ok && cur.PrevPath == "" { + cur.PrevPath = p + } + case strings.HasPrefix(line, "+++ "): + // The new-side header, and the only unambiguous statement of the + // path in the whole patch: the `diff --git a/x b/x` line cannot be + // split reliably when a path contains a space. + if p, ok := strings.CutPrefix(line, "+++ b/"); ok { + cur.Path = p + } + inBody = true + case !inBody: + // Still in the file header block, where a line beginning with a + // dash is metadata rather than a removed line. + case strings.HasPrefix(line, "+"): + add++ + case strings.HasPrefix(line, "-"): + del++ + } + } + flush() + // A rename with no content change has no +++ header at all, so its path + // still came from the diff --git line. That is fine: rename to said it. + for i := range out { + if out[i].Status == "modified" && out[i].PrevPath != "" && out[i].PrevPath != out[i].Path { + out[i].Status = "renamed" + } + if out[i].Status != "renamed" { + out[i].PrevPath = "" + } + } + return out +} + +// pathFromDiffHeader reads the b-side path off `diff --git a/x b/x`. +// +// This is a guess and it has to be. git writes both paths on one line with no +// quoting for a plain space, so `a/my file b/my file` cannot be split without +// knowing the answer already. The guess is the first " b/", which is right +// whenever no path contains that sequence, and filesInPatch overwrites it from +// the +++ header, which is unambiguous, as soon as one shows up. +func pathFromDiffHeader(line string) string { + rest := strings.TrimPrefix(line, "diff --git ") + i := strings.Index(rest, " b/") + if i < 0 { + return strings.TrimPrefix(rest, "a/") + } + return strings.TrimPrefix(rest[i+1:], "b/") +} diff --git a/gh/commit_test.go b/gh/commit_test.go new file mode 100644 index 0000000..6254ce9 --- /dev/null +++ b/gh/commit_test.go @@ -0,0 +1,217 @@ +package gh + +import "testing" + +// The patch parser is the one part of the history layer that is pure text in +// and records out, so it is the one part that can be tested without the +// network. Everything it gets wrong, it gets wrong silently, which is exactly +// what these cases are for. + +const twoCommitMailbox = `From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +From: Ada Lovelace <1234+ada@users.noreply.github.com> +Date: Thu, 7 Nov 2024 14:39:11 -0700 +Subject: [PATCH 1/2] teach the engine to count + +The analytical engine could not previously count past ten. +--- + engine.go | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/engine.go b/engine.go +index aaa..bbb 100644 +--- a/engine.go ++++ b/engine.go +@@ -1,3 +1,4 @@ + package engine +-const max = 10 ++const max = 1000 ++const min = 0 +-- +2.47.0 + +From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001 +From: Grace Hopper +Date: Fri, 8 Nov 2024 09:00:00 +0000 +Subject: [PATCH 2/2] rename the moth file + +--- + moth.txt => bug.txt | 0 + 1 file changed, 0 insertions(+), 0 deletions(-) + +diff --git a/moth.txt b/bug.txt +similarity index 100% +rename from moth.txt +rename to bug.txt +-- +2.47.0 +` + +func TestSplitMailbox(t *testing.T) { + parts := splitMailbox(twoCommitMailbox) + if len(parts) != 2 { + t.Fatalf("split into %d parts, want 2", len(parts)) + } + if got := parts[0][:45]; got[:5] != "From " { + t.Errorf("first part starts %q", got) + } +} + +func TestSplitMailboxIgnoresFromInsideADiff(t *testing.T) { + // A changed file whose content begins with "From " is the trap this + // parser exists to avoid. Splitting on "From " alone cuts this in half. + const tricky = `From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +From: Ada +Subject: [PATCH] add a letter + +--- +diff --git a/letter.txt b/letter.txt ++From Ada, with regards ++From 2222222222222222222222222222222222222222 was not a header +` + if n := len(splitMailbox(tricky)); n != 1 { + t.Fatalf("split into %d parts, want 1", n) + } +} + +func TestCommitFromPatch(t *testing.T) { + parts := splitMailbox(twoCommitMailbox) + first := commitFromPatch("cli/cli", parts[0]) + if first == nil { + t.Fatal("first commit did not parse") + } + if first.SHA != "1111111111111111111111111111111111111111" { + t.Errorf("sha %q", first.SHA) + } + // The [PATCH 1/2] prefix is git's, not the author's, and it has no place + // on a record. + if first.Subject != "teach the engine to count" { + t.Errorf("subject %q", first.Subject) + } + if first.Body != "The analytical engine could not previously count past ten." { + t.Errorf("body %q", first.Body) + } + if len(first.Authors) != 1 { + t.Fatalf("authors %+v", first.Authors) + } + a := first.Authors[0] + if a.Name != "Ada Lovelace" { + t.Errorf("author name %q", a.Name) + } + // The noreply address is the only place a patch carries a GitHub login. + if a.Login != "ada" { + t.Errorf("author login %q, the noreply address should have given it", a.Login) + } + if first.AuthoredAt == nil || first.AuthoredAt.UTC().Format("2006-01-02T15:04:05Z") != "2024-11-07T21:39:11Z" { + t.Errorf("authored at %v", first.AuthoredAt) + } + if first.ID != "cli/cli@1111111111111111111111111111111111111111" { + t.Errorf("id %q", first.ID) + } + + second := commitFromPatch("cli/cli", parts[1]) + if second == nil { + t.Fatal("second commit did not parse") + } + // A plain address has no login and the record should say so rather than + // invent one from the local part. + if len(second.Authors) != 1 || second.Authors[0].Login != "" { + t.Errorf("second authors %+v", second.Authors) + } + if second.Authors[0].Name != "Grace Hopper" { + t.Errorf("second author name %q", second.Authors[0].Name) + } +} + +func TestFilesInPatch(t *testing.T) { + files := filesInPatch(twoCommitMailbox) + if len(files) != 2 { + t.Fatalf("files %+v", files) + } + e := files[0] + if e.Path != "engine.go" || e.Status != "modified" { + t.Errorf("first file %+v", e) + } + // Two + lines and one - line. Neither file header counts, and neither + // does the "--" that ends the mailbox entry. + if e.Additions == nil || *e.Additions != 2 { + t.Errorf("additions %v, want 2", e.Additions) + } + if e.Deletions == nil || *e.Deletions != 1 { + t.Errorf("deletions %v, want 1", e.Deletions) + } + r := files[1] + if r.Status != "renamed" || r.Path != "bug.txt" || r.PrevPath != "moth.txt" { + t.Errorf("rename %+v", r) + } +} + +func TestLoginFromNoreply(t *testing.T) { + cases := []struct { + email string + want string + }{ + {"1234+octocat@users.noreply.github.com", "octocat"}, + {"octocat@users.noreply.github.com", "octocat"}, + {"octocat@example.com", ""}, + {"", ""}, + {"@users.noreply.github.com", ""}, + } + for _, c := range cases { + got, ok := loginFromNoreply(c.email) + if !ok { + got = "" + } + if got != c.want { + t.Errorf("%q gave %q, want %q", c.email, got, c.want) + } + } +} + +func TestLooksLikeSize(t *testing.T) { + yes := []string{"13.1 MB", "742 Bytes", "1 KB", "2.5 GB"} + no := []string{"", "Latest", "13.1MB", "sha256:abc", "v2.96.0", "MB 13.1"} + for _, s := range yes { + if !looksLikeSize(s) { + t.Errorf("%q should read as a size", s) + } + } + for _, s := range no { + if looksLikeSize(s) { + t.Errorf("%q should not read as a size", s) + } + } +} + +func TestPathFromDiffHeader(t *testing.T) { + cases := map[string]string{ + "diff --git a/engine.go b/engine.go": "engine.go", + "diff --git a/moth.txt b/bug.txt": "bug.txt", + "diff --git a/docs/api.md b/docs/api.md": "docs/api.md", + "diff --git a/x.go b/pkg/nested/b/deep/y.go": "pkg/nested/b/deep/y.go", + } + for line, want := range cases { + if got := pathFromDiffHeader(line); got != want { + t.Errorf("%q gave %q, want %q", line, got, want) + } + } +} + +func TestFilesInPatchPrefersTheHeaderPath(t *testing.T) { + // The diff --git line cannot be split when a path contains a space, so + // the +++ header is the authority and has to win. + const spaced = `diff --git a/my file b/my file +index aaa..bbb 100644 +--- a/my file ++++ b/my file +@@ -1 +1 @@ +-old ++new +` + files := filesInPatch(spaced) + if len(files) != 1 { + t.Fatalf("files %+v", files) + } + if files[0].Path != "my file" { + t.Errorf("path %q, the +++ header should have corrected the guess", files[0].Path) + } +} diff --git a/gh/live_test.go b/gh/live_test.go index 231f214..896c3e2 100644 --- a/gh/live_test.go +++ b/gh/live_test.go @@ -616,6 +616,398 @@ func TestLiveContents(t *testing.T) { }) } +// TestLiveHistory covers the six surfaces the history layer reads. They are one +// test because they are one question asked six ways, and when GitHub changes a +// payload it is usually the disagreement between two of them that shows it. +func TestLiveHistory(t *testing.T) { + c := liveClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + t.Run("commits", func(t *testing.T) { + var got []Commit + err := c.Commits(ctx, "cli/cli", CommitOptions{Limit: 40}, func(cm Commit) error { + got = append(got, cm) + return nil + }) + if err != nil { + t.Fatal(err) + } + // A page is 35, so 40 proves the cursor came back and was accepted. + if len(got) != 40 { + t.Fatalf("walked %d commits, want the limit of 40", len(got)) + } + cm := got[0] + if len(cm.SHA) != 40 { + t.Errorf("sha %q", cm.SHA) + } + if cm.Subject == "" { + t.Error("subject empty, shortMessage is often null and the markdown fallback did not run") + } + if cm.AuthoredAt == nil { + t.Error("no authored date") + } + if len(cm.Authors) == 0 { + t.Error("no authors") + } + if cm.DateGroup == "" { + t.Error("no day heading, commitGroups lost its title") + } + if cm.ID != "cli/cli@"+cm.SHA { + t.Errorf("id %q", cm.ID) + } + logExtra(t, "commit", cm.Extra) + }) + + t.Run("commits_filtered", func(t *testing.T) { + // The filters go to GitHub, so a path that exists and an author who + // touched it should come back non-empty and every record should be on + // that path. + n := 0 + err := c.Commits(ctx, "cli/cli", CommitOptions{Path: "go.mod", Limit: 5}, func(Commit) error { + n++ + return nil + }) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("no commits touched go.mod, the path filter is being dropped") + } + }) + + t.Run("commit", func(t *testing.T) { + cm, err := c.CommitInfo(ctx, "cli/cli", "trunk", CommitInfoOptions{Files: true}) + if err != nil { + t.Fatal(err) + } + if len(cm.SHA) != 40 { + t.Errorf("a branch name did not resolve to a sha: %q", cm.SHA) + } + if cm.Additions == nil || cm.Deletions == nil { + t.Error("headerInfo did not decode") + } + if len(cm.Files) == 0 { + t.Fatal("no files, diffEntryData did not decode") + } + f := cm.Files[0] + if f.Path == "" || f.Status == "" { + t.Errorf("half a file change: %+v", f) + } + if len(cm.Parents) == 0 { + t.Error("no parents on a commit that is not the root") + } + logExtra(t, "commit info", cm.Extra) + }) + + t.Run("verify", func(t *testing.T) { + // GitHub signs every commit it makes itself, so a merge on cli/cli is + // the reliable case. This is the only surface that says so. + var head []*Commit + err := c.Commits(ctx, "cli/cli", CommitOptions{Limit: 5}, func(cm Commit) error { + head = append(head, &cm) + return nil + }) + if err != nil { + t.Fatal(err) + } + if err := c.VerifyCommits(ctx, "cli/cli", head); err != nil { + t.Fatal(err) + } + signed := 0 + for _, cm := range head { + if cm.Verification != "" { + signed++ + } + } + if signed == 0 { + t.Error("commit search reported verification on none of five commits") + } + }) + + t.Run("branches", func(t *testing.T) { + var got []GitRef + err := c.Branches(ctx, "cli/cli", RefOptions{Limit: 10}, func(r GitRef) error { + got = append(got, r) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Fatal("no branches") + } + // The page carries what the protocol cannot: who last pushed and when. + hasAuthor := false + for _, r := range got { + if r.Type != "branch" { + t.Errorf("type %q on a branch", r.Type) + } + if r.Author != nil && r.AuthoredAt != nil { + hasAuthor = true + } + } + if !hasAuthor { + t.Error("no branch carried an author, which is the only reason to read the page") + } + if got[0].Repo != "cli/cli" { + t.Errorf("repo %q", got[0].Repo) + } + }) + + t.Run("branches_complete", func(t *testing.T) { + // The advertisement has no cap, so it should beat the page's list and + // every entry should carry a SHA. + var got []GitRef + err := c.Branches(ctx, "cli/cli", RefOptions{Complete: true}, func(r GitRef) error { + got = append(got, r) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(got) < 5 { + t.Fatalf("the advertisement gave %d branches", len(got)) + } + def := 0 + for _, r := range got { + if len(r.SHA) != 40 { + t.Errorf("%s has sha %q", r.Name, r.SHA) + } + if r.IsDefault { + def++ + } + } + if def != 1 { + t.Errorf("%d branches claim to be the default", def) + } + }) + + t.Run("tags", func(t *testing.T) { + var got []GitRef + err := c.Tags(ctx, "cli/cli", RefOptions{Limit: 50}, func(r GitRef) error { + got = append(got, r) + return nil + }) + if err != nil { + t.Fatal(err) + } + // The point of reading the protocol by default is that neither the feed + // nor the page gives more than ten. + if len(got) != 50 { + t.Fatalf("%d tags, want 50 from a repository with hundreds", len(got)) + } + annotated := 0 + for _, r := range got { + if r.Type != "tag" { + t.Errorf("type %q on a tag", r.Type) + } + if r.PeeledSHA != "" { + annotated++ + } + } + t.Logf("%d of %d tags are annotated", annotated, len(got)) + }) + + t.Run("refs", func(t *testing.T) { + // No limit: it is one response either way, and a limit here truncates + // inside the branch list and never reaches the tags. + heads, tags := 0, 0 + err := c.Refs(ctx, "cli/cli", RefOptions{}, func(r GitRef) error { + switch r.Type { + case "branch": + heads++ + case "tag": + tags++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if heads == 0 || tags == 0 { + t.Errorf("refs gave %d branches and %d tags, it should give both", heads, tags) + } + }) + + t.Run("default_branch", func(t *testing.T) { + // symref=HEAD comes free with the advertisement and should agree with + // the repository page, which reads it from a completely different place. + name, err := c.DefaultBranch(ctx, "cli/cli") + if err != nil { + t.Fatal(err) + } + if name != "trunk" { + t.Errorf("default branch %q, want trunk", name) + } + }) + + t.Run("releases", func(t *testing.T) { + var got []Release + err := c.Releases(ctx, "cli/cli", ReleaseOptions{Limit: 15, Body: true}, func(r Release) error { + got = append(got, r) + return nil + }) + if err != nil { + t.Fatal(err) + } + // Ten a page, so fifteen proves rel="next" was found and followed. + if len(got) != 15 { + t.Fatalf("%d releases, want the limit of 15", len(got)) + } + latest := 0 + for _, r := range got { + if r.Tag == "" { + t.Errorf("release with no tag: %+v", r.Base) + } + if r.IsLatest { + latest++ + } + } + if latest != 1 { + t.Errorf("%d releases are labelled Latest", latest) + } + if got[0].PublishedAt == nil { + t.Error("no publish date on the newest release") + } + if got[0].Body == "" { + t.Error("no release notes with Body set") + } + logExtra(t, "release", got[0].Extra) + }) + + t.Run("release_assets", func(t *testing.T) { + rel, err := c.Release(ctx, "cli/cli", "v2.63.2", ReleaseOptions{Assets: true, Body: true}) + if err != nil { + t.Fatal(err) + } + if rel.Tag != "v2.63.2" { + t.Errorf("tag %q", rel.Tag) + } + if rel.Title == "" { + t.Error("no title") + } + if rel.Author == nil { + t.Error("no publisher, the byline link did not match") + } + if rel.PublishedAt == nil { + t.Error("no publish date") + } + if rel.Body == "" { + t.Error("no release notes") + } + // The commit the tag points at is the one thing the per-tag page has + // that a list entry does not. + if len(rel.CommitSHA) != 40 { + t.Errorf("commit sha %q", rel.CommitSHA) + } + if len(rel.Assets) < 5 { + t.Fatalf("%d assets, the expanded_assets fragment did not decode", len(rel.Assets)) + } + a := rel.Assets[0] + if a.Name == "" || a.URL == "" { + t.Errorf("half an asset: %+v", a) + } + if a.SizeDisplay == "" { + t.Error("no size on an asset") + } + if a.Label == "" { + t.Error("no label, the row's first truncated span stopped matching") + } + if a.UpdatedAt == nil { + t.Error("no upload time on an asset") + } + // Download counts are gone for a logged-out client. If they ever come + // back this logs it rather than failing. + if a.DownloadCount != nil { + t.Logf("download counts are being served again: %d", *a.DownloadCount) + } + }) + + t.Run("release_latest", func(t *testing.T) { + // "latest" is a redirect, so this proves the decoder reads whatever it + // lands on rather than the tag it was handed. It is also the release + // that carries digests: GitHub started attaching them recently and old + // releases do not have them. + rel, err := c.Release(ctx, "cli/cli", "latest", ReleaseOptions{Assets: true}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(rel.Tag, "v") { + t.Errorf("tag %q, the redirect target did not decode", rel.Tag) + } + if !rel.IsLatest { + t.Error("the latest release is not labelled Latest") + } + if len(rel.Assets) == 0 { + t.Fatal("no assets on the latest release") + } + digests := 0 + for _, a := range rel.Assets { + if strings.HasPrefix(a.Digest, "sha256:") { + digests++ + } + } + if digests == 0 { + t.Error("no sha256 digests, which is the one thing that replaced download counts") + } + }) + + t.Run("compare", func(t *testing.T) { + cmp, err := c.CompareRefs(ctx, "cli/cli", "v2.63.1", "v2.63.2", CompareOptions{Files: true}) + if err != nil { + t.Fatal(err) + } + if cmp.CommitCount == 0 { + t.Fatal("no commits in the range, the mailbox did not split") + } + if cmp.FileCount == 0 { + t.Fatal("no files in the range") + } + if cmp.Additions == 0 && cmp.Deletions == 0 { + t.Error("a release range with no line changes") + } + first := cmp.Commits[0] + if len(first.SHA) != 40 || first.Subject == "" || first.AuthoredAt == nil { + t.Errorf("half a commit from the patch: %+v", first.Base) + } + // The mailbox has names and emails, not logins, except where the email + // is a noreply address. At least one of these should be. + logins := 0 + for _, cm := range cmp.Commits { + for _, a := range cm.Authors { + if a.Login != "" { + logins++ + } + } + } + t.Logf("%d of %d commits gave a login through a noreply address", logins, cmp.CommitCount) + if cmp.ID != "cli/cli@v2.63.1...v2.63.2" { + t.Errorf("id %q", cmp.ID) + } + }) + + t.Run("diff", func(t *testing.T) { + // The diff is the patch without the mail headers, so it should be + // smaller and it should not carry a From line. + url := BaseURL + "/cli/cli/compare/v2.63.1...v2.63.2" + diff, err := c.Diff(ctx, url) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(diff, "diff --git ") { + t.Errorf("a diff should start with a diff header: %.60q", diff) + } + patch, err := c.Patch(ctx, url) + if err != nil { + t.Fatal(err) + } + if len(patch) <= len(diff) { + t.Errorf("patch %d bytes is not bigger than diff %d bytes", len(patch), len(diff)) + } + }) +} + // TestLiveCodeSearchStaysRefused guards the one search type that answers 200 // with nothing. If GitHub ever opens it up this test fails, which is the // notification to go implement it. diff --git a/gh/types.go b/gh/types.go index df88b17..8c66958 100644 --- a/gh/types.go +++ b/gh/types.go @@ -415,8 +415,9 @@ type TimelineItem struct { type Commit struct { Base - Repo string `json:"repo" table:"-"` - SHA string `json:"sha" table:"sha"` + Repo string `json:"repo" table:"-"` + SHA string `json:"sha" table:"sha"` + NodeID string `json:"node_id,omitempty" table:"-"` Subject string `json:"subject" table:"subject,truncate"` SubjectHighlight string `json:"subject_highlight,omitempty" table:"-"` @@ -551,15 +552,59 @@ type Release struct { // Asset is one release download. type Asset struct { - Name string `json:"name" table:"name"` - Size *int64 `json:"size,omitempty" table:"size"` - SizeDisplay string `json:"size_display,omitempty" table:"-"` - DownloadCount *int `json:"download_count,omitempty" table:"downloads"` + Name string `json:"name" table:"name"` + // Label is the text GitHub prints in place of the filename, "GitHub CLI + // 2.63.2 checksums" for gh_2.63.2_checksums.txt. It is set per asset at + // upload time and is usually the only human-readable thing in the row. + Label string `json:"label,omitempty" table:"-"` + Size *int64 `json:"size,omitempty" table:"-"` + SizeDisplay string `json:"size_display,omitempty" table:"size"` + // Digest is the sha256 the assets fragment publishes, prefixed "sha256:". + // It is new: GitHub added it around the time it stopped showing download + // counts to logged-out clients, so this record trades a popularity number + // for something you can actually verify a download against. + Digest string `json:"digest,omitempty" table:"-"` + // DownloadCount is left absent on a keyless read. The release page used to + // print it next to each asset and no longer does, and no other public + // surface carries it. The field stays because the shape of the record + // should not change when GitHub changes its mind again. + DownloadCount *int `json:"download_count,omitempty" table:"-"` URL string `json:"url" table:"url,url"` UpdatedAt *time.Time `json:"updated_at,omitempty" table:"-"` ContentType string `json:"content_type,omitempty" table:"-"` } +// Compare is a range between two commits: what `github compare` returns. +// +// It is built from the plain-text patch mailbox rather than the compare page. +// The page is 227 KB of HTML with no JSON payload at all, and the .patch suffix +// on the same range is a git-format-patch stream that carries every commit's +// author, date, subject, and diff with no markup to guess at. Parsing a format +// GitHub cannot restyle is the whole point. +type Compare struct { + Base + + Repo string `json:"repo" table:"-"` + BaseRef string `json:"base_ref" table:"base"` + HeadRef string `json:"head_ref" table:"head"` + PatchURL string `json:"patch_url" table:"-"` + DiffURL string `json:"diff_url" table:"-"` + + Commits []Commit `json:"commits,omitempty" table:"-"` + // CommitCount is len(Commits) and is here so a table row says something + // useful without the caller reaching into the slice. + CommitCount int `json:"commit_count" table:"commits"` + + Files []FileChange `json:"files,omitempty" table:"-"` + FileCount int `json:"file_count" table:"files"` + Additions int `json:"additions" table:"+"` + Deletions int `json:"deletions" table:"-"` + + // Patch is the raw stream, kept only when the caller asked for it. It is + // megabytes on a wide range. + Patch string `json:"patch,omitempty" table:"-"` +} + // --- the long tail --- // Topic is a curated or uncurated topic. The search result carries most of it; diff --git a/pkg/gitproto/gitproto.go b/pkg/gitproto/gitproto.go new file mode 100644 index 0000000..6caff88 --- /dev/null +++ b/pkg/gitproto/gitproto.go @@ -0,0 +1,161 @@ +// Package gitproto reads the git smart HTTP advertisement. +// +// This is the one surface on github.com that answers "what refs does this +// repository have" completely, in a single request, with no login, no page +// limit, and no truncation. The branches page caps its list and says so, the +// refs fragment gives names and nothing else, and the tags feed gives ten. The +// advertisement gives every branch, every tag, every pull request head, and the +// object each one points at. +// +// The format is pkt-line, which is four hex digits of length followed by that +// many bytes including the four. 0000 is a flush packet. The first line after +// the service header carries the capability list after a NUL byte, and one of +// those capabilities is symref=HEAD:refs/heads/main, which is where the default +// branch comes from for free. +package gitproto + +import ( + "errors" + "strconv" + "strings" +) + +// Ref is one advertised ref. +type Ref struct { + Name string + SHA string + // Peeled is set on the ^{} entry of an annotated tag: the tag object's own + // SHA is in SHA and the commit it points at is here. A lightweight tag has + // no peeled entry, which is how you tell the two apart. + Peeled string +} + +// Advertisement is a parsed info/refs response. +type Advertisement struct { + Refs []Ref + // Head is the SHA the HEAD line advertised. + Head string + // DefaultBranch is the target of symref=HEAD:..., short form, so "main" + // rather than "refs/heads/main". Empty when the server did not advertise it. + DefaultBranch string + Capabilities []string +} + +// ErrNotGit is returned when the body is not an advertisement. It usually means +// github.com answered with an HTML page, which is what a private or missing +// repository does. +var ErrNotGit = errors.New("not a git upload-pack advertisement") + +// Parse reads an info/refs?service=git-upload-pack body. +// +// Peeled entries are folded into the ref they belong to rather than kept as +// separate refs, because "refs/tags/v1.0.0^{}" is not a ref anybody can check +// out and a caller that has to know about the fold is a caller doing the +// parser's job. +func Parse(body []byte) (*Advertisement, error) { + lines, err := pktLines(body) + if err != nil { + return nil, err + } + ad := &Advertisement{} + byName := map[string]int{} + for _, line := range lines { + line = strings.TrimRight(line, "\n") + if line == "" || strings.HasPrefix(line, "# service=") { + continue + } + // The first ref line carries the capabilities after a NUL. + if i := strings.IndexByte(line, 0); i >= 0 { + ad.Capabilities = strings.Fields(line[i+1:]) + for _, c := range ad.Capabilities { + if v, ok := strings.CutPrefix(c, "symref=HEAD:"); ok { + ad.DefaultBranch = shortName(v) + } + } + line = line[:i] + } + sha, name, ok := strings.Cut(line, " ") + if !ok || len(sha) != 40 { + continue + } + if name == "HEAD" { + ad.Head = sha + continue + } + if base, ok := strings.CutSuffix(name, "^{}"); ok { + if i, seen := byName[base]; seen { + ad.Refs[i].Peeled = sha + } + continue + } + byName[name] = len(ad.Refs) + ad.Refs = append(ad.Refs, Ref{Name: name, SHA: sha}) + } + if len(ad.Refs) == 0 && ad.Head == "" { + return nil, ErrNotGit + } + return ad, nil +} + +// pktLines splits a pkt-line stream into its payloads. +// +// A malformed length is a hard error rather than a skipped line. Half-reading a +// binary protocol and carrying on gives a ref list that looks fine and is +// missing entries, which is worse than not answering. +func pktLines(body []byte) ([]string, error) { + var out []string + for len(body) > 0 { + if len(body) < 4 { + return nil, ErrNotGit + } + n, err := strconv.ParseUint(string(body[:4]), 16, 32) + if err != nil { + return nil, ErrNotGit + } + if n == 0 { + // Flush packet. The advertisement carries one after the service + // header and one at the end, and neither ends the stream for us. + body = body[4:] + continue + } + if n < 4 || int(n) > len(body) { + return nil, ErrNotGit + } + out = append(out, string(body[4:n])) + body = body[n:] + } + return out, nil +} + +// Branches returns the refs under refs/heads, with the prefix stripped. +func (a *Advertisement) Branches() []Ref { return a.under("refs/heads/") } + +// Tags returns the refs under refs/tags, with the prefix stripped. An annotated +// tag keeps both SHAs: SHA is the tag object and Peeled is the commit. +func (a *Advertisement) Tags() []Ref { return a.under("refs/tags/") } + +// PullHeads returns the refs under refs/pull, which github.com advertises for +// every pull request ever opened against the repository. The name keeps its +// shape, "1234/head" or "1234/merge", because those two are different objects +// and flattening them would lose that. +func (a *Advertisement) PullHeads() []Ref { return a.under("refs/pull/") } + +func (a *Advertisement) under(prefix string) []Ref { + var out []Ref + for _, r := range a.Refs { + if name, ok := strings.CutPrefix(r.Name, prefix); ok { + r.Name = name + out = append(out, r) + } + } + return out +} + +func shortName(full string) string { + for _, p := range []string{"refs/heads/", "refs/tags/", "refs/remotes/"} { + if s, ok := strings.CutPrefix(full, p); ok { + return s + } + } + return full +} diff --git a/pkg/gitproto/gitproto_test.go b/pkg/gitproto/gitproto_test.go new file mode 100644 index 0000000..2527e66 --- /dev/null +++ b/pkg/gitproto/gitproto_test.go @@ -0,0 +1,84 @@ +package gitproto + +import "testing" + +// pkt builds a pkt-line stream from payloads. A "" payload is a flush packet. +func pkt(payloads ...string) []byte { + var b []byte + for _, p := range payloads { + if p == "" { + b = append(b, "0000"...) + continue + } + n := len(p) + 4 + const hex = "0123456789abcdef" + b = append(b, hex[n>>12&0xf], hex[n>>8&0xf], hex[n>>4&0xf], hex[n&0xf]) + b = append(b, p...) + } + return b +} + +const ( + shaHead = "1111111111111111111111111111111111111111" + shaMain = "2222222222222222222222222222222222222222" + shaTag = "3333333333333333333333333333333333333333" + shaPeel = "4444444444444444444444444444444444444444" + shaPull = "5555555555555555555555555555555555555555" +) + +func TestParse(t *testing.T) { + body := pkt( + "# service=git-upload-pack\n", + "", + shaHead+" HEAD\x00multi_ack symref=HEAD:refs/heads/trunk object-format=sha1\n", + shaMain+" refs/heads/trunk\n", + shaTag+" refs/tags/v1.0.0\n", + shaPeel+" refs/tags/v1.0.0^{}\n", + shaPull+" refs/pull/42/head\n", + "", + ) + ad, err := Parse(body) + if err != nil { + t.Fatal(err) + } + if ad.Head != shaHead { + t.Errorf("head %q", ad.Head) + } + if ad.DefaultBranch != "trunk" { + t.Errorf("default branch %q", ad.DefaultBranch) + } + if len(ad.Refs) != 3 { + t.Fatalf("refs %d, the peeled entry should have folded into its tag", len(ad.Refs)) + } + branches := ad.Branches() + if len(branches) != 1 || branches[0].Name != "trunk" || branches[0].SHA != shaMain { + t.Errorf("branches %+v", branches) + } + tags := ad.Tags() + if len(tags) != 1 || tags[0].Name != "v1.0.0" { + t.Fatalf("tags %+v", tags) + } + // An annotated tag keeps both: the tag object and the commit it points at. + if tags[0].SHA != shaTag || tags[0].Peeled != shaPeel { + t.Errorf("tag sha %q peeled %q", tags[0].SHA, tags[0].Peeled) + } + pulls := ad.PullHeads() + if len(pulls) != 1 || pulls[0].Name != "42/head" { + t.Errorf("pull heads %+v", pulls) + } +} + +func TestParseRejectsHTML(t *testing.T) { + // A private or missing repository answers with a page, and a parser that + // shrugs at that hands back an empty ref list that looks like a real answer. + for _, body := range []string{ + "\nGitHub", + "", + "00x4bad length", + "0005", + } { + if _, err := Parse([]byte(body)); err == nil { + t.Errorf("accepted %q", body[:min(len(body), 20)]) + } + } +} diff --git a/pkg/page/dom.go b/pkg/page/dom.go index d5bed8a..06b298f 100644 --- a/pkg/page/dom.go +++ b/pkg/page/dom.go @@ -116,6 +116,13 @@ func HasClass(n *html.Node, want string) bool { func Find(root *html.Node, s Sel) *html.Node { var found *html.Node Walk(root, func(n *html.Node) bool { + // Walk keeps visiting siblings after a subtree says stop, so the + // answer has to be latched. Without this the last match wins instead + // of the first, which on a page where a dialog repeats the same shape + // as the content silently returns the dialog. + if found != nil { + return false + } if s.Match(n) { found = n return false diff --git a/pkg/page/dom_test.go b/pkg/page/dom_test.go new file mode 100644 index 0000000..cf1399e --- /dev/null +++ b/pkg/page/dom_test.go @@ -0,0 +1,104 @@ +package page + +import ( + "strings" + "testing" + + "golang.org/x/net/html" +) + +func parse(t *testing.T, s string) *html.Node { + t.Helper() + doc, err := html.Parse(strings.NewReader(s)) + if err != nil { + t.Fatal(err) + } + return doc +} + +// The shape here is GitHub's, not an invention: a page renders its content and +// then renders a dialog that repeats the same tags for the picker overlay. A +// Find that answers with the last match reads the dialog and looks like it +// worked. +const twoOfEverything = ` +
    +
    +

    GitHub CLI 2.63.2

    + a while ago + +

    Choose a tag to compare

    + then +
    +
    +
    +` + +func TestFindReturnsTheFirstMatch(t *testing.T) { + doc := parse(t, twoOfEverything) + h := Find(doc, Sel{Tag: "h1"}) + if h == nil { + t.Fatal("no h1") + } + if got := Text(h); got != "GitHub CLI 2.63.2" { + t.Errorf("h1 is %q, the dialog's copy won", got) + } + rt := Find(doc, RelTimeEl) + if rt == nil { + t.Fatal("no relative-time") + } + if got := Attr(rt, "datetime"); got != "2024-12-05T18:15:12Z" { + t.Errorf("datetime is %q, the dialog's copy won", got) + } +} + +func TestFindAllIsInDocumentOrder(t *testing.T) { + doc := parse(t, twoOfEverything) + all := FindAll(doc, Sel{Tag: "h1"}) + if len(all) != 2 { + t.Fatalf("found %d h1s", len(all)) + } + if Text(all[0]) != "GitHub CLI 2.63.2" || Text(all[1]) != "Choose a tag to compare" { + t.Errorf("out of order: %q then %q", Text(all[0]), Text(all[1])) + } +} + +func TestFindNested(t *testing.T) { + // A match inside a match is still a match, and the outer one is first. + doc := parse(t, `
    inner
    `) + n := Find(doc, Sel{Class: "Box"}) + if n == nil { + t.Fatal("no Box") + } + if inner := Find(n.FirstChild, Sel{Class: "Box"}); inner == nil { + t.Error("the outer Box has no inner Box, so Find picked the inner one") + } +} + +func TestSelMatch(t *testing.T) { + doc := parse(t, ` +
    tag +sha256:abc +`) + cases := []struct { + name string + sel Sel + want bool + }{ + {"tag", Sel{Tag: "a"}, true}, + {"id", Sel{ID: "x"}, true}, + {"wrong id", Sel{ID: "y"}, false}, + {"one class of several", Sel{Class: "Link--muted"}, true}, + {"class is not a substring", Sel{Class: "Link--mut"}, false}, + {"bare attribute", Sel{Tag: "a", Attr: "data-hpc"}, true}, + {"attr contains", Sel{Attr: "href", AttrContains: "/releases/tag/"}, true}, + {"attr prefix", Sel{Attr: "href", AttrPrefix: "/cli/"}, true}, + {"attr suffix", Sel{Attr: "href", AttrSuffix: "v1.0.0"}, true}, + {"attr value must be exact", Sel{Attr: "id", AttrValue: "x"}, true}, + {"attr value mismatch", Sel{Attr: "id", AttrValue: "xx"}, false}, + } + for _, c := range cases { + if got := Find(doc, c.sel) != nil; got != c.want { + t.Errorf("%s: matched %v, want %v", c.name, got, c.want) + } + } +} From 8135c602fd8bd104596724bfd4153ca51b3d909e Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:13:12 +0700 Subject: [PATCH 08/21] Wire the whole command surface onto the gh client This is the layer that was missing between the readers and the binary. gh/domain.go declares the domain to kit, including the raw and gist hosts so a pasted link resolves here, and gh/ops.go registers every verb: the single-record reads, the nine searches, the contents commands, the history commands, and the two meta commands. cli/ adds the byte plane that does not belong on a record, so cat, readme, archive, and diff write to stdout instead of into a field. The old github/ package and pkg/render are gone. Both predate the rewrite and nothing pointed at them any more. Four things came out of running it against the live site. Release authors decoded as whole URLs. GitHub writes the same link two ways: the release list uses absolute hrefs and the per-tag page relative ones, and the decoder only trimmed a leading slash. hrefPath now reduces either form to a path and releaseAuthor is shared by both decoders, which also gets the /apps/ case right for a release cut by a workflow. Bare references like cli/cli/blob/trunk/go.mod classified as wiki pages. The URL parser already knew what they meant, so classifyBare hands them over instead of guessing. The symbols error printed a title-cased path. The path moved out of first position in the sentence, since that is the word the renderer capitalises. readme printed nothing, because Repo.ReadmeText was never filled. page.Text was the wrong tool for it: collapsing a twenty-kilobyte README onto one line is not a rendering of anything. page.BlockText keeps one line per block, one blank line between paragraphs, and the interior whitespace of a pre block, and the release, discussion, and profile bodies use it too. The organization profile readme has no class of its own, so that lookup falls back to the markdown article. --- cli/files.go | 286 +++++++++++ cli/root.go | 34 +- cli/version.go | 41 ++ gh/account.go | 17 +- gh/base.go | 33 ++ gh/commit.go | 39 +- gh/contents.go | 21 + gh/domain.go | 143 ++++++ gh/ops.go | 1084 +++++++++++++++++++++++++++++++++++++++++ gh/repo.go | 3 + gh/thread.go | 13 +- gh/uri.go | 26 +- github/api.go | 232 --------- github/domain.go | 58 --- github/github.go | 213 -------- github/github_test.go | 588 ---------------------- github/ids.go | 37 -- github/ids_test.go | 40 -- github/ops.go | 418 ---------------- github/parse.go | 712 --------------------------- github/types.go | 130 ----- pkg/page/dom.go | 133 +++++ pkg/page/dom_test.go | 70 +++ pkg/page/selectors.go | 6 +- pkg/render/render.go | 350 ------------- 25 files changed, 1908 insertions(+), 2819 deletions(-) create mode 100644 cli/files.go create mode 100644 cli/version.go create mode 100644 gh/domain.go create mode 100644 gh/ops.go delete mode 100644 github/api.go delete mode 100644 github/domain.go delete mode 100644 github/github.go delete mode 100644 github/github_test.go delete mode 100644 github/ids.go delete mode 100644 github/ids_test.go delete mode 100644 github/ops.go delete mode 100644 github/parse.go delete mode 100644 github/types.go delete mode 100644 pkg/render/render.go diff --git a/cli/files.go b/cli/files.go new file mode 100644 index 0000000..d699bd7 --- /dev/null +++ b/cli/files.go @@ -0,0 +1,286 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" + "github.com/tamnd/github-cli/gh" +) + +// files.go holds the commands that emit bytes rather than records. They are the +// reason the tool is useful for actual file work and not only metadata, and they +// are escape hatches precisely because a file is not a record: piping a tarball +// through a JSON renderer would be a mistake in every direction. + +// clientFrom reaches the one client kit built for this run. Escape-hatch +// commands do not get the kit:"inject" treatment, so they ask for it here, and +// asking here means they share the run's pacing and cache with every operation. +func clientFrom(ctx context.Context) (*gh.Client, error) { + st := kit.FromContext(ctx) + if st == nil { + return nil, errs.New(errs.KindGeneric, "no run state on the context") + } + v, err := st.Client(ctx) + if err != nil { + return nil, err + } + c, ok := v.(*gh.Client) + if !ok { + return nil, errs.New(errs.KindGeneric, "the run has no github client") + } + return c, nil +} + +type catCmd struct{ ref string } + +func newCatCmd() kit.Command { + c := &catCmd{} + return kit.Command{ + Use: "cat ", + Short: "Write one file from a repository to stdout", + Long: "cat streams the bytes straight through raw.githubusercontent.com, so a\n" + + "large file costs no memory and is never written to the cache. A blob URL\n" + + "works as a single argument, since it already names the path and the ref.", + Group: "contents", + Args: kit.RangeArgs(1, 2), + Flags: c.flags, + Run: c.run, + } +} + +func (c *catCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.ref, "rev", "", "branch, tag, or commit sha") +} + +func (c *catCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + repo, ref, path, err := resolvePath(args, c.ref) + if err != nil { + return err + } + if path == "" { + return errs.Usage("cat needs a path, either as a second argument or in the URL") + } + _, err = cl.Download(ctx, repo, ref, path, os.Stdout) + return err +} + +// resolvePath is the byte-plane twin of the resolver the operations use: it +// accepts a blob URL that carries everything, or a repository and a path. +func resolvePath(args []string, rev string) (repo, ref, path string, err error) { + arg := "" + if len(args) > 1 { + arg = args[1] + } + kind, id, err := gh.Classify(args[0]) + if err != nil { + return "", "", "", err + } + if kind == gh.KindFile || kind == gh.KindTree { + if r, v, p, ok := gh.SplitPathID(id); ok { + if rev != "" { + v = rev + } + if arg != "" { + p = arg + } + return r, v, p, nil + } + } + repo, err = gh.ResolveRepo(args[0]) + if err != nil { + return "", "", "", err + } + return repo, rev, arg, nil +} + +type readmeCmd struct { + ref string + html bool +} + +func newReadmeCmd() kit.Command { + c := &readmeCmd{} + return kit.Command{ + Use: "readme ", + Short: "Write a repository's README to stdout", + Long: "readme prints the rendered README as text. GitHub renders it server side,\n" + + "so what comes back is what the page shows, badges resolved and relative\n" + + "links rewritten. Use --html for the markup the page carries.", + Group: "contents", + Args: kit.ExactArgs(1), + Flags: c.flags, + Run: c.run, + } +} + +func (c *readmeCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.ref, "rev", "", "branch, tag, or commit sha") + f.BoolVar(&c.html, "html", false, "print the rendered markup instead of the text") +} + +func (c *readmeCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + id, err := gh.ResolveRepo(args[0]) + if err != nil { + return err + } + // A ref makes this a file read rather than a repository read, because the + // repository page only ever renders the default branch's README. + if c.ref != "" { + r, err := cl.Repo(ctx, id, gh.RepoOptions{}) + if err != nil { + return err + } + path := r.ReadmePath + if path == "" { + path = "README.md" + } + _, err = cl.Download(ctx, id, c.ref, path, os.Stdout) + return err + } + r, err := cl.Repo(ctx, id, gh.RepoOptions{Readme: true}) + if err != nil { + return err + } + text := r.ReadmeText + if c.html { + text = r.ReadmeHTML + } + if text == "" { + return errs.NotFound("%s has no README", id) + } + _, err = io.WriteString(os.Stdout, text) + return err +} + +type archiveCmd struct { + format string + out string +} + +func newArchiveCmd() kit.Command { + c := &archiveCmd{} + return kit.Command{ + Use: "archive [ref]", + Short: "Download a repository as a tarball or a zip", + Long: "One request to codeload gets the whole tree. For anything past a few\n" + + "directories this beats walking `github tree --recursive`, which is one\n" + + "request per directory. Nothing is buffered: the stream goes straight to\n" + + "the file or to stdout.", + Group: "contents", + Args: kit.RangeArgs(1, 2), + Flags: c.flags, + Run: c.run, + } +} + +func (c *archiveCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.format, "format", "tar.gz", "tar.gz or zip") + f.StringVarP(&c.out, "output", "o", "", "write here instead of stdout") +} + +func (c *archiveCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + repo, err := gh.ResolveRepo(args[0]) + if err != nil { + return err + } + ref := "" + if len(args) > 1 { + ref = args[1] + } + body, err := cl.Archive(ctx, repo, ref, c.format) + if err != nil { + return err + } + defer body.Close() + + w := io.Writer(os.Stdout) + if c.out != "" { + f, err := os.Create(c.out) + if err != nil { + return err + } + defer f.Close() + w = f + } + _, err = io.Copy(w, body) + return err +} + +type diffCmd struct{ patch bool } + +func newDiffCmd() kit.Command { + c := &diffCmd{} + return kit.Command{ + Use: "diff [base] [head]", + Short: "Write a commit's or a range's diff to stdout", + Long: "diff takes a commit reference for one commit's changes, or a repository\n" + + "and two refs for a range. --patch gives the git-format-patch mailbox\n" + + "instead, which carries the author, the date, and the message of every\n" + + "commit and applies cleanly with git am.", + Group: "contents", + Args: kit.RangeArgs(1, 3), + Flags: c.flags, + Run: c.run, + } +} + +func (c *diffCmd) flags(f *kit.FlagSet) { + f.BoolVar(&c.patch, "patch", false, "the format-patch mailbox rather than the plain diff") +} + +func (c *diffCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + url, err := diffURL(args) + if err != nil { + return err + } + fetch := cl.Diff + if c.patch { + fetch = cl.Patch + } + text, err := fetch(ctx, url) + if err != nil { + return err + } + _, err = io.WriteString(os.Stdout, text) + return err +} + +// diffURL turns the two shapes into the one page URL both diffs hang off. +func diffURL(args []string) (string, error) { + if len(args) >= 3 { + repo, err := gh.ResolveRepo(args[0]) + if err != nil { + return "", err + } + return fmt.Sprintf("%s/%s/compare/%s...%s", gh.BaseURL, repo, args[1], args[2]), nil + } + kind, id, err := gh.Classify(args[0]) + if err != nil { + return "", err + } + switch kind { + case gh.KindCommit, gh.KindCompare, gh.KindPR: + return gh.Locate(kind, id) + } + return "", errs.Usage("%q is a %s; diff needs a commit, a pull request, a compare URL, or a repository with two refs", args[0], kind) +} diff --git a/cli/root.go b/cli/root.go index 82be40f..def5d66 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,10 +1,10 @@ -// Package cli assembles the github command tree from the github domain -// on top of the any-cli/kit framework. +// Package cli assembles the github command tree from the gh domain on top of +// the any-cli/kit framework. package cli import ( "github.com/tamnd/any-cli/kit" - "github.com/tamnd/github-cli/github" + "github.com/tamnd/github-cli/gh" ) // Build metadata, set via -ldflags at release time. @@ -14,19 +14,29 @@ var ( Date = "unknown" ) -// NewApp assembles the kit application from the github domain. The domain's +// NewApp assembles the kit application from the gh domain. The domain's // Register installs the client factory and every operation, so the binary and a -// multi-domain host (ant) share one source of truth. kit.Run turns the App into -// the CLI, the serve surface, and the MCP tool surface. +// multi-domain host (ant, which blank-imports the package) share one source of +// truth. kit.Run turns the App into the CLI, plus the serve and mcp surfaces and +// the typed-error-to-exit-code mapping. // -// To add a command, declare it in github/ops.go with kit.Handle and it appears -// here automatically. Reach for app.AddCommand only for a verb that does not fit -// the emit-records shape. +// To add a command, declare it in gh/ops.go with kit.Handle and it appears here +// automatically. Reach for app.AddCommand only for a verb that does not fit the +// emit-records shape, the way the byte-plane commands below do not. func NewApp() *kit.App { - id := github.Domain{}.Info().Identity + id := gh.Domain{}.Info().Identity id.Version = Version - app := kit.New(id) - (github.Domain{}).Register(app) + // WithDefaults is how the site's own baseline reaches the resolved config. + // Without it the run would use the framework's numbers, which are tuned for + // an API with a published rate limit rather than for a CDN. + app := kit.New(id, kit.WithDefaults(gh.DomainDefaults)) + (gh.Domain{}).Register(app) + + app.AddCommand(newVersionCmd()) + app.AddCommand(newCatCmd()) + app.AddCommand(newReadmeCmd()) + app.AddCommand(newArchiveCmd()) + app.AddCommand(newDiffCmd()) return app } diff --git a/cli/version.go b/cli/version.go new file mode 100644 index 0000000..54a0fd3 --- /dev/null +++ b/cli/version.go @@ -0,0 +1,41 @@ +package cli + +import ( + "context" + "fmt" + "os" + "runtime" + + "github.com/tamnd/any-cli/kit" +) + +// versionCmd is an escape-hatch command: it prints build info rather than +// emitting records, so it does not fit the operation shape. fang also wires +// --version from the App identity; this adds a `version` subcommand and a +// --short form for scripts. +type versionCmd struct{ short bool } + +func newVersionCmd() kit.Command { + v := &versionCmd{} + return kit.Command{ + Use: "version", + Short: "Print version information", + Args: kit.NoArgs, + Flags: v.flags, + Run: v.run, + } +} + +func (v *versionCmd) flags(f *kit.FlagSet) { + f.BoolVar(&v.short, "short", false, "print just the version number") +} + +func (v *versionCmd) run(_ context.Context, _ []string) error { + if v.short { + _, _ = fmt.Fprintln(os.Stdout, Version) + return nil + } + _, _ = fmt.Fprintf(os.Stdout, "github %s (commit %s, built %s, %s/%s, %s)\n", + Version, Commit, Date, runtime.GOOS, runtime.GOARCH, runtime.Version()) + return nil +} diff --git a/gh/account.go b/gh/account.go index 811da05..6980ca5 100644 --- a/gh/account.go +++ b/gh/account.go @@ -326,17 +326,22 @@ func (o *Org) readAchievements(doc *html.Node) { // readReadme keeps both forms. The HTML is what GitHub rendered and the text is // what it says, and a knowledge-graph consumer wants the second while a viewer // wants the first. +// +// A user profile wraps its readme in a box named for the purpose. An +// organization's sits in a plain Box with nothing on it to match, so the +// search falls back to the markdown article, which both templates render and +// of which a profile page has exactly one. func (o *Org) readReadme(doc *html.Node) { - n := page.Find(doc, page.ProfileReadme) - if n == nil { - return + root := doc + if n := page.Find(doc, page.ProfileReadme); n != nil { + root = n } - body := page.Find(n, page.MarkdownBody) + body := page.Find(root, page.MarkdownBody) if body == nil { - body = n + return } o.ReadmeHTML = page.OuterHTML(body) - o.ReadmeText = page.Text(body) + o.ReadmeText = page.BlockText(body) } // --- small shared helpers --- diff --git a/gh/base.go b/gh/base.go index 9c13123..18c8706 100644 --- a/gh/base.go +++ b/gh/base.go @@ -120,6 +120,39 @@ func actor(login string) Actor { } } +// hrefPath reduces a link to its site-relative path, with no leading slash and +// no query or fragment. +// +// It exists because GitHub writes the same link two ways on two templates: +// "/BagToad" on a release page and "https://github.com/BagToad" on the release +// list. A decoder that trims a leading slash and stops there works on one of +// them and produces a login with a whole URL inside it on the other. +func hrefPath(href string) string { + s := strings.TrimSpace(href) + if i := strings.Index(s, "://"); i >= 0 { + _, rest, ok := strings.Cut(s[i+3:], "/") + if !ok { + return "" + } + s = rest + } + if i := strings.IndexAny(s, "?#"); i >= 0 { + s = s[:i] + } + return strings.Trim(s, "/") +} + +// actorFromHref builds an Actor from a profile link however the template wrote +// it. A link with more than one path segment is not a profile, so it gives an +// empty result rather than a login with a slash in it. +func actorFromHref(href string) Actor { + p := hrefPath(href) + if p == "" || strings.Contains(p, "/") { + return Actor{} + } + return actor(p) +} + // --- the data-loss guard --- // decodeExtra returns the keys of raw that v did not claim, minus the keys in diff --git a/gh/commit.go b/gh/commit.go index 705f621..3dd4779 100644 --- a/gh/commit.go +++ b/gh/commit.go @@ -754,15 +754,14 @@ func decodeReleaseSection(repo string, sec *html.Node, keepBody bool) Release { } } if a := page.Find(sec, page.Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "user"}); a != nil { - author := actor(strings.TrimPrefix(page.Attr(a, "href"), "/")) - rel.Author = &author + rel.Author = releaseAuthor(page.Attr(a, "href")) } if t := page.Find(sec, page.RelTimeEl); t != nil { rel.PublishedAt = parseTime(page.Attr(t, "datetime")) } if body := page.Find(sec, page.Sel{Class: "markdown-body"}); body != nil && keepBody { rel.BodyHTML = page.OuterHTML(body) - rel.Body = page.Text(body) + rel.Body = page.BlockText(body) } for _, a := range page.FindAll(sec, page.Sel{Tag: "a", Attr: "href", AttrContains: "/archive/refs/tags/"}) { href := page.Attr(a, "href") @@ -783,6 +782,27 @@ func decodeReleaseSection(repo string, sec *html.Node, keepBody bool) Release { // has something with class Label. var primaryContent = page.Sel{Tag: "div", Attr: "data-hpc"} +// releaseAuthor turns a publisher link into an actor, whichever of the two +// release templates wrote it. +// +// A release cut by a workflow links to /apps/github-actions, which is not a +// login: passing it through actor gives a profile URL that 404s. The app name +// is the useful half, and the type says why it has no profile. +func releaseAuthor(href string) *Actor { + p := hrefPath(href) + if p == "" { + return nil + } + if name, ok := strings.CutPrefix(p, "apps/"); ok { + return &Actor{Login: name, Type: "Bot", URL: BaseURL + "/apps/" + name} + } + if strings.Contains(p, "/") { + return nil + } + a := actor(p) + return &a +} + // decodeReleasePage reads the single-release template. // // The false return is a real answer: a tag that has no release, which happens @@ -838,16 +858,7 @@ func decodeReleasePage(repo string, doc *html.Node) (Release, bool) { // first user hovercard instead would pick a contributor avatar from the // footer, which is a different person and a wrong answer. if a := page.Find(root, page.Sel{Tag: "a", Class: "text-bold"}); a != nil { - href := strings.TrimPrefix(page.Attr(a, "href"), "/") - // A release cut by a workflow links to /apps/github-actions, which is - // not a login and would give a profile URL that 404s. The app name is - // the useful half and the type says why it has no profile. - if name, ok := strings.CutPrefix(href, "apps/"); ok { - rel.Author = &Actor{Login: name, Type: "Bot", URL: BaseURL + "/apps/" + name} - } else if href != "" { - author := actor(href) - rel.Author = &author - } + rel.Author = releaseAuthor(page.Attr(a, "href")) } if t := page.Find(root, page.RelTimeEl); t != nil { rel.PublishedAt = parseTime(page.Attr(t, "datetime")) @@ -860,7 +871,7 @@ func decodeReleasePage(repo string, doc *html.Node) (Release, bool) { } if body := page.Find(root, page.Sel{Class: "markdown-body"}); body != nil { rel.BodyHTML = page.OuterHTML(body) - rel.Body = page.Text(body) + rel.Body = page.BlockText(body) } for _, a := range page.FindAll(root, page.Sel{Tag: "a", Attr: "href", AttrContains: "/archive/refs/tags/"}) { href := page.Attr(a, "href") diff --git a/gh/contents.go b/gh/contents.go index 7cba62e..7455ae9 100644 --- a/gh/contents.go +++ b/gh/contents.go @@ -482,6 +482,27 @@ func (c *Client) Raw(ctx context.Context, repo, ref, path string) ([]byte, error return res.Body, nil } +// Download streams one file's bytes to w and reports how many it wrote. +// +// It exists next to Raw because the two have different costs. Raw buffers, so a +// caller who wants to look at the bytes gets them in one piece and gets the +// cache; Download does not buffer and does not cache, so a caller piping a +// hundred-megabyte binary through to a disk pays for none of it. +func (c *Client) Download(ctx context.Context, repo, ref, path string, w io.Writer) (int64, error) { + if _, _, ok := SplitRepo(repo); !ok { + return 0, usageBadID("repository", repo, "owner/name") + } + if ref == "" { + ref = "HEAD" + } + body, _, err := c.Stream(ctx, rawURL(repo, ref, strings.TrimPrefix(path, "/"))) + if err != nil { + return 0, err + } + defer body.Close() + return io.Copy(w, body) +} + // Archive streams a repository tarball or zipball from codeload. The caller // closes the reader. Nothing here is cached or buffered: an archive is measured // in tens of megabytes and belongs on a disk, not in a map. diff --git a/gh/domain.go b/gh/domain.go new file mode 100644 index 0000000..a7f58b0 --- /dev/null +++ b/gh/domain.go @@ -0,0 +1,143 @@ +package gh + +import ( + "context" + "net/http" + "path/filepath" + "time" + + "github.com/tamnd/any-cli/kit" +) + +// domain.go is the seam between this library and the kit framework. It declares +// what the site is called, how its addresses are parsed, and how a client is +// built from the resolved config. Every verb a person can type is registered in +// ops.go; nothing else in the package imports kit. + +// Domain is the kit driver for github.com. A blank import of this package +// enables it in any multi-domain host, the way a database driver registers +// itself, and the same Domain builds the single github binary. +type Domain struct{} + +func init() { kit.Register(Domain{}) } + +// Info names the domain and every hostname that means it. The extra hosts are +// not decoration: a pasted raw.githubusercontent.com or gist.github.com link is +// a github address and has to resolve here rather than fall through as an +// unknown site. +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: Scheme, + Aliases: []string{"gh"}, + Hosts: []string{ + "github.com", "www.github.com", + "raw.githubusercontent.com", "gist.github.com", + "gist.githubusercontent.com", "codeload.github.com", + }, + Identity: kit.Identity{ + Binary: "github", + Short: "Read all of GitHub as structured data, with no token", + Long: "github reads github.com and gives back records rather than pages.\n\n" + + "Every repository, user, organization, issue, pull request, discussion,\n" + + "commit, branch, tag, release, file, topic, gist, package, and marketplace\n" + + "action has a canonical github:// address, a typed record carrying every\n" + + "field its source returned, and edges to the other things it names.\n\n" + + "There is no API token anywhere in this tool and there will not be one.\n" + + "The unauthenticated REST API allows sixty requests an hour, which is not\n" + + "enough to read one organization, while the pages are behind a CDN and are\n" + + "faster than the API even where the API would work. The cost is that this\n" + + "is read-only and public-only. For anything else, use the official gh.", + Site: BaseURL, + Repo: "https://github.com/tamnd/github-cli", + }, + } +} + +// Classify satisfies kit.Resolver, so a URI typed at a multi-domain host and one +// typed at github are read by the same parser. +func (Domain) Classify(input string) (uriType, id string, err error) { + return Classify(input) +} + +// Locate satisfies kit.Resolver: the https location of one resource. +func (Domain) Locate(uriType, id string) (string, error) { + return Locate(uriType, id) +} + +// DomainDefaults overlays this site's baseline onto the framework's. GitHub +// publishes no rate limit for the pages, so these are chosen to be quieter than +// a person browsing with a few tabs open: eight requests a second across four +// workers. +func DomainDefaults(c *kit.Config) { + c.Rate = Defaults.Rate + c.Retries = Defaults.Retries + c.Workers = Defaults.Workers + c.Timeout = Defaults.Timeout +} + +// flags holds the domain's own global flags. kit resolves the framework globals +// (--limit, --rate, --timeout, --no-cache) itself; these are the ones only this +// tool has, and they are read once when the client is built. +// +// Package-level state is the framework's contract here: GlobalFlags binds to the +// domain's variables and the client factory reads them, and there is exactly one +// run per process. +var flags struct { + deep bool + jobs int + cache string +} + +// Register installs the client factory, the domain globals, and every +// operation. It does no I/O and is deterministic, so a host can call it at +// startup. +func (d Domain) Register(app *kit.App) { + app.SetClient(newClientFor) + app.GlobalFlags(bindFlags) + registerOps(app) +} + +func bindFlags(f *kit.FlagSet) { + f.BoolVar(&flags.deep, "deep", false, "also fetch the fragments a page defers, and merge what only they carry") + f.IntVarP(&flags.jobs, "jobs", "j", 0, "concurrent requests (0 = the default 4)") + f.StringVar(&flags.cache, "cache", "", "response cache directory (default under the data dir)") +} + +// newClientFor builds the one client a run shares. Every command reaches it +// through a kit:"inject" field, so pacing and the cache are shared across a +// whole pipeline rather than per command. +func newClientFor(_ context.Context, cfg kit.Config) (any, error) { + conf := Defaults + if cfg.UserAgent != "" { + conf.UserAgent = cfg.UserAgent + } + if cfg.Rate > 0 { + conf.Rate = cfg.Rate + } + if cfg.Retries > 0 { + conf.Retries = cfg.Retries + } + if cfg.Workers > 0 { + conf.Workers = cfg.Workers + } + if cfg.Timeout > 0 { + conf.Timeout = cfg.Timeout + } + conf.NoCache = cfg.NoCache + conf.CacheDir = filepath.Join(cfg.CacheDir, "http") + if flags.cache != "" { + conf.CacheDir = flags.cache + } + if flags.jobs > 0 { + conf.Workers = flags.jobs + } + conf.Deep = flags.deep + + c := NewClient(conf) + c.HTTP = &http.Client{Timeout: conf.Timeout} + return c, nil +} + +// timeoutFor is the per-command ceiling for the walks that can run long, used +// by the byte-plane commands that do not get a kit-managed context. +const timeoutFor = 10 * time.Minute diff --git a/gh/ops.go b/gh/ops.go new file mode 100644 index 0000000..33396bc --- /dev/null +++ b/gh/ops.go @@ -0,0 +1,1084 @@ +package gh + +import ( + "context" + "strconv" + "strings" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" +) + +// ops.go is the table of contents for the whole tool. Every verb a person can +// type is registered here and nowhere else, so the answer to "what can github +// do" is one file, and each registration is simultaneously a CLI subcommand, an +// HTTP route under `github serve`, and an MCP tool under `github mcp`. +// +// The handlers are thin on purpose. Anything with a decision in it belongs in +// the library next to the data it decides about; what is left here is reference +// resolution and one call. + +func registerOps(app *kit.App) { + registerReadOps(app) + registerSearchOps(app) + registerContentOps(app) + registerHistoryOps(app) + registerMetaOps(app) +} + +// --- reference resolution --- + +// ResolveRef resolves any accepted reference to the id for the kind a command +// names. +// +// Classify has two guesses in it: one bare word is a user, and one bare +// owner/name is a repository. Those are guesses because a pure function cannot +// tell a user from an organization or a repository from anything else without +// asking, so a guess yields to the command that names its own kind while an +// explicit URL or URI does not. That is what makes `github org golang` work and +// `github org https://github.com/golang/go` fail with a message that says why. +func ResolveRef(want, input string) (string, error) { + kind, id, err := Classify(input) + if err != nil { + return "", err + } + if kind == want { + return id, nil + } + if guessed(input, kind) { + return id, nil + } + // A sub-resource names its repository, so a file URL or an issue URL is a + // fine way to refer to the repository it lives in. + if want == KindRepo { + if repo, ok := RepoOf(kind, id); ok { + return repo, nil + } + } + return "", errs.Usage("%q is a %s, not a %s", input, kind, want) +} + +// guessed reports whether Classify was guessing rather than reading. Anything +// with a scheme in it was read: github.com states the kind in the path and a +// github:// URI states it outright. +func guessed(input, kind string) bool { + if strings.Contains(input, "://") { + return false + } + return kind == KindUser || kind == KindRepo || kind == KindIssue +} + +// ResolveRepo resolves any reference to the repository it belongs to. Commands +// that work on a repository use it so that a pasted file URL, issue URL, or +// release URL all name the repository they are part of, which is what makes +// `github commits ` work. +func ResolveRepo(input string) (string, error) { + return ResolveRef(KindRepo, input) +} + +// resolveThread resolves the two ways to name an issue, pull request, or +// discussion: a repository and a number, or one URL that already carries both. +// The second form is the one people have in their clipboard. +func resolveThread(want, ref string, num int) (repo string, number int, err error) { + kind, id, err := Classify(ref) + if err != nil { + return "", 0, err + } + if r, n, ok := SplitThreadID(id); ok && num == 0 { + // An issue URL and a pull URL are different paths, so a mismatch here + // is a real error rather than a guess to be forgiven. A bare + // owner/name#123 is a guess: nothing in it says which of the two it is. + if kind != want && !guessed(ref, kind) { + return "", 0, errs.Usage("%q is a %s, not a %s", ref, kind, want) + } + number, _ = strconv.Atoi(n) + return r, number, nil + } + repo, err = ResolveRepo(ref) + if err != nil { + return "", 0, err + } + if num <= 0 { + return "", 0, errs.Usage("%s needs a number, either as a second argument or in the URL", want) + } + return repo, num, nil +} + +// resolveRev resolves the two ways to name a thing that hangs off a git +// revision: a repository and a rev, or one URL carrying both. rev may be empty +// when the caller has a default for it. +func resolveRev(want, ref, rev string) (repo, out string, err error) { + kind, id, err := Classify(ref) + if err != nil { + return "", "", err + } + if r, v, ok := cutRev(id); ok && rev == "" { + if kind != want && !guessed(ref, kind) { + return "", "", errs.Usage("%q is a %s, not a %s", ref, kind, want) + } + return r, v, nil + } + repo, err = ResolveRepo(ref) + if err != nil { + return "", "", err + } + return repo, rev, nil +} + +// resolvePath resolves a repository and a path inside it, from either a blob or +// tree URL or a repository plus a path argument. +func resolvePath(ref, path, rev string) (repo, outRef, outPath string, err error) { + kind, id, err := Classify(ref) + if err != nil { + return "", "", "", err + } + if kind == KindFile || kind == KindTree { + r, v, p, ok := SplitPathID(id) + if ok { + if rev != "" { + v = rev + } + if path != "" { + p = path + } + return r, v, p, nil + } + } + repo, err = ResolveRepo(ref) + if err != nil { + return "", "", "", err + } + return repo, rev, strings.TrimPrefix(path, "/"), nil +} + +// emitEach sends a slice one record at a time. The client methods that return a +// slice do so because one document holds the whole answer; the command surface +// still wants records, and a single document is never large enough to be worth +// streaming through a channel. +func emitEach[T any](items []T, emit func(*T) error) error { + for i := range items { + if err := emit(&items[i]); err != nil { + return err + } + } + return nil +} + +// byValue adapts a client method that emits values to a handler that emits +// pointers. The client methods emit values because a record built inside a +// pager has no reason to escape to the heap; kit wants a pointer because the +// record may be rendered, stored, and serialised after the call returns. +func byValue[T any](emit func(*T) error) func(T) error { + return func(v T) error { return emit(&v) } +} + +// --- reading one thing --- + +type repoIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, a github.com URL, or a github:// URI"` + Readme bool `kit:"flag" help:"include the rendered README, which is most of the bytes"` + Deep bool `kit:"flag" help:"also fetch the language histogram and the dependent count"` +} + +type nameIn struct { + C *Client `kit:"inject"` + Name string `kit:"arg" help:"a login, a profile URL, or a github:// URI"` +} + +type threadIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a full thread URL that already carries the number"` + Num int `kit:"arg" help:"the number, when the reference does not carry one"` +} + +type bareRefIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"any github reference"` +} + +func registerReadOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repo", Group: "read", Single: true, URIType: KindRepo, Resolver: true, + Summary: "Read one repository with every field the page states", + Long: "The repository page carries more than the API does: the sidebar's about\n" + + "block, the topic list, the license name as rendered, the release the page\n" + + "is pointing at, and the counts for stars, forks, watchers, and open issues.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, URL, or github:// URI"}}, + }, getRepo) + + kit.Handle(app, kit.OpMeta{ + Name: "user", Group: "read", Single: true, URIType: KindUser, Resolver: true, + Summary: "Read one user profile", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, getUser) + + kit.Handle(app, kit.OpMeta{ + Name: "org", Group: "read", Single: true, URIType: KindOrg, Resolver: true, + Summary: "Read one organization", + Long: "An organization renders a different template from a user, so this returns\n" + + "the fields only that template has. For a name whose kind nobody knows yet,\n" + + "use `github user`, which reads whichever template answers and reports the\n" + + "kind it found.", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, getOrg) + + kit.Handle(app, kit.OpMeta{ + Name: "issue", Group: "read", Single: true, URIType: KindIssue, Resolver: true, + Summary: "Read one issue with its body, labels, and participants", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or an issue URL"}, + {Name: "num", Help: "issue number", Optional: true}, + }, + }, getIssue) + + kit.Handle(app, kit.OpMeta{ + Name: "pr", Group: "read", Single: true, URIType: KindPR, Resolver: true, + Aliases: []string{"pull"}, + Summary: "Read one pull request, merge state and review state included", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a pull request URL"}, + {Name: "num", Help: "pull request number", Optional: true}, + }, + }, getPull) + + kit.Handle(app, kit.OpMeta{ + Name: "discussion", Group: "read", Single: true, URIType: KindDiscussion, Resolver: true, + Summary: "Read one discussion, its category, and its answer", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a discussion URL"}, + {Name: "num", Help: "discussion number", Optional: true}, + }, + }, getDiscussion) + + kit.Handle(app, kit.OpMeta{ + Name: "commit", Group: "read", Single: true, URIType: KindCommit, Resolver: true, + Summary: "Read one commit, with its author, verification, and changed files", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a commit URL"}, + {Name: "sha", Help: "commit sha", Optional: true}, + }, + }, getCommit) + + kit.Handle(app, kit.OpMeta{ + Name: "release", Group: "read", Single: true, URIType: KindRelease, Resolver: true, + Summary: "Read one release by tag, or the latest one", + Long: "The tag defaults to latest, which github.com redirects to whatever that is\n" + + "today. Assets live behind a lazy fragment the page does not load until you\n" + + "scroll, which is why a release page can be 238 KB and show no downloads at\n" + + "all. Reading one release fetches the fragment, because a release without\n" + + "its downloads is not the thing you asked for. The list command makes that\n" + + "a flag, since there the cost is one request per release.", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a release URL"}, + {Name: "tag", Help: "tag name, or latest", Optional: true}, + }, + }, getRelease) + + kit.Handle(app, kit.OpMeta{ + Name: "compare", Group: "read", Single: true, URIType: KindCompare, Resolver: true, + Aliases: []string{"range"}, + Summary: "Compare two refs and list what changed between them", + Long: "Compare has no JSON route: the page answers Rails HTML however you ask.\n" + + "The patch does have everything, so this reads the git-format-patch mailbox\n" + + "and takes the commit list and the per-file changes out of it.", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a compare URL"}, + {Name: "base", Help: "the ref to compare from", Optional: true}, + {Name: "head", Help: "the ref to compare to", Optional: true}, + }, + }, getCompare) + + kit.Handle(app, kit.OpMeta{ + Name: "get", Group: "read", Single: true, + Summary: "Read whatever a reference points at", + Long: "get classifies the reference and dispatches to the right reader, which is\n" + + "what makes `github commits ... -o url | xargs -n1 github get` work across\n" + + "mixed kinds.", + Args: []kit.Arg{{Name: "ref", Help: "any github reference"}}, + }, getAny) +} + +func getRepo(ctx context.Context, in repoIn, emit func(*Repo) error) error { + id, err := ResolveRef(KindRepo, in.Ref) + if err != nil { + return err + } + r, err := in.C.Repo(ctx, id, RepoOptions{Deep: in.Deep || in.C.Deep, Readme: in.Readme}) + if err != nil { + return err + } + return emit(r) +} + +// getUser reads a profile without caring which template answers. Account +// reports the kind it found, so a name that turns out to be an organization +// comes back as one rather than as an error. +func getUser(ctx context.Context, in nameIn, emit func(*Account) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + a, err := in.C.Account(ctx, login) + if err != nil { + return err + } + return emit(a) +} + +func getOrg(ctx context.Context, in nameIn, emit func(*Org) error) error { + login, err := ResolveRef(KindOrg, in.Name) + if err != nil { + return err + } + o, err := in.C.Org(ctx, login) + if err != nil { + return err + } + return emit(o) +} + +func getIssue(ctx context.Context, in threadIn, emit func(*Issue) error) error { + repo, num, err := resolveThread(KindIssue, in.Ref, in.Num) + if err != nil { + return err + } + i, err := in.C.Issue(ctx, repo, num) + if err != nil { + return err + } + return emit(i) +} + +func getPull(ctx context.Context, in threadIn, emit func(*PullRequest) error) error { + repo, num, err := resolveThread(KindPR, in.Ref, in.Num) + if err != nil { + return err + } + p, err := in.C.PullRequest(ctx, repo, num) + if err != nil { + return err + } + return emit(p) +} + +func getDiscussion(ctx context.Context, in threadIn, emit func(*Discussion) error) error { + repo, num, err := resolveThread(KindDiscussion, in.Ref, in.Num) + if err != nil { + return err + } + d, err := in.C.Discussion(ctx, repo, num) + if err != nil { + return err + } + return emit(d) +} + +// commitIn has no --files flag for the same reason compareIn has none: the +// route ships the whole diff whether it is decoded or not, so the per-file list +// costs parsing rather than a request. +type commitIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a commit URL"` + SHA string `kit:"arg" help:"commit sha, when the reference does not carry one"` + Patch bool `kit:"flag" help:"also fetch the patch, for applying the change rather than describing it"` +} + +func getCommit(ctx context.Context, in commitIn, emit func(*Commit) error) error { + repo, sha, err := resolveRev(KindCommit, in.Ref, in.SHA) + if err != nil { + return err + } + if sha == "" { + return errs.Usage("commit needs a sha, either as a second argument or in the URL") + } + c, err := in.C.CommitInfo(ctx, repo, sha, CommitInfoOptions{Files: true, Patch: in.Patch}) + if err != nil { + return err + } + return emit(c) +} + +type releaseIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a release URL"` + Tag string `kit:"arg" help:"tag name, or latest"` +} + +func getRelease(ctx context.Context, in releaseIn, emit func(*Release) error) error { + repo, tag, err := resolveRev(KindRelease, in.Ref, in.Tag) + if err != nil { + return err + } + if tag == "" { + tag = "latest" + } + r, err := in.C.Release(ctx, repo, tag, ReleaseOptions{Assets: true, Body: true}) + if err != nil { + return err + } + return emit(r) +} + +// compareIn has no --files flag because the per-file list is free: the patch is +// already downloaded and parsing it costs nothing anyone would notice. --patch +// is a flag because keeping the raw stream on the record can be megabytes. +type compareIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a compare URL"` + Base string `kit:"arg" help:"the ref to compare from"` + Head string `kit:"arg" help:"the ref to compare to"` + Patch bool `kit:"flag" help:"keep the raw patch on the record"` +} + +func getCompare(ctx context.Context, in compareIn, emit func(*Compare) error) error { + repo, base, head, err := resolveCompare(in.Ref, in.Base, in.Head) + if err != nil { + return err + } + cmp, err := in.C.CompareRefs(ctx, repo, base, head, CompareOptions{Files: true, Patch: in.Patch}) + if err != nil { + return err + } + return emit(cmp) +} + +// resolveCompare accepts a compare URL, which carries both ends, or a +// repository and two refs. +func resolveCompare(ref, base, head string) (string, string, string, error) { + kind, id, err := Classify(ref) + if err != nil { + return "", "", "", err + } + if kind == KindCompare && base == "" { + repo, rng, ok := cutRev(id) + if !ok { + return "", "", "", errs.Usage("%q is not a range", ref) + } + // Three dots is the merge-base form and two is the direct diff. + // github.com accepts both and means different things by them, so the + // separator is kept as the caller wrote it. + for _, sep := range []string{"...", ".."} { + if a, b, found := strings.Cut(rng, sep); found { + return repo, a, b, nil + } + } + return "", "", "", errs.Usage("%q has no base...head in it", ref) + } + repo, err := ResolveRepo(ref) + if err != nil { + return "", "", "", err + } + if base == "" || head == "" { + return "", "", "", errs.Usage("compare needs a base and a head, or a compare URL that carries both") + } + return repo, base, head, nil +} + +// getAny dispatches on the kind the reference names. The kinds it declines are +// declined by name, because "not implemented yet" and "no such thing" are +// different answers and a script should be able to tell them apart. +func getAny(ctx context.Context, in bareRefIn, emit func(any) error) error { + kind, id, err := Classify(in.Ref) + if err != nil { + return err + } + rec, err := in.C.fetchOne(ctx, kind, id) + if err != nil { + return err + } + return emit(rec) +} + +// fetchOne reads one record of any kind by id. The graph walk and `github get` +// share it, so a kind that reads correctly in one reads correctly in both. +func (c *Client) fetchOne(ctx context.Context, kind, id string) (any, error) { + switch kind { + case KindRepo: + return c.Repo(ctx, id, RepoOptions{Deep: c.Deep}) + case KindUser: + return c.Account(ctx, id) + case KindOrg: + return c.Org(ctx, id) + case KindIssue, KindPR, KindDiscussion: + repo, n, ok := SplitThreadID(id) + if !ok { + return nil, errs.Usage("%q is not a thread id", id) + } + num, _ := strconv.Atoi(n) + switch kind { + case KindIssue: + return c.Issue(ctx, repo, num) + case KindPR: + return c.PullRequest(ctx, repo, num) + default: + return c.Discussion(ctx, repo, num) + } + case KindCommit: + repo, sha, ok := cutRev(id) + if !ok { + return nil, errs.Usage("%q is not a commit id", id) + } + return c.CommitInfo(ctx, repo, sha, CommitInfoOptions{}) + case KindRelease: + repo, tag, ok := cutRev(id) + if !ok { + return nil, errs.Usage("%q is not a release id", id) + } + return c.Release(ctx, repo, tag, ReleaseOptions{Assets: true, Body: true}) + case KindBranch, KindTag: + repo, name, ok := cutRev(id) + if !ok { + return nil, errs.Usage("%q is not a %s id", id, kind) + } + return c.oneRef(ctx, kind, repo, name) + case KindCompare: + repo, rng, ok := cutRev(id) + if !ok { + return nil, errs.Usage("%q is not a range", id) + } + base, head, found := strings.Cut(rng, "...") + if !found { + base, head, found = strings.Cut(rng, "..") + } + if !found { + return nil, errs.Usage("%q has no base...head in it", id) + } + return c.CompareRefs(ctx, repo, base, head, CompareOptions{Files: true}) + case KindFile: + repo, ref, path, ok := SplitPathID(id) + if !ok { + return nil, errs.Usage("%q is not a file id", id) + } + return c.Blob(ctx, repo, path, BlobOptions{Ref: ref}) + } + return nil, errs.Unsupported("reading a %s is not implemented yet", kind) +} + +// oneRef finds a single branch or tag by name. The git advertisement is one +// request for every ref in the repository, which beats paging the branches page +// looking for one row. +func (c *Client) oneRef(ctx context.Context, kind, repo, name string) (*GitRef, error) { + var found *GitRef + list := c.Branches + if kind == KindTag { + list = c.Tags + } + err := list(ctx, repo, RefOptions{Complete: true}, func(r GitRef) error { + if r.Name == name { + cp := r + found = &cp + } + return nil + }) + if err != nil { + return nil, err + } + if found == nil { + return nil, errs.NotFound("%s %s has no %s named %s", repo, kind, kind, name) + } + return found, nil +} + +// --- searching --- + +// searchIn is the query surface every search type shares. GitHub's search box +// takes qualifiers inline (repo:, org:, language:, is:open) and this passes the +// query through untouched, so anything that works in the box works here. The +// flags are sugar that appends a qualifier, for the ones people reach for often +// enough that quoting them gets old. +type searchIn struct { + C *Client `kit:"inject"` + Query string `kit:"arg" help:"a search query, with the same qualifiers the search box takes"` + Repo string `kit:"flag" help:"restrict to one repository, owner/name"` + Owner string `kit:"flag" help:"restrict to one user or organization"` + Language string `kit:"flag" help:"restrict to one language"` + Sort string `kit:"flag" help:"a sort qualifier, e.g. stars or updated"` + Limit int `kit:"flag,inherit"` +} + +// query folds the sugar flags into the one string search actually takes. +func (in searchIn) query() string { + q := in.Query + add := func(qualifier, value string) { + if value == "" { + return + } + q = strings.TrimSpace(q + " " + qualifier + ":" + value) + } + add("repo", in.Repo) + add("user", in.Owner) + add("language", in.Language) + add("sort", in.Sort) + return strings.TrimSpace(q) +} + +func registerSearchOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "repos", Group: "search", URIType: KindRepo, List: true, + Summary: "Search repositories", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listRepos) + + kit.Handle(app, kit.OpMeta{ + Name: "issues", Group: "search", URIType: KindIssue, List: true, + Summary: "Search issues", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listIssues) + + kit.Handle(app, kit.OpMeta{ + Name: "prs", Group: "search", URIType: KindPR, List: true, + Aliases: []string{"pulls"}, + Summary: "Search pull requests", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listPulls) + + kit.Handle(app, kit.OpMeta{ + Name: "users", Group: "search", URIType: KindUser, List: true, + Summary: "Search users and organizations", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listUsers) + + kit.Handle(app, kit.OpMeta{ + Name: "topics", Group: "search", URIType: KindTopic, List: true, + Summary: "Search topics", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listTopics) + + kit.Handle(app, kit.OpMeta{ + Name: "packages", Group: "search", URIType: KindPackage, List: true, + Summary: "Search published packages", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listPackages) + + kit.Handle(app, kit.OpMeta{ + Name: "wikis", Group: "search", URIType: KindWiki, List: true, + Summary: "Search wiki pages", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listWikis) + + kit.Handle(app, kit.OpMeta{ + Name: "actions", Group: "search", URIType: KindAction, List: true, + Summary: "Search the marketplace for actions", + Args: []kit.Arg{{Name: "query", Help: "a search query", Optional: true}}, + }, listActions) + + kit.Handle(app, kit.OpMeta{ + Name: "code", Group: "search", + Summary: "Search code, which needs a session and so does not work here", + Long: "Code search is the one search type that requires a signed-in session. It\n" + + "answers 200 with an empty result set to an anonymous request, which looks\n" + + "exactly like a query that found nothing, so this says so instead of\n" + + "returning zero hits and letting you conclude the query was wrong.", + Args: []kit.Arg{{Name: "query", Help: "a search query"}}, + }, listCode) + + kit.Handle(app, kit.OpMeta{ + Name: "search", Group: "search", + Summary: "Search every type at once", + Long: "Nine of GitHub's ten search types answer JSON to an anonymous request.\n" + + "This asks all of them, or the ones named by --type, and streams the\n" + + "records as they arrive.", + Args: []kit.Arg{{Name: "query", Help: "what to look for"}}, + }, searchAll) +} + +func listRepos(ctx context.Context, in searchIn, emit func(*Repo) error) error { + return in.C.SearchRepositories(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listIssues(ctx context.Context, in searchIn, emit func(*Thread) error) error { + return in.C.SearchIssuesAndPulls(ctx, in.query(), SearchIssues, in.Limit, byValue(emit)) +} + +func listPulls(ctx context.Context, in searchIn, emit func(*Thread) error) error { + return in.C.SearchIssuesAndPulls(ctx, in.query(), SearchPulls, in.Limit, byValue(emit)) +} + +func listUsers(ctx context.Context, in searchIn, emit func(*Account) error) error { + return in.C.SearchAccounts(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listTopics(ctx context.Context, in searchIn, emit func(*Topic) error) error { + return in.C.SearchTopicsBy(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listPackages(ctx context.Context, in searchIn, emit func(*Package) error) error { + return in.C.SearchPackagesBy(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listWikis(ctx context.Context, in searchIn, emit func(*WikiPage) error) error { + return in.C.SearchWikisBy(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listActions(ctx context.Context, in searchIn, emit func(*Action) error) error { + return in.C.SearchMarketplace(ctx, in.query(), in.Limit, byValue(emit)) +} + +func listCode(ctx context.Context, in searchIn, emit func(*File) error) error { + return in.C.SearchCodeBy(ctx, in.query(), in.Limit, byValue(emit)) +} + +type searchAllIn struct { + C *Client `kit:"inject"` + Query string `kit:"arg" help:"what to look for"` + Type []string `kit:"flag" help:"restrict to some types, e.g. repositories,issues"` + Limit int `kit:"flag,inherit"` +} + +func searchAll(ctx context.Context, in searchAllIn, emit func(any) error) error { + types := in.Type + if len(types) == 0 { + types = SearchTypes + } + // The per-type limit is the whole limit: a caller asking for ten of + // everything gets ten of each rather than ten split nine ways, which is + // what anyone piping this into a filter wants. + for _, t := range types { + if err := in.C.searchOne(ctx, t, in.Query, in.Limit, emit); err != nil { + return err + } + } + return nil +} + +func (c *Client) searchOne(ctx context.Context, typ, query string, limit int, emit func(any) error) error { + any1 := func(v any) error { return emit(v) } + switch typ { + case SearchRepos: + return c.SearchRepositories(ctx, query, limit, func(r Repo) error { return any1(&r) }) + case SearchIssues, SearchPulls: + return c.SearchIssuesAndPulls(ctx, query, typ, limit, func(t Thread) error { return any1(&t) }) + case SearchUsers: + return c.SearchAccounts(ctx, query, limit, func(a Account) error { return any1(&a) }) + case SearchCommits: + return c.SearchCommitsBy(ctx, query, limit, func(v Commit) error { return any1(&v) }) + case SearchDiscussions: + return c.SearchDiscussionsBy(ctx, query, limit, func(d Discussion) error { return any1(&d) }) + case SearchTopics: + return c.SearchTopicsBy(ctx, query, limit, func(t Topic) error { return any1(&t) }) + case SearchPackages: + return c.SearchPackagesBy(ctx, query, limit, func(p Package) error { return any1(&p) }) + case SearchWikis: + return c.SearchWikisBy(ctx, query, limit, func(w WikiPage) error { return any1(&w) }) + case SearchMarket: + return c.SearchMarketplace(ctx, query, limit, func(a Action) error { return any1(&a) }) + case SearchCode: + return c.SearchCodeBy(ctx, query, limit, func(f File) error { return any1(&f) }) + } + return errs.Usage("%q is not a search type; the types are %s", typ, strings.Join(SearchTypes, ", ")) +} + +// --- contents --- + +type treeIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a tree URL"` + Path string `kit:"arg" help:"a directory inside the repository"` + Rev string `kit:"flag,name=rev" help:"branch, tag, or sha; the default branch when empty"` + Recursive bool `kit:"flag" help:"walk subdirectories, one request each"` + Sizes bool `kit:"flag" help:"fill the byte size of each file, one request each"` + Limit int `kit:"flag,inherit"` +} + +type blobIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a blob URL"` + Path string `kit:"arg" help:"a file inside the repository"` + Rev string `kit:"flag,name=rev" help:"branch, tag, or sha; the default branch when empty"` + Content bool `kit:"flag" help:"include the bytes, fetched from raw"` + Styled bool `kit:"flag" help:"include the rendered lines and the syntax highlighting spans"` +} + +func registerContentOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "tree", Group: "contents", URIType: KindTree, List: true, + Aliases: []string{"ls"}, + Summary: "List a directory, or the whole tree", + Long: "There is no recursive parameter on this route, so --recursive is one\n" + + "request per directory. For a whole large repository, `github archive` is\n" + + "one request instead of hundreds.", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a tree URL"}, + {Name: "path", Help: "a directory inside the repository", Optional: true}, + }, + }, listTree) + + kit.Handle(app, kit.OpMeta{ + Name: "blob", Group: "contents", Single: true, URIType: KindFile, Resolver: true, + Aliases: []string{"file"}, + Summary: "Read one file's metadata, and its bytes on request", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a blob URL"}, + {Name: "path", Help: "a file inside the repository", Optional: true}, + }, + }, getBlob) + + kit.Handle(app, kit.OpMeta{ + Name: "symbols", Group: "contents", + Summary: "List the definitions GitHub extracted from a file", + Long: "GitHub runs a symbol extractor over every blob it renders and ships the\n" + + "result in the route payload. There is no unauthenticated REST equivalent\n" + + "anywhere. The extractor is asynchronous, so an empty list can mean the\n" + + "language is unsupported or that the analysis had not finished; the record\n" + + "says which, and this command reports it rather than guessing.", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a blob URL"}, + {Name: "path", Help: "a file inside the repository", Optional: true}, + }, + }, listSymbols) +} + +func listTree(ctx context.Context, in treeIn, emit func(*TreeEntry) error) error { + repo, ref, path, err := resolvePath(in.Ref, in.Path, in.Rev) + if err != nil { + return err + } + return in.C.Tree(ctx, repo, path, TreeOptions{ + Ref: ref, + Recursive: in.Recursive, + Sizes: in.Sizes, + Limit: in.Limit, + }, byValue(emit)) +} + +func getBlob(ctx context.Context, in blobIn, emit func(*File) error) error { + repo, ref, path, err := resolvePath(in.Ref, in.Path, in.Rev) + if err != nil { + return err + } + if path == "" { + return errs.Usage("blob needs a path, either as a second argument or in the URL") + } + f, err := in.C.Blob(ctx, repo, path, BlobOptions{Ref: ref, Content: in.Content, Styled: in.Styled}) + if err != nil { + return err + } + return emit(f) +} + +type symbolIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a blob URL"` + Path string `kit:"arg" help:"a file inside the repository"` + Rev string `kit:"flag,name=rev" help:"branch, tag, or sha; the default branch when empty"` +} + +func listSymbols(ctx context.Context, in symbolIn, emit func(*Symbol) error) error { + repo, ref, path, err := resolvePath(in.Ref, in.Path, in.Rev) + if err != nil { + return err + } + if path == "" { + return errs.Usage("symbols needs a path, either as a second argument or in the URL") + } + f, err := in.C.Blob(ctx, repo, path, BlobOptions{Ref: ref}) + if err != nil { + return err + } + // The path goes in the middle of these sentences rather than at the front, + // because the CLI title-cases the first word of an error and a path is the + // one thing that must not be title-cased. + switch f.SymbolsStatus { + case "not_analyzed": + return errs.Unsupported("GitHub does not extract symbols from the language %s is written in", path) + case "unavailable", "timed_out": + return errs.Network("GitHub's symbol analysis for %s had not finished; ask again in a moment", path) + } + return emitEach(f.Symbols, emit) +} + +// --- history --- + +type commitsIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` + Rev string `kit:"flag,name=rev" help:"branch, tag, or sha to walk from"` + Path string `kit:"flag" help:"limit history to one file or directory"` + Author string `kit:"flag" help:"a login, not an email address"` + Since string `kit:"flag" help:"only commits after this date, YYYY-MM-DD"` + Until string `kit:"flag" help:"only commits before this date, YYYY-MM-DD"` + PR int `kit:"flag,name=pr" help:"walk one pull request's commits instead of the branch"` + Limit int `kit:"flag,inherit"` +} + +type refsIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` + Complete bool `kit:"flag" help:"read the git advertisement: every ref in one request, with shas"` + Pulls bool `kit:"flag" help:"include refs/pull/*, which on a busy repository is most of the response"` + Limit int `kit:"flag,inherit"` +} + +type releasesIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` + Assets bool `kit:"flag" help:"fetch each release's lazy asset fragment, one request each"` + Body bool `kit:"flag" help:"keep the rendered release notes"` + Limit int `kit:"flag,inherit"` +} + +func registerHistoryOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "commits", Group: "history", URIType: KindCommit, List: true, + Aliases: []string{"log"}, + Summary: "Walk a repository's history", + Long: "Every filter here is a query parameter the route already understands, so\n" + + "the filtering happens on GitHub's side rather than after a full download.\n" + + "A page is 35 commits, and an unbounded walk of a large repository is\n" + + "thousands of requests, so set --limit unless you mean it.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listCommits) + + kit.Handle(app, kit.OpMeta{ + Name: "refs", Group: "history", List: true, + Summary: "List every ref in a repository", + Long: "Without --complete this reads the refs fragment, which is names only but\n" + + "is 6 KB where the advertisement is 588 KB. With it, one request returns\n" + + "every branch, tag, and pull head with its sha, and no cap.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listRefs) + + kit.Handle(app, kit.OpMeta{ + Name: "branches", Group: "history", URIType: KindBranch, List: true, + Summary: "List branches", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listBranches) + + kit.Handle(app, kit.OpMeta{ + Name: "tags", Group: "history", URIType: KindTag, List: true, + Summary: "List tags", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listTags) + + kit.Handle(app, kit.OpMeta{ + Name: "releases", Group: "history", URIType: KindRelease, List: true, + Summary: "List releases", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listReleases) + + kit.Handle(app, kit.OpMeta{ + Name: "timeline", Group: "history", List: true, + Summary: "List everything that happened on one issue or pull request", + Long: "The timeline is the comments and the events in one stream: labels applied,\n" + + "milestones set, commits referenced, reviews requested, the lot.", + Args: []kit.Arg{ + {Name: "ref", Help: "owner/name, or a thread URL"}, + {Name: "num", Help: "the number, when the reference does not carry one", Optional: true}, + }, + }, listTimeline) +} + +func listCommits(ctx context.Context, in commitsIn, emit func(*Commit) error) error { + repo, rev, err := resolveRev(KindCommit, in.Ref, in.Rev) + if err != nil { + return err + } + if in.PR > 0 { + return in.C.PullCommits(ctx, repo, in.PR, in.Limit, byValue(emit)) + } + return in.C.Commits(ctx, repo, CommitOptions{ + Ref: rev, + Path: in.Path, + Author: in.Author, + Since: in.Since, + Until: in.Until, + Limit: in.Limit, + }, byValue(emit)) +} + +func listRefs(ctx context.Context, in refsIn, emit func(*GitRef) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Refs(ctx, repo, in.options(), byValue(emit)) +} + +func listBranches(ctx context.Context, in refsIn, emit func(*GitRef) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Branches(ctx, repo, in.options(), byValue(emit)) +} + +func listTags(ctx context.Context, in refsIn, emit func(*GitRef) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Tags(ctx, repo, in.options(), byValue(emit)) +} + +func (in refsIn) options() RefOptions { + return RefOptions{Complete: in.Complete, Pulls: in.Pulls, Limit: in.Limit} +} + +func listReleases(ctx context.Context, in releasesIn, emit func(*Release) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Releases(ctx, repo, ReleaseOptions{ + Assets: in.Assets, + Body: in.Body, + Limit: in.Limit, + }, byValue(emit)) +} + +type timelineIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or a thread URL"` + Num int `kit:"arg" help:"the number, when the reference does not carry one"` + Limit int `kit:"flag,inherit"` +} + +func listTimeline(ctx context.Context, in timelineIn, emit func(*TimelineItem) error) error { + repo, num, err := resolveThread(KindIssue, in.Ref, in.Num) + if err != nil { + return err + } + return in.C.Timeline(ctx, repo, num, in.Limit, byValue(emit)) +} + +// --- meta --- + +func registerMetaOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "url", Group: "meta", Single: true, + Aliases: []string{"classify"}, + Summary: "Parse a reference and report what it names", + Long: "url does no network work. It takes anything a person might paste, a\n" + + "github.com URL, a github:// URI, owner/name, owner/name#123, a blob URL\n" + + "with a line anchor, and returns the kind, the canonical id, the URI, and\n" + + "the https location.", + Args: []kit.Arg{{Name: "ref", Help: "any github reference"}}, + }, parseRef) + + kit.Handle(app, kit.OpMeta{ + Name: "routes", Group: "meta", List: true, + Summary: "List which surface answers for which route", + Long: "This is the index the readers work from: for each route, the surface that\n" + + "answers it best and what to fall back to when that surface declines. A\n" + + "route that is not in the table is unsupported rather than guessed at.", + }, listRoutes) +} + +type parseIn struct { + Ref string `kit:"arg" help:"any github reference"` +} + +func parseRef(_ context.Context, in parseIn, emit func(*Ident) error) error { + id, err := Parse(in.Ref) + if err != nil { + return err + } + return emit(&id) +} + +type noIn struct{} + +func listRoutes(_ context.Context, _ noIn, emit func(*RouteInfo) error) error { + return emitEach(Routes, emit) +} diff --git a/gh/repo.go b/gh/repo.go index e47c724..e8577c7 100644 --- a/gh/repo.go +++ b/gh/repo.go @@ -297,6 +297,9 @@ func (r *Repo) readRepoRoute(raw json.RawMessage, keepReadme bool) bool { r.ReadmePath = f.Path if keepReadme { r.ReadmeHTML = f.RichText + // The payload has no plain-text form, and a README is the one + // field most consumers want as prose rather than as markup. + r.ReadmeText = page.FragmentText(f.RichText) } break } diff --git a/gh/thread.go b/gh/thread.go index f1026c5..b8d2f9b 100644 --- a/gh/thread.go +++ b/gh/thread.go @@ -939,6 +939,7 @@ func (rc routeCommit) commit(repo string) Commit { cm.setIdentity(KindCommit, repo+"@"+rc.OID) cm.Subject = rc.ShortMessage cm.BodyHTML = rc.BodyHTML + cm.Body = stripTags(rc.BodyHTML) cm.AuthoredAt = parseTime(rc.AuthoredDate) cm.CommittedAt = parseTime(rc.CommittedDate) for _, a := range rc.Authors { @@ -1038,7 +1039,7 @@ func readDiscussionHeader(d *Discussion, doc *html.Node) { } } if n := page.Find(doc, page.DiscussionAuthor); n != nil { - d.Author = actor(strings.TrimPrefix(page.Attr(n, "href"), "/")) + d.Author = actorFromHref(page.Attr(n, "href")) } if n := page.Find(doc, page.RelTimeEl); n != nil { d.CreatedAt = parseTime(page.Attr(n, "datetime")) @@ -1058,9 +1059,7 @@ func readDiscussionHeader(d *Discussion, doc *html.Node) { // the answer link, and the answer link's parent holds both. if parent := n.Parent; parent != nil { for _, a := range page.FindAll(parent, page.ProfileAnyLink) { - href := page.Attr(a, "href") - if strings.Count(strings.Trim(href, "/"), "/") == 0 && strings.HasPrefix(href, "/") { - who := actor(strings.TrimPrefix(href, "/")) + if who := actorFromHref(page.Attr(a, "href")); who.Login != "" { d.AnswerAuthor = &who break } @@ -1093,7 +1092,7 @@ func readDiscussionBody(d *Discussion, doc *html.Node) { } if body := page.Find(n, page.DiscussionBody); body != nil { d.BodyHTML = page.OuterHTML(body) - d.Body = page.Text(body) + d.Body = page.BlockText(body) } return } @@ -1125,7 +1124,11 @@ func readQAPage(d *Discussion, p *page.Page) { d.Title = e.Name } if e.Text != "" { + // Both halves move together. Letting the markup win here while the + // prose still came off the DOM would leave a record whose two body + // fields describe different text. d.BodyHTML = e.Text + d.Body = page.FragmentText(e.Text) } if e.UpvoteCount != nil { d.Upvotes = e.UpvoteCount diff --git a/gh/uri.go b/gh/uri.go index 5e80805..f2221db 100644 --- a/gh/uri.go +++ b/gh/uri.go @@ -365,10 +365,19 @@ func classifyBare(s string) (kind, id string, err error) { case 1: return KindRepo, s, nil default: - // owner/name/something. Ambiguous between wiki, label, milestone, and - // package, so it goes to the one whose ids are numeric when it is - // numeric and to a file path otherwise. + // owner/name/something. When "something" is one of github.com's own + // route words this is a URL with the host left off, and the URL parser + // already knows exactly what it means, so hand it over rather than + // guess. That is what makes `github url cli/cli/blob/trunk/go.mod` and + // the full URL agree, which they did not when this guessed first. p := strings.SplitN(s, "/", 3) + if word, _, _ := strings.Cut(p[2], "/"); routeWord[word] { + kind, id, _, err := classifyPath(s, "", s) + return kind, id, err + } + // Anything else is ambiguous between wiki, label, milestone, and + // package, so it goes to the one whose ids are numeric when it is + // numeric and to a wiki page otherwise. if isNumber(p[2]) { return KindMilestone, s, nil } @@ -376,6 +385,17 @@ func classifyBare(s string) (kind, id string, err error) { } } +// routeWord is the set of third segments that make a bare reference a route +// rather than a name. It is exactly the case list of the switch in +// classifyPath, and the two have to stay in step: a word here that the switch +// does not handle resolves to the repository instead of to the thing named. +var routeWord = map[string]bool{ + "issues": true, "pull": true, "pulls": true, "discussions": true, + "commit": true, "commits": true, "tree": true, "blob": true, "raw": true, + "blame": true, "releases": true, "labels": true, "milestone": true, + "wiki": true, "pkgs": true, "compare": true, "archive": true, +} + // Locate turns a kind and id back into the canonical github.com URL. // Locate(Classify(u)) is the canonical form of u, which is what makes -o url // safe to pipe back into the tool. diff --git a/github/api.go b/github/api.go deleted file mode 100644 index 5bba227..0000000 --- a/github/api.go +++ /dev/null @@ -1,232 +0,0 @@ -package github - -import ( - "context" - "fmt" - "time" -) - -// Trending returns trending repositories from github.com/trending. -// lang is optional (empty = all languages). -// since is "daily", "weekly", or "monthly" (default "daily"). -func (c *Client) Trending(ctx context.Context, lang, since string) ([]TrendingRepo, error) { - if since == "" { - since = "daily" - } - u := c.cfg.BaseURL + "/trending" - if lang != "" { - u += "/" + lang - } - if since != "daily" { - u += "?since=" + since - } - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseTrending(body), nil -} - -// GetUser fetches a GitHub user profile. -func (c *Client) GetUser(ctx context.Context, username string) (User, error) { - u := c.cfg.BaseURL + "/" + username - body, err := c.fetchHTML(ctx, u) - if err != nil { - return User{}, err - } - return ParseUser(body, username) -} - -// UserRepos lists a user's public repositories (30 per page). -func (c *Client) UserRepos(ctx context.Context, username string, page int) ([]Repo, error) { - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s?tab=repositories&page=%d", c.cfg.BaseURL, username, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseRepos(body, username), nil -} - -// GetRepo fetches a single repository's metadata. -func (c *Client) GetRepo(ctx context.Context, owner, repo string) (Repo, error) { - u := c.cfg.BaseURL + "/" + owner + "/" + repo - body, err := c.fetchHTML(ctx, u) - if err != nil { - return Repo{}, err - } - return ParseRepo(body, owner, repo) -} - -// Commits lists commits from the Atom feed. -func (c *Client) Commits(ctx context.Context, owner, repo, branch string) ([]Commit, error) { - if branch == "" { - branch = "main" - } - u := fmt.Sprintf("%s/%s/%s/commits/%s.atom", c.cfg.BaseURL, owner, repo, branch) - body, err := c.fetchAtom(ctx, u) - if err != nil { - return nil, err - } - return ParseAtomCommits(body) -} - -// Releases lists releases from the Atom feed. -func (c *Client) Releases(ctx context.Context, owner, repo string) ([]Release, error) { - u := fmt.Sprintf("%s/%s/%s/releases.atom", c.cfg.BaseURL, owner, repo) - body, err := c.fetchAtom(ctx, u) - if err != nil { - return nil, err - } - return ParseAtomReleases(body) -} - -// Tags lists tags from the Atom feed. -func (c *Client) Tags(ctx context.Context, owner, repo string) ([]Tag, error) { - u := fmt.Sprintf("%s/%s/%s/tags.atom", c.cfg.BaseURL, owner, repo) - body, err := c.fetchAtom(ctx, u) - if err != nil { - return nil, err - } - return ParseAtomTags(body) -} - -// Issues lists issues from the HTML page. -// state is "open", "closed", or "all". -func (c *Client) Issues(ctx context.Context, owner, repo, state string, page int) ([]Issue, error) { - if state == "" { - state = "open" - } - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s/%s/issues?state=%s&page=%d", c.cfg.BaseURL, owner, repo, state, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseIssues(body, owner, repo, state), nil -} - -// Pulls lists pull requests from the HTML page. -func (c *Client) Pulls(ctx context.Context, owner, repo, state string, page int) ([]PullRequest, error) { - if state == "" { - state = "open" - } - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s/%s/pulls?state=%s&page=%d", c.cfg.BaseURL, owner, repo, state, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParsePulls(body, owner, repo, state), nil -} - -// Readme fetches the README of a repository. -// If branch is empty, it tries "main" then "master". -func (c *Client) Readme(ctx context.Context, owner, repo, branch string) (FileContent, error) { - branches := []string{branch} - if branch == "" { - branches = []string{"main", "master"} - } - filenames := []string{"README.md", "README.rst", "README"} - - for _, br := range branches { - for _, fn := range filenames { - u := fmt.Sprintf("%s/%s/%s/%s/%s", c.cfg.RawBaseURL, owner, repo, br, fn) - body, err := c.fetchRaw(ctx, u) - if err != nil { - continue - } - return FileContent{ - Path: fn, - Content: body, - URL: u, - }, nil - } - } - return FileContent{}, fmt.Errorf("README not found for %s/%s", owner, repo) -} - -// File fetches a file from raw.githubusercontent.com. -func (c *Client) File(ctx context.Context, owner, repo, branch, path string) (FileContent, error) { - if branch == "" { - branch = "main" - } - u := fmt.Sprintf("%s/%s/%s/%s/%s", c.cfg.RawBaseURL, owner, repo, branch, path) - body, err := c.fetchRaw(ctx, u) - if err != nil { - return FileContent{}, err - } - return FileContent{ - Path: path, - Content: body, - URL: u, - }, nil -} - -// Search searches repositories on github.com/search. -// GitHub may throttle search from datacenter IPs. -func (c *Client) Search(ctx context.Context, query string, page int) ([]SearchRepo, error) { - if page <= 0 { - page = 1 - } - // extra courtesy delay for search to avoid 429 - if page > 1 && c.cfg.SearchWait > 0 { - wait := c.cfg.SearchWait * time.Duration(page-1) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(wait): - } - } - u := fmt.Sprintf("%s/search?q=%s&type=repositories&p=%d", c.cfg.BaseURL, query, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseSearch(body), nil -} - -// Followers lists a user's followers. -func (c *Client) Followers(ctx context.Context, username string, page int) ([]User, error) { - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s?tab=followers&page=%d", c.cfg.BaseURL, username, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseFollowers(body), nil -} - -// Following lists users that a user follows. -func (c *Client) Following(ctx context.Context, username string, page int) ([]User, error) { - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s?tab=following&page=%d", c.cfg.BaseURL, username, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseFollowing(body), nil -} - -// Stars lists repositories starred by a user. -func (c *Client) Stars(ctx context.Context, username string, page int) ([]StarredRepo, error) { - if page <= 0 { - page = 1 - } - u := fmt.Sprintf("%s/%s?tab=stars&page=%d", c.cfg.BaseURL, username, page) - body, err := c.fetchHTML(ctx, u) - if err != nil { - return nil, err - } - return ParseStars(body), nil -} diff --git a/github/domain.go b/github/domain.go deleted file mode 100644 index 22000fb..0000000 --- a/github/domain.go +++ /dev/null @@ -1,58 +0,0 @@ -package github - -import ( - "context" - - "github.com/tamnd/any-cli/kit" -) - -// init registers the github domain so a multi-domain host (ant) can load it -// with a blank import. -func init() { kit.Register(Domain{}) } - -// Domain is the GitHub scraper driver. It carries no state; the per-run client -// is built by the factory Register hands kit. -type Domain struct{} - -// Info describes the scheme, the hostnames a pasted link is matched against, and -// the identity reused for the binary's help and version. -func (Domain) Info() kit.DomainInfo { - return kit.DomainInfo{ - Scheme: "github", - Hosts: []string{"github.com", "raw.githubusercontent.com"}, - Identity: kit.Identity{ - Binary: "github", - Short: "A command-line for GitHub (scrapes HTML, no API key needed).", - Long: `github reads public GitHub data by scraping HTML pages and Atom feeds. -No API token is required. No rate limit from the official REST API. - -github is an independent tool and is not affiliated with GitHub or Microsoft.`, - Site: "https://github.com", - Repo: "https://github.com/tamnd/github-cli", - }, - } -} - -// Register installs the client factory and every operation onto app. -func (Domain) Register(app *kit.App) { - app.SetClient(newClientFromConfig) - RegisterOps(app) -} - -// newClientFromConfig builds a Client from the kit-resolved Config. -func newClientFromConfig(_ context.Context, cfg kit.Config) (any, error) { - c := DefaultConfig() - if cfg.UserAgent != "" { - c.UserAgent = cfg.UserAgent - } - if cfg.Rate > 0 { - c.Rate = cfg.Rate - } - if cfg.Retries > 0 { - c.Retries = cfg.Retries - } - if cfg.Timeout > 0 { - c.Timeout = cfg.Timeout - } - return NewClient(c), nil -} diff --git a/github/github.go b/github/github.go deleted file mode 100644 index 0560cea..0000000 --- a/github/github.go +++ /dev/null @@ -1,213 +0,0 @@ -// Package github is the scraper library behind the github CLI. -// It reads public GitHub data from HTML pages, Atom feeds, and -// raw.githubusercontent.com. No API key or authentication is required. -// -// github is an independent tool and is not affiliated with GitHub or Microsoft. -package github - -import ( - "context" - "fmt" - "io" - "net/http" - "strconv" - "sync" - "time" -) - -const ( - defaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - defaultRawUA = "go-http-client/1.1" - defaultRate = 500 * time.Millisecond - defaultTimeout = 30 * time.Second - defaultRetries = 3 -) - -// ErrRateLimit is returned when GitHub returns HTTP 429 on search. -var ErrRateLimit = fmt.Errorf("rate limited by GitHub search; try again later") - -// Config holds constructor parameters for Client. -type Config struct { - BaseURL string // "https://github.com" - RawBaseURL string // "https://raw.githubusercontent.com" - UserAgent string - Rate time.Duration - Retries int - Timeout time.Duration - SearchWait time.Duration // extra delay before search pages; default 3s -} - -// DefaultConfig returns sensible defaults. -func DefaultConfig() Config { - return Config{ - BaseURL: "https://github.com", - RawBaseURL: "https://raw.githubusercontent.com", - UserAgent: defaultUA, - Rate: defaultRate, - Retries: defaultRetries, - Timeout: defaultTimeout, - SearchWait: 3 * time.Second, - } -} - -// Client scrapes public GitHub data. -type Client struct { - cfg Config - http *http.Client - mu sync.Mutex - last time.Time -} - -// NewClient creates a Client with the given Config. -func NewClient(cfg Config) *Client { - return &Client{ - cfg: cfg, - http: &http.Client{Timeout: cfg.Timeout}, - } -} - -// fetchHTML fetches a GitHub HTML page with browser-like headers. -func (c *Client) fetchHTML(ctx context.Context, u string) (string, error) { - headers := map[string]string{ - "User-Agent": c.cfg.UserAgent, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - } - b, status, err := c.do(ctx, u, headers) - if err != nil { - return "", err - } - if status == http.StatusNotFound { - return "", fmt.Errorf("not found: %s", u) - } - if status == http.StatusTooManyRequests { - return "", ErrRateLimit - } - if status != http.StatusOK { - return "", fmt.Errorf("http %d: %s", status, u) - } - return string(b), nil -} - -// fetchAtom fetches an Atom feed. -func (c *Client) fetchAtom(ctx context.Context, u string) (string, error) { - headers := map[string]string{ - "User-Agent": c.cfg.UserAgent, - "Accept": "application/atom+xml, application/xml;q=0.9, */*;q=0.8", - } - b, status, err := c.do(ctx, u, headers) - if err != nil { - return "", err - } - if status == http.StatusNotFound { - return "", fmt.Errorf("not found: %s", u) - } - if status != http.StatusOK { - return "", fmt.Errorf("http %d: %s", status, u) - } - return string(b), nil -} - -// fetchRaw fetches raw content from raw.githubusercontent.com. -func (c *Client) fetchRaw(ctx context.Context, u string) (string, error) { - headers := map[string]string{ - "User-Agent": defaultRawUA, - } - b, status, err := c.do(ctx, u, headers) - if err != nil { - return "", err - } - if status == http.StatusNotFound { - return "", fmt.Errorf("not found: %s", u) - } - if status != http.StatusOK { - return "", fmt.Errorf("http %d: %s", status, u) - } - return string(b), nil -} - -// do is the low-level sender: paces requests, then retries on 429/5xx. -// Returns (body, statusCode, error). -func (c *Client) do(ctx context.Context, u string, headers map[string]string) ([]byte, int, error) { - var lastErr error - var lastStatus int - for attempt := 0; attempt <= c.cfg.Retries; attempt++ { - if attempt > 0 { - wait := time.Duration(1< 0 { - select { - case <-ctx.Done(): - return nil, resp.StatusCode, ctx.Err() - case <-time.After(time.Duration(secs) * time.Second): - } - } - } - lastStatus = resp.StatusCode - lastErr = fmt.Errorf("http %d", resp.StatusCode) - continue - } - - // retry on 5xx - if resp.StatusCode >= 500 { - lastStatus = resp.StatusCode - lastErr = fmt.Errorf("http %d", resp.StatusCode) - continue - } - - return body, resp.StatusCode, nil - } - if lastErr != nil { - return nil, lastStatus, fmt.Errorf("fetch %s: %w (after %d retries)", u, lastErr, c.cfg.Retries) - } - return nil, lastStatus, fmt.Errorf("fetch %s: http %d (after %d retries)", u, lastStatus, c.cfg.Retries) -} - -// pace enforces the minimum gap between outbound requests. -func (c *Client) pace(ctx context.Context) { - c.mu.Lock() - defer c.mu.Unlock() - if c.cfg.Rate <= 0 { - return - } - if wait := c.cfg.Rate - time.Since(c.last); wait > 0 { - select { - case <-ctx.Done(): - case <-time.After(wait): - } - } - c.last = time.Now() -} diff --git a/github/github_test.go b/github/github_test.go deleted file mode 100644 index 224235c..0000000 --- a/github/github_test.go +++ /dev/null @@ -1,588 +0,0 @@ -package github - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// ── parser unit tests ──────────────────────────────────────────────────────── - -const trendingFixture = ` - - - - - -` - -func TestParseTrending(t *testing.T) { - repos := ParseTrending(trendingFixture) - if len(repos) != 2 { - t.Fatalf("want 2 repos, got %d", len(repos)) - } - r := repos[0] - if r.Rank != 1 { - t.Errorf("rank: want 1, got %d", r.Rank) - } - if r.FullName != "golang/go" { - t.Errorf("full_name: want golang/go, got %q", r.FullName) - } - if r.Stars != 123456 { - t.Errorf("stars: want 123456, got %d", r.Stars) - } - if r.Language != "Go" { - t.Errorf("language: want Go, got %q", r.Language) - } - if r.PeriodStars != 42 { - t.Errorf("period_stars: want 42, got %d", r.PeriodStars) - } - if r.URL != "https://github.com/golang/go" { - t.Errorf("url: got %q", r.URL) - } - - r2 := repos[1] - if r2.Rank != 2 { - t.Errorf("rank[1]: want 2, got %d", r2.Rank) - } - if r2.FullName != "kubernetes/kubernetes" { - t.Errorf("full_name[1]: got %q", r2.FullName) - } -} - -const userFixture = ` - - -Linus Torvalds - -@linux -kernel.org - - 230000 - - - 0 - - - 6 - - -` - -func TestParseUser(t *testing.T) { - u, err := ParseUser(userFixture, "torvalds") - if err != nil { - t.Fatal(err) - } - if u.Login != "torvalds" { - t.Errorf("login: want torvalds, got %q", u.Login) - } - if u.Name != "Linus Torvalds" { - t.Errorf("name: want 'Linus Torvalds', got %q", u.Name) - } - if u.URL != "https://github.com/torvalds" { - t.Errorf("url: got %q", u.URL) - } -} - -const reposFixture = ` - - - - -` - -func TestParseRepos(t *testing.T) { - repos := ParseRepos(reposFixture, "torvalds") - if len(repos) != 2 { - t.Fatalf("want 2 repos, got %d", len(repos)) - } - r := repos[0] - if r.FullName != "torvalds/linux" { - t.Errorf("full_name: want torvalds/linux, got %q", r.FullName) - } - if r.Description != "Linux kernel source tree" { - t.Errorf("description: got %q", r.Description) - } - if r.Language != "C" { - t.Errorf("language: want C, got %q", r.Language) - } - if r.Stars != 182000 { - t.Errorf("stars: want 182000, got %d", r.Stars) - } -} - -const atomCommitsFixture = ` - - - tag:github.com,2008:Grit::Commit/abc1234567890abcdef - Fix memory leak in parser - 2024-01-15T12:00:00Z - Jane Doejane@example.com - - - - tag:github.com,2008:Grit::Commit/def9876543210fedcba - Add test coverage for edge cases - 2024-01-14T08:00:00Z - John Smithjohn@example.com - - -` - -func TestParseAtomCommits(t *testing.T) { - commits, err := ParseAtomCommits(atomCommitsFixture) - if err != nil { - t.Fatal(err) - } - if len(commits) != 2 { - t.Fatalf("want 2 commits, got %d", len(commits)) - } - c := commits[0] - if c.SHA != "abc1234" { - t.Errorf("sha: want abc1234, got %q", c.SHA) - } - if c.Message != "Fix memory leak in parser" { - t.Errorf("message: got %q", c.Message) - } - if c.Author != "Jane Doe" { - t.Errorf("author: got %q", c.Author) - } - if c.Date != "2024-01-15T12:00:00Z" { - t.Errorf("date: got %q", c.Date) - } - if c.URL != "https://github.com/foo/bar/commit/abc1234567890abcdef" { - t.Errorf("url: got %q", c.URL) - } - - c2 := commits[1] - if c2.Author != "John Smith" { - t.Errorf("author[1]: got %q", c2.Author) - } -} - -const atomReleasesFixture = ` - - - tag:github.com,2008:Repository/12345:v1.23.0 - Go 1.23.0 - 2024-08-13T18:00:00Z - 2024-08-13T18:00:00Z - golang - - - - tag:github.com,2008:Repository/12345:v1.22.5 - Go 1.22.5 - 2024-07-01T18:00:00Z - 2024-07-01T18:00:00Z - golang - - -` - -func TestParseAtomReleases(t *testing.T) { - releases, err := ParseAtomReleases(atomReleasesFixture) - if err != nil { - t.Fatal(err) - } - if len(releases) != 2 { - t.Fatalf("want 2 releases, got %d", len(releases)) - } - r := releases[0] - if r.Tag != "v1.23.0" { - t.Errorf("tag: want v1.23.0, got %q", r.Tag) - } - if r.Name != "Go 1.23.0" { - t.Errorf("name: got %q", r.Name) - } - if r.Author != "golang" { - t.Errorf("author: got %q", r.Author) - } - if r.Published != "2024-08-13T18:00:00Z" { - t.Errorf("published: got %q", r.Published) - } -} - -const atomTagsFixture = ` - - - v1.23.0 - 2024-08-13T18:00:00Z - - - - v1.22.5 - 2024-07-01T18:00:00Z - - -` - -func TestParseAtomTags(t *testing.T) { - tags, err := ParseAtomTags(atomTagsFixture) - if err != nil { - t.Fatal(err) - } - if len(tags) != 2 { - t.Fatalf("want 2 tags, got %d", len(tags)) - } - if tags[0].Name != "v1.23.0" { - t.Errorf("name: want v1.23.0, got %q", tags[0].Name) - } - if tags[0].Updated != "2024-08-13T18:00:00Z" { - t.Errorf("updated: got %q", tags[0].Updated) - } - if tags[1].Name != "v1.22.5" { - t.Errorf("name[1]: got %q", tags[1].Name) - } -} - -const issuesFixture = ` - - -
    - Fix crash on empty input - Jan 15 - opened by janedoe - bug -
    -
    - Add support for generics - Jan 10 - opened by johnsmith - enhancement - 5 comments -
    - -` - -func TestParseIssues(t *testing.T) { - issues := ParseIssues(issuesFixture, "golang", "go", "open") - if len(issues) < 1 { - t.Fatalf("want at least 1 issue, got %d", len(issues)) - } - iss := issues[0] - if iss.Number != 42 { - t.Errorf("number: want 42, got %d", iss.Number) - } - if !strings.Contains(iss.Title, "crash") && !strings.Contains(iss.Title, "empty") { - t.Errorf("title should mention crash or empty, got %q", iss.Title) - } -} - -const searchFixture = ` - - - -

    The Go programming language

    -Go -112,000 stars -Jan 15 - -

    Empowering everyone to build reliable and efficient software.

    -Rust -90,000 stars -Jan 14 - -` - -func TestParseSearch(t *testing.T) { - results := ParseSearch(searchFixture) - if len(results) < 1 { - t.Fatalf("want at least 1 result, got %d", len(results)) - } - r := results[0] - if r.FullName != "golang/go" { - t.Errorf("full_name: want golang/go, got %q", r.FullName) - } - if r.Stars != 112000 { - t.Errorf("stars: want 112000, got %d", r.Stars) - } -} - -const followersFixture = ` - - -janedoe -Jane Doe -johnsmith -John Smith - -` - -func TestParseFollowers(t *testing.T) { - users := ParseFollowers(followersFixture) - if len(users) < 2 { - t.Fatalf("want at least 2 followers, got %d", len(users)) - } - if users[0].Login != "janedoe" { - t.Errorf("login: want janedoe, got %q", users[0].Login) - } - if users[1].Login != "johnsmith" { - t.Errorf("login[1]: want johnsmith, got %q", users[1].Login) - } -} - -const starsFixture = ` - - -
    -

    golang/go

    -

    The Go programming language

    - Go - 123,456 -
    -
    -

    rust-lang/rust

    -

    Empowering everyone to build reliable and efficient software.

    - Rust - 90,000 -
    - -` - -func TestParseStars(t *testing.T) { - repos := ParseStars(starsFixture) - if len(repos) < 2 { - t.Fatalf("want at least 2 starred repos, got %d", len(repos)) - } - r := repos[0] - if r.FullName != "golang/go" { - t.Errorf("full_name: want golang/go, got %q", r.FullName) - } - if r.Language != "Go" { - t.Errorf("language: want Go, got %q", r.Language) - } -} - -// ── cleanInt tests ─────────────────────────────────────────────────────────── - -func TestCleanInt(t *testing.T) { - cases := []struct { - in string - want int - }{ - {"12,345", 12345}, - {"1,234,567", 1234567}, - {"3.2k", 3200}, - {"5k", 5000}, - {"0", 0}, - {"", 0}, - {"abc", 0}, - {"100", 100}, - } - for _, tc := range cases { - got := cleanInt(tc.in) - if got != tc.want { - t.Errorf("cleanInt(%q): want %d, got %d", tc.in, tc.want, got) - } - } -} - -// ── HTTP integration tests ─────────────────────────────────────────────────── - -func TestClientTrending(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, "/trending") { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(trendingFixture)) - })) - defer ts.Close() - - cfg := DefaultConfig() - cfg.BaseURL = ts.URL - cfg.Rate = 0 // no pacing in tests - c := NewClient(cfg) - - repos, err := c.Trending(context.Background(), "", "daily") - if err != nil { - t.Fatal(err) - } - if len(repos) != 2 { - t.Fatalf("want 2 repos, got %d", len(repos)) - } - if repos[0].FullName != "golang/go" { - t.Errorf("full_name: got %q", repos[0].FullName) - } -} - -func TestClientRetry429(t *testing.T) { - attempt := 0 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempt++ - if attempt == 1 { - w.Header().Set("Retry-After", "0") - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(trendingFixture)) - })) - defer ts.Close() - - cfg := DefaultConfig() - cfg.BaseURL = ts.URL - cfg.Rate = 0 - cfg.Timeout = 5 * time.Second - c := NewClient(cfg) - - repos, err := c.Trending(context.Background(), "", "daily") - if err != nil { - t.Fatal(err) - } - if len(repos) != 2 { - t.Errorf("want 2 repos after retry, got %d", len(repos)) - } - if attempt < 2 { - t.Errorf("want at least 2 attempts, got %d", attempt) - } -} - -func TestClientRateLimit(t *testing.T) { - times := make([]time.Time, 0, 3) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - times = append(times, time.Now()) - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(trendingFixture)) - })) - defer ts.Close() - - cfg := DefaultConfig() - cfg.BaseURL = ts.URL - cfg.Rate = 100 * time.Millisecond - c := NewClient(cfg) - - ctx := context.Background() - for i := 0; i < 3; i++ { - _, err := c.Trending(ctx, "", "daily") - if err != nil { - t.Fatal(err) - } - } - - if len(times) < 3 { - t.Fatalf("want 3 requests, got %d", len(times)) - } - for i := 1; i < len(times); i++ { - gap := times[i].Sub(times[i-1]) - if gap < 90*time.Millisecond { - t.Errorf("gap between request %d and %d: %v < 90ms (rate not enforced)", i-1, i, gap) - } - } -} - -func TestClientAtomCommits(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, ".atom") { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/atom+xml") - _, _ = w.Write([]byte(atomCommitsFixture)) - })) - defer ts.Close() - - cfg := DefaultConfig() - cfg.BaseURL = ts.URL - cfg.Rate = 0 - c := NewClient(cfg) - - commits, err := c.Commits(context.Background(), "foo", "bar", "main") - if err != nil { - t.Fatal(err) - } - if len(commits) != 2 { - t.Fatalf("want 2 commits, got %d", len(commits)) - } - if commits[0].SHA != "abc1234" { - t.Errorf("sha: want abc1234, got %q", commits[0].SHA) - } -} - -func TestClientReadme(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "README.md") { - _, _ = w.Write([]byte("# Hello\n\nThis is the README.")) - return - } - http.NotFound(w, r) - })) - defer ts.Close() - - cfg := DefaultConfig() - cfg.RawBaseURL = ts.URL - cfg.Rate = 0 - c := NewClient(cfg) - - fc, err := c.Readme(context.Background(), "owner", "repo", "main") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(fc.Content, "Hello") { - t.Errorf("content should contain Hello, got %q", fc.Content) - } - if fc.Path != "README.md" { - t.Errorf("path: want README.md, got %q", fc.Path) - } -} diff --git a/github/ids.go b/github/ids.go deleted file mode 100644 index c4af9ea..0000000 --- a/github/ids.go +++ /dev/null @@ -1,37 +0,0 @@ -package github - -import ( - "fmt" - "path" - "strings" -) - -// ParseRepoSlug splits an "owner/repo" string into its two parts. -// Returns an error if the string is empty, has no slash, or has more than -// one slash (e.g. "owner/repo/extra"). -func ParseRepoSlug(slug string) (owner, repo string, err error) { - slug = strings.TrimSpace(slug) - if slug == "" { - return "", "", fmt.Errorf("repo slug is empty") - } - parts := strings.SplitN(slug, "/", 3) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repo %q: must be owner/repo", slug) - } - return parts[0], parts[1], nil -} - -// repoURL returns the canonical GitHub HTML URL for a repo. -func repoURL(owner, repo string) string { - return "https://github.com/" + owner + "/" + repo -} - -// userURL returns the canonical GitHub HTML URL for a user. -func userURL(username string) string { - return "https://github.com/" + username -} - -// lastPathSegment returns the last non-empty segment of a URL path. -func lastPathSegment(u string) string { - return path.Base(u) -} diff --git a/github/ids_test.go b/github/ids_test.go deleted file mode 100644 index bb370c1..0000000 --- a/github/ids_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package github - -import "testing" - -func TestParseRepoSlug(t *testing.T) { - cases := []struct { - in string - wantOwner string - wantRepo string - wantErr bool - }{ - {"golang/go", "golang", "go", false}, - {"torvalds/linux", "torvalds", "linux", false}, - {"owner/repo-name", "owner", "repo-name", false}, - {"", "", "", true}, - {"noslash", "", "", true}, - {"too/many/slashes", "", "", true}, - {"/noleadingslash", "", "", true}, - {"trailingslash/", "", "", true}, - } - for _, tc := range cases { - owner, repo, err := ParseRepoSlug(tc.in) - if tc.wantErr { - if err == nil { - t.Errorf("ParseRepoSlug(%q): want error, got owner=%q repo=%q", tc.in, owner, repo) - } - continue - } - if err != nil { - t.Errorf("ParseRepoSlug(%q): unexpected error: %v", tc.in, err) - continue - } - if owner != tc.wantOwner { - t.Errorf("ParseRepoSlug(%q): owner want %q, got %q", tc.in, tc.wantOwner, owner) - } - if repo != tc.wantRepo { - t.Errorf("ParseRepoSlug(%q): repo want %q, got %q", tc.in, tc.wantRepo, repo) - } - } -} diff --git a/github/ops.go b/github/ops.go deleted file mode 100644 index 317a59a..0000000 --- a/github/ops.go +++ /dev/null @@ -1,418 +0,0 @@ -package github - -import ( - "context" - - "github.com/tamnd/any-cli/kit" -) - -// RegisterOps installs all 15 GitHub operations onto app. -func RegisterOps(app *kit.App) { - - // trending: top trending repositories - kit.Handle(app, kit.OpMeta{ - Name: "trending", - Group: "read", - List: true, - Summary: "List trending GitHub repositories", - Long: "Fetches the github.com/trending page and returns repository cards.", - }, func(ctx context.Context, in trendingIn, emit func(TrendingRepo) error) error { - results, err := in.Client.Trending(ctx, in.Lang, in.Since) - if err != nil { - return err - } - for _, r := range results { - if err := emit(r); err != nil { - return err - } - } - return nil - }) - - // user: GitHub user profile - kit.Handle(app, kit.OpMeta{ - Name: "user", - Group: "read", - Single: true, - Summary: "Show a GitHub user profile", - Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, - }, func(ctx context.Context, in userIn, emit func(User) error) error { - u, err := in.Client.GetUser(ctx, in.Username) - if err != nil { - return err - } - return emit(u) - }) - - // repos: list a user's public repos - kit.Handle(app, kit.OpMeta{ - Name: "repos", - Group: "read", - List: true, - Summary: "List a user's public repositories", - Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, - }, func(ctx context.Context, in reposIn, emit func(Repo) error) error { - results, err := in.Client.UserRepos(ctx, in.Username, in.Page) - if err != nil { - return err - } - for _, r := range results { - if err := emit(r); err != nil { - return err - } - } - return nil - }) - - // repo: single repository metadata - kit.Handle(app, kit.OpMeta{ - Name: "repo", - Group: "read", - Single: true, - Summary: "Show a repository's metadata", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in repoIn, emit func(Repo) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - r, err := in.Client.GetRepo(ctx, owner, repoName) - if err != nil { - return err - } - return emit(r) - }) - - // commits: commits from the Atom feed - kit.Handle(app, kit.OpMeta{ - Name: "commits", - Group: "read", - List: true, - Summary: "List recent commits (from Atom feed)", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in commitsIn, emit func(Commit) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - results, err := in.Client.Commits(ctx, owner, repoName, in.Branch) - if err != nil { - return err - } - for _, c := range results { - if err := emit(c); err != nil { - return err - } - } - return nil - }) - - // releases: releases from the Atom feed - kit.Handle(app, kit.OpMeta{ - Name: "releases", - Group: "read", - List: true, - Summary: "List releases (from Atom feed)", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in releasesIn, emit func(Release) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - results, err := in.Client.Releases(ctx, owner, repoName) - if err != nil { - return err - } - for _, r := range results { - if err := emit(r); err != nil { - return err - } - } - return nil - }) - - // tags: tags from the Atom feed - kit.Handle(app, kit.OpMeta{ - Name: "tags", - Group: "read", - List: true, - Summary: "List tags (from Atom feed)", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in tagsIn, emit func(Tag) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - results, err := in.Client.Tags(ctx, owner, repoName) - if err != nil { - return err - } - for _, t := range results { - if err := emit(t); err != nil { - return err - } - } - return nil - }) - - // issues: open issues list - kit.Handle(app, kit.OpMeta{ - Name: "issues", - Group: "read", - List: true, - Summary: "List issues for a repository", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in issuesIn, emit func(Issue) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - results, err := in.Client.Issues(ctx, owner, repoName, in.State, in.Page) - if err != nil { - return err - } - for _, iss := range results { - if err := emit(iss); err != nil { - return err - } - } - return nil - }) - - // pulls: pull requests list - kit.Handle(app, kit.OpMeta{ - Name: "pulls", - Group: "read", - List: true, - Summary: "List pull requests for a repository", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in pullsIn, emit func(PullRequest) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - results, err := in.Client.Pulls(ctx, owner, repoName, in.State, in.Page) - if err != nil { - return err - } - for _, pr := range results { - if err := emit(pr); err != nil { - return err - } - } - return nil - }) - - // readme: fetch README content - kit.Handle(app, kit.OpMeta{ - Name: "readme", - Group: "read", - Single: true, - Summary: "Fetch the README of a repository", - Args: []kit.Arg{{Name: "repo", Help: "owner/repo slug"}}, - }, func(ctx context.Context, in readmeIn, emit func(FileContent) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - fc, err := in.Client.Readme(ctx, owner, repoName, in.Branch) - if err != nil { - return err - } - return emit(fc) - }) - - // file: fetch any file from a repo - kit.Handle(app, kit.OpMeta{ - Name: "file", - Group: "read", - Single: true, - Summary: "Fetch a file from a repository", - Args: []kit.Arg{ - {Name: "repo", Help: "owner/repo slug"}, - {Name: "path", Help: "file path in the repository"}, - }, - }, func(ctx context.Context, in fileIn, emit func(FileContent) error) error { - owner, repoName, err := ParseRepoSlug(in.Repo) - if err != nil { - return err - } - fc, err := in.Client.File(ctx, owner, repoName, in.Branch, in.Path) - if err != nil { - return err - } - return emit(fc) - }) - - // search: search repositories - kit.Handle(app, kit.OpMeta{ - Name: "search", - Group: "read", - List: true, - Summary: "Search GitHub repositories", - Long: "Scrapes github.com/search. May be rate-limited from datacenter IPs (exit 5).", - Args: []kit.Arg{{Name: "query", Help: "search query"}}, - }, func(ctx context.Context, in searchIn, emit func(SearchRepo) error) error { - results, err := in.Client.Search(ctx, in.Query, in.Page) - if err != nil { - return err - } - for _, r := range results { - if err := emit(r); err != nil { - return err - } - } - return nil - }) - - // followers: list a user's followers - kit.Handle(app, kit.OpMeta{ - Name: "followers", - Group: "read", - List: true, - Summary: "List a user's followers", - Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, - }, func(ctx context.Context, in followersIn, emit func(User) error) error { - results, err := in.Client.Followers(ctx, in.Username, in.Page) - if err != nil { - return err - } - for _, u := range results { - if err := emit(u); err != nil { - return err - } - } - return nil - }) - - // following: list users that a user follows - kit.Handle(app, kit.OpMeta{ - Name: "following", - Group: "read", - List: true, - Summary: "List users that a user follows", - Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, - }, func(ctx context.Context, in followingIn, emit func(User) error) error { - results, err := in.Client.Following(ctx, in.Username, in.Page) - if err != nil { - return err - } - for _, u := range results { - if err := emit(u); err != nil { - return err - } - } - return nil - }) - - // stars: list starred repositories for a user - kit.Handle(app, kit.OpMeta{ - Name: "stars", - Group: "read", - List: true, - Summary: "List repositories starred by a user", - Args: []kit.Arg{{Name: "username", Help: "GitHub username"}}, - }, func(ctx context.Context, in starsIn, emit func(StarredRepo) error) error { - results, err := in.Client.Stars(ctx, in.Username, in.Page) - if err != nil { - return err - } - for _, r := range results { - if err := emit(r); err != nil { - return err - } - } - return nil - }) -} - -// ── input structs ────────────────────────────────────────────────────────── - -type trendingIn struct { - Client *Client `kit:"inject"` - Lang string `kit:"flag" help:"language filter (e.g. go, python, c++)"` - Since string `kit:"flag" help:"time window: daily, weekly, monthly" default:"daily"` -} - -type userIn struct { - Client *Client `kit:"inject"` - Username string `kit:"arg" help:"GitHub username"` -} - -type reposIn struct { - Client *Client `kit:"inject"` - Username string `kit:"arg" help:"GitHub username"` - Page int `kit:"flag" help:"page number (30 repos per page)" default:"1"` -} - -type repoIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` -} - -type commitsIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` - Branch string `kit:"flag" help:"branch name" default:"main"` -} - -type releasesIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` -} - -type tagsIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` -} - -type issuesIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` - State string `kit:"flag" help:"open, closed, or all" default:"open"` - Page int `kit:"flag" help:"page number" default:"1"` -} - -type pullsIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` - State string `kit:"flag" help:"open, closed, or all" default:"open"` - Page int `kit:"flag" help:"page number" default:"1"` -} - -type readmeIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` - Branch string `kit:"flag" help:"branch name (default: try main then master)"` -} - -type fileIn struct { - Client *Client `kit:"inject"` - Repo string `kit:"arg" help:"owner/repo slug"` - Path string `kit:"arg" help:"file path in the repository"` - Branch string `kit:"flag" help:"branch name" default:"main"` -} - -type searchIn struct { - Client *Client `kit:"inject"` - Query string `kit:"arg" help:"search query"` - Page int `kit:"flag" help:"page number" default:"1"` -} - -type followersIn struct { - Client *Client `kit:"inject"` - Username string `kit:"arg" help:"GitHub username"` - Page int `kit:"flag" help:"page number" default:"1"` -} - -type followingIn struct { - Client *Client `kit:"inject"` - Username string `kit:"arg" help:"GitHub username"` - Page int `kit:"flag" help:"page number" default:"1"` -} - -type starsIn struct { - Client *Client `kit:"inject"` - Username string `kit:"arg" help:"GitHub username"` - Page int `kit:"flag" help:"page number" default:"1"` -} diff --git a/github/parse.go b/github/parse.go deleted file mode 100644 index 848a80c..0000000 --- a/github/parse.go +++ /dev/null @@ -1,712 +0,0 @@ -package github - -import ( - "encoding/xml" - "html" - "regexp" - "strconv" - "strings" -) - -// ── Atom wire types ────────────────────────────────────────────────────────── - -type atomFeed struct { - XMLName xml.Name `xml:"feed"` - Entries []atomEntry `xml:"entry"` -} - -type atomEntry struct { - ID string `xml:"id"` - Title string `xml:"title"` - Published string `xml:"published"` - Updated string `xml:"updated"` - Author struct { - Name string `xml:"name"` - Email string `xml:"email"` - } `xml:"author"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - Content string `xml:"content"` -} - -// ── compile-time regexes ───────────────────────────────────────────────────── - -// Trending -var ( - reTrendingArticle = regexp.MustCompile(`(?s)]*class="[^"]*Box-row[^"]*"[^>]*>(.*?)`) - reTrendingLink = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"`) - reTrendingDesc = regexp.MustCompile(`(?s)]*class="[^"]*col-9[^"]*"[^>]*>(.*?)

    `) - reTrendingLang = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>\s*([^<]+?)\s*<`) - reTrendingStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*(?:<[^>]+>)*\s*([0-9,]+)`) - reTrendingForks = regexp.MustCompile(`href="[^"]+/network/members"[^>]*>\s*(?:<[^>]+>)*\s*([0-9,]+)`) - reTrendingPeriod = regexp.MustCompile(`([0-9,]+)\s+stars?\s+(?:today|this week|this month)`) -) - -// User profile -var ( - reUserName = regexp.MustCompile(`(?:itemprop="name"|class="[^"]*p-name[^"]*")[^>]*>\s*([^<\n]+?)\s*<`) - reUserBio = regexp.MustCompile(`class="[^"]*p-note[^"]*"[^>]*>\s*]*>\s*(.*?)\s*`) - reUserBioAlt = regexp.MustCompile(`class="[^"]*p-note[^"]*"[^>]*>([^<]+)<`) - reUserCompany = regexp.MustCompile(`class="[^"]*p-org[^"]*"[^>]*>(?:<[^>]+>)*\s*([^<\n]+?)\s*(?:]*>(?:[^<]*<[^/][^>]*>)*\s*([^<]+?)\s*<`) - reUserEmail = regexp.MustCompile(`class="[^"]*u-email[^"]*"[^>]*>([^<]+)<`) - reUserBlog = regexp.MustCompile(`href="(https?://[^"]+)"[^>]*rel="nofollow me"`) - reUserFollowers = regexp.MustCompile(`tab=followers"[^>]*>\s*]*>([\d,k]+)<`) - reUserFollowing = regexp.MustCompile(`tab=following"[^>]*>\s*]*>([\d,k]+)<`) - reUserRepos = regexp.MustCompile(`tab=repositories"[^>]*>\s*]*>([\d,k]+)<`) -) - -// Repo list (repos tab) — each repo in an
  • block -var ( - reRepoItem = regexp.MustCompile(`(?s)]*itemprop="owns"[^>]*>(.*?)
  • `) - reRepoItemLink = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"`) - reRepoItemDesc = regexp.MustCompile(`(?s)itemprop="description"[^>]*>\s*(.*?)\s*]*>([^<]+)<`) - reRepoItemStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*([0-9,k]+)`) - reRepoItemForks = regexp.MustCompile(`href="[^"]+/network/members"[^>]*>\s*([0-9,k]+)`) - reRepoItemDate = regexp.MustCompile(`]+datetime="([^"]+)"`) -) - -// Single repo page -var ( - reRepoDesc = regexp.MustCompile(`(?s)class="[^"]*f4 my-3[^"]*"[^>]*>\s*(.*?)\s*

    `) - reRepoDescAlt = regexp.MustCompile(`(?s)class="[^"]*about-description[^"]*"[^>]*>\s*(.*?)\s*

    `) - reRepoLang = regexp.MustCompile(`class="[^"]*color-fg-default[^"]*"\s+itemprop="programmingLanguage"[^>]*>([^<]+)<`) - reRepoLangAlt = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`) - reRepoStars = regexp.MustCompile(`href="/[^/]+/[^/]+/stargazers[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) - reRepoForks = regexp.MustCompile(`href="/[^/]+/[^/]+/forks[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) - reRepoTopics = regexp.MustCompile(`class="[^"]*topic-tag[^"]*"[^>]*>\s*([^<]+?)\s*<`) - reRepoLicense = regexp.MustCompile(`/blob/[^"]*LICENSE[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([^<]+?)\s*<`) - reRepoBranch = regexp.MustCompile(`data-menu-button[^>]*>\s*(?:<[^>]+>)*\s*([^\s<]+)\s*(?:<|$)`) - reRepoFork = regexp.MustCompile(`Forked from`) - reRepoArchive = regexp.MustCompile(`(?i)archived`) - reRepoIssues = regexp.MustCompile(`href="/[^/]+/[^/]+/issues"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) - reRepoWatchers = regexp.MustCompile(`href="/[^/]+/[^/]+/watchers[^"]*"[^>]*>(?:[^<]*<[^>]+>)*\s*([0-9,]+)`) -) - -// Issues -var ( - reIssueBlock = regexp.MustCompile(`(?s)id="issue_(\d+)"[^>]*>(.*?)(?:id="issue_\d+"|\s*\s*)`) - reIssueTitle = regexp.MustCompile(`class="Link--primary[^"]*"[^>]*href="([^"]+)"[^>]*>\s*\n?\s*([^<\n]+)`) - reIssueDate = regexp.MustCompile(`]+datetime="([^"]+)"`) - reIssueAuthor = regexp.MustCompile(`opened by\s*]*>([^<]+)<`) - reIssueLabel = regexp.MustCompile(`class="[^"]*IssueLabel[^"]*"[^>]*>([^<]+)<`) - reIssueComments = regexp.MustCompile(`(\d+)\s+comment`) -) - -// Pull requests -var ( - rePRTitle = regexp.MustCompile(`class="Link--primary[^"]*"[^>]*href="(/[^/]+/[^/]+/pull/(\d+))"[^>]*>\s*\n?\s*([^<\n]+)`) -) - -// Search -var ( - reSearchName = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"[^>]*class="v-align-middle`) - reSearchNameB = regexp.MustCompile(`class="v-align-middle[^"]*"[^>]*href="/([^/"]+/[^/"]+)"`) - reSearchDesc = regexp.MustCompile(`(?s)]*class="[^"]*mb-1[^"]*"[^>]*>\s*(.*?)\s*

    `) - reSearchStars = regexp.MustCompile(`([0-9,]+)\s+stars?`) - reSearchLang = regexp.MustCompile(`(?s)]*class="[^"]*search-match[^"]*"[^>]*>([^<]+)<`) - reSearchDate = regexp.MustCompile(`]+datetime="([^"]+)"`) -) - -// Followers/Following -var ( - reFollowerLogin = regexp.MustCompile(`data-hovercard-type="user"[^>]*href="/([^"]+)"`) - reFollowerName = regexp.MustCompile(`class="[^"]*Link--secondary[^"]*"[^>]*>([^<]+)<`) -) - -// Stars tab -var ( - reStarName = regexp.MustCompile(`href="/([^/"]+/[^/"]+)"[^>]*class="[^"]*Link--primary`) - reStarNameB = regexp.MustCompile(`class="[^"]*Link--primary[^"]*"[^>]*href="/([^/"]+/[^/"]+)"`) - reStarDesc = regexp.MustCompile(`(?s)]*class="[^"]*col-9[^"]*"[^>]*>(.*?)

    `) - reStarLang = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`) - reStarStars = regexp.MustCompile(`href="[^"]+/stargazers"[^>]*>\s*([0-9,]+)`) -) - -// ── helpers ────────────────────────────────────────────────────────────────── - -// cleanInt parses a comma-formatted or k-suffixed integer string. -// "12,345" → 12345; "3.2k" → 3200; returns 0 on failure. -func cleanInt(s string) int { - s = strings.TrimSpace(s) - if s == "" { - return 0 - } - // handle k suffix - if strings.HasSuffix(s, "k") || strings.HasSuffix(s, "K") { - f, err := strconv.ParseFloat(strings.ReplaceAll(s[:len(s)-1], ",", ""), 64) - if err != nil { - return 0 - } - return int(f * 1000) - } - s = strings.ReplaceAll(s, ",", "") - n, _ := strconv.Atoi(s) - return n -} - -// cleanStr strips HTML tags, decodes HTML entities, and trims whitespace. -func cleanStr(s string) string { - // strip tags - reTag := regexp.MustCompile(`<[^>]+>`) - s = reTag.ReplaceAllString(s, " ") - // decode entities - s = html.UnescapeString(s) - // collapse whitespace - reWS := regexp.MustCompile(`\s+`) - s = reWS.ReplaceAllString(s, " ") - return strings.TrimSpace(s) -} - -// extractSHA extracts the commit SHA from a GitHub Atom entry ID. -// IDs look like: tag:github.com,2008:Grit::Commit/abc1234567890 -func extractSHA(id string) string { - if idx := strings.LastIndex(id, "/"); idx >= 0 { - return id[idx+1:] - } - return id -} - -// first returns the first capture group match, or "". -func first(re *regexp.Regexp, s string) string { - m := re.FindStringSubmatch(s) - if m == nil || len(m) < 2 { - return "" - } - return cleanStr(m[1]) -} - -// ── ParseTrending ──────────────────────────────────────────────────────────── - -// ParseTrending parses the github.com/trending HTML page. -func ParseTrending(body string) []TrendingRepo { - articles := reTrendingArticle.FindAllStringSubmatch(body, -1) - out := make([]TrendingRepo, 0, len(articles)) - for i, m := range articles { - block := m[1] - - fullName := first(reTrendingLink, block) - if fullName == "" { - continue - } - - desc := "" - if dm := reTrendingDesc.FindStringSubmatch(block); dm != nil { - desc = cleanStr(dm[1]) - } - - lang := first(reTrendingLang, block) - stars := 0 - if sm := reTrendingStars.FindStringSubmatch(block); sm != nil { - stars = cleanInt(sm[1]) - } - forks := 0 - if fm := reTrendingForks.FindStringSubmatch(block); fm != nil { - forks = cleanInt(fm[1]) - } - period := 0 - if pm := reTrendingPeriod.FindStringSubmatch(block); pm != nil { - period = cleanInt(pm[1]) - } - - out = append(out, TrendingRepo{ - Rank: i + 1, - FullName: fullName, - Description: desc, - Language: lang, - Stars: stars, - Forks: forks, - PeriodStars: period, - URL: "https://github.com/" + fullName, - }) - } - return out -} - -// ── ParseUser ──────────────────────────────────────────────────────────────── - -// ParseUser parses a github.com/{username} profile page. -func ParseUser(body, username string) (User, error) { - name := first(reUserName, body) - - bio := "" - if bm := reUserBio.FindStringSubmatch(body); bm != nil { - bio = cleanStr(bm[1]) - } else if bm2 := reUserBioAlt.FindStringSubmatch(body); bm2 != nil { - bio = cleanStr(bm2[1]) - } - - company := first(reUserCompany, body) - location := first(reUserLocation, body) - email := first(reUserEmail, body) - blog := first(reUserBlog, body) - - followers := 0 - if fm := reUserFollowers.FindStringSubmatch(body); fm != nil { - followers = cleanInt(fm[1]) - } - following := 0 - if fm := reUserFollowing.FindStringSubmatch(body); fm != nil { - following = cleanInt(fm[1]) - } - repos := 0 - if rm := reUserRepos.FindStringSubmatch(body); rm != nil { - repos = cleanInt(rm[1]) - } - - return User{ - Login: username, - Name: name, - Bio: bio, - Company: company, - Location: location, - Email: email, - Blog: blog, - Followers: followers, - Following: following, - Repos: repos, - URL: userURL(username), - }, nil -} - -// ── ParseRepos ─────────────────────────────────────────────────────────────── - -// ParseRepos parses the github.com/{username}?tab=repositories HTML page. -func ParseRepos(body, username string) []Repo { - items := reRepoItem.FindAllStringSubmatch(body, -1) - out := make([]Repo, 0, len(items)) - for _, m := range items { - block := m[1] - - fullName := first(reRepoItemLink, block) - if fullName == "" { - continue - } - - desc := "" - if dm := reRepoItemDesc.FindStringSubmatch(block); dm != nil { - desc = cleanStr(dm[1]) - } - - lang := first(reRepoItemLang, block) - stars := 0 - if sm := reRepoItemStars.FindStringSubmatch(block); sm != nil { - stars = cleanInt(sm[1]) - } - forks := 0 - if fm := reRepoItemForks.FindStringSubmatch(block); fm != nil { - forks = cleanInt(fm[1]) - } - pushedAt := first(reRepoItemDate, block) - - out = append(out, Repo{ - FullName: fullName, - Description: desc, - Language: lang, - Stars: stars, - Forks: forks, - PushedAt: pushedAt, - URL: "https://github.com/" + fullName, - }) - } - return out -} - -// ── ParseRepo ──────────────────────────────────────────────────────────────── - -// ParseRepo parses the github.com/{owner}/{repo} page. -func ParseRepo(body, owner, repo string) (Repo, error) { - fullName := owner + "/" + repo - - desc := "" - if dm := reRepoDesc.FindStringSubmatch(body); dm != nil { - desc = cleanStr(dm[1]) - } else if dm2 := reRepoDescAlt.FindStringSubmatch(body); dm2 != nil { - desc = cleanStr(dm2[1]) - } - - lang := "" - if lm := reRepoLang.FindStringSubmatch(body); lm != nil { - lang = cleanStr(lm[1]) - } else if lm2 := reRepoLangAlt.FindStringSubmatch(body); lm2 != nil { - lang = cleanStr(lm2[1]) - } - - stars := 0 - if sm := reRepoStars.FindStringSubmatch(body); sm != nil { - stars = cleanInt(sm[1]) - } - forks := 0 - if fm := reRepoForks.FindStringSubmatch(body); fm != nil { - forks = cleanInt(fm[1]) - } - openIssues := 0 - if im := reRepoIssues.FindStringSubmatch(body); im != nil { - openIssues = cleanInt(im[1]) - } - watchers := 0 - if wm := reRepoWatchers.FindStringSubmatch(body); wm != nil { - watchers = cleanInt(wm[1]) - } - - // topics - topicMatches := reRepoTopics.FindAllStringSubmatch(body, -1) - topics := make([]string, 0, len(topicMatches)) - for _, tm := range topicMatches { - t := cleanStr(tm[1]) - if t != "" { - topics = append(topics, t) - } - } - - license := first(reRepoLicense, body) - branch := first(reRepoBranch, body) - if branch == "" { - branch = "main" - } - - isFork := reRepoFork.MatchString(body) - isArchived := reRepoArchive.MatchString(body) - - return Repo{ - FullName: fullName, - Description: desc, - Language: lang, - Stars: stars, - Forks: forks, - Watchers: watchers, - OpenIssues: openIssues, - DefaultBranch: branch, - License: license, - Topics: topics, - Fork: isFork, - Archived: isArchived, - URL: repoURL(owner, repo), - }, nil -} - -// ── Atom feeds ─────────────────────────────────────────────────────────────── - -// ParseAtomCommits parses the /commits/{branch}.atom feed. -func ParseAtomCommits(body string) ([]Commit, error) { - var feed atomFeed - if err := xml.Unmarshal([]byte(body), &feed); err != nil { - return nil, err - } - out := make([]Commit, 0, len(feed.Entries)) - for _, e := range feed.Entries { - sha := extractSHA(e.ID) - if len(sha) > 7 { - sha = sha[:7] - } - out = append(out, Commit{ - SHA: sha, - Message: strings.TrimSpace(e.Title), - Author: strings.TrimSpace(e.Author.Name), - Date: e.Published, - URL: e.Link.Href, - }) - } - return out, nil -} - -// ParseAtomReleases parses the /releases.atom feed. -func ParseAtomReleases(body string) ([]Release, error) { - var feed atomFeed - if err := xml.Unmarshal([]byte(body), &feed); err != nil { - return nil, err - } - out := make([]Release, 0, len(feed.Entries)) - for _, e := range feed.Entries { - tag := lastPathSegment(e.Link.Href) - out = append(out, Release{ - Tag: tag, - Name: strings.TrimSpace(e.Title), - Author: strings.TrimSpace(e.Author.Name), - Published: e.Published, - URL: e.Link.Href, - }) - } - return out, nil -} - -// ParseAtomTags parses the /tags.atom feed. -func ParseAtomTags(body string) ([]Tag, error) { - var feed atomFeed - if err := xml.Unmarshal([]byte(body), &feed); err != nil { - return nil, err - } - out := make([]Tag, 0, len(feed.Entries)) - for _, e := range feed.Entries { - out = append(out, Tag{ - Name: strings.TrimSpace(e.Title), - Updated: e.Updated, - URL: e.Link.Href, - }) - } - return out, nil -} - -// ── ParseIssues ────────────────────────────────────────────────────────────── - -// ParseIssues parses the /{owner}/{repo}/issues HTML page. -func ParseIssues(body, owner, repo, state string) []Issue { - // find all issue-N id blocks - matches := reIssueBlock.FindAllStringSubmatch(body, -1) - out := make([]Issue, 0, len(matches)) - for _, m := range matches { - numStr := m[1] - block := m[2] - num, _ := strconv.Atoi(numStr) - if num == 0 { - continue - } - - title := "" - issueURL := "" - if tm := reIssueTitle.FindStringSubmatch(block); tm != nil { - issueURL = "https://github.com" + tm[1] - title = cleanStr(tm[2]) - } - - createdAt := first(reIssueDate, block) - author := first(reIssueAuthor, block) - - labelMatches := reIssueLabel.FindAllStringSubmatch(block, -1) - labels := make([]string, 0, len(labelMatches)) - for _, lm := range labelMatches { - labels = append(labels, cleanStr(lm[1])) - } - - comments := 0 - if cm := reIssueComments.FindStringSubmatch(block); cm != nil { - comments, _ = strconv.Atoi(cm[1]) - } - - if issueURL == "" { - issueURL = "https://github.com/" + owner + "/" + repo + "/issues/" + numStr - } - - out = append(out, Issue{ - Number: num, - Title: title, - State: state, - Author: author, - Comments: comments, - Labels: strings.Join(labels, ", "), - CreatedAt: createdAt, - URL: issueURL, - }) - } - return out -} - -// ── ParsePulls ─────────────────────────────────────────────────────────────── - -// ParsePulls parses the /{owner}/{repo}/pulls HTML page. -// The PR list uses the same HTML structure as issues with a different URL path. -func ParsePulls(body, owner, repo, state string) []PullRequest { - // use the same issue_N id structure - matches := reIssueBlock.FindAllStringSubmatch(body, -1) - out := make([]PullRequest, 0, len(matches)) - for _, m := range matches { - numStr := m[1] - block := m[2] - num, _ := strconv.Atoi(numStr) - if num == 0 { - continue - } - - title := "" - prURL := "" - prNum := num - if tm := rePRTitle.FindStringSubmatch(block); tm != nil { - prURL = "https://github.com" + tm[1] - n, _ := strconv.Atoi(tm[2]) - if n > 0 { - prNum = n - } - title = cleanStr(tm[3]) - } else if tm2 := reIssueTitle.FindStringSubmatch(block); tm2 != nil { - prURL = "https://github.com" + tm2[1] - title = cleanStr(tm2[2]) - } - - createdAt := first(reIssueDate, block) - author := first(reIssueAuthor, block) - - comments := 0 - if cm := reIssueComments.FindStringSubmatch(block); cm != nil { - comments, _ = strconv.Atoi(cm[1]) - } - - if prURL == "" { - prURL = "https://github.com/" + owner + "/" + repo + "/pull/" + numStr - } - - out = append(out, PullRequest{ - Number: prNum, - Title: title, - State: state, - Author: author, - Comments: comments, - CreatedAt: createdAt, - URL: prURL, - }) - } - return out -} - -// ── ParseSearch ────────────────────────────────────────────────────────────── - -// ParseSearch parses the github.com/search?type=repositories results page. -func ParseSearch(body string) []SearchRepo { - // find all result items by looking for full_name links - nameMatches := reSearchName.FindAllStringSubmatch(body, -1) - if len(nameMatches) == 0 { - nameMatches = reSearchNameB.FindAllStringSubmatch(body, -1) - } - - out := make([]SearchRepo, 0, len(nameMatches)) - // split body on each result card anchor to get per-card blocks - // Use a simpler approach: find all v-align-middle hrefs - reCard := regexp.MustCompile(`(?s)class="v-align-middle[^"]*"[^>]*href="/([^/"]+/[^/"]+)"[^>]*>.*?(?:class="v-align-middle|$)`) - _ = reCard - - for i, nm := range nameMatches { - fullName := nm[1] - // carve out a block around this match to extract nearby metadata - idx := strings.Index(body, nm[0]) - block := "" - if idx >= 0 { - end := idx + 2000 - if end > len(body) { - end = len(body) - } - block = body[idx:end] - } - - desc := "" - if dm := reSearchDesc.FindStringSubmatch(block); dm != nil { - desc = cleanStr(dm[1]) - } - stars := 0 - if sm := reSearchStars.FindStringSubmatch(block); sm != nil { - stars = cleanInt(sm[1]) - } - lang := first(reSearchLang, block) - updatedAt := first(reSearchDate, block) - - out = append(out, SearchRepo{ - Rank: i + 1, - FullName: fullName, - Description: desc, - Language: lang, - Stars: stars, - UpdatedAt: updatedAt, - URL: "https://github.com/" + fullName, - }) - } - return out -} - -// ── ParseFollowers / ParseFollowing ───────────────────────────────────────── - -// ParseFollowers parses the ?tab=followers HTML page. -func ParseFollowers(body string) []User { - return parseUserGrid(body) -} - -// ParseFollowing parses the ?tab=following HTML page. -func ParseFollowing(body string) []User { - return parseUserGrid(body) -} - -func parseUserGrid(body string) []User { - loginMatches := reFollowerLogin.FindAllStringSubmatch(body, -1) - out := make([]User, 0, len(loginMatches)) - seen := map[string]bool{} - for _, lm := range loginMatches { - login := cleanStr(lm[1]) - if login == "" || seen[login] { - continue - } - // skip orgs and special pages - if strings.Contains(login, "/") || strings.HasPrefix(login, "?") { - continue - } - seen[login] = true - - // carve out a block near this login to look for display name - idx := strings.Index(body, lm[0]) - name := "" - if idx >= 0 { - end := idx + 500 - if end > len(body) { - end = len(body) - } - block := body[idx:end] - name = first(reFollowerName, block) - } - - out = append(out, User{ - Login: login, - Name: name, - URL: userURL(login), - }) - } - return out -} - -// ── ParseStars ─────────────────────────────────────────────────────────────── - -// ParseStars parses the ?tab=stars HTML page. -func ParseStars(body string) []StarredRepo { - nameMatches := reStarName.FindAllStringSubmatch(body, -1) - if len(nameMatches) == 0 { - nameMatches = reStarNameB.FindAllStringSubmatch(body, -1) - } - out := make([]StarredRepo, 0, len(nameMatches)) - seen := map[string]bool{} - for _, nm := range nameMatches { - fullName := nm[1] - if fullName == "" || seen[fullName] { - continue - } - if !strings.Contains(fullName, "/") { - continue - } - seen[fullName] = true - - idx := strings.Index(body, nm[0]) - block := "" - if idx >= 0 { - end := idx + 1000 - if end > len(body) { - end = len(body) - } - block = body[idx:end] - } - - desc := "" - if dm := reStarDesc.FindStringSubmatch(block); dm != nil { - desc = cleanStr(dm[1]) - } - lang := first(reStarLang, block) - stars := 0 - if sm := reStarStars.FindStringSubmatch(block); sm != nil { - stars = cleanInt(sm[1]) - } - - out = append(out, StarredRepo{ - FullName: fullName, - Description: desc, - Language: lang, - Stars: stars, - URL: "https://github.com/" + fullName, - }) - } - return out -} diff --git a/github/types.go b/github/types.go deleted file mode 100644 index a8579f1..0000000 --- a/github/types.go +++ /dev/null @@ -1,130 +0,0 @@ -// Package github is the scraper library behind the github CLI. -// It reads public GitHub data from HTML pages, Atom feeds, and -// raw.githubusercontent.com. No API key or authentication is required. -// -// github is an independent tool and is not affiliated with GitHub or Microsoft. -package github - -// TrendingRepo is one entry from the GitHub trending page. -type TrendingRepo struct { - Rank int `json:"rank" table:"Rank,right"` - FullName string `json:"full_name" table:"Repo"` - Description string `json:"description" table:"Description"` - Language string `json:"language" table:"Lang"` - Stars int `json:"stars" table:"Stars,right"` - Forks int `json:"forks" table:"Forks,right"` - PeriodStars int `json:"period_stars" table:"New Stars,right"` - URL string `json:"url" table:"-" kit:"url"` -} - -// User is a GitHub user profile record. -// It is also used for the followers and following listings; -// counts are 0 on listing pages where they are not shown. -type User struct { - Login string `json:"login" table:"Login"` - Name string `json:"name" table:"Name"` - Bio string `json:"bio" table:"-"` - Company string `json:"company" table:"Company"` - Location string `json:"location" table:"Location"` - Email string `json:"email" table:"-"` - Blog string `json:"blog" table:"-"` - Followers int `json:"followers" table:"Followers,right"` - Following int `json:"following" table:"Following,right"` - Repos int `json:"repos" table:"Repos,right"` - URL string `json:"url" table:"-" kit:"url"` -} - -// Repo is a repository record used by both repo (single) and repos (list). -type Repo struct { - FullName string `json:"full_name" table:"Repo"` - Description string `json:"description" table:"Description"` - Language string `json:"language" table:"Lang"` - Stars int `json:"stars" table:"Stars,right"` - Forks int `json:"forks" table:"Forks,right"` - Watchers int `json:"watchers" table:"-"` - OpenIssues int `json:"open_issues" table:"Issues,right"` - DefaultBranch string `json:"default_branch" table:"-"` - License string `json:"license" table:"License"` - Topics []string `json:"topics" table:"-"` - Fork bool `json:"fork" table:"-"` - Archived bool `json:"archived" table:"-"` - PushedAt string `json:"pushed_at" table:"Pushed"` - CreatedAt string `json:"created_at" table:"-"` - UpdatedAt string `json:"updated_at" table:"-"` - URL string `json:"url" table:"-" kit:"url"` -} - -// Commit is one entry from the commits Atom feed. -type Commit struct { - SHA string `json:"sha" table:"SHA"` - Message string `json:"message" table:"Message"` - Author string `json:"author" table:"Author"` - Date string `json:"date" table:"Date"` - URL string `json:"url" table:"-" kit:"url"` -} - -// Release is one entry from the releases Atom feed. -type Release struct { - Tag string `json:"tag" table:"Tag"` - Name string `json:"name" table:"Name"` - Author string `json:"author" table:"Author"` - Published string `json:"published" table:"Published"` - URL string `json:"url" table:"-" kit:"url"` -} - -// Tag is one entry from the tags Atom feed. -type Tag struct { - Name string `json:"name" table:"Tag"` - Updated string `json:"updated" table:"Updated"` - URL string `json:"url" table:"-" kit:"url"` -} - -// Issue is one issue row scraped from the issues HTML page. -type Issue struct { - Number int `json:"number" table:"#,right"` - Title string `json:"title" table:"Title"` - State string `json:"state" table:"State"` - Author string `json:"author" table:"Author"` - Comments int `json:"comments" table:"Comments,right"` - Labels string `json:"labels" table:"Labels"` - CreatedAt string `json:"created_at" table:"Created"` - URL string `json:"url" table:"-" kit:"url"` -} - -// PullRequest is one PR row scraped from the pulls HTML page. -type PullRequest struct { - Number int `json:"number" table:"#,right"` - Title string `json:"title" table:"Title"` - State string `json:"state" table:"State"` - Author string `json:"author" table:"Author"` - Comments int `json:"comments" table:"Comments,right"` - CreatedAt string `json:"created_at" table:"Created"` - URL string `json:"url" table:"-" kit:"url"` -} - -// SearchRepo is one repository card from the search results page. -type SearchRepo struct { - Rank int `json:"rank" table:"Rank,right"` - FullName string `json:"full_name" table:"Repo"` - Description string `json:"description" table:"Description"` - Language string `json:"language" table:"Lang"` - Stars int `json:"stars" table:"Stars,right"` - UpdatedAt string `json:"updated_at" table:"Updated"` - URL string `json:"url" table:"-" kit:"url"` -} - -// StarredRepo is one repository card from the stars tab. -type StarredRepo struct { - FullName string `json:"full_name" table:"Repo"` - Description string `json:"description" table:"Description"` - Language string `json:"language" table:"Lang"` - Stars int `json:"stars" table:"Stars,right"` - URL string `json:"url" table:"-" kit:"url"` -} - -// FileContent is the result of the readme and file commands. -type FileContent struct { - Path string `json:"path" table:"Path"` - Content string `json:"content" table:"-"` - URL string `json:"url" table:"-" kit:"url"` -} diff --git a/pkg/page/dom.go b/pkg/page/dom.go index 06b298f..051c26e 100644 --- a/pkg/page/dom.go +++ b/pkg/page/dom.go @@ -176,6 +176,139 @@ func Text(n *html.Node) string { func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } +// blockTag is the set of elements that end a line of prose. It does not need to +// be the full HTML block list, only the tags GitHub's renderer actually emits +// into a README, a release note, or a comment body. +var blockTag = map[string]bool{ + "address": true, "article": true, "aside": true, "blockquote": true, + "br": true, "dd": true, "details": true, "div": true, "dl": true, + "dt": true, "figcaption": true, "figure": true, "footer": true, + "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, + "header": true, "hr": true, "li": true, "main": true, "nav": true, + "ol": true, "p": true, "pre": true, "section": true, "summary": true, + "table": true, "tbody": true, "td": true, "th": true, "thead": true, + "tr": true, "ul": true, +} + +// BlockText returns the prose of a subtree with the line structure the markup +// implies, which is what Text deliberately throws away. +// +// Text collapses a whole subtree onto one line, which is right for a label and +// wrong for a document: a twenty-kilobyte README as a single line is not a +// readable rendering of anything. This keeps one line per block element, one +// blank line between paragraphs, and the interior whitespace of a
     exactly
    +// as it was, since indentation is the meaning of a code block rather than
    +// decoration on it.
    +func BlockText(n *html.Node) string {
    +	if n == nil {
    +		return ""
    +	}
    +	var t textLines
    +	t.walk(n)
    +	return t.done()
    +}
    +
    +// FragmentText is BlockText over an HTML fragment that arrived as a string.
    +// Several of GitHub's payloads carry rendered markup as a JSON value rather
    +// than as part of the document, so there is no node to walk until this parses
    +// one.
    +func FragmentText(s string) string {
    +	if strings.TrimSpace(s) == "" {
    +		return ""
    +	}
    +	doc, err := html.Parse(strings.NewReader(s))
    +	if err != nil {
    +		return ""
    +	}
    +	return BlockText(doc)
    +}
    +
    +// textLines accumulates prose one line at a time. It exists because whether a
    +// line keeps its whitespace depends on where the line started, which a single
    +// pass over a string builder cannot know after the fact.
    +type textLines struct {
    +	out []string
    +	cur strings.Builder
    +	pre int  // depth inside 
    +	raw bool // the line being built started inside a 
    +}
    +
    +func (t *textLines) walk(n *html.Node) {
    +	switch n.Type {
    +	case html.TextNode:
    +		t.text(n.Data)
    +		return
    +	case html.ElementNode:
    +		switch n.Data {
    +		case "script", "style", "template":
    +			return
    +		case "pre":
    +			t.pre++
    +			defer func() { t.pre-- }()
    +		}
    +		if blockTag[n.Data] {
    +			t.brk()
    +		}
    +	}
    +	for c := n.FirstChild; c != nil; c = c.NextSibling {
    +		t.walk(c)
    +	}
    +	if n.Type == html.ElementNode && blockTag[n.Data] {
    +		t.brk()
    +	}
    +}
    +
    +func (t *textLines) text(s string) {
    +	if t.pre == 0 {
    +		t.cur.WriteString(s)
    +		return
    +	}
    +	t.raw = true
    +	for i, part := range strings.Split(s, "\n") {
    +		if i > 0 {
    +			t.brk()
    +			t.raw = true
    +		}
    +		t.cur.WriteString(part)
    +	}
    +}
    +
    +func (t *textLines) brk() {
    +	line := t.cur.String()
    +	t.cur.Reset()
    +	if t.raw {
    +		line = strings.TrimRight(line, " \t\r")
    +	} else {
    +		line = collapse(line)
    +	}
    +	t.raw = false
    +	t.out = append(t.out, line)
    +}
    +
    +// done joins the lines, dropping runs of blank ones. A rendered document is
    +// full of wrapper divs, and one blank line between paragraphs is the intent
    +// while six is an artifact of the markup.
    +func (t *textLines) done() string {
    +	t.brk()
    +	var b strings.Builder
    +	blank := false
    +	for _, line := range t.out {
    +		if line == "" {
    +			blank = true
    +			continue
    +		}
    +		if blank && b.Len() > 0 {
    +			b.WriteString("\n")
    +		}
    +		blank = false
    +		if b.Len() > 0 {
    +			b.WriteString("\n")
    +		}
    +		b.WriteString(line)
    +	}
    +	return b.String()
    +}
    +
     // RelTime returns the datetime attribute of the first 
     // descendant. The element's own text is never read: it is localised and
     // relative, and parsing it would be a whole class of bug for no gain.
    diff --git a/pkg/page/dom_test.go b/pkg/page/dom_test.go
    index cf1399e..64f868b 100644
    --- a/pkg/page/dom_test.go
    +++ b/pkg/page/dom_test.go
    @@ -102,3 +102,73 @@ func TestSelMatch(t *testing.T) {
     		}
     	}
     }
    +
    +// The markup here is the shape GitHub's markdown renderer emits into a README:
    +// a heading, a paragraph broken across source lines, a list, and a fenced code
    +// block that came through as 
    .
    +func TestBlockText(t *testing.T) {
    +	doc := parse(t, `
    +

    gh

    +

    GitHub on +the command line.

    +

    It brings pull requests to the terminal.

    +
    • one
    • two
    +
    func main() {
    +	println("hi")
    +}
    +
    +

    Done.

    +
    `) + + want := strings.Join([]string{ + "gh", + "", + "GitHub on the command line.", + "", + "It brings pull requests to the terminal.", + "", + "one", + "", + "two", + "", + "func main() {", + "\tprintln(\"hi\")", + "}", + "", + "Done.", + }, "\n") + + got := BlockText(Find(doc, Sel{Class: "markdown-body"})) + if got != want { + t.Errorf("BlockText:\n%q\nwant:\n%q", got, want) + } +} + +func TestBlockTextKeepsCodeIndentation(t *testing.T) { + // A code block's leading whitespace is its meaning, so it survives even + // though every other line gets collapsed. + got := FragmentText("
      indented\n    more\n
    ") + if got != " indented\n more" { + t.Errorf("FragmentText(pre) = %q", got) + } +} + +func TestBlockTextDropsChrome(t *testing.T) { + // Wrapper divs are the bulk of GitHub's markup and none of its prose, so a + // stack of them must not turn into a stack of blank lines. + got := FragmentText(`

    a

    + +

    b

    `) + if got != "a\n\nb" { + t.Errorf("FragmentText = %q, want %q", got, "a\n\nb") + } +} + +func TestBlockTextEmpty(t *testing.T) { + if got := BlockText(nil); got != "" { + t.Errorf("BlockText(nil) = %q", got) + } + if got := FragmentText(" "); got != "" { + t.Errorf("FragmentText(blank) = %q", got) + } +} diff --git a/pkg/page/selectors.go b/pkg/page/selectors.go index 577e082..4e84cb3 100644 --- a/pkg/page/selectors.go +++ b/pkg/page/selectors.go @@ -135,7 +135,11 @@ var ( ProfilePinnedList = Sel{Tag: "ol", Class: "js-pinned-items-reorder-list"} ProfileOrgAvatar = Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "organization"} ProfileUserLink = Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "user"} - ProfileReadme = Sel{Class: "js-profile-readme"} + // ProfileReadme is the box a user profile puts its readme in. An + // organization's readme has no class of its own, so the caller falls back + // to the markdown article, which is the same on both. + // Verified 2026-07-25 against sindresorhus and github. + ProfileReadme = Sel{Class: "profile-readme"} ProfileVCardList = Sel{Class: "vcard-details"} ProfileAchieve = Sel{Class: "js-profile-achievements"} ) diff --git a/pkg/render/render.go b/pkg/render/render.go deleted file mode 100644 index 34393b6..0000000 --- a/pkg/render/render.go +++ /dev/null @@ -1,350 +0,0 @@ -// Package render turns slices of record structs into one of the output formats -// hackernews-cli supports: table, json, jsonl, csv, tsv, url, and raw. It works -// off struct reflection and json tags, so any record type renders without -// per-type code. -package render - -import ( - "encoding/csv" - "encoding/json" - "fmt" - "io" - "reflect" - "strconv" - "strings" - "text/tabwriter" - "text/template" - "time" -) - -// Format is an output rendering format. -type Format string - -const ( - FormatTable Format = "table" - FormatJSON Format = "json" - FormatJSONL Format = "jsonl" - FormatCSV Format = "csv" - FormatTSV Format = "tsv" - FormatURL Format = "url" - FormatRaw Format = "raw" -) - -// Valid reports whether f is one of the supported formats. -func (f Format) Valid() bool { - switch f { - case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw: - return true - } - return false -} - -// Renderer writes records in a chosen format. -type Renderer struct { - Format Format - Fields []string - NoHeader bool - Template string - w io.Writer -} - -// New builds a Renderer writing to w. -func New(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer { - return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w} -} - -// Render writes records (a slice of structs, or a single struct) in the configured format. -func (r *Renderer) Render(records any) error { - rv := reflect.ValueOf(records) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Slice { - s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1) - s.Index(0).Set(rv) - rv = s - } - n := rv.Len() - items := make([]any, n) - for i := 0; i < n; i++ { - items[i] = rv.Index(i).Interface() - } - - if r.Template != "" { - return r.renderTemplate(items) - } - switch r.Format { - case FormatJSON: - return r.renderJSON(items) - case FormatJSONL: - return r.renderJSONL(items) - case FormatCSV: - return r.renderDelimited(items, ',') - case FormatTSV: - return r.renderDelimited(items, '\t') - case FormatURL: - return r.renderURL(items) - case FormatRaw: - return r.renderRaw(items) - default: - return r.renderTable(items) - } -} - -func (r *Renderer) renderJSON(items []any) error { - enc := json.NewEncoder(r.w) - enc.SetIndent("", " ") - if len(items) == 1 { - return enc.Encode(items[0]) - } - return enc.Encode(items) -} - -func (r *Renderer) renderJSONL(items []any) error { - enc := json.NewEncoder(r.w) - for _, it := range items { - if err := enc.Encode(it); err != nil { - return err - } - } - return nil -} - -func (r *Renderer) renderTemplate(items []any) error { - t, err := template.New("row").Funcs(template.FuncMap{ - "join": func(sep string, v any) string { return joinAny(sep, v) }, - }).Parse(r.Template) - if err != nil { - return fmt.Errorf("parse --template: %w", err) - } - for _, it := range items { - if err := t.Execute(r.w, toAnyMap(it)); err != nil { - return err - } - _, _ = fmt.Fprintln(r.w) - } - return nil -} - -func (r *Renderer) renderURL(items []any) error { - for _, it := range items { - m := toMap(it) - if u := firstNonEmpty(m["url"], m["hn_url"], m["permalink"]); u != "" { - _, _ = fmt.Fprintln(r.w, u) - } - } - return nil -} - -func (r *Renderer) renderRaw(items []any) error { - cols := r.columns(items) - for _, it := range items { - m := toMap(it) - vals := make([]string, 0, len(cols)) - for _, c := range cols { - vals = append(vals, m[c]) - } - _, _ = fmt.Fprintln(r.w, strings.Join(vals, " ")) - } - return nil -} - -func (r *Renderer) renderTable(items []any) error { - if len(items) == 0 { - return nil - } - cols := r.columns(items) - tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0) - if !r.NoHeader { - _, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t")) - } - for _, it := range items { - m := toMap(it) - cells := make([]string, len(cols)) - for i, c := range cols { - cells[i] = truncate(m[c], 60) - } - _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) - } - return tw.Flush() -} - -func (r *Renderer) renderDelimited(items []any, comma rune) error { - if len(items) == 0 { - return nil - } - cols := r.columns(items) - cw := csv.NewWriter(r.w) - cw.Comma = comma - if !r.NoHeader { - if err := cw.Write(cols); err != nil { - return err - } - } - for _, it := range items { - m := toMap(it) - row := make([]string, len(cols)) - for i, c := range cols { - row[i] = m[c] - } - if err := cw.Write(row); err != nil { - return err - } - } - cw.Flush() - return cw.Error() -} - -func (r *Renderer) columns(items []any) []string { - if len(r.Fields) > 0 { - return r.Fields - } - if len(items) == 0 { - return nil - } - return structJSONKeys(items[0]) -} - -func toAnyMap(v any) any { - data, err := json.Marshal(v) - if err != nil { - return v - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return v - } - return m -} - -func joinAny(sep string, v any) string { - switch vv := v.(type) { - case nil: - return "" - case []string: - return strings.Join(vv, sep) - case []any: - parts := make([]string, len(vv)) - for i, e := range vv { - parts[i] = fmt.Sprintf("%v", e) - } - return strings.Join(parts, sep) - default: - return fmt.Sprintf("%v", v) - } -} - -func toMap(v any) map[string]string { - out := map[string]string{} - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Struct { - return out - } - rt := rv.Type() - for i := 0; i < rt.NumField(); i++ { - f := rt.Field(i) - if f.PkgPath != "" { - continue - } - key := jsonKey(f) - if key == "-" { - continue - } - out[key] = formatValue(rv.Field(i)) - } - return out -} - -func structJSONKeys(v any) []string { - rv := reflect.ValueOf(v) - if rv.Kind() == reflect.Pointer { - rv = rv.Elem() - } - if rv.Kind() != reflect.Struct { - return nil - } - rt := rv.Type() - var keys []string - for i := 0; i < rt.NumField(); i++ { - f := rt.Field(i) - if f.PkgPath != "" { - continue - } - key := jsonKey(f) - if key == "-" { - continue - } - keys = append(keys, key) - } - return keys -} - -func jsonKey(f reflect.StructField) string { - tag := f.Tag.Get("json") - if tag == "" { - return f.Name - } - name := strings.Split(tag, ",")[0] - if name == "" { - return f.Name - } - return name -} - -func formatValue(v reflect.Value) string { - switch v.Kind() { - case reflect.String: - return v.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return strconv.FormatInt(v.Int(), 10) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return strconv.FormatUint(v.Uint(), 10) - case reflect.Float32, reflect.Float64: - return strconv.FormatFloat(v.Float(), 'g', -1, 64) - case reflect.Bool: - return strconv.FormatBool(v.Bool()) - case reflect.Slice: - parts := make([]string, v.Len()) - for i := 0; i < v.Len(); i++ { - parts[i] = formatValue(v.Index(i)) - } - return strings.Join(parts, ";") - case reflect.Struct: - if t, ok := v.Interface().(time.Time); ok { - if t.IsZero() { - return "" - } - return t.Format(time.RFC3339) - } - } - return fmt.Sprintf("%v", v.Interface()) -} - -func upperAll(ss []string) []string { - out := make([]string, len(ss)) - for i, s := range ss { - out[i] = strings.ToUpper(s) - } - return out -} - -func firstNonEmpty(ss ...string) string { - for _, s := range ss { - if s != "" { - return s - } - } - return "" -} - -func truncate(s string, n int) string { - s = strings.ReplaceAll(s, "\n", " ") - if len([]rune(s)) <= n { - return s - } - rs := []rune(s) - return string(rs[:n-1]) + "..." -} From 17cd10520fdb27886bbb1c444b47cee657460e78 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:37:18 +0700 Subject: [PATCH 09/21] gh: people, gists, activity, trending, and repository statistics This is the long tail of the read surface: the profile tabs, the organization roster, gists, the contribution calendar, activity feeds, trending, topic pages, fork networks, and the contributor graph. Two findings worth naming. The contributor graph has a data route of its own, /graphs/contributors-data, which answers 202 with an empty body while GitHub computes the numbers, so the reader polls rather than treating that as a failure. And the language histogram does have a keyless source after all: /{owner}/{repo}/_sidebar is the fragment the repository page's own front end waits for, it needs no credential, and it carries the contributor count and the dependent count alongside the languages. repo.go used to say the histogram had no keyless source. It does now, and --deep uses it. The calendar fragment is HTML and answers 406 to a request that asks for JSON, which the client reports as a response rather than an error, so it reads on the HTML surface and not the XHR one. --- gh/base.go | 14 +- gh/discover.go | 578 ++++++++++++++++++++++++++++++++++++++ gh/gh.go | 1 + gh/ops.go | 325 ++++++++++++++++++++++ gh/people.go | 738 +++++++++++++++++++++++++++++++++++++++++++++++++ gh/repo.go | 100 ++++++- gh/types.go | 44 ++- gh/uri.go | 32 ++- 8 files changed, 1815 insertions(+), 17 deletions(-) create mode 100644 gh/discover.go create mode 100644 gh/people.go diff --git a/gh/base.go b/gh/base.go index 18c8706..fdd2bee 100644 --- a/gh/base.go +++ b/gh/base.go @@ -263,15 +263,21 @@ func claimedKeys(t reflect.Type) []string { // --- small shared helpers --- -// parseTime accepts the three time formats GitHub uses across its surfaces: -// RFC 3339 with a zone, RFC 3339 in UTC with a Z, and the datetime attribute -// on a element, which is the same thing. +// parseTime accepts the time formats GitHub uses across its surfaces: RFC 3339 +// with a zone, RFC 3339 in UTC with a Z, the bare date on a commit calendar, +// and the space-separated form the activity Atom feed puts in its published +// element, which is the only surface that does not use RFC 3339. func parseTime(s string) *time.Time { s = strings.TrimSpace(s) if s == "" { return nil } - for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} { + for _, layout := range []string{ + time.RFC3339, + "2006-01-02T15:04:05Z0700", + "2006-01-02 15:04:05 MST", + "2006-01-02", + } { if t, err := time.Parse(layout, s); err == nil { u := t.UTC() return &u diff --git a/gh/discover.go b/gh/discover.go new file mode 100644 index 0000000..87bbb7b --- /dev/null +++ b/gh/discover.go @@ -0,0 +1,578 @@ +package gh + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" + + "github.com/tamnd/github-cli/pkg/page" +) + +// discover.go reads the pages that answer "what is out there": trending, topic +// pages, fork networks, and repository statistics. +// +// Trending is the clearest case for this whole tool. There is no JSON version +// of it anywhere, with a token or without, so a page decoder is not a fallback +// here, it is the only implementation that can exist. + +// TrendingOptions are the three knobs the trending page has. +type TrendingOptions struct { + // Since is daily, weekly, or monthly. Empty means daily, which is what the + // page defaults to. + Since string + // Language filters by the language slug in the URL, not by a query. + Language string + // SpokenLanguage is the natural-language filter, a two-letter code. + SpokenLanguage string + Limit int +} + +// Trending lists the trending repositories. Rank is the position on the page, +// which is the only ordering the surface has and is worth keeping, since the +// list has no other stable key. +func (c *Client) Trending(ctx context.Context, opts TrendingOptions, emit func(Trending) error) error { + u := trendingURL("", opts) + res, err := c.GetHTML(ctx, u) + if err != nil { + return err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return structureChanged("trending") + } + period := firstNonEmpty(opts.Since, "daily") + rank := 0 + for _, row := range page.FindAll(doc, page.TrendingRow) { + t, ok := trendingRow(row, period, res.FinalURL) + if !ok { + continue + } + rank++ + t.Rank = rank + if err := emit(t); err != nil { + return err + } + if opts.Limit > 0 && rank >= opts.Limit { + return nil + } + } + if rank == 0 { + return structureChanged("trending") + } + return nil +} + +// TrendingDevelopers lists the trending developers, each with the repository +// the page picked out for them. +func (c *Client) TrendingDevelopers(ctx context.Context, opts TrendingOptions, emit func(Account) error) error { + u := trendingURL("developers", opts) + res, err := c.GetHTML(ctx, u) + if err != nil { + return err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return structureChanged("trending developers") + } + seen := 0 + for _, row := range page.FindAll(doc, page.Sel{Tag: "article", Class: "Box-row"}) { + a, ok := trendingDev(row, res.FinalURL) + if !ok { + continue + } + if err := emit(a); err != nil { + return err + } + seen++ + if opts.Limit > 0 && seen >= opts.Limit { + return nil + } + } + if seen == 0 { + return structureChanged("trending developers") + } + return nil +} + +// trendingURL builds the trending address. The language is a path segment and +// the period is a query parameter, which is the site's own split and not one +// worth normalising away. +func trendingURL(section string, opts TrendingOptions) string { + u := BaseURL + "/trending" + if section != "" { + u += "/" + section + } else if opts.Language != "" { + u += "/" + strings.ToLower(opts.Language) + } + var kv []string + if opts.Since != "" { + kv = append(kv, "since", opts.Since) + } + if opts.SpokenLanguage != "" { + kv = append(kv, "spoken_language_code", opts.SpokenLanguage) + } + if len(kv) == 0 { + return u + } + return query(u, kv...) +} + +// trendingRow reads one card. The three counts on it are the same shape and +// only their link tells them apart: stargazers, forks, and the period figure, +// which has no link at all. +func trendingRow(row *html.Node, period, source string) (Trending, bool) { + h := page.Find(row, page.Sel{Tag: "h2"}) + if h == nil { + return Trending{}, false + } + a := page.Find(h, page.Sel{Tag: "a", Attr: "href"}) + if a == nil { + return Trending{}, false + } + id := hrefPath(page.Attr(a, "href")) + owner, name, ok := SplitRepo(id) + if !ok { + return Trending{}, false + } + t := Trending{Period: period} + t.Owner, t.Name = owner, name + t.setIdentity(KindRepo, id) + t.addSource(source) + + if p := page.Find(row, page.Sel{Tag: "p"}); p != nil { + t.Description = page.Text(p) + } + if l := page.Find(row, page.Sel{Attr: "itemprop", AttrValue: "programmingLanguage"}); l != nil { + t.Language = page.Text(l) + } + if col := page.Find(row, page.Sel{Class: "repo-language-color"}); col != nil { + t.LanguageColor = styleColor(page.Attr(col, "style")) + } + for _, link := range page.FindAll(row, page.Sel{Tag: "a", Attr: "href"}) { + n, _, ok := page.ParseCompactCount(page.Text(link)) + if !ok { + continue + } + switch href := page.Attr(link, "href"); { + case strings.HasSuffix(href, "/stargazers"): + t.Stars = intp(n) + case strings.HasSuffix(href, "/forks"): + t.Forks = intp(n) + } + } + if s := page.Find(row, page.Sel{Class: "float-sm-right"}); s != nil { + if n, _, ok := page.CountIn(page.Text(s)); ok { + t.StarsInPeriod = intp(n) + } + } + for _, img := range page.FindAll(row, page.Sel{Tag: "img", Class: "avatar-user"}) { + login := strings.TrimPrefix(page.Attr(img, "alt"), "@") + if login == "" { + continue + } + who := actor(login) + who.AvatarURL = page.Attr(img, "src") + t.BuiltBy = append(t.BuiltBy, who) + } + return t, true +} + +// trendingDev reads one developer card. The popular repository on it is a +// pointer, not a record: it has a name and a description and nothing else, so +// it goes into PinnedRepos where the profile's own picks go. +func trendingDev(row *html.Node, source string) (Account, bool) { + link := page.Find(row, page.Sel{Tag: "h1", Class: "h3"}) + if link == nil { + return Account{}, false + } + nameLink := page.Find(link, page.Sel{Tag: "a", Attr: "href"}) + if nameLink == nil { + return Account{}, false + } + login := hrefPath(page.Attr(nameLink, "href")) + if login == "" || strings.Contains(login, "/") { + return Account{}, false + } + a := Account{Login: login, Type: "User", Name: page.Text(nameLink)} + a.setIdentity(KindUser, login) + a.addSource(source) + if a.Name == a.Login { + a.Name = "" + } + if img := page.Find(row, page.Sel{Tag: "img", Class: "avatar-user"}); img != nil { + a.AvatarURL = page.Attr(img, "src") + } + if h := page.Find(row, page.Sel{Tag: "h1", Class: "h4"}); h != nil { + if repo := page.Find(h, page.Sel{Tag: "a", Attr: "href"}); repo != nil { + if id := hrefPath(page.Attr(repo, "href")); strings.Count(id, "/") == 1 { + a.PinnedRepos = append(a.PinnedRepos, id) + } + } + } + return a, true +} + +// --- topic pages --- + +// TopicPage reads one topic. The search result for a topic carries the name and +// a short blurb; the page carries the long description, the logo, who created +// the thing, when it was released, the Wikipedia link, and the related topics, +// which is most of what makes a topic worth having a record for. +func (c *Client) TopicPage(ctx context.Context, slug string) (*Topic, error) { + slug = strings.Trim(slug, "/") + if slug == "" || strings.Contains(slug, "/") { + return nil, usageBadID("topic", slug, "a topic slug") + } + res, err := c.GetHTML(ctx, BaseURL+"/topics/"+slug) + if err != nil { + return nil, err + } + p := page.Extract(res.FinalURL, res.Body) + doc := p.Doc() + if doc == nil { + return nil, structureChanged(slug) + } + t := &Topic{Name: slug} + t.setIdentity(KindTopic, slug) + t.addSource(res.FinalURL) + t.GitHubURL = t.URL + + if h := page.Find(doc, page.Sel{Tag: "h1", Class: "h1"}); h != nil { + t.DisplayName = page.Text(h) + } + if trigger := page.Find(doc, page.Sel{Tag: "topic-feeds-toast-trigger"}); trigger != nil { + t.DisplayName = firstNonEmpty(page.Attr(trigger, "data-topic-display-name"), t.DisplayName) + } + if md := page.Find(doc, page.MarkdownBody); md != nil { + t.DescriptionHTML = page.OuterHTML(md) + t.Description = page.BlockText(md) + // The page has one description where the search result has two. The + // first paragraph is the same string the short one would be, so it is + // filled from here rather than left empty for no reason. + t.ShortDescription, _, _ = strings.Cut(t.Description, "\n") + } + if img := page.Find(doc, page.Sel{Tag: "img", Attr: "alt", AttrSuffix: " logo"}); img != nil { + t.LogoURL = page.Attr(img, "src") + } + if w := page.Find(doc, page.TopicWikipedia); w != nil { + t.WikipediaURL = page.Attr(w, "href") + } + t.CreatedBy = labelledText(doc, "Created by") + t.Released = labelledText(doc, "Released") + if n := page.Find(doc, page.Sel{Tag: "h2", Class: "h3"}); n != nil { + // "Here are 89,195 public repositories matching this topic..." + if count, _, ok := page.CountIn(strings.TrimPrefix(page.Text(n), "Here are ")); ok { + t.AppliedCount = intp(count) + } + } + for _, dd := range page.FindAll(doc, page.Sel{Tag: "dd"}) { + if n, _, ok := page.ParseCompactCount(strings.TrimSuffix(page.Text(dd), " followers")); ok && + strings.HasSuffix(page.Text(dd), "followers") { + t.StargazerCount = intp(n) + } + } + for _, rel := range relatedTopics(doc, slug) { + t.Related = append(t.Related, rel) + } + if t.DisplayName == "" && t.Description == "" { + return nil, structureChanged(slug) + } + return t, nil +} + +// relatedTopics reads the sidebar's related topics. +// +// They are not in a container. The heading and the links are siblings, and the +// same link class is on every topic chip of every repository in the result +// list below, so scoping by class alone pulls in a few hundred unrelated +// topics. The heading is the only boundary the markup gives, so the walk +// starts there and stops at the next heading. +func relatedTopics(doc *html.Node, slug string) []string { + var head *html.Node + for _, h := range page.FindAll(doc, page.Sel{Tag: "h2"}) { + if page.Text(h) == "Related topics" { + head = h + break + } + } + if head == nil { + return nil + } + var out []string + for n := head.NextSibling; n != nil; n = n.NextSibling { + if n.Type == html.ElementNode && (n.Data == "h2" || n.Data == "h3") { + break + } + for _, a := range page.FindAll(n, page.Sel{Tag: "a", Class: "topic-tag-link"}) { + rel := strings.TrimPrefix(hrefPath(page.Attr(a, "href")), "topics/") + if rel != "" && rel != slug && !contains(out, rel) { + out = append(out, rel) + } + } + } + return out +} + +// labelledText reads the value beside a muted label in the topic sidebar. The +// label is a span inside the paragraph and the value is the rest of it, which +// is the only structure the markup offers. +func labelledText(doc *html.Node, label string) string { + for _, p := range page.FindAll(doc, page.Sel{Tag: "p"}) { + span := page.Find(p, page.Sel{Tag: "span", Class: "color-fg-muted"}) + if span == nil || page.Text(span) != label { + continue + } + return strings.TrimSpace(strings.TrimPrefix(page.Text(p), label)) + } + return "" +} + +// --- fork networks --- + +// Forks lists the public forks of a repository. The page is the only keyless +// source: the network graph route needs a session and the search index does not +// model the parent link. +func (c *Client) Forks(ctx context.Context, repo string, limit int, emit func(Repo) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + base := repoSubURL(repo, "forks") + fetch := func(ctx context.Context, token string) ([]Repo, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, "", structureChanged(repo + " forks") + } + var out []Repo + for _, row := range page.FindAll(doc, page.BoxRow) { + f, ok := forkRow(row, repo, res.FinalURL) + if ok { + out = append(out, f) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// forkRow reads one row of a fork list. The owner and the name are separate +// links, so the id is assembled rather than read off one href. +func forkRow(row *html.Node, parent, source string) (Repo, bool) { + h := page.Find(row, page.Sel{Tag: "h2"}) + if h == nil { + return Repo{}, false + } + var owner, name string + for _, a := range page.FindAll(h, page.Sel{Tag: "a", Attr: "href"}) { + p := hrefPath(page.Attr(a, "href")) + switch { + case owner == "" && !strings.Contains(p, "/"): + owner = p + case strings.Count(p, "/") == 1: + owner, name, _ = SplitRepo(p) + } + } + if owner == "" || name == "" { + return Repo{}, false + } + id := owner + "/" + name + r := Repo{Owner: owner, Name: name, IsFork: true, ForkOf: parent} + r.setIdentity(KindRepo, id) + r.addSource(source) + for _, a := range page.FindAll(row, page.Sel{Tag: "a", Attr: "href"}) { + n, _, ok := page.ParseCompactCount(page.Text(a)) + if !ok { + continue + } + switch href := page.Attr(a, "href"); { + case strings.HasSuffix(href, "/stargazers"): + r.Stars = intp(n) + case strings.HasSuffix(href, "/forks"): + r.Forks = intp(n) + } + } + if t := page.Find(row, page.RelTimeEl); t != nil { + r.PushedAt = parseTime(page.Attr(t, "datetime")) + } + return r, true +} + +// --- statistics --- + +// Contributors reads the contributor graph's own data route. +// +// The route answers 202 with an empty body while GitHub computes the numbers, +// which is normal rather than an error and is why this polls. A large +// repository takes a few seconds the first time and is instant afterwards. +func (c *Client) Contributors(ctx context.Context, repo string, opts ContributorOptions, emit func(Contributor) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + u := repoSubURL(repo, "graphs/contributors-data") + res, err := c.Poll(ctx, u, SurfaceXHR) + if err != nil { + return err + } + var raw []contributorData + if err := json.Unmarshal(res.Body, &raw); err != nil { + return badPayload(shortURL(u), err) + } + if len(raw) == 0 { + return structureChanged(repo + " contributors") + } + // The route answers in ascending order of contribution, which is the + // reverse of what anyone asking for contributors wants. + seen := 0 + for i := len(raw) - 1; i >= 0; i-- { + rec := raw[i].contributor(repo, res.FinalURL, opts.Weeks) + if err := emit(rec); err != nil { + return err + } + seen++ + if opts.Limit > 0 && seen >= opts.Limit { + return nil + } + } + return nil +} + +// ContributorOptions is what to do with the week array. +type ContributorOptions struct { + // Weeks keeps the per-week breakdown. It is off by default because the + // route sends every week since the repository began for every contributor, + // which on an old project is a few hundred entries each and megabytes of + // mostly zeroes for an answer whose question was "who wrote this". + Weeks bool + Limit int +} + +type contributorData struct { + Author *struct { + ID int `json:"id"` + Login string `json:"login"` + Avatar string `json:"avatar"` + Path string `json:"path"` + } `json:"author"` + Total int `json:"total"` + Weeks []struct { + W int64 `json:"w"` + A int `json:"a"` + D int `json:"d"` + C int `json:"c"` + } `json:"weeks"` +} + +// contributor folds the week array into the record. The array is six hundred +// entries for an old repository and nearly all of them are zero, so the +// summable fields are summed here and the first and last weeks with any +// activity are kept as dates, which is what a table can show. +func (d contributorData) contributor(repo, source string, keepWeeks bool) Contributor { + rec := Contributor{Repo: repo, Commits: intp(d.Total)} + if d.Author != nil { + rec.Login = d.Author.Login + rec.AvatarURL = d.Author.Avatar + if d.Author.ID > 0 { + rec.DatabaseID = intp(d.Author.ID) + } + } + rec.setIdentity(KindContributor, repo+"@"+rec.Login) + rec.addSource(source) + + adds, dels := 0, 0 + for _, w := range d.Weeks { + adds += w.A + dels += w.D + if w.A == 0 && w.D == 0 && w.C == 0 { + continue + } + at := time.Unix(w.W, 0).UTC() + if rec.FirstWeek == nil { + first := at + rec.FirstWeek = &first + } + last := at + rec.LastWeek = &last + if keepWeeks { + rec.Weeks = append(rec.Weeks, ContributorWeek{Week: at, Additions: w.A, Deletions: w.D, Commits: w.C}) + } + } + rec.Additions = intp(adds) + rec.Deletions = intp(dels) + return rec +} + +// Languages reports the language histogram as one record per language. The +// numbers are on the repository record already; this exists because "what is +// this written in, in what proportion" is a question worth one command rather +// than a field selector on another one. +// Languages reports the language breakdown, largest first. +// +// This reads the sidebar fragment rather than a whole repository page, because +// the fragment is where the numbers are and it is 3 KB where the page is 300. +// The numbers are percentages: GitHub computes byte counts and publishes only +// the proportions, so a byte count is not something this can report honestly. +func (c *Client) Languages(ctx context.Context, repo string, emit func(LanguageShare) error) error { + sb, err := c.sidebar(ctx, repo) + if err != nil { + return err + } + langs := sb.langs() + if len(langs) == 0 { + return structureChanged(repo + " languages") + } + source := repoSubURL(repo, "_sidebar") + for _, l := range langs { + share := LanguageShare{ + Repo: repo, + Language: l.Name, + Percent: l.Percentage, + Color: l.Color, + } + share.setIdentity(KindRepo, repo) + share.addSource(source) + if err := emit(share); err != nil { + return err + } + } + return nil +} + +// Stats is the counts in one record. Everything in it is already on the +// repository record; the point is a record with nothing else in it, so +// `github stats x -o json` is a thing you can diff week to week. +func (c *Client) Stats(ctx context.Context, repo string) (*RepoStats, error) { + // Deep, because the contributor and dependent counts are behind their own + // fragments and a counts record missing two of the counts is not worth + // having. + r, err := c.Repo(ctx, repo, RepoOptions{Deep: true}) + if err != nil { + return nil, err + } + s := &RepoStats{ + Repo: repo, + Stars: r.Stars, + Forks: r.Forks, + Watchers: r.Watchers, + OpenIssues: r.OpenIssues, + Commits: r.CommitCount, + Releases: r.ReleaseCount, + Tags: r.TagCount, + Contributors: r.ContributorCount, + Dependents: r.DependentCount, + PushedAt: r.PushedAt, + } + s.setIdentity(KindRepo, repo) + s.addSource(r.Sources...) + return s, nil +} diff --git a/gh/gh.go b/gh/gh.go index 17dc6f6..57e077a 100644 --- a/gh/gh.go +++ b/gh/gh.go @@ -19,6 +19,7 @@ const ( BaseURL = "https://github.com" RawURL = "https://raw.githubusercontent.com" CodeLoad = "https://codeload.github.com" + GistURL = "https://gist.github.com" GistRaw = "https://gist.githubusercontent.com" AvatarURL = "https://avatars.githubusercontent.com" OpenGraph = "https://opengraph.githubassets.com" diff --git a/gh/ops.go b/gh/ops.go index 33396bc..8c74e79 100644 --- a/gh/ops.go +++ b/gh/ops.go @@ -23,6 +23,8 @@ func registerOps(app *kit.App) { registerSearchOps(app) registerContentOps(app) registerHistoryOps(app) + registerPeopleOps(app) + registerDiscoverOps(app) registerMetaOps(app) } @@ -1042,6 +1044,329 @@ func listTimeline(ctx context.Context, in timelineIn, emit func(*TimelineItem) e return in.C.Timeline(ctx, repo, num, in.Limit, byValue(emit)) } +// --- people --- + +type accountListIn struct { + C *Client `kit:"inject"` + Name string `kit:"arg" help:"a login, a profile URL, or a github:// URI"` + Limit int `kit:"flag,inherit"` +} + +type gistIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"a gist id, a gist URL, or a github:// URI"` + Content bool `kit:"flag" help:"fetch each file's raw content, one request per file"` +} + +type contributionsIn struct { + C *Client `kit:"inject"` + Name string `kit:"arg" help:"a login, a profile URL, or a github:// URI"` + Year int `kit:"flag" help:"calendar year, defaulting to the rolling last twelve months"` +} + +type activityIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"a login for a person's stream, or owner/name for a repository's"` + Limit int `kit:"flag,inherit"` +} + +func registerPeopleOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "followers", Group: "people", URIType: KindUser, List: true, + Summary: "List who follows an account", + Long: "The tab is 50 people a page and the pager is a plain next link, so an\n" + + "account with a hundred thousand followers is two thousand requests. Set\n" + + "--limit unless you mean all of them.", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listFollowers) + + kit.Handle(app, kit.OpMeta{ + Name: "following", Group: "people", URIType: KindUser, List: true, + Summary: "List who an account follows", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listFollowing) + + kit.Handle(app, kit.OpMeta{ + Name: "members", Group: "people", URIType: KindUser, List: true, + Summary: "List an organization's public members", + Long: "Public members only, which is the organization's own choice per person\n" + + "and not something a token would widen for someone outside the org.", + Args: []kit.Arg{{Name: "name", Help: "organization login, URL, or github:// URI"}}, + }, listMembers) + + kit.Handle(app, kit.OpMeta{ + Name: "stars", Group: "people", URIType: KindRepo, List: true, + Aliases: []string{"starred"}, + Summary: "List what an account has starred", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listStarred) + + kit.Handle(app, kit.OpMeta{ + Name: "owned", Group: "people", URIType: KindRepo, List: true, + Summary: "List an account's repositories as the profile shows them", + Long: "This reads the profile's repositories tab, which is the only surface that\n" + + "lists forks and archived repositories in the account's own order. It is\n" + + "not called repos because `github repos --owner name` already exists, goes\n" + + "through search, and is the better tool when you want to filter or sort.", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listAccountRepos) + + kit.Handle(app, kit.OpMeta{ + Name: "gists", Group: "people", URIType: KindGist, List: true, + Summary: "List an account's public gists", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listGists) + + kit.Handle(app, kit.OpMeta{ + Name: "gist", Group: "people", URIType: KindGist, Single: true, Resolver: true, + Summary: "Read one gist with its files", + Long: "The index gives each file's first few lines only, which is what the page\n" + + "renders. With --content each file is fetched whole from the raw host.", + Args: []kit.Arg{{Name: "ref", Help: "gist id, gist URL, or github:// URI"}}, + }, getGist) + + kit.Handle(app, kit.OpMeta{ + Name: "contributions", Group: "people", URIType: KindContribution, List: true, + Aliases: []string{"calendar"}, + Summary: "Read an account's contribution calendar, one record per day", + Long: "The count is not on the square. Each square points at a tooltip by id and\n" + + "the tooltip holds the sentence with the number in it, so this indexes the\n" + + "tooltips first and reads the squares against that index.", + Args: []kit.Arg{{Name: "name", Help: "login, profile URL, or github:// URI"}}, + }, listContributions) + + kit.Handle(app, kit.OpMeta{ + Name: "activity", Group: "people", URIType: KindEvent, List: true, + Aliases: []string{"events", "feed"}, + Summary: "Read a public activity feed", + Long: "One login gives that person's public events; one owner/name gives that\n" + + "repository's commit feed. Both are Atom, both are public, and neither has\n" + + "a pager, so a feed is however many entries GitHub decided to put in it.", + Args: []kit.Arg{{Name: "ref", Help: "a login, or owner/name"}}, + }, listActivity) +} + +func listFollowers(ctx context.Context, in accountListIn, emit func(*Account) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.Followers(ctx, login, in.Limit, byValue(emit)) +} + +func listFollowing(ctx context.Context, in accountListIn, emit func(*Account) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.Following(ctx, login, in.Limit, byValue(emit)) +} + +func listMembers(ctx context.Context, in accountListIn, emit func(*Account) error) error { + login, err := ResolveRef(KindOrg, in.Name) + if err != nil { + return err + } + return in.C.Members(ctx, login, in.Limit, byValue(emit)) +} + +func listStarred(ctx context.Context, in accountListIn, emit func(*Repo) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.Starred(ctx, login, in.Limit, byValue(emit)) +} + +func listAccountRepos(ctx context.Context, in accountListIn, emit func(*Repo) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.ReposAsShown(ctx, login, in.Limit, byValue(emit)) +} + +func listGists(ctx context.Context, in accountListIn, emit func(*Gist) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.Gists(ctx, login, in.Limit, byValue(emit)) +} + +func getGist(ctx context.Context, in gistIn, emit func(*Gist) error) error { + id, err := ResolveRef(KindGist, in.Ref) + if err != nil { + return err + } + g, err := in.C.Gist(ctx, id, in.Content) + if err != nil { + return err + } + return emit(g) +} + +func listContributions(ctx context.Context, in contributionsIn, emit func(*ContributionDay) error) error { + login, err := ResolveRef(KindUser, in.Name) + if err != nil { + return err + } + return in.C.Contributions(ctx, login, in.Year, byValue(emit)) +} + +// listActivity does not resolve the reference, because the feed takes both a +// login and an owner/name and the reader tells them apart itself. Sending it +// through ResolveRef would force a choice that neither kind wins. +func listActivity(ctx context.Context, in activityIn, emit func(*Event) error) error { + return in.C.Activity(ctx, strings.TrimPrefix(in.Ref, BaseURL+"/"), in.Limit, byValue(emit)) +} + +// --- discovery and statistics --- + +type trendingIn struct { + C *Client `kit:"inject"` + Since string `kit:"flag" help:"daily, weekly, or monthly"` + Language string `kit:"flag" help:"a language slug, as it appears in the trending URL"` + SpokenLanguage string `kit:"flag,name=spoken" help:"a two-letter natural language code"` + Developers bool `kit:"flag" help:"list trending developers instead of repositories"` + Limit int `kit:"flag,inherit"` +} + +type topicIn struct { + C *Client `kit:"inject"` + Name string `kit:"arg" help:"a topic slug, a topic URL, or a github:// URI"` +} + +type repoListIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` + Limit int `kit:"flag,inherit"` +} + +type repoRefIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` +} + +func registerDiscoverOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "trending", Group: "discover", URIType: KindRepo, List: true, + Summary: "List what is trending", + Long: "Trending is the clearest case for reading pages. There is no JSON version\n" + + "of it anywhere, with a token or without, so a page decoder is not a\n" + + "fallback here, it is the only implementation that can exist.", + }, listTrending) + + kit.Handle(app, kit.OpMeta{ + Name: "topic", Group: "discover", URIType: KindTopic, Single: true, Resolver: true, + Summary: "Read one topic page", + Long: "The search result for a topic has a name and a blurb. The page has the long\n" + + "description, the logo, who created the thing, when it was released, the\n" + + "Wikipedia link, and the related topics, which is most of what makes a topic\n" + + "worth a record.", + Args: []kit.Arg{{Name: "name", Help: "topic slug, URL, or github:// URI"}}, + }, getTopic) + + kit.Handle(app, kit.OpMeta{ + Name: "forks", Group: "discover", URIType: KindRepo, List: true, + Summary: "List a repository's public forks", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listForks) + + kit.Handle(app, kit.OpMeta{ + Name: "contributors", Group: "discover", URIType: KindContributor, List: true, + Summary: "List contributors with their commit, addition, and deletion counts", + Long: "This reads the contributor graph's own data route, which answers 202 with\n" + + "an empty body while GitHub computes the numbers. That is normal rather\n" + + "than an error, so the first call on a large repository waits a few seconds\n" + + "and every call after it is instant.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listContributors) + + kit.Handle(app, kit.OpMeta{ + Name: "languages", Group: "discover", URIType: KindRepo, List: true, + Summary: "Report the language histogram, one record per language", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listLanguages) + + kit.Handle(app, kit.OpMeta{ + Name: "stats", Group: "discover", URIType: KindRepo, Single: true, + Summary: "Report a repository's counts and nothing else", + Long: "Every field here is on the repository record too. The point of having it\n" + + "separately is that a record with eight numbers in it is something you can\n" + + "store once a day and diff, and a record with a readme in it is not.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, getStats) +} + +func listTrending(ctx context.Context, in trendingIn, emit func(any) error) error { + opts := TrendingOptions{ + Since: in.Since, + Language: in.Language, + SpokenLanguage: in.SpokenLanguage, + Limit: in.Limit, + } + if in.Developers { + return in.C.TrendingDevelopers(ctx, opts, func(a Account) error { return emit(&a) }) + } + return in.C.Trending(ctx, opts, func(t Trending) error { return emit(&t) }) +} + +func getTopic(ctx context.Context, in topicIn, emit func(*Topic) error) error { + slug, err := ResolveRef(KindTopic, in.Name) + if err != nil { + return err + } + t, err := in.C.TopicPage(ctx, slug) + if err != nil { + return err + } + return emit(t) +} + +func listForks(ctx context.Context, in repoListIn, emit func(*Repo) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Forks(ctx, repo, in.Limit, byValue(emit)) +} + +type contributorsIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"owner/name, or any URL from the repository"` + Weeks bool `kit:"flag" help:"keep the per-week breakdown, which is large"` + Limit int `kit:"flag,inherit"` +} + +func listContributors(ctx context.Context, in contributorsIn, emit func(*Contributor) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Contributors(ctx, repo, ContributorOptions{Weeks: in.Weeks, Limit: in.Limit}, byValue(emit)) +} + +func listLanguages(ctx context.Context, in repoRefIn, emit func(*LanguageShare) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Languages(ctx, repo, byValue(emit)) +} + +func getStats(ctx context.Context, in repoRefIn, emit func(*RepoStats) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + s, err := in.C.Stats(ctx, repo) + if err != nil { + return err + } + return emit(s) +} + // --- meta --- func registerMetaOps(app *kit.App) { diff --git a/gh/people.go b/gh/people.go new file mode 100644 index 0000000..23f7c42 --- /dev/null +++ b/gh/people.go @@ -0,0 +1,738 @@ +package gh + +import ( + "context" + "encoding/xml" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" + + "github.com/tamnd/any-cli/kit/errs" + "github.com/tamnd/github-cli/pkg/page" +) + +// people.go is everything that hangs off an account: who follows whom, what +// they starred, what they published, and what they did. +// +// None of it has a JSON payload. The profile tabs, the organization roster, and +// the gist index are all Rails, and the activity stream is Atom. That is the +// whole reason this file exists as its own unit: the surfaces here share a +// pager and a row shape with each other and with nothing else in the tool. +// +// The pager is the "rails" one from surface.go: fetch a page, decode the rows, +// look for the link that says next. Two templates write that link two ways and +// nextPageHref knows both. + +// Followers lists the accounts following a login, newest first, which is the +// order the page uses and the only order it offers. +func (c *Client) Followers(ctx context.Context, login string, limit int, emit func(Account) error) error { + return c.profileAccounts(ctx, login, "followers", limit, emit) +} + +// Following lists the accounts a login follows. +func (c *Client) Following(ctx context.Context, login string, limit int, emit func(Account) error) error { + return c.profileAccounts(ctx, login, "following", limit, emit) +} + +func (c *Client) profileAccounts(ctx context.Context, login, tab string, limit int, emit func(Account) error) error { + if login == "" || strings.Contains(login, "/") { + return usageBadID("account", login, "a bare login") + } + base := query(accountURL(login), "tab", tab) + fetch := func(ctx context.Context, token string) ([]Account, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, "", structureChanged(login + " " + tab) + } + var out []Account + for _, row := range page.FindAll(doc, page.Sel{Class: "d-table"}) { + a, ok := followRow(row, res.FinalURL) + if ok { + out = append(out, a) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// followRow reads one row of a followers or following list. The row has the +// login twice, once in the avatar link and once in the muted span, and the +// display name in the primary span when the person set one. +func followRow(row *html.Node, source string) (Account, bool) { + link := page.Find(row, page.Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "user"}) + if link == nil { + return Account{}, false + } + who := actorFromHref(page.Attr(link, "href")) + if who.Login == "" { + return Account{}, false + } + a := Account{Login: who.Login, Type: "User"} + a.setIdentity(KindUser, who.Login) + if n := page.Find(row, page.Sel{Class: "Link--primary"}); n != nil { + a.Name = page.Text(n) + } + if img := page.Find(row, page.Sel{Tag: "img", Class: "avatar-user"}); img != nil { + a.AvatarURL = page.Attr(img, "src") + } + a.addSource(source) + return a, true +} + +// Members lists an organization's public members. The roster is at +// /orgs/{login}/people rather than on the profile, and the profile's avatar +// strip is a sample of it rather than a short version of it. +func (c *Client) Members(ctx context.Context, login string, limit int, emit func(Account) error) error { + if login == "" || strings.Contains(login, "/") { + return usageBadID("organization", login, "a bare login") + } + base := BaseURL + "/orgs/" + login + "/people" + fetch := func(ctx context.Context, token string) ([]Account, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, "", structureChanged(login + " members") + } + var out []Account + for _, li := range page.FindAll(doc, page.Sel{Class: "member-list-item"}) { + m, ok := memberRow(li, res.FinalURL) + if ok { + out = append(out, m) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// memberRow reads one member. The role is behind a batch-deferred fragment that +// needs a session, so it is absent here rather than wrong. +func memberRow(li *html.Node, source string) (Account, bool) { + name := page.Find(li, page.Sel{Tag: "a", Attr: "id", AttrPrefix: "member-"}) + if name == nil { + return Account{}, false + } + who := actorFromHref(page.Attr(name, "href")) + if who.Login == "" { + return Account{}, false + } + a := Account{Login: who.Login, Type: "User", Name: page.Text(name)} + a.setIdentity(KindUser, who.Login) + // The name anchor falls back to the login when the person set no display + // name, and a Name that repeats the Login says nothing. + if a.Name == a.Login { + a.Name = "" + } + if img := page.Find(li, page.Sel{Tag: "img", Class: "avatar-user"}); img != nil { + a.AvatarURL = page.Attr(img, "src") + if id := avatarUserID(page.Attr(img, "src")); id > 0 { + a.DatabaseID = intp(id) + } + } + a.addSource(source) + return a, true +} + +// avatarUserID pulls the numeric account id out of an avatar URL. It is the +// only place a listing states it, and having it lets a record join to search +// results, which key on the same number. +func avatarUserID(src string) int { + _, rest, ok := strings.Cut(src, "/u/") + if !ok { + return 0 + } + digits, _, _ := strings.Cut(rest, "?") + n, err := strconv.Atoi(digits) + if err != nil { + return 0 + } + return n +} + +// Starred lists the repositories an account has starred. It is a different +// template from the repositories tab, so it gets its own row reader even though +// the two records are the same shape. +func (c *Client) Starred(ctx context.Context, login string, limit int, emit func(Repo) error) error { + return c.profileRepos(ctx, login, "stars", limit, emit) +} + +// ReposAsShown lists an account's repositories in the order and with the +// filters the profile tab itself uses. `github repos --user x` runs a search +// instead, which sorts and pages better; this is what --as-shown selects when +// the exact page order is the point. +func (c *Client) ReposAsShown(ctx context.Context, login string, limit int, emit func(Repo) error) error { + return c.profileRepos(ctx, login, "repositories", limit, emit) +} + +func (c *Client) profileRepos(ctx context.Context, login, tab string, limit int, emit func(Repo) error) error { + if login == "" || strings.Contains(login, "/") { + return usageBadID("account", login, "a bare login") + } + base := query(accountURL(login), "tab", tab) + fetch := func(ctx context.Context, token string) ([]Repo, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, "", structureChanged(login + " " + tab) + } + var out []Repo + for _, h := range repoCardHeadings(doc) { + if r, ok := repoCard(h, res.FinalURL); ok { + out = append(out, r) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// repoCardHeadings finds the heading of every repository card on a listing +// page. The repositories tab marks the name with microdata and the stars tab +// does not, so both hooks are tried and the results are kept in document order +// rather than merged, since no page uses both. +func repoCardHeadings(doc *html.Node) []*html.Node { + if named := page.FindAll(doc, page.Sel{Attr: "itemprop", AttrValue: "name codeRepository"}); len(named) > 0 { + return named + } + var out []*html.Node + for _, h := range page.FindAll(doc, page.Sel{Tag: "h3"}) { + if a := page.Find(h, page.Sel{Tag: "a", Attr: "href"}); a != nil { + if p := hrefPath(page.Attr(a, "href")); strings.Count(p, "/") == 1 { + out = append(out, a) + } + } + } + return out +} + +// repoCard reads a repository out of a listing card, walking up from the name +// link to the row that holds the rest of the fields. +func repoCard(nameLink *html.Node, source string) (Repo, bool) { + id := hrefPath(page.Attr(nameLink, "href")) + if strings.Count(id, "/") != 1 { + return Repo{}, false + } + owner, name, ok := SplitRepo(id) + if !ok { + return Repo{}, false + } + r := Repo{Owner: owner, Name: name} + r.setIdentity(KindRepo, id) + r.addSource(source) + + row := cardRow(nameLink) + if row == nil { + return r, true + } + if d := page.Find(row, page.Sel{Attr: "itemprop", AttrValue: "description"}); d != nil { + r.Description = page.Text(d) + } + if l := page.Find(row, page.Sel{Attr: "itemprop", AttrValue: "programmingLanguage"}); l != nil { + r.Language = page.Text(l) + } + if c := page.Find(row, page.Sel{Class: "repo-language-color"}); c != nil { + r.LanguageColor = styleColor(page.Attr(c, "style")) + } + for _, a := range page.FindAll(row, page.Sel{Tag: "a", Attr: "href"}) { + href := page.Attr(a, "href") + n, _, ok := page.ParseCompactCount(page.Text(a)) + if !ok { + continue + } + switch { + case strings.HasSuffix(href, "/stargazers"): + r.Stars = intp(n) + case strings.HasSuffix(href, "/forks"): + r.Forks = intp(n) + } + } + if t := page.Find(row, page.RelTimeEl); t != nil { + r.PushedAt = parseTime(page.Attr(t, "datetime")) + } + for _, tag := range page.FindAll(row, page.Sel{Class: "topic-tag"}) { + if s := page.Text(tag); s != "" { + r.Topics = append(r.Topics, s) + } + } + return r, true +} + +// cardRow walks up to the element that contains a whole listing card. Four +// levels is what separates the name link from the row on every template that +// uses one, and stopping there keeps a malformed page from handing back the +// document root and with it every field on it. +func cardRow(n *html.Node) *html.Node { + for i := 0; i < 4 && n != nil; i++ { + n = n.Parent + if n == nil { + return nil + } + if n.Type == html.ElementNode && (n.Data == "li" || page.HasClass(n, "col-12") || page.HasClass(n, "Box-row")) { + return n + } + } + return n +} + +// styleColor pulls a colour out of an inline style, which is where GitHub puts +// the language colour on every listing template. +func styleColor(style string) string { + _, rest, ok := strings.Cut(style, "background-color:") + if !ok { + return "" + } + v, _, _ := strings.Cut(rest, ";") + return strings.TrimSpace(v) +} + +// --- gists --- + +// Gists lists an account's public gists. +func (c *Client) Gists(ctx context.Context, login string, limit int, emit func(Gist) error) error { + if login == "" || strings.Contains(login, "/") { + return usageBadID("account", login, "a bare login") + } + base := GistURL + "/" + login + fetch := func(ctx context.Context, token string) ([]Gist, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, "", structureChanged(login + " gists") + } + var out []Gist + for _, snip := range page.FindAll(doc, page.Sel{Class: "gist-snippet"}) { + if g, ok := gistSnippet(snip, res.FinalURL); ok { + out = append(out, g) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// gistSnippet reads one entry of a gist index. The counts are in the text of +// the links beside it: "1 file", "6 forks", "62 stars". +func gistSnippet(snip *html.Node, source string) (Gist, bool) { + var id, owner string + for _, a := range page.FindAll(snip, page.Sel{Tag: "a", Attr: "href"}) { + p := hrefPath(page.Attr(a, "href")) + o, rest, ok := strings.Cut(p, "/") + if !ok { + continue + } + hex, _, _ := strings.Cut(rest, "/") + if isGistID(hex) { + owner, id = o, hex + break + } + } + if id == "" { + return Gist{}, false + } + g := Gist{Owner: owner, IsPublic: true} + g.setIdentity(KindGist, id) + g.addSource(source) + for _, a := range page.FindAll(snip, page.Sel{Tag: "a", Attr: "href"}) { + text := page.Text(a) + n, _, ok := page.CountIn(text) + if !ok { + continue + } + switch { + case strings.HasSuffix(text, "file"), strings.HasSuffix(text, "files"): + g.FileCount = intp(n) + case strings.HasSuffix(text, "fork"), strings.HasSuffix(text, "forks"): + g.Forks = intp(n) + case strings.HasSuffix(text, "star"), strings.HasSuffix(text, "stars"): + g.Stars = intp(n) + } + } + if d := page.Find(snip, page.Sel{Class: "gist-snippet-meta"}); d != nil { + if p := page.Find(d, page.Sel{Tag: "span", Class: "f6"}); p != nil { + g.Description = page.Text(p) + } + } + if t := page.Find(snip, page.RelTimeEl); t != nil { + g.UpdatedAt = parseTime(page.Attr(t, "datetime")) + } + return g, true +} + +// Gist reads one gist and its file list. Contents are a second request per file +// and are opt-in, because a gist can hold a megabyte of log paste. +func (c *Client) Gist(ctx context.Context, id string, withContent bool) (*Gist, error) { + id = strings.TrimSpace(id) + if i := strings.LastIndex(id, "/"); i >= 0 { + id = id[i+1:] + } + if !isGistID(id) { + return nil, usageBadID("gist", id, "a hexadecimal gist id") + } + res, err := c.GetHTML(ctx, GistURL+"/"+id) + if err != nil { + return nil, err + } + p := page.Extract(res.FinalURL, res.Body) + doc := p.Doc() + if doc == nil { + return nil, structureChanged(id) + } + g := &Gist{IsPublic: true} + g.setIdentity(KindGist, id) + g.addSource(res.FinalURL) + g.Owner = hrefOwner(res.FinalURL) + if d := page.Find(doc, page.Sel{Attr: "itemprop", AttrValue: "about"}); d != nil { + g.Description = page.Text(d) + } + if t := page.Find(doc, page.RelTimeEl); t != nil { + g.UpdatedAt = parseTime(page.Attr(t, "datetime")) + } + for _, box := range page.FindAll(doc, page.Sel{Class: "file"}) { + f, ok := gistFile(box) + if !ok { + continue + } + g.Files = append(g.Files, f) + } + if len(g.Files) == 0 { + return nil, structureChanged(id) + } + g.FileCount = intp(len(g.Files)) + if withContent { + for i := range g.Files { + text, err := c.text(ctx, g.Files[i].RawURL) + if err != nil { + return nil, err + } + g.Files[i].Content = text + } + } + return g, nil +} + +// gistFile reads one file block. The raw link is the useful half: it is the +// only address on the page that returns the bytes rather than the rendering. +func gistFile(box *html.Node) (GistFile, bool) { + name := page.Find(box, page.Sel{Class: "gist-blob-name"}) + if name == nil { + return GistFile{}, false + } + f := GistFile{Name: page.Text(name)} + if f.Name == "" { + return GistFile{}, false + } + for _, a := range page.FindAll(box, page.Sel{Tag: "a", Attr: "href", AttrContains: "/raw/"}) { + f.RawURL = absoluteGistURL(page.Attr(a, "href")) + break + } + if f.RawURL == "" { + return GistFile{}, false + } + if i := strings.LastIndex(f.Name, "."); i > 0 { + f.Language = f.Name[i+1:] + } + return f, true +} + +// text fetches a URL and returns it as a string. It is the small sibling of +// Raw, for the addresses that are already absolute. +func (c *Client) text(ctx context.Context, rawURL string) (string, error) { + res, err := c.Get(ctx, rawURL, SurfaceRaw) + if err != nil { + return "", err + } + return string(res.Body), nil +} + +func absoluteGistURL(href string) string { + if strings.Contains(href, "://") { + return href + } + return GistURL + "/" + strings.TrimPrefix(href, "/") +} + +func hrefOwner(rawURL string) string { + p := hrefPath(rawURL) + owner, rest, ok := strings.Cut(p, "/") + if !ok || !isGistID(rest) { + return "" + } + return owner +} + +// isGistID matches the twenty-or-more hexadecimal characters a gist is named +// with. Anything shorter is a login or a route word. +func isGistID(s string) bool { + if len(s) < 20 { + return false + } + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +// --- the contribution calendar --- + +// Contributions reads a year of a profile's contribution graph, one record per +// day. This is the only representation of the numbers that exists without a +// token: the GraphQL field that carries them refuses anonymous callers. +// +// A year is the largest window the fragment serves. Asking for a wider range +// gets the last year, so the range is stated rather than inferred. +func (c *Client) Contributions(ctx context.Context, login string, year int, emit func(ContributionDay) error) error { + if login == "" || strings.Contains(login, "/") { + return usageBadID("account", login, "a bare login") + } + if year == 0 { + year = time.Now().UTC().Year() + } + from := strconv.Itoa(year) + "-01-01" + to := strconv.Itoa(year) + "-12-31" + u := query(BaseURL+"/users/"+login+"/contributions", "from", from, "to", to) + // HTML rather than XHR. This is a fragment the front end swaps into the + // profile, so it serves markup and answers 406 to a request that asks for + // JSON, which the client reports as a response rather than an error and + // would show up here as an empty calendar. + res, err := c.Get(ctx, u, SurfaceHTML) + if err != nil { + return err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return structureChanged(login + " contributions") + } + + // The count is not on the cell. Each cell points at a tooltip by id and the + // tooltip holds the sentence with the number in it, so the tooltips are + // indexed first and the cells read against that index. + counts := map[string]int{} + for _, tip := range page.FindAll(doc, page.Sel{Tag: "tool-tip"}) { + counts[page.Attr(tip, "for")] = leadingCount(page.Text(tip)) + } + found := false + for _, td := range page.FindAll(doc, page.Sel{Tag: "td", Attr: "data-date"}) { + day := ContributionDay{Login: login} + at := parseTime(page.Attr(td, "data-date")) + if at == nil { + continue + } + found = true + day.Date = *at + day.Level, _ = strconv.Atoi(page.Attr(td, "data-level")) + day.Count = counts[page.Attr(td, "id")] + day.Kind = KindContribution + day.ID = login + "@" + page.Attr(td, "data-date") + day.URI = URI(KindContribution, day.ID) + day.URL = accountURL(login) + day.addSource(res.FinalURL) + if err := emit(day); err != nil { + return err + } + } + if !found { + return structureChanged(login + " contributions") + } + return nil +} + +// leadingCount reads the number off the front of "7 contributions on January +// 4th." and treats "No contributions" as the zero it is. +func leadingCount(s string) int { + field, _, _ := strings.Cut(strings.TrimSpace(s), " ") + n, err := strconv.Atoi(strings.ReplaceAll(field, ",", "")) + if err != nil { + return 0 + } + return n +} + +// --- activity --- + +// Activity reads a public event stream. The same feed shape serves an account +// and a repository, so the argument is either a login or owner/name and the +// URL is the only thing that differs. +// +// This replaces the REST events endpoint outright. The feed is public, cheap, +// and needs no credential, and the event class is encoded in each entry's id, +// so the type comes from a field rather than from matching on prose. +func (c *Client) Activity(ctx context.Context, ref string, limit int, emit func(Event) error) error { + ref = strings.Trim(ref, "/") + if ref == "" { + return usageBadID("account or repository", ref, "a login or owner/name") + } + u := feedURL(ref + ".atom") + if _, _, ok := SplitRepo(ref); ok { + u = repoSubURL(ref, "commits.atom") + } + res, err := c.Get(ctx, u, SurfaceFeed) + if err != nil { + return err + } + var feed atomFeed + if err := xml.Unmarshal(res.Body, &feed); err != nil { + return badPayload(shortURL(u), err) + } + seen := 0 + for _, e := range feed.Entries { + ev := e.event(res.FinalURL) + if err := emit(ev); err != nil { + return err + } + seen++ + if limit > 0 && seen >= limit { + return nil + } + } + if seen == 0 { + return errs.NotFound("%s: the feed carried no entries", shortURL(u)) + } + return nil +} + +// atomFeed is the shape all five of GitHub's feeds share. Only the id encoding +// differs between them, and that is read per entry rather than per feed. +type atomFeed struct { + Title string `xml:"title"` + Updated string `xml:"updated"` + Entries []atomEntry `xml:"entry"` +} + +type atomEntry struct { + ID string `xml:"id"` + Title string `xml:"title"` + Published string `xml:"published"` + Updated string `xml:"updated"` + Content string `xml:"content"` + Link struct { + Href string `xml:"href,attr"` + } `xml:"link"` + Author struct { + Name string `xml:"name"` + URI string `xml:"uri"` + } `xml:"author"` + Thumbnail struct { + URL string `xml:"url,attr"` + } `xml:"thumbnail"` +} + +// event turns one entry into a record. The id is +// "tag:github.com,2008:push/15757005823", so the segment after the colon is the +// event class and the tool never has to read the localised title to find out +// what happened. +func (e atomEntry) event(source string) Event { + ev := Event{Title: page.FragmentText(e.Title)} + _, tail, _ := strings.Cut(e.ID, "2008:") + class, rest, _ := strings.Cut(tail, "/") + ev.Type = eventType(class) + ev.Kind = KindEvent + ev.ID = e.ID + if ev.Type != "" && rest != "" { + ev.ID = ev.Type + "/" + rest + } + ev.URI = URI(KindEvent, ev.ID) + ev.URL = e.Link.Href + ev.Target = e.Link.Href + ev.At = firstTime(e.Published, e.Updated) + ev.BodyHTML = e.Content + if e.Author.Name != "" { + ev.Actor = actor(e.Author.Name) + ev.Actor.AvatarURL = e.Thumbnail.URL + } + // The alternate link points at whatever the event touched, and the first + // two segments of it are the repository whenever there is one. + if p := hrefPath(e.Link.Href); strings.Count(p, "/") >= 1 { + owner, rest, _ := strings.Cut(p, "/") + name, _, _ := strings.Cut(rest, "/") + if owner != "" && name != "" && !routeWord[name] { + ev.Repo = owner + "/" + name + } + } + ev.addSource(source) + return ev +} + +// eventType turns the class out of the entry id into one word. +// +// A person's feed names the class in lower case, push or fork or watch. A +// repository's commit feed names it after the Ruby object that used to render +// it, Grit::Commit, which is an implementation detail from 2008 and not a thing +// anyone should have to filter on. +func eventType(class string) string { + if _, tail, ok := strings.Cut(class, "::"); ok { + class = tail + } + return strings.ToLower(class) +} + +func firstTime(ss ...string) *time.Time { + for _, s := range ss { + if t := parseTime(s); t != nil { + return t + } + } + return nil +} + +// --- the rails pager --- + +// nextPageToken returns the page number to ask for next, or empty when the page +// says there is no next. +// +// Two templates write the same link two ways: the organization roster marks it +// rel="next" and the profile tabs use a plain anchor whose text is Next. Both +// are checked because both are load-bearing. +func railsNext(doc *html.Node, token string) string { + if !hasNextLink(doc) { + return "" + } + return strconv.Itoa(pageToken(token) + 1) +} + +func hasNextLink(doc *html.Node) bool { + if page.Find(doc, page.NextPage) != nil { + return true + } + for _, a := range page.FindAll(doc, page.Sel{Tag: "a", Attr: "href"}) { + if strings.EqualFold(strings.TrimSpace(page.Text(a)), "next") { + return true + } + } + return false +} diff --git a/gh/repo.go b/gh/repo.go index e8577c7..51ae7ac 100644 --- a/gh/repo.go +++ b/gh/repo.go @@ -462,10 +462,16 @@ func descriptionFromOG(title, id string) string { // --- the deep pass --- -// deepenRepo runs the extra fetch --deep opts into: the dependent count off the -// dependency graph. The failure is soft. A dependency graph that is disabled is -// a fact about the repository, not an error in the read. +// deepenRepo runs the two extra fetches --deep opts into: the sidebar +// fragment, which is where the language histogram and the contributor count +// actually live, and the dependency graph for the dependent count. Both +// failures are soft. A dependency graph that is disabled is a fact about the +// repository, not an error in the read. func (c *Client) deepenRepo(ctx context.Context, r *Repo) error { + if sb, err := c.sidebar(ctx, r.ID); err == nil && sb != nil { + sb.apply(r) + r.addSource(repoSubURL(r.ID, "_sidebar")) + } if n, err := c.dependents(ctx, r.ID); err == nil && n != nil { r.DependentCount = n } @@ -473,16 +479,92 @@ func (c *Client) deepenRepo(ctx context.Context, r *Repo) error { return nil } +// sidebarData is the fragment the repository page's own front end fetches to +// fill the About column in. Everything on it is deferred, which is why a cold +// page has a skeleton where the language bar goes. +type sidebarData struct { + Languages *struct { + Languages []sidebarLanguage `json:"languages"` + } `json:"languages"` + Contributors *struct { + ContributorCount *int `json:"contributorCount"` + } `json:"contributors"` + UsedBy *struct { + DependentsCount *int `json:"dependentsCount"` + } `json:"usedBy"` +} + +type sidebarLanguage struct { + Name string `json:"name"` + Percentage float64 `json:"percentage"` + Color string `json:"color"` +} + +// sidebar reads /{owner}/{repo}/_sidebar. +// +// This is the answer to a question the rest of this file used to give up on. +// /{owner}/{repo}/graphs/languages 301s back to the repository page for an +// anonymous client, and none of show_partial, /languages, or +// /graphs/languages-data exist, so the conclusion was that the histogram had no +// keyless source. It has one: the same fragment the page itself waits for, and +// it needs no credential, only the header that says a script is asking. +func (c *Client) sidebar(ctx context.Context, id string) (*sidebarData, error) { + if _, _, ok := SplitRepo(id); !ok { + return nil, usageBadID("repository", id, "owner/name") + } + res, err := c.Get(ctx, repoSubURL(id, "_sidebar"), SurfaceXHR) + if err != nil { + return nil, err + } + var sb sidebarData + if err := json.Unmarshal(res.Body, &sb); err != nil { + return nil, badPayload(id, err) + } + return &sb, nil +} + +// apply folds the fragment into the record. +// +// The percentages are what the fragment states, so they are stored as +// percentages times one hundred and marked as such. A byte count and a +// percentage are not the same number and nothing downstream should be able to +// mistake one for the other. +func (sb *sidebarData) apply(r *Repo) { + if sb.Contributors != nil && sb.Contributors.ContributorCount != nil { + r.ContributorCount = sb.Contributors.ContributorCount + } + if sb.UsedBy != nil && sb.UsedBy.DependentsCount != nil { + r.DependentCount = sb.UsedBy.DependentsCount + } + langs := sb.langs() + if len(langs) == 0 { + return + } + out := map[string]int64{} + for _, l := range langs { + out[l.Name] = int64(l.Percentage * 100) + } + r.Languages = out + r.Language = topLanguage(out) + if r.LanguageColor == "" { + r.LanguageColor = langs[0].Color + } + recordVia(&r.Base, "languages", "sidebar-percent") +} + +func (sb *sidebarData) langs() []sidebarLanguage { + if sb == nil || sb.Languages == nil { + return nil + } + return sb.Languages.Languages +} + // searchLanguage asks repository search for the repository by name, because the // search result carries the primary language and its colour and the repository // page does not. // -// The obvious place to look is /{owner}/{repo}/graphs/languages, and it is a -// dead end: it 301s back to the repository page for an anonymous client, and -// none of show_partial, /languages, or /graphs/languages-data exist. The -// language bar in the sidebar is the other source, and on a cold page it is a -// skeleton with no /search?l= links in it at all. So the histogram with real -// byte counts has no keyless source, and the language name does, one search away. +// This is the shallow path. It is one request and gives the primary language +// only; the sidebar fragment gives the whole histogram and is what --deep uses. func (c *Client) searchLanguage(ctx context.Context, id string) (lang, color string, err error) { owner, name, ok := SplitRepo(id) if !ok { diff --git a/gh/types.go b/gh/types.go index 8c66958..67dbc49 100644 --- a/gh/types.go +++ b/gh/types.go @@ -58,6 +58,7 @@ type Repo struct { TagCount *int `json:"tag_count,omitempty" table:"-"` FileCount *int `json:"file_count,omitempty" table:"-"` DependentCount *int `json:"dependent_count,omitempty" table:"-"` + ContributorCount *int `json:"contributor_count,omitempty" table:"-"` // License comes from one sidebar anchor and from nowhere else on any // keyless surface. See page.LicenseLink. @@ -763,8 +764,10 @@ type Trending struct { // --- contributions --- // Contributor is one person's contribution statistics for a repository. Weeks -// arrives with the response so it is kept by default, and it is never a table -// column because a hundred weeks is not a column. +// arrives with the response but is dropped unless asked for, because the route +// sends every week since the repository began for every contributor and that is +// megabytes of mostly zeroes. It is never a table column either way, because a +// hundred weeks is not a column. type Contributor struct { Base @@ -821,3 +824,40 @@ type Event struct { Target string `json:"target,omitempty" table:"-"` At *time.Time `json:"at,omitempty" table:"at,time"` } + +// --- projections --- + +// LanguageShare is one language of one repository. The repository record +// carries the same numbers as a map, which is the right shape to keep and the +// wrong shape to print, so this is the row form of it. +type LanguageShare struct { + Base + + Repo string `json:"repo" table:"repo"` + Language string `json:"language" table:"language"` + Percent float64 `json:"percent" table:"percent"` + Color string `json:"color,omitempty" table:"-"` +} + +// RepoStats is the counts and nothing else. +// +// Every field is already on Repo. The reason to have it separately is that a +// record with eight numbers in it is something you can store once a day and +// diff; a record with a readme in it is not. +type RepoStats struct { + Base + + Repo string `json:"repo" table:"repo"` + + Stars *int `json:"stars,omitempty" table:"stars"` + Forks *int `json:"forks,omitempty" table:"forks"` + Watchers *int `json:"watchers,omitempty" table:"watching"` + OpenIssues *int `json:"open_issues,omitempty" table:"issues"` + Commits *int `json:"commits,omitempty" table:"commits"` + Releases *int `json:"releases,omitempty" table:"releases"` + Tags *int `json:"tags,omitempty" table:"tags"` + Contributors *int `json:"contributors,omitempty" table:"people"` + Dependents *int `json:"dependents,omitempty" table:"used_by"` + + PushedAt *time.Time `json:"pushed_at,omitempty" table:"pushed,time"` +} diff --git a/gh/uri.go b/gh/uri.go index f2221db..02d55b2 100644 --- a/gh/uri.go +++ b/gh/uri.go @@ -47,6 +47,14 @@ const ( KindWiki = "wiki" KindAdvisory = "advisory" KindCompare = "compare" + + // These three name records GitHub derives rather than serves. There is no + // page whose address is one contributor's statistics or one day of a + // calendar, so they get a URI and no canonical URL, and Locate points at + // the page they were read from instead of inventing one. + KindContributor = "contributor" + KindContribution = "contribution" + KindEvent = "event" ) // Scheme is the URI scheme this package mints and dereferences. @@ -163,7 +171,8 @@ func knownKind(k string) bool { switch k { case KindRepo, KindUser, KindOrg, KindIssue, KindPR, KindDiscussion, KindCommit, KindBranch, KindTag, KindRelease, KindFile, KindTree, KindLabel, KindMilestone, - KindTopic, KindGist, KindPackage, KindAction, KindWiki, KindAdvisory, KindCompare: + KindTopic, KindGist, KindPackage, KindAction, KindWiki, KindAdvisory, KindCompare, + KindContributor, KindContribution, KindEvent: return true } return false @@ -477,7 +486,26 @@ func Locate(kind, id string) (string, error) { case KindAdvisory: return BaseURL + "/advisories/" + id, nil case KindGist: - return "https://gist.github.com/" + id, nil + return GistURL + "/" + id, nil + case KindContributor: + // The id is owner/name@login and the page that states it is the graph, + // which is the whole roster rather than the one row. That is the + // closest true address, so it is the one given. + repo, _, ok := cutRev(id) + if !ok { + return "", errs.Usage("contributor id %q is not owner/name@login", id) + } + return BaseURL + "/" + repo + "/graphs/contributors", nil + case KindContribution: + login, _, ok := cutRev(id) + if !ok { + return "", errs.Usage("contribution id %q is not login@date", id) + } + return BaseURL + "/" + login, nil + case KindEvent: + // An event's address is the thing it happened to, which the feed states + // per entry and no rule can reconstruct from the id. + return "", errs.Usage("an event has no address of its own; read its url field") case KindCompare: repo, rng, ok := cutRev(id) if !ok { From 7fcae273a3cc3c8b9c9bf2a3fdc68e4e83058160 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:15:44 +0700 Subject: [PATCH 10/21] gh: the graph plane, plus the dependency graph pages Every reader so far produced records. This turns those records into a graph: one extractor per type that says what a record points at, five trust levels so a caller can ask for only the edges that came from an id rather than from prose, and a walk that follows them. The crawler is sequential on purpose. Doc 04 gives it a Concurrency knob and it would be a lie here, because every request already queues through one rate limiter, so workers would only queue deeper behind the same pacer while making the output order unpredictable. deps and dependents are the two dependency graph pages. They are the most valuable keyless surface on the site and the least reliable one, and they need two readers rather than one: the rows have different shapes, and so do the two pagers, ?page=N with a rel="next" anchor on one side and an opaque cursor in a button on the other. The empty-page retry in rowPage is not defensive coding, it is the fix for a real truncation. GitHub answers a cursor page with a 200, the right title, and no rows often enough to hit on the first repo tried, and the walk cannot tell that from the end of the list, so it stopped at sixty rows out of two hundred and then cached the empty page and kept stopping there for fifteen minutes. rdf and export are byte-plane commands for the same reason cat is: N-Triples is a serialisation with its own rules, not a record, and pushing it through the record renderer would produce something that is neither. --- cli/export.go | 118 ++++++ cli/rdf.go | 121 ++++++ cli/root.go | 2 + gh/crawl.go | 313 ++++++++++++++ gh/deps.go | 263 ++++++++++++ gh/graph.go | 949 ++++++++++++++++++++++++++++++++++++++++++ gh/ops.go | 194 +++++++++ gh/rdf.go | 617 +++++++++++++++++++++++++++ gh/types.go | 38 ++ pkg/page/selectors.go | 38 +- 10 files changed, 2650 insertions(+), 3 deletions(-) create mode 100644 cli/export.go create mode 100644 cli/rdf.go create mode 100644 gh/crawl.go create mode 100644 gh/deps.go create mode 100644 gh/graph.go create mode 100644 gh/rdf.go diff --git a/cli/export.go b/cli/export.go new file mode 100644 index 0000000..8b17e1c --- /dev/null +++ b/cli/export.go @@ -0,0 +1,118 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" + "github.com/tamnd/github-cli/gh" +) + +// export.go writes a whole graph to one file. Everything it does can be had by +// redirecting `github crawl` or `github rdf`, and it exists because what people +// want at the end of a walk is one file they can load somewhere else, named once +// rather than assembled out of two commands and a shell operator. + +type exportCmd struct { + format string + out string + depth int + follow []string + minTrust string + limit int +} + +func newExportCmd() kit.Command { + c := &exportCmd{} + return kit.Command{ + Use: "export ", + Short: "Write a whole graph to one file", + Long: "export walks from the seed and writes the result in one go. The formats are\n" + + "jsonl (one node, edge, or fact per line), json (a single object), and the four\n" + + "RDF serialisations nt, ttl, jsonld, and nq.\n\n" + + "Without --out it writes to stdout, which makes it a drop-in for a pipeline.", + Group: "graph", + Args: kit.ExactArgs(1), + Flags: c.flags, + Run: c.run, + } +} + +func (c *exportCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.format, "format", "jsonl", "jsonl, json, nt, ttl, jsonld, or nq") + f.StringVarP(&c.out, "out", "O", "", "write here instead of stdout") + f.IntVar(&c.depth, "depth", 1, "walk this many edges out") + f.StringSliceVar(&c.follow, "follow", nil, "predicates to follow (default: the structural ones)") + f.StringVar(&c.minTrust, "min-trust", gh.DefaultMinTrust, "drop edges below this rule") + f.IntVar(&c.limit, "limit", 0, "stop after this many nodes") +} + +func (c *exportCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + kind, id, g, err := buildGraph(ctx, cl, args[0], graphWalk{ + depth: c.depth, + follow: c.follow, + minTrust: c.minTrust, + limit: c.limit, + }) + if err != nil { + return err + } + gh.SortEdges(g.Edges) + + out := io.Writer(os.Stdout) + if c.out != "" { + f, err := os.Create(c.out) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + out = f + } + w := bufio.NewWriter(out) + defer func() { _ = w.Flush() }() + + switch c.format { + case "jsonl": + return writeJSONL(w, g) + case "json": + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(g) + case gh.FormatNT, gh.FormatTurtle, gh.FormatJSONLD, gh.FormatNQuads: + graph, _ := gh.Locate(kind, id) + return gh.WriteRDF(w, g, gh.RDFOptions{Format: c.format, Graph: graph}) + default: + return errs.Usage("unknown --format %q", c.format) + } +} + +// writeJSONL puts the nodes first and everything that points at them after, so a +// reader building an index in one pass never sees an edge before both of its +// ends. +func writeJSONL(w io.Writer, g *gh.Graph) error { + enc := json.NewEncoder(w) + for i := range g.Nodes { + if err := enc.Encode(&g.Nodes[i]); err != nil { + return err + } + } + for i := range g.Edges { + if err := enc.Encode(&g.Edges[i]); err != nil { + return err + } + } + for i := range g.Facts { + if err := enc.Encode(&g.Facts[i]); err != nil { + return err + } + } + return nil +} diff --git a/cli/rdf.go b/cli/rdf.go new file mode 100644 index 0000000..acdf944 --- /dev/null +++ b/cli/rdf.go @@ -0,0 +1,121 @@ +package cli + +import ( + "bufio" + "context" + "os" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/github-cli/gh" +) + +// rdf.go holds the linked-data output. It is a byte-plane command for the same +// reason cat is: N-Triples and Turtle are not records, they are a serialisation +// with their own rules, and putting them through the record renderer would +// produce something that is neither. + +type rdfCmd struct { + format string + graph string + depth int + follow []string + minTrust string + limit int +} + +func newRDFCmd() kit.Command { + c := &rdfCmd{} + return kit.Command{ + Use: "rdf ", + Short: "Write an entity as RDF triples", + Long: "rdf serialises one entity, its edges, and its literals. N-Triples is the\n" + + "default because it streams line by line, so a deep walk never needs the whole\n" + + "graph in memory; Turtle and JSON-LD do need it and are slower on large graphs\n" + + "for that reason.\n\n" + + "Subjects are the github.com URLs rather than the github:// URIs, so the output\n" + + "is dereferenceable by anything on the web. The URI is kept as a gh:uri\n" + + "literal, so nothing is lost.\n\n" + + "With --depth it walks first and serialises the whole result, which is how you\n" + + "get a loadable dataset rather than one subject.", + Group: "graph", + Args: kit.ExactArgs(1), + Flags: c.flags, + Run: c.run, + } +} + +func (c *rdfCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.format, "format", gh.FormatNT, "nt, ttl, jsonld, or nq") + f.StringVar(&c.graph, "graph", "", "the named graph for nq output (default: the entity URL)") + f.IntVar(&c.depth, "depth", 0, "walk this many edges out before serialising") + f.StringSliceVar(&c.follow, "follow", nil, "predicates to follow when walking") + f.StringVar(&c.minTrust, "min-trust", gh.DefaultMinTrust, "drop edges below this rule") + f.IntVar(&c.limit, "limit", 0, "stop a walk after this many nodes") +} + +func (c *rdfCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + kind, id, g, err := buildGraph(ctx, cl, args[0], graphWalk{ + depth: c.depth, + follow: c.follow, + minTrust: c.minTrust, + limit: c.limit, + }) + if err != nil { + return err + } + gh.SortEdges(g.Edges) + + w := bufio.NewWriter(os.Stdout) + defer func() { _ = w.Flush() }() + + graph := c.graph + if graph == "" { + graph, _ = gh.Locate(kind, id) + } + return gh.WriteRDF(w, g, gh.RDFOptions{Format: c.format, Graph: graph}) +} + +// graphWalk is the set of knobs rdf and export share. They are the same walk +// with a different writer on the end, so the flags are declared twice and read +// once. +type graphWalk struct { + depth int + follow []string + minTrust string + limit int +} + +// buildGraph resolves a reference and returns either the one entity or the whole +// walk, depending on depth. Both come back as a Graph, so the serialisers never +// need to know which it was. +// +// It holds the result in memory, which is the price of the formats that cannot +// stream. `github crawl` is the streaming answer for a walk too big for this. +func buildGraph(ctx context.Context, cl *gh.Client, ref string, o graphWalk) (string, string, *gh.Graph, error) { + kind, id, g, err := cl.GraphOfRef(ctx, ref) + if err != nil { + return "", "", nil, err + } + if o.depth <= 0 { + return kind, id, g, nil + } + g = &gh.Graph{} + err = cl.Crawl(ctx, gh.URI(kind, id), gh.CrawlOptions{ + Depth: o.depth, + Follow: o.follow, + MinTrust: o.minTrust, + Limit: o.limit, + }, gh.CrawlSink{ + Node: func(n *gh.Node) error { g.AddNode(*n); return nil }, + Edge: func(e *gh.Edge) error { g.Edges = append(g.Edges, *e); return nil }, + Fact: func(f *gh.Fact) error { g.Facts = append(g.Facts, *f); return nil }, + }) + if err != nil { + return "", "", nil, err + } + return kind, id, g, nil +} diff --git a/cli/root.go b/cli/root.go index def5d66..2c78431 100644 --- a/cli/root.go +++ b/cli/root.go @@ -38,5 +38,7 @@ func NewApp() *kit.App { app.AddCommand(newReadmeCmd()) app.AddCommand(newArchiveCmd()) app.AddCommand(newDiffCmd()) + app.AddCommand(newRDFCmd()) + app.AddCommand(newExportCmd()) return app } diff --git a/gh/crawl.go b/gh/crawl.go new file mode 100644 index 0000000..8322773 --- /dev/null +++ b/gh/crawl.go @@ -0,0 +1,313 @@ +package gh + +import ( + "context" + "fmt" + "strings" + + "github.com/tamnd/any-cli/kit/errs" +) + +// crawl.go walks the graph. The algorithm is a breadth-first frontier with a +// visited set and a hard budget, and that is all it should ever be: the +// interesting decisions here are about what not to follow and when to stop, not +// about traversal. +// +// The walk is sequential. Doc 04 section 5 gives the crawler a Concurrency +// field, and it would be a lie in this client: every request already queues +// through one rate limiter, so N workers would only queue N deep behind the same +// pacer while making the output order unpredictable. If the pacer ever grows a +// real parallel mode, this is the place to add the workers. + +// CrawlOptions bounds a walk. The budgets are the point of the struct. A tool +// that can accidentally send a million requests at somebody else's servers +// should be hard to point that way by accident. +type CrawlOptions struct { + Depth int + Follow []string + Kinds []string + MinTrust string + Limit int + + NodesOnly bool + EdgesOnly bool +} + +// CrawlSink receives what the walk finds. Emission is streaming: a crawl of a +// large organization must never need the whole graph in memory, and a crawl that +// is interrupted has already emitted everything it found. +type CrawlSink struct { + Node func(*Node) error + Edge func(*Edge) error + Fact func(*Fact) error +} + +// defaults fills in the spec's defaults. Depth 1 and the structural follow set +// are the safe walk: references, stars, follows, and the two dependency +// predicates fan out without bound, so each has to be asked for by name. +func (o *CrawlOptions) defaults() { + if o.Depth <= 0 { + o.Depth = 1 + } + if o.MinTrust == "" { + o.MinTrust = DefaultMinTrust + } + if len(o.Follow) == 0 { + o.Follow = DefaultFollow + } +} + +// followSet accepts a predicate written either way, so --follow ownedBy and +// --follow gh:ownedBy mean the same thing. The word "all" turns the filter off. +func followSet(follow []string) map[string]bool { + out := map[string]bool{} + for _, f := range follow { + for _, part := range strings.Split(f, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "all" { + return nil + } + out[strings.TrimPrefix(part, "gh:")] = true + } + } + return out +} + +// kindSet is the same idea for --kinds. An empty set expands every kind. +func kindSet(kinds []string) map[string]bool { + out := map[string]bool{} + for _, k := range kinds { + for _, part := range strings.Split(k, ",") { + part = strings.TrimSpace(part) + if part != "" { + out[part] = true + } + } + } + return out +} + +// splitURI takes a github:// URI apart. The crawler holds URIs rather than +// records, which is what keeps its memory proportional to the number of nodes +// seen and not to their size. +func splitURI(uri string) (kind, id string, ok bool) { + k, i, _, err := parseURI(uri) + if err != nil { + return "", "", false + } + return k, i, true +} + +// Crawl walks outward from a seed reference. Nodes, edges, and facts come out as +// they are discovered, and the walk stops cleanly at either bound and reports +// what it had rather than failing. +func (c *Client) Crawl(ctx context.Context, seed string, o CrawlOptions, sink CrawlSink) error { + o.defaults() + kind, id, err := Classify(seed) + if err != nil { + return err + } + allow := followSet(o.Follow) + expand := kindSet(o.Kinds) + + type item struct { + uri string + depth int + } + start := URI(kind, id) + visited := map[string]bool{start: true} + frontier := []item{{start, 0}} + nodes := 0 + + for len(frontier) > 0 { + cur := frontier[0] + frontier = frontier[1:] + + // The limit is checked before the fetch, which is what makes it a + // budget rather than a suggestion. + if o.Limit > 0 && nodes >= o.Limit { + return nil + } + if err := ctx.Err(); err != nil { + return wrapNetwork("", err) + } + curKind, curID, ok := splitURI(cur.uri) + if !ok { + continue + } + rec, err := c.fetchOne(ctx, curKind, curID) + if err != nil { + // One unreachable node must not end a walk that has already + // produced useful output. A deleted repository, a kind this tool + // cannot dereference yet, and a page that has moved are all normal + // mid-crawl, and the alternative is a two-hour walk that throws + // away its results on the last hop. + if softSkip(err) { + continue + } + return err + } + node, edges, facts := Extract(rec) + if node.URI == "" { + continue + } + nodes++ + if !o.EdgesOnly && sink.Node != nil { + n := node + if err := sink.Node(&n); err != nil { + return err + } + } + edges = FilterTrust(edges, o.MinTrust) + if !o.NodesOnly { + for i := range edges { + if sink.Edge != nil { + e := edges[i] + if err := sink.Edge(&e); err != nil { + return err + } + } + } + for i := range facts { + if sink.Fact != nil { + f := facts[i] + if err := sink.Fact(&f); err != nil { + return err + } + } + } + } + if cur.depth >= o.Depth { + continue + } + for _, e := range edges { + // A language or a licence is a bare string with no page behind it, + // so it is an edge but never a target. + if !strings.HasPrefix(e.Object, Scheme+"://") { + continue + } + if len(allow) > 0 && !allow[e.Predicate] { + continue + } + objKind, _, ok := splitURI(e.Object) + if !ok { + continue + } + if len(expand) > 0 && !expand[objKind] { + continue + } + // Cycles are normal on this graph. A fork points at its parent and + // the parent's fork list points back, and the visited set is the + // only defence that needs. + if visited[e.Object] { + continue + } + visited[e.Object] = true + frontier = append(frontier, item{e.Object, cur.depth + 1}) + } + } + return nil +} + +// softSkip reports whether an error is one node's problem rather than the +// walk's. Not found, needs a login, and a kind that has no reader yet are all +// "skip this one", and anything else stops the crawl. +func softSkip(err error) bool { + switch errs.KindOf(err) { + case errs.KindNotFound, errs.KindNeedAuth, errs.KindUnsupported: + return true + default: + return false + } +} + +// CrawlPlan is what --dry-run answers with. It is a record rather than a line +// on stderr so the answer goes through the same renderer, formats, and pipes as +// every other command, and so a script can size a walk without reading prose. +type CrawlPlan struct { + Base + + Seed string `json:"seed" table:"seed"` + Depth int `json:"depth" table:"depth"` + Nodes int `json:"nodes" table:"nodes"` + + Note string `json:"note" table:"note"` +} + +// Estimate reads the seed and reports what one more level would cost. It is +// deliberately a lower bound and the note says so: the first level is countable +// because the seed's edges are in hand, and everything past it depends on a +// branching factor that cannot be seen from here without doing the walk. +func (c *Client) Estimate(ctx context.Context, seed string, o CrawlOptions) (*CrawlPlan, error) { + o.defaults() + kind, id, err := Classify(seed) + if err != nil { + return nil, err + } + rec, err := c.fetchOne(ctx, kind, id) + if err != nil { + return nil, err + } + _, edges, _ := Extract(rec) + edges = FilterTrust(edges, o.MinTrust) + allow := followSet(o.Follow) + expand := kindSet(o.Kinds) + + seen := map[string]bool{URI(kind, id): true} + next := 0 + for _, e := range edges { + if !strings.HasPrefix(e.Object, Scheme+"://") || seen[e.Object] { + continue + } + if len(allow) > 0 && !allow[e.Predicate] { + continue + } + objKind, _, ok := splitURI(e.Object) + if !ok || (len(expand) > 0 && !expand[objKind]) { + continue + } + seen[e.Object] = true + next++ + } + nodes := 1 + next + if o.Limit > 0 && nodes > o.Limit { + nodes = o.Limit + } + note := fmt.Sprintf("at least %d nodes through depth 1, about one request each", nodes) + if o.Depth > 1 { + note += fmt.Sprintf(", and more at depth %d depending on how the next level branches", o.Depth) + } + plan := &CrawlPlan{Seed: seed, Depth: o.Depth, Nodes: nodes, Note: note} + plan.setIdentity(kind, id) + return plan, nil +} + +// GraphOf builds the node, edges, and facts for one entity. `github graph`, +// `github edges`, and `github rdf` all call it, so the three never disagree +// about what an entity's edges are. +func (c *Client) GraphOf(ctx context.Context, kind, id string) (*Graph, error) { + rec, err := c.fetchOne(ctx, kind, id) + if err != nil { + return nil, err + } + g := &Graph{} + g.Add(rec) + return g, nil +} + +// GraphOfRef is GraphOf for a reference that has not been classified yet, and it +// reports the kind it turned out to be so a caller can say what it read. +func (c *Client) GraphOfRef(ctx context.Context, ref string) (string, string, *Graph, error) { + kind, id, err := Classify(ref) + if err != nil { + return "", "", nil, err + } + g, err := c.GraphOf(ctx, kind, id) + if err != nil { + return "", "", nil, err + } + return kind, id, g, nil +} diff --git a/gh/deps.go b/gh/deps.go new file mode 100644 index 0000000..4539fba --- /dev/null +++ b/gh/deps.go @@ -0,0 +1,263 @@ +package gh + +import ( + "context" + "net/url" + "strconv" + "strings" + + "golang.org/x/net/html" + + "github.com/tamnd/github-cli/pkg/page" +) + +// deps.go reads the two dependency graph pages. They are the most valuable +// keyless surface on the site and the least reliable one: the graph is opt-in +// per repository, the rows are prose, and a package GitHub cannot resolve to a +// repository is a name and nothing else. +// +// Both pages are read rather than one, because they are not two views of the +// same list. Dependencies come from the manifests in this repository and +// dependents come from every other repository's manifests, so neither can be +// derived from the other. +// +// The two pagers disagree, which is why there are two of them here. The +// dependency list is a Rails pager with ?page=N and a rel="next" anchor, and the +// dependents list is a cursor in a button. + +// Dependencies lists what a repository declares in its manifests. +// +// A repository with the dependency graph switched off answers with a page and +// no rows, which is an empty list rather than an error: the difference between +// "nothing to report" and "not enabled" is not on the page, so claiming to know +// which one it is would be making it up. +func (c *Client) Dependencies(ctx context.Context, repo string, limit int, emit func(Dependency) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + base := repoSubURL(repo, "network/dependencies") + fetch := func(ctx context.Context, token string) ([]Dependency, string, error) { + u := base + if n := pageToken(token); n > 1 { + u = query(u, "page", strconv.Itoa(n)) + } + doc, final, err := c.rowPage(ctx, u, page.DependencyRow) + if err != nil { + return nil, "", err + } + if doc == nil { + return nil, "", structureChanged(repo + " dependencies") + } + var out []Dependency + for _, row := range page.FindAll(doc, page.BoxRow) { + d, ok := dependencyRow(row, repo, final) + if ok { + out = append(out, d) + } + } + return out, railsNext(doc, token), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// rowPage reads one page of a dependency graph listing. +// +// It exists because GitHub answers a cursor page with a 200, the right title, +// the right chrome, and no rows at all, often enough to matter. An empty page is +// indistinguishable from the end of the list, so the walk stops early and +// reports a third of the dependents as the whole set. Asking a second time gets +// the rows, so the read is repeated once before an empty page is believed. +// +// Dropping the cached copy first is the part that matters. Without it the retry +// reads the same empty bytes back and the wrong answer sticks for the life of +// the entry, which is how this was found: --no-cache returned two hundred rows +// and the cached run returned sixty, over and over. +// +// The cost is one extra request for a repository whose dependency graph really +// is empty, which is the right trade: a repository with the graph switched off +// is cheap to ask twice, and silently reporting an empty list for one with +// thousands of dependents is not recoverable by the caller. +func (c *Client) rowPage(ctx context.Context, u string, rows page.Sel) (*html.Node, string, error) { + doc, final, n, err := c.readRows(ctx, u, rows) + if err != nil || n > 0 { + return doc, final, err + } + c.cacheDrop(u, SurfaceHTML) + doc, final, n, err = c.readRows(ctx, u, rows) + if err != nil { + return nil, "", err + } + // An empty page that stays empty is not worth keeping either. The next run + // would read it back and stop in the same place without ever asking again. + if n == 0 { + c.cacheDrop(u, SurfaceHTML) + } + return doc, final, nil +} + +func (c *Client) readRows(ctx context.Context, u string, rows page.Sel) (*html.Node, string, int, error) { + res, err := c.GetHTML(ctx, u) + if err != nil { + return nil, "", 0, err + } + doc := page.Extract(res.FinalURL, res.Body).Doc() + if doc == nil { + return nil, res.FinalURL, 0, nil + } + return doc, res.FinalURL, len(page.FindAll(doc, rows)), nil +} + +// dependencyRow reads one manifest entry. The interesting half is the line under +// the package name, which is one span holding the ecosystem, the manifest, who +// detected it and when, and sometimes the licence, separated by middots. +func dependencyRow(row *html.Node, repo, source string) (Dependency, bool) { + box := page.Find(row, page.DependencyRow) + if box == nil { + return Dependency{}, false + } + name := page.Find(box, page.DependencyName) + if name == nil { + return Dependency{}, false + } + d := Dependency{Repo: repo, Package: strings.TrimSpace(page.Text(name))} + if d.Package == "" { + return Dependency{}, false + } + if a := page.Find(box, page.DependencyLink); a != nil { + if p := hrefPath(page.Attr(a, "href")); strings.Count(p, "/") == 1 { + d.SourceRepo = p + d.setIdentity(KindRepo, p) + } + } + if v := page.Find(box, page.DependencyVersion); v != nil { + d.Version = strings.TrimSpace(page.Text(v)) + } + if r := page.Find(box, page.DependencyRelation); r != nil { + d.Relationship = strings.ToLower(strings.TrimSpace(page.Text(r))) + } + if m := page.Find(row, page.DependencyManifest); m != nil { + d.Manifest = strings.TrimSpace(page.Text(m)) + if m.Parent != nil { + d.Ecosystem, d.License = manifestLine(page.Text(m.Parent), d.Manifest) + } + } + d.addSource(source) + return d, true +} + +// manifestLine takes the middot-separated line apart. The ecosystem is always +// first and the licence, when there is one, is always last; the middle is the +// manifest name and the detection note, neither of which needs splitting out +// here. +// +// The note comes in two wordings, "Detected by dependabot on " and +// "Detected automatically on ", and when there is no licence the note is +// what sits last, so both prefixes have to be recognised or the date ends up +// filed as the licence. +func manifestLine(text, manifest string) (ecosystem, license string) { + var parts []string + for _, p := range strings.Split(text, "·") { + if p = strings.TrimSpace(p); p != "" { + parts = append(parts, p) + } + } + if len(parts) == 0 { + return "", "" + } + ecosystem = parts[0] + last := parts[len(parts)-1] + if last != ecosystem && last != manifest && !strings.HasPrefix(last, "Detected ") { + license = last + } + return ecosystem, license +} + +// Dependents lists the repositories that depend on this one. +// +// The list is ordered by stars and it is long: a popular library has tens of +// thousands of rows at thirty a page, so --limit is the flag that matters here +// and the walk stops the moment it is reached. +func (c *Client) Dependents(ctx context.Context, repo string, limit int, emit func(Dependent) error) error { + if _, _, ok := SplitRepo(repo); !ok { + return usageBadID("repository", repo, "owner/name") + } + base := repoSubURL(repo, "network/dependents") + fetch := func(ctx context.Context, token string) ([]Dependent, string, error) { + u := base + if token != "" { + u = query(u, "dependents_after", token) + } + doc, final, err := c.rowPage(ctx, u, page.DependentRow) + if err != nil { + return nil, "", err + } + if doc == nil { + return nil, "", structureChanged(repo + " dependents") + } + var out []Dependent + for _, row := range page.FindAll(doc, page.DependentRow) { + d, ok := dependentRow(row, repo, final) + if ok { + out = append(out, d) + } + } + return out, dependentsCursor(doc), nil + } + return paginate(ctx, limit, fetch, emit) +} + +// dependentRow reads one dependent. Owner and name are two anchors rather than +// one, the same shape the fork list uses, so the id is assembled. +func dependentRow(row *html.Node, repo, source string) (Dependent, bool) { + link := page.Find(row, page.DependentRepo) + if link == nil { + return Dependent{}, false + } + id := hrefPath(page.Attr(link, "href")) + owner, _, ok := SplitRepo(id) + if !ok { + return Dependent{}, false + } + d := Dependent{Repo: repo, Dependent: id, Owner: owner} + d.setIdentity(KindRepo, id) + if u := page.Find(row, page.DependentUser); u != nil { + if login := hrefPath(page.Attr(u, "href")); login != "" { + d.Owner = login + } + } + if img := page.Find(row, page.Sel{Tag: "img", Class: "avatar"}); img != nil { + d.AvatarURL = page.Attr(img, "src") + } + d.Stars = iconCount(row, page.DependentStars) + d.Forks = iconCount(row, page.DependentForks) + d.addSource(source) + return d, true +} + +// iconCount reads the number beside an icon. The icon is what says which count +// it is, because the two spans are otherwise identical. +func iconCount(row *html.Node, sel page.Sel) *int { + el := page.Find(row, sel) + if el == nil { + return nil + } + if n, _, ok := page.CountIn(page.Text(el)); ok { + return intp(n) + } + return nil +} + +// dependentsCursor pulls the opaque cursor out of the Next button. There is no +// page number on this listing and no total to count against, so the cursor the +// page hands back is the only way forward. +func dependentsCursor(doc *html.Node) string { + a := page.Find(doc, page.DependentNext) + if a == nil { + return "" + } + u, err := url.Parse(page.Attr(a, "href")) + if err != nil { + return "" + } + return u.Query().Get("dependents_after") +} diff --git a/gh/graph.go b/gh/graph.go new file mode 100644 index 0000000..03cb55e --- /dev/null +++ b/gh/graph.go @@ -0,0 +1,949 @@ +package gh + +import ( + "sort" + "strconv" + "strings" + "time" +) + +// graph.go turns records into triples. github.com is already a knowledge graph: +// a repository names its owner, its topics, its licence, and the repository it +// was forked from; a pull request names the issue it closes and the branch it +// targets; a commit names its parents. This file reads those declarations off a +// record and emits them as typed edges. +// +// It is pure. No network, no client, no ordering dependency, which is what makes +// the whole graph plane testable against a fixture and what makes +// `github edges golang/go#1 --min-trust id` answer without a request. + +// Node is one entity. It is deliberately thin: the label and the two addresses +// and nothing else, because the full record is one `github get` away by URI and +// duplicating it here would make a crawl of ten thousand nodes unprintable. +type Node struct { + URI string `json:"uri" table:"uri"` + Kind string `json:"kind" table:"kind"` + ID string `json:"id" table:"id"` + Label string `json:"label,omitempty" table:"label,truncate"` + URL string `json:"url,omitempty" table:"url,url"` +} + +// Edge is one directed, typed relation between two entities. +// +// There is no inverse flag. Every predicate has exactly one direction, and +// where the inverse is what you want, the edge is emitted with the other node as +// its subject rather than with a flag saying to read it backwards. +type Edge struct { + Subject string `json:"subject" table:"subject"` + Predicate string `json:"predicate" table:"predicate"` + Object string `json:"object" table:"object"` + Source string `json:"source" table:"source"` + Weight *int `json:"weight,omitempty" table:"weight"` + At *time.Time `json:"at,omitempty" table:"-"` +} + +// Fact is a literal statement about a node: a star count, a description, a +// timestamp. +// +// It is a separate type from Edge on purpose. Edge.Object is a URI and every +// consumer of the graph is entitled to treat it as one, so putting "12000" in +// that field to carry a star count would break each of them for the sake of +// saving a struct. RDF emits both; `github edges` emits only edges, which is why +// its output reads as relations rather than as a flattened record. +type Fact struct { + Subject string `json:"subject" table:"subject"` + Predicate string `json:"predicate" table:"predicate"` + Value string `json:"value" table:"value,truncate"` + Datatype string `json:"datatype,omitempty" table:"-"` +} + +// The five extraction rules, in descending order of trust. Every edge carries +// the one that produced it, which is the field a consumer uses to decide how +// much to believe. +const ( + // SrcID is derived from the id structure alone. No fetch, always correct. + SrcID = "id" + // SrcPayload is an explicit reference in a JSON payload or a Relay result. + SrcPayload = "payload" + // SrcFeed is an explicit reference in an Atom feed. + SrcFeed = "feed" + // SrcHTML was parsed out of rendered markup with a selector. Good, and it + // degrades to a missing edge rather than a wrong one when a template moves. + SrcHTML = "html" + // SrcText is a pattern matched in free text: #42, a bare SHA. Heuristic, and + // dropped by the default --min-trust. + SrcText = "text" +) + +// trustRank orders the rules. Higher is more trustworthy. +var trustRank = map[string]int{ + SrcID: 4, + SrcPayload: 3, + SrcFeed: 2, + SrcHTML: 1, + SrcText: 0, +} + +// DefaultMinTrust keeps everything except free-text guesses. +const DefaultMinTrust = SrcHTML + +// TrustAtLeast reports whether a source meets a floor. An unknown floor lets +// everything through rather than silently dropping the whole graph, and an +// unknown source is treated as the weakest thing there is. +func TrustAtLeast(source, min string) bool { + floor, ok := trustRank[min] + if !ok { + return true + } + return trustRank[source] >= floor +} + +// TrustLevels is the accepted set, for help text and for validation. +var TrustLevels = []string{SrcID, SrcPayload, SrcFeed, SrcHTML, SrcText} + +// The predicate vocabulary. This is the complete set: an edge this tool emits +// has its predicate here, and adding a relation means adding a constant first. +const ( + // Ownership and membership. + PredOwnedBy = "ownedBy" + PredMemberOf = "memberOf" + PredPartOf = "partOf" + PredBelongsToPackage = "belongsToPackage" + + // Derivation. The edges that make a graph worth walking. + PredForkOf = "forkOf" + PredTemplateOf = "templateOf" + PredMirrorOf = "mirrorOf" + PredDependsOn = "dependsOn" + PredUsedBy = "usedBy" + + // Authorship and activity. + PredAuthoredBy = "authoredBy" + PredCommittedBy = "committedBy" + PredContributedTo = "contributedTo" + PredAssignedTo = "assignedTo" + PredReviewedBy = "reviewedBy" + PredReviewRequestedFrom = "reviewRequestedFrom" + PredMergedBy = "mergedBy" + + // Reference. + PredReferences = "references" + PredCloses = "closes" + PredClosedBy = "closedBy" + PredDuplicateOf = "duplicateOf" + PredSubIssueOf = "subIssueOf" + PredLinkedTo = "linkedTo" + PredTargetsBranch = "targetsBranch" + PredFromBranch = "fromBranch" + PredPointsAt = "pointsAt" + PredParentOf = "parentOf" + + // Classification. + PredHasTopic = "hasTopic" + PredHasLabel = "hasLabel" + PredInMilestone = "inMilestone" + PredWrittenIn = "writtenIn" + PredLicensedUnder = "licensedUnder" + PredRelatedTopic = "relatedTopic" + + // Social. Opt-in everywhere, because the star list of a popular repository + // is thousands of pages and nobody wants that by accident. + PredStarredBy = "starredBy" + PredFollows = "follows" + PredSponsors = "sponsors" + PredReactedWith = "reactedWith" +) + +// The literal predicates. These name Fact rows rather than edges. +const ( + FactName = "name" + FactDescription = "description" + FactHomepage = "homepage" + FactCreated = "created" + FactUpdated = "updated" + FactStars = "stars" + FactForks = "forks" + FactWatchers = "watchers" + FactCommits = "commits" + FactURI = "uri" + FactAvatar = "avatar" + FactState = "state" + FactCount = "count" +) + +// SocialPredicates are the ones a command has to be asked for by name. +var SocialPredicates = []string{PredStarredBy, PredFollows, PredSponsors, PredReactedWith} + +// DefaultFollow is the crawler's follow set. It deliberately excludes +// references, starredBy, follows, dependsOn, and usedBy: those five turn a +// bounded walk into an unbounded one, and each has to be asked for by name. +var DefaultFollow = []string{PredPartOf, PredOwnedBy, PredForkOf, PredHasTopic, PredAuthoredBy} + +// LiteralPredicates are the two whose object is a bare string rather than a +// URI, because a language and a licence are not github.com entities. RDF gives +// them synthetic IRIs in the gh: namespace; `github edges` prints them as they +// are written on the page. +var LiteralPredicates = map[string]bool{ + PredWrittenIn: true, + PredLicensedUnder: true, + PredReactedWith: true, +} + +// --- the builder --- + +// builder accumulates one node with its edges and facts while an extractor +// walks a record. +type builder struct { + node Node + edges []Edge + facts []Fact +} + +// start sets the node. Every extractor calls it first, and nothing is emitted +// for a record whose identity did not resolve. +func (b *builder) start(kind, id, label, url string) { + if id == "" { + return + } + if label == "" { + label = id + } + if url == "" { + if u, err := Locate(kind, id); err == nil { + url = u + } + } + b.node = Node{URI: URI(kind, id), Kind: kind, ID: id, Label: label, URL: url} +} + +// to emits an edge from this node to another entity named by kind and id. +func (b *builder) to(pred, objKind, objID, source string) { + if objID == "" { + return + } + b.toURI(pred, URI(objKind, objID), source) +} + +// toURI is to for an object whose URI is already built. +func (b *builder) toURI(pred, objURI, source string) { + if b.node.URI == "" || objURI == "" { + return + } + b.edges = append(b.edges, Edge{Subject: b.node.URI, Predicate: pred, Object: objURI, Source: source}) +} + +// toURL emits an edge to whatever a github.com URL names. Relay results carry +// links rather than ids for linked pull requests, duplicates, and cross +// references, and classification is exactly the function that turns one into +// the other. +func (b *builder) toURL(pred, rawURL, source string) { + if uri := uriOfURL(rawURL); uri != "" { + b.toURI(pred, uri, source) + } +} + +// raw emits an edge whose object is a bare string rather than a URI: a +// language, a licence, a reaction. +func (b *builder) raw(pred, value, source string) { + if b.node.URI == "" || value == "" { + return + } + b.edges = append(b.edges, Edge{Subject: b.node.URI, Predicate: pred, Object: value, Source: source}) +} + +// from emits an edge whose subject is not this node. A contributor edge points +// at the repository rather than away from it, and inverting it to make this node +// the subject would be a lie about which way the relation runs. +func (b *builder) from(subjURI, pred, objURI, source string) { + if subjURI == "" || objURI == "" { + return + } + b.edges = append(b.edges, Edge{Subject: subjURI, Predicate: pred, Object: objURI, Source: source}) +} + +// weigh attaches a count to the last edge appended. It is separate from the +// emitters so the common case stays a one-liner. +func (b *builder) weigh(n *int) { + if n == nil || len(b.edges) == 0 { + return + } + b.edges[len(b.edges)-1].Weight = n +} + +// when attaches a time to the last edge appended. +func (b *builder) when(t *time.Time) { + if t == nil || len(b.edges) == 0 { + return + } + b.edges[len(b.edges)-1].At = t +} + +// fact records a literal. An empty value is skipped, because "this repository +// has no description" is better said by the absence of a statement than by an +// empty one. +func (b *builder) fact(pred, value, datatype string) { + if b.node.URI == "" || value == "" { + return + } + b.facts = append(b.facts, Fact{Subject: b.node.URI, Predicate: pred, Value: value, Datatype: datatype}) +} + +// num records a count. A nil count is a count the surface did not state, which +// is not the same as zero and does not become a statement. +func (b *builder) num(pred string, n *int) { + if n == nil { + return + } + b.fact(pred, strconv.Itoa(*n), TypeInteger) +} + +func (b *builder) at(pred string, t *time.Time) { + if t == nil || t.IsZero() { + return + } + b.fact(pred, t.UTC().Format(time.RFC3339), TypeDateTime) +} + +// actorEdge emits an edge to a person. The actor's own type is used when it says +// one, so a bot or an organization does not silently become a user. +func (b *builder) actorEdge(pred string, a Actor, source string) { + if a.Login == "" { + return + } + b.to(pred, actorKind(a), a.Login, source) +} + +func actorKind(a Actor) string { + if strings.EqualFold(a.Type, "Organization") { + return KindOrg + } + return KindUser +} + +// uriOfURL classifies a github.com URL into a URI, and answers empty for +// anything that is not one. Extractors use it rather than Classify directly so +// a link to an external site drops out instead of producing an error nobody can +// act on. +func uriOfURL(raw string) string { + if raw == "" { + return "" + } + kind, id, err := Classify(raw) + if err != nil { + return "" + } + return URI(kind, id) +} + +// --- extraction --- + +// Extract turns one record into its node, its edges, and its facts. A record +// kind it does not know produces an empty node, which every caller treats as +// nothing to say rather than as an error. +func Extract(rec any) (Node, []Edge, []Fact) { + b := &builder{} + switch r := rec.(type) { + case *Repo: + b.repo(r) + case *Trending: + b.trending(r) + case *Account: + b.account(r) + case *Org: + b.org(r) + case *Issue: + b.issue(r) + case *PullRequest: + b.pull(r) + case *Discussion: + b.discussion(r) + case *Thread: + b.thread(r) + case *Commit: + b.commit(r) + case *GitRef: + b.gitRef(r) + case *Release: + b.release(r) + case *Topic: + b.topic(r) + case *Package: + b.pkg(r) + case *WikiPage: + b.wiki(r) + case *Gist: + b.gist(r) + case *File: + b.file(r) + case *TreeEntry: + b.treeEntry(r) + case *Contributor: + b.contributor(r) + case *Dependency: + b.dependency(r) + case *Dependent: + b.dependent(r) + case *LanguageShare: + b.languageShare(r) + case *RepoStats: + b.stats(r) + default: + return Node{}, nil, nil + } + return b.node, b.edges, b.facts +} + +// repo is the centre of the graph. Everything else hangs off a repository, and +// most of what a walk finds interesting is stated on this one record. +func (b *builder) repo(r *Repo) { + id := r.ID + if id == "" && r.Owner != "" && r.Name != "" { + id = r.Owner + "/" + r.Name + } + b.start(KindRepo, id, id, r.URL) + + // The owner comes from the id, which is why this edge costs nothing. Which + // of the two account kinds it is comes from the page, so a repository read + // from a surface that did not say defaults to user and is corrected the + // moment the owner itself is fetched. + if r.Owner != "" { + b.to(PredOwnedBy, ownerKind(r), r.Owner, SrcID) + } + // ForkOf is the "Forked from" line in the header, and it is the only place + // any keyless surface names the parent. templateOf and mirrorOf have their + // constants in the vocabulary and no producer here, because the page states + // that a repository is a template or a mirror without ever naming what it + // was generated from or what it mirrors. + b.to(PredForkOf, KindRepo, r.ForkOf, SrcHTML) + + for _, t := range r.Topics { + b.to(PredHasTopic, KindTopic, t, SrcPayload) + } + b.raw(PredLicensedUnder, r.License, SrcHTML) + b.languages(r) + + for i := range r.Tree { + e := &r.Tree[i] + if e.URI != "" { + b.from(e.URI, PredPartOf, b.node.URI, SrcID) + } + } + + b.fact(FactName, id, "") + b.fact(FactDescription, r.Description, "") + b.fact(FactHomepage, r.Homepage, "") + b.num(FactStars, r.Stars) + b.num(FactForks, r.Forks) + b.num(FactWatchers, r.Watchers) + b.num(FactCommits, r.CommitCount) + b.at(FactCreated, r.CreatedAt) + b.at(FactUpdated, firstSetTime(r.PushedAt, r.UpdatedAt)) + b.fact(FactAvatar, r.OwnerAvatarURL, "") + b.fact(FactURI, b.node.URI, "") +} + +// ownerKind decides between a user and an organization. IsOrgOwned is set by +// the page template, which is the only surface that states it without a token. +func ownerKind(r *Repo) string { + if r.IsOrgOwned { + return KindOrg + } + return KindUser +} + +// languages emits one writtenIn edge per language, weighted by the percentage +// the histogram gave. +// +// The source is honest about where the number came from: Via records +// sidebar-percent when the histogram was read from the deferred sidebar +// fragment, which is a payload, and anything else was read off the language bar +// in the markup. +func (b *builder) languages(r *Repo) { + source := SrcHTML + if r.Via["languages"] == "sidebar-percent" { + source = SrcPayload + } + if len(r.Languages) == 0 { + b.raw(PredWrittenIn, r.Language, source) + return + } + for _, name := range sortedLanguages(r.Languages) { + b.raw(PredWrittenIn, name, source) + if n := r.Languages[name]; n > 0 { + b.weigh(intp(int(n))) + } + } +} + +// sortedLanguages orders a histogram by share and then by name, so two runs over +// the same repository produce the same edge order. +func sortedLanguages(m map[string]int64) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.SliceStable(out, func(i, j int) bool { + if m[out[i]] != m[out[j]] { + return m[out[i]] > m[out[j]] + } + return out[i] < out[j] + }) + return out +} + +// trending is a repository plus the people the trending page credits. +func (b *builder) trending(t *Trending) { + b.repo(&t.Repo) + for _, a := range t.BuiltBy { + if a.Login != "" { + b.from(URI(actorKind(a), a.Login), PredContributedTo, b.node.URI, SrcHTML) + } + } +} + +func (b *builder) account(a *Account) { + kind := KindUser + if strings.EqualFold(a.Type, "Organization") { + kind = KindOrg + } + b.start(kind, a.Login, firstNonEmpty(a.Name, a.Login), a.URL) + + for _, org := range a.Organizations { + b.to(PredMemberOf, KindOrg, org, SrcHTML) + } + // A pinned repository the person does not own is pinned work they + // contributed to, and this tool cannot tell which from the profile alone. + // Only the ones whose id carries this login become ownership edges; the rest + // are left out rather than asserted wrongly. + for _, repo := range a.PinnedRepos { + if owner, _, ok := SplitRepo(repo); ok && strings.EqualFold(owner, a.Login) { + b.from(URI(KindRepo, repo), PredOwnedBy, b.node.URI, SrcHTML) + } + } + + b.fact(FactName, firstNonEmpty(a.Name, a.Login), "") + b.fact(FactDescription, a.Bio, "") + b.fact(FactHomepage, a.Website, "") + b.fact(FactAvatar, a.AvatarURL, "") + b.at(FactCreated, a.CreatedAt) + b.fact(FactURI, b.node.URI, "") + if a.Followers != nil { + b.fact("followers", strconv.Itoa(*a.Followers), TypeInteger) + } +} + +func (b *builder) org(o *Org) { + b.account(&o.Account) + // The node kind comes from the template that answered, and this one is the + // organization template, so it is an organization whatever the account + // record's Type string says. + if b.node.URI != "" { + b.node.Kind = KindOrg + b.node.URI = URI(KindOrg, o.Login) + } + for _, m := range o.Members { + b.from(URI(KindUser, m), PredMemberOf, b.node.URI, SrcHTML) + } + if o.MemberCount != nil { + b.fact("members", strconv.Itoa(*o.MemberCount), TypeInteger) + } + // TopTopics and TopLanguages are aggregates over the organization's + // repositories rather than properties of the organization, so they produce + // no hasTopic or writtenIn edge here. The repositories state their own. +} + +// thread covers what issues, pull requests, and discussions share. +func (b *builder) thread(t *Thread) { + kind := t.Kind + if kind == "" { + kind = KindIssue + } + b.start(kind, t.ID, t.Title, t.URL) + + if t.Repo != "" { + b.to(PredPartOf, KindRepo, t.Repo, SrcID) + } + b.actorEdge(PredAuthoredBy, t.Author, SrcPayload) + for _, l := range t.Labels { + if l.Name != "" && t.Repo != "" { + b.to(PredHasLabel, KindLabel, t.Repo+"/"+l.Name, SrcPayload) + } + } + if m := t.Milestone; m != nil && m.Number != nil && t.Repo != "" { + b.to(PredInMilestone, KindMilestone, t.Repo+"/"+strconv.Itoa(*m.Number), SrcPayload) + } + for _, a := range t.Assignees { + b.actorEdge(PredAssignedTo, a, SrcPayload) + } + for _, r := range t.Reactions { + b.raw(PredReactedWith, strings.ToLower(r.Content), SrcPayload) + b.weigh(intp(r.Count)) + } + + b.fact(FactName, t.Title, "") + b.fact(FactState, t.State, "") + b.at(FactCreated, t.CreatedAt) + b.at(FactUpdated, t.UpdatedAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) issue(i *Issue) { + b.thread(&i.Thread) + b.toURL(PredDuplicateOf, i.DuplicateOf, SrcPayload) + for _, u := range i.LinkedPRs { + b.toURL(PredLinkedTo, u, SrcPayload) + } + for _, u := range i.ClosedByPRs { + b.toURL(PredClosedBy, u, SrcPayload) + } + // SubIssueTotal is a count and not a list, so subIssueOf has no producer on + // this record. The timeline carries the parent, which is a separate read. +} + +func (b *builder) pull(p *PullRequest) { + b.thread(&p.Thread) + if p.Repo != "" { + b.to(PredTargetsBranch, KindBranch, refID(p.Repo, p.BaseRef), SrcPayload) + b.to(PredFromBranch, KindBranch, refID(p.Repo, p.HeadRef), SrcPayload) + } + if p.MergedBy != nil { + b.actorEdge(PredMergedBy, *p.MergedBy, SrcPayload) + } + for _, a := range p.ReviewRequests { + b.actorEdge(PredReviewRequestedFrom, a, SrcPayload) + } + for _, u := range p.ClosesIssues { + b.toURL(PredCloses, u, SrcPayload) + } +} + +// refID builds owner/name@ref, and answers empty for an empty ref so that a +// pull request read from a surface that did not state its base does not point +// at a branch called nothing. +func refID(repo, ref string) string { + if repo == "" || ref == "" { + return "" + } + return repo + "@" + ref +} + +func (b *builder) discussion(d *Discussion) { + b.thread(&d.Thread) + // The answer's author is not the discussion's author and there is no + // predicate for "answered by" in the vocabulary, so the fact records who it + // was rather than inventing one. + if d.AnswerAuthor != nil && d.AnswerAuthor.Login != "" { + b.fact("answeredBy", d.AnswerAuthor.Login, "") + } +} + +func (b *builder) commit(c *Commit) { + id := c.ID + if id == "" && c.Repo != "" && c.SHA != "" { + id = c.Repo + "@" + c.SHA + } + b.start(KindCommit, id, c.Subject, c.URL) + + if c.Repo != "" { + b.to(PredPartOf, KindRepo, c.Repo, SrcID) + for _, p := range c.Parents { + // The arrow reads "that commit is the parent of this one", which is + // why the parent is the subject and not the object. + b.from(URI(KindCommit, c.Repo+"@"+p), PredParentOf, b.node.URI, SrcPayload) + } + } + for _, a := range c.Authors { + b.actorEdge(PredAuthoredBy, a, SrcPayload) + } + if c.Committer != nil { + b.actorEdge(PredCommittedBy, *c.Committer, SrcPayload) + } + // GitHub resolved these references itself when it rendered the message, so + // they are payload rather than the text rule that would find the same #N in + // the raw subject line. + for _, ref := range c.IssueRefs { + b.toURL(PredReferences, ref.URL, SrcPayload) + } + + b.fact(FactName, c.Subject, "") + b.at(FactCreated, firstSetTime(c.AuthoredAt, c.CommittedAt)) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) gitRef(r *GitRef) { + kind := r.Type + if kind != KindTag { + kind = KindBranch + } + id := r.ID + if id == "" { + id = refID(r.Repo, r.Name) + } + b.start(kind, id, r.Name, r.URL) + + if r.Repo != "" { + b.to(PredPartOf, KindRepo, r.Repo, SrcID) + // A ref read from the git protocol carries its object name, which is the + // one edge in this whole file that comes from git rather than from + // github.com. + if r.SHA != "" { + b.to(PredPointsAt, KindCommit, r.Repo+"@"+firstNonEmpty(r.PeeledSHA, r.SHA), SrcPayload) + } + } + if r.Author != nil { + b.actorEdge(PredAuthoredBy, *r.Author, SrcHTML) + } + b.fact(FactName, r.Name, "") + b.at(FactCreated, r.AuthoredAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) release(r *Release) { + id := r.ID + if id == "" { + id = refID(r.Repo, r.Tag) + } + b.start(KindRelease, id, firstNonEmpty(r.Title, r.Tag), r.URL) + + if r.Repo != "" { + b.to(PredPartOf, KindRepo, r.Repo, SrcID) + b.to(PredPointsAt, KindCommit, refID(r.Repo, r.CommitSHA), SrcHTML) + } + if r.Author != nil { + // The releases listing comes from the Atom feed, where the author is an + // element rather than a selector. + b.actorEdge(PredAuthoredBy, *r.Author, SrcFeed) + } + b.fact(FactName, firstNonEmpty(r.Title, r.Tag), "") + b.at(FactCreated, r.PublishedAt) + b.at(FactUpdated, r.UpdatedAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) topic(t *Topic) { + b.start(KindTopic, firstNonEmpty(t.ID, t.Name), firstNonEmpty(t.DisplayName, t.Name), t.URL) + for _, rel := range t.Related { + b.to(PredRelatedTopic, KindTopic, rel, SrcHTML) + } + b.fact(FactName, firstNonEmpty(t.DisplayName, t.Name), "") + b.fact(FactDescription, firstNonEmpty(t.ShortDescription, t.Description), "") + b.num(FactStars, t.StargazerCount) + b.num(FactCount, t.AppliedCount) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) pkg(p *Package) { + id := p.ID + if id == "" && p.Repo != "" { + id = p.Repo + "/" + p.Name + } + b.start(KindPackage, id, p.Name, p.URL) + // The direction is the vocabulary's: the package is the subject and the + // repository it was published from is the object. + b.to(PredBelongsToPackage, KindRepo, p.Repo, SrcPayload) + for _, t := range p.Topics { + b.to(PredHasTopic, KindTopic, t, SrcPayload) + } + b.fact(FactName, p.Name, "") + b.fact(FactDescription, p.Summary, "") + b.at(FactUpdated, p.UpdatedAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) wiki(w *WikiPage) { + id := w.ID + if id == "" && w.Repo != "" { + id = w.Repo + "/" + firstNonEmpty(w.Path, w.Title) + } + b.start(KindWiki, id, w.Title, w.URL) + b.to(PredPartOf, KindRepo, w.Repo, SrcID) + if w.Author != nil { + b.actorEdge(PredAuthoredBy, *w.Author, SrcHTML) + } + b.fact(FactName, w.Title, "") + b.at(FactUpdated, w.UpdatedAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) gist(g *Gist) { + b.start(KindGist, g.ID, firstNonEmpty(g.Description, g.ID), g.URL) + b.to(PredOwnedBy, KindUser, g.Owner, SrcHTML) + for _, f := range g.Files { + b.raw(PredWrittenIn, f.Language, SrcHTML) + } + b.fact(FactDescription, g.Description, "") + b.num(FactStars, g.Stars) + b.num(FactForks, g.Forks) + b.at(FactCreated, g.CreatedAt) + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) file(f *File) { + b.start(KindFile, f.ID, f.Path, f.URL) + b.to(PredPartOf, KindRepo, f.Repo, SrcID) + b.raw(PredWrittenIn, f.Language, SrcPayload) + b.fact(FactName, f.Path, "") + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) treeEntry(t *TreeEntry) { + kind := KindFile + if strings.Contains(t.Type, "directory") { + kind = KindTree + } + b.start(kind, t.ID, t.Path, t.URL) + b.to(PredPartOf, KindRepo, t.Repo, SrcID) + b.fact(FactName, t.Path, "") + b.fact(FactURI, b.node.URI, "") +} + +// contributor is the one weighted authorship edge, and the weight is what makes +// `github edges --predicate contributedTo` a ranked list rather than a set. +func (b *builder) contributor(c *Contributor) { + b.start(KindUser, c.Login, c.Login, BaseURL+"/"+c.Login) + if c.Repo != "" { + b.toURI(PredContributedTo, URI(KindRepo, c.Repo), SrcPayload) + b.weigh(c.Commits) + b.when(c.LastWeek) + } + b.fact(FactURI, b.node.URI, "") +} + +func (b *builder) languageShare(l *LanguageShare) { + if l.Repo == "" { + return + } + b.start(KindRepo, l.Repo, l.Repo, "") + b.raw(PredWrittenIn, l.Language, SrcPayload) +} + +// dependency and dependent are the two halves of the same relation read off two +// different pages. Both put the page's repository on the subject side, so a +// dependency row from hugo says hugo dependsOn chroma and a dependent row from +// hugo says hugo usedBy someone. A package GitHub could not resolve to a +// repository has nothing to point at and produces no node. +func (b *builder) dependency(d *Dependency) { + if d.SourceRepo == "" || d.Repo == "" { + return + } + b.start(KindRepo, d.SourceRepo, d.SourceRepo, d.URL) + b.from(URI(KindRepo, d.Repo), PredDependsOn, b.node.URI, SrcHTML) +} + +func (b *builder) dependent(d *Dependent) { + if d.Dependent == "" || d.Repo == "" { + return + } + b.start(KindRepo, d.Dependent, d.Dependent, d.URL) + b.from(URI(KindRepo, d.Repo), PredUsedBy, b.node.URI, SrcHTML) + if d.Owner != "" { + b.to(PredOwnedBy, KindUser, d.Owner, SrcID) + } + b.num(FactStars, d.Stars) + b.num(FactForks, d.Forks) +} + +func (b *builder) stats(s *RepoStats) { + if s.Repo == "" { + return + } + b.start(KindRepo, s.Repo, s.Repo, "") + b.num(FactStars, s.Stars) + b.num(FactForks, s.Forks) + b.num(FactWatchers, s.Watchers) + b.num(FactCommits, s.Commits) + b.at(FactUpdated, s.PushedAt) + b.fact(FactURI, b.node.URI, "") +} + +func firstSetTime(ts ...*time.Time) *time.Time { + for _, t := range ts { + if t != nil && !t.IsZero() { + return t + } + } + return nil +} + +// --- the materialised graph --- + +// Graph is a set of nodes, edges, and facts held in memory. The streaming +// commands never build one; `github graph` for a single entity, `github rdf` for +// the buffered serialisations, and the tests all want the whole thing in hand. +type Graph struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` + Facts []Fact `json:"facts,omitempty"` +} + +// Add folds a record into the graph, skipping a node already present so that a +// repeat visit does not duplicate it. +func (g *Graph) Add(rec any) { + node, edges, facts := Extract(rec) + if node.URI == "" { + return + } + g.AddNode(node) + g.Edges = append(g.Edges, edges...) + g.Facts = append(g.Facts, facts...) +} + +// AddNode adds one node if its URI is new. +func (g *Graph) AddNode(n Node) { + if n.URI == "" { + return + } + for _, have := range g.Nodes { + if have.URI == n.URI { + return + } + } + g.Nodes = append(g.Nodes, n) +} + +// Targets returns the object URIs reachable under an allowed predicate set, +// which is what the crawler walks. A bare-string object is never a target: +// there is no page for a language. +func (g *Graph) Targets(allow map[string]bool) []string { + var out []string + seen := map[string]bool{} + for _, e := range g.Edges { + if !strings.HasPrefix(e.Object, Scheme+"://") { + continue + } + if len(allow) > 0 && !allow[e.Predicate] { + continue + } + if !seen[e.Object] { + seen[e.Object] = true + out = append(out, e.Object) + } + } + return out +} + +// FilterTrust drops the edges below a floor, in place. +func FilterTrust(edges []Edge, min string) []Edge { + out := edges[:0] + for _, e := range edges { + if TrustAtLeast(e.Source, min) { + out = append(out, e) + } + } + return out +} + +// SortEdges gives an export a stable order, which is what makes a diff of two +// runs readable. +func SortEdges(edges []Edge) { + sort.SliceStable(edges, func(i, j int) bool { + a, b := edges[i], edges[j] + if a.Subject != b.Subject { + return a.Subject < b.Subject + } + if a.Predicate != b.Predicate { + return a.Predicate < b.Predicate + } + return a.Object < b.Object + }) +} diff --git a/gh/ops.go b/gh/ops.go index 8c74e79..2160e41 100644 --- a/gh/ops.go +++ b/gh/ops.go @@ -25,6 +25,7 @@ func registerOps(app *kit.App) { registerHistoryOps(app) registerPeopleOps(app) registerDiscoverOps(app) + registerGraphOps(app) registerMetaOps(app) } @@ -553,6 +554,10 @@ func (c *Client) fetchOne(ctx context.Context, kind, id string) (any, error) { return nil, errs.Usage("%q is not a file id", id) } return c.Blob(ctx, repo, path, BlobOptions{Ref: ref}) + case KindTopic: + return c.TopicPage(ctx, id) + case KindGist: + return c.Gist(ctx, id, false) } return nil, errs.Unsupported("reading a %s is not implemented yet", kind) } @@ -1369,6 +1374,195 @@ func getStats(ctx context.Context, in repoRefIn, emit func(*RepoStats) error) er // --- meta --- +// --- the graph plane --- + +func registerGraphOps(app *kit.App) { + kit.Handle(app, kit.OpMeta{ + Name: "graph", Group: "graph", + Summary: "Emit the node, edges, and facts for one entity", + Args: []kit.Arg{{Name: "ref", Help: "any github reference"}}, + }, graph) + + kit.Handle(app, kit.OpMeta{ + Name: "edges", Group: "graph", List: true, + Summary: "Emit only the edges for one entity", + Long: "Every edge carries the rule that produced it. --min-trust id is the\n" + + "interesting case: the edges derived from the id alone need no request at\n" + + "all, so `github edges golang/go#1 --min-trust id` answers offline.", + Args: []kit.Arg{{Name: "ref", Help: "any github reference"}}, + }, edges) + + kit.Handle(app, kit.OpMeta{ + Name: "crawl", Group: "graph", + Summary: "Walk the graph breadth-first from a seed", + Long: "crawl follows only the predicates named by --follow, which defaults to the\n" + + "structural ones: references, stars, follows, and the two dependency\n" + + "predicates fan out without bound and have to be asked for by name. Nodes\n" + + "and edges stream as they are found, so an interrupted walk has still\n" + + "emitted everything it reached.", + Args: []kit.Arg{{Name: "ref", Help: "seed reference"}}, + }, crawl) + + kit.Handle(app, kit.OpMeta{ + Name: "deps", Group: "graph", URIType: KindRepo, List: true, + Summary: "List what a repository depends on", + Long: "The dependency graph is opt-in per repository. A repository with it off\n" + + "answers with a page and no rows, which comes back as an empty list rather\n" + + "than an error, because the page does not say which of the two it is.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listDeps) + + kit.Handle(app, kit.OpMeta{ + Name: "dependents", Group: "graph", URIType: KindRepo, List: true, + Summary: "List the repositories that depend on this one", + Long: "The list is ordered by stars and it is long, so --limit is the flag that\n" + + "matters: a popular library has tens of thousands of rows at thirty a page.", + Args: []kit.Arg{{Name: "ref", Help: "owner/name, or any URL from the repository"}}, + }, listDependents) +} + +func graph(ctx context.Context, in bareRefIn, emit func(any) error) error { + _, _, g, err := in.C.GraphOfRef(ctx, in.Ref) + if err != nil { + return err + } + for i := range g.Nodes { + if err := emit(&g.Nodes[i]); err != nil { + return err + } + } + for i := range g.Edges { + if err := emit(&g.Edges[i]); err != nil { + return err + } + } + for i := range g.Facts { + if err := emit(&g.Facts[i]); err != nil { + return err + } + } + return nil +} + +type edgesIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"any github reference"` + Predicate string `kit:"flag" help:"keep only this predicate"` + MinTrust string `kit:"flag,name=min-trust" help:"drop edges below this rule: id, payload, feed, html, text" default:"html"` +} + +func edges(ctx context.Context, in edgesIn, emit func(*Edge) error) error { + // The id rules produce their edges without a fetch, so asking for them + // alone is answered from the reference and nothing else. + if in.MinTrust == SrcID { + kind, id, err := Classify(in.Ref) + if err != nil { + return err + } + return emitEach(pickEdges(idEdges(kind, id), in), emit) + } + _, _, g, err := in.C.GraphOfRef(ctx, in.Ref) + if err != nil { + return err + } + return emitEach(pickEdges(g.Edges, in), emit) +} + +// idEdges is what the id alone says. It builds the record shell rather than +// fetching one, which is the whole point: a thread id names its repository and +// a repository id names its owner, and neither fact needs github.com. +func idEdges(kind, id string) []Edge { + var rec any + switch kind { + case KindRepo: + owner, name, _ := SplitRepo(id) + r := &Repo{Owner: owner, Name: name} + r.setIdentity(KindRepo, id) + rec = r + case KindIssue, KindPR, KindDiscussion: + repo, num, ok := SplitThreadID(id) + if !ok { + return nil + } + n, _ := strconv.Atoi(num) + t := &Thread{Repo: repo, Number: n} + t.setIdentity(kind, id) + rec = &Issue{Thread: *t} + default: + return nil + } + _, out, _ := Extract(rec) + return FilterTrust(out, SrcID) +} + +func pickEdges(in []Edge, opts edgesIn) []Edge { + out := FilterTrust(in, opts.MinTrust) + if opts.Predicate == "" { + return out + } + want := strings.TrimPrefix(opts.Predicate, "gh:") + kept := out[:0] + for _, e := range out { + if e.Predicate == want { + kept = append(kept, e) + } + } + return kept +} + +type crawlIn struct { + C *Client `kit:"inject"` + Ref string `kit:"arg" help:"seed reference"` + Depth int `kit:"flag" help:"how many edges out to walk" default:"1"` + Follow []string `kit:"flag" help:"predicates to follow, or all (default: the structural ones)"` + Kinds []string `kit:"flag" help:"expand only these kinds"` + MinTrust string `kit:"flag,name=min-trust" help:"drop edges below this rule" default:"html"` + DryRun bool `kit:"flag,name=dry-run" help:"print the estimate and stop"` + NodesOnly bool `kit:"flag,name=nodes-only" help:"emit nodes only"` + EdgesOnly bool `kit:"flag,name=edges-only" help:"emit edges only"` + Limit int `kit:"flag,inherit"` +} + +func crawl(ctx context.Context, in crawlIn, emit func(any) error) error { + o := CrawlOptions{ + Depth: in.Depth, + Follow: in.Follow, + Kinds: in.Kinds, + MinTrust: in.MinTrust, + Limit: in.Limit, + NodesOnly: in.NodesOnly, + EdgesOnly: in.EdgesOnly, + } + if in.DryRun { + plan, err := in.C.Estimate(ctx, in.Ref, o) + if err != nil { + return err + } + return emit(plan) + } + return in.C.Crawl(ctx, in.Ref, o, CrawlSink{ + Node: func(n *Node) error { return emit(n) }, + Edge: func(e *Edge) error { return emit(e) }, + Fact: func(f *Fact) error { return emit(f) }, + }) +} + +func listDeps(ctx context.Context, in repoListIn, emit func(*Dependency) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Dependencies(ctx, repo, in.Limit, byValue(emit)) +} + +func listDependents(ctx context.Context, in repoListIn, emit func(*Dependent) error) error { + repo, err := ResolveRepo(in.Ref) + if err != nil { + return err + } + return in.C.Dependents(ctx, repo, in.Limit, byValue(emit)) +} + func registerMetaOps(app *kit.App) { kit.Handle(app, kit.OpMeta{ Name: "url", Group: "meta", Single: true, diff --git a/gh/rdf.go b/gh/rdf.go new file mode 100644 index 0000000..cda8f99 --- /dev/null +++ b/gh/rdf.go @@ -0,0 +1,617 @@ +package gh + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +// rdf.go serialises the graph. The graph plane is already triples, so this is a +// serialisation and not a transformation. +// +// The one real decision here is the schema.org alignment. A consumer that has +// never heard of gh:forkOf still understands schema:author and schema:isPartOf, +// and using the standard term where one exists is what lets a github export and +// an hf export join in the same triple store. +// +// Subject IRIs are the canonical github.com URLs rather than the github:// URIs. +// A triple whose subject is https://github.com/golang/go is dereferenceable by +// anything on the web; one whose subject is github://repo/golang/go is +// dereferenceable only by this tool. The github:// form survives as a gh:uri +// literal so nothing is lost. + +// The namespaces. +const ( + NSSchema = "https://schema.org/" + NSGH = "https://github.com/ns#" + NSGHR = "https://github.com/" + NSRdf = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + NSRdfs = "http://www.w3.org/2000/01/rdf-schema#" + NSXsd = "http://www.w3.org/2001/XMLSchema#" + NSDoap = "http://usefulinc.com/ns/doap#" + NSFoaf = "http://xmlns.com/foaf/0.1/" +) + +var rdfPrefixes = [][2]string{ + {"schema", NSSchema}, + {"gh", NSGH}, + {"ghr", NSGHR}, + {"rdf", NSRdf}, + {"rdfs", NSRdfs}, + {"xsd", NSXsd}, + {"doap", NSDoap}, + {"foaf", NSFoaf}, +} + +// The datatypes a Fact can carry. They are CURIEs so a Fact reads the same in +// every serialisation. +const ( + TypeInteger = "xsd:integer" + TypeDecimal = "xsd:decimal" + TypeBoolean = "xsd:boolean" + TypeDateTime = "xsd:dateTime" +) + +// rdfTypes is the class mapping. Marking an issue as schema:DiscussionForumPosting +// is not this tool's invention: it is what GitHub's own structured_data block +// says, and following the publisher's vocabulary for its own content is the +// whole point. +var rdfTypes = map[string][]string{ + KindRepo: {"schema:SoftwareSourceCode", "doap:Project"}, + KindUser: {"schema:Person", "foaf:Person"}, + KindOrg: {"schema:Organization"}, + KindIssue: {"gh:Issue", "schema:DiscussionForumPosting"}, + KindPR: {"gh:PullRequest", "schema:DiscussionForumPosting"}, + KindDiscussion: {"schema:DiscussionForumPosting"}, + KindCommit: {"gh:Commit"}, + KindRelease: {"schema:SoftwareApplication"}, + KindTag: {"gh:Ref"}, + KindBranch: {"gh:Ref"}, + KindFile: {"schema:MediaObject"}, + KindTree: {"schema:MediaObject"}, + KindTopic: {"schema:DefinedTerm"}, + KindLabel: {"schema:DefinedTerm"}, + KindPackage: {"schema:SoftwareApplication"}, + KindGist: {"schema:SoftwareSourceCode"}, + KindAction: {"schema:SoftwareApplication"}, + KindWiki: {"schema:Article"}, +} + +// rdfPredicates maps this tool's vocabulary onto RDF terms. A predicate with no +// entry is emitted in the gh: namespace under its own name, which is what makes +// adding a predicate to graph.go a one-line change rather than two. +var rdfPredicates = map[string]string{ + PredOwnedBy: "schema:author", + PredAuthoredBy: "schema:author", + PredPartOf: "schema:isPartOf", + PredMemberOf: "schema:memberOf", + PredHasTopic: "schema:keywords", + PredHasLabel: "schema:keywords", + PredWrittenIn: "schema:programmingLanguage", + PredLicensedUnder: "schema:license", + PredReferences: "schema:citation", + PredFollows: "schema:follows", +} + +// rdfFacts maps the literal predicates. The counts get gh: terms because +// schema.org has no stargazer count, and the dates get the standard ones +// because it does. +var rdfFacts = map[string]string{ + FactName: "schema:name", + FactDescription: "schema:description", + FactHomepage: "schema:url", + FactCreated: "schema:dateCreated", + FactUpdated: "schema:dateModified", + FactStars: "gh:stargazerCount", + FactForks: "gh:forkCount", + FactWatchers: "gh:watcherCount", + FactCommits: "gh:commitCount", + FactURI: "gh:uri", + FactAvatar: "schema:image", +} + +// The output formats. +const ( + FormatNT = "nt" + FormatNQuads = "nq" + FormatTurtle = "ttl" + FormatJSONLD = "jsonld" +) + +// RDFFormats is the accepted set, for help text and validation. +var RDFFormats = []string{FormatNT, FormatNQuads, FormatTurtle, FormatJSONLD} + +// RDFOptions controls a serialisation. +type RDFOptions struct { + Format string + // Graph is the fourth position for N-Quads. Putting the source URL there + // means the provenance survives into the RDF and a quad store can answer + // which page told us this. + Graph string +} + +// WriteRDF serialises a whole graph. N-Triples is the default because it +// streams: nt and nq write a line per triple as it is produced, ttl buffers one +// subject at a time, and jsonld buffers the lot. +func WriteRDF(w io.Writer, g *Graph, o RDFOptions) error { + switch o.Format { + case "", FormatNT: + return writeTriples(w, g, "") + case FormatNQuads: + return writeTriples(w, g, o.Graph) + case FormatTurtle: + return writeTurtle(w, g) + case FormatJSONLD: + return writeJSONLD(w, g) + default: + return fmt.Errorf("unknown rdf format %q, want one of %s", o.Format, strings.Join(RDFFormats, ", ")) + } +} + +// --- streaming --- + +// RDFWriter is the streaming form. A crawl hands it nodes, edges, and facts as +// it finds them and it writes lines, so `github export --depth 3 --format nt` +// over a large organization never holds the graph in memory. The buffered +// formats are handled by collecting into a Graph and calling WriteRDF, and this +// type reports which is which through Streams. +type RDFWriter struct { + w io.Writer + suffix string +} + +// NewRDFWriter returns a streaming writer for nt or nq, and nil for the formats +// that cannot stream. +func NewRDFWriter(w io.Writer, o RDFOptions) *RDFWriter { + switch o.Format { + case "", FormatNT: + return &RDFWriter{w: w, suffix: " .\n"} + case FormatNQuads: + suffix := " .\n" + if o.Graph != "" { + suffix = " <" + o.Graph + "> .\n" + } + return &RDFWriter{w: w, suffix: suffix} + } + return nil +} + +// Streams reports whether a format can be written a triple at a time. +func Streams(format string) bool { + return format == "" || format == FormatNT || format == FormatNQuads +} + +func (r *RDFWriter) Node(n *Node) error { return r.lines(nodeLines(*n)) } + +func (r *RDFWriter) Edge(e *Edge) error { return r.lines(edgeLines(*e)) } + +func (r *RDFWriter) Fact(f *Fact) error { return r.lines(factLines(*f)) } + +func (r *RDFWriter) lines(ls []string) error { + for _, l := range ls { + if _, err := io.WriteString(r.w, l+r.suffix); err != nil { + return err + } + } + return nil +} + +func writeTriples(w io.Writer, g *Graph, graph string) error { + r := NewRDFWriter(w, RDFOptions{Format: FormatNQuads, Graph: graph}) + for i := range g.Nodes { + if err := r.Node(&g.Nodes[i]); err != nil { + return err + } + } + for i := range g.Edges { + if err := r.Edge(&g.Edges[i]); err != nil { + return err + } + } + for i := range g.Facts { + if err := r.Fact(&g.Facts[i]); err != nil { + return err + } + } + return nil +} + +// nodeLines states a node's classes and its label. +func nodeLines(n Node) []string { + subj := iri(n.URI) + var out []string + for _, t := range rdfTypes[n.Kind] { + out = append(out, subj+" <"+NSRdf+"type> "+expand(t)) + } + if n.Label != "" { + out = append(out, subj+" "+expand("rdfs:label")+" "+quote(n.Label)) + } + return out +} + +// edgeLines renders one edge. +// +// A weighted edge is reified: a contributor's commit count is a property of the +// relation and not of either end, and the only honest way to say that in RDF is +// to give the relation a node of its own. +func edgeLines(e Edge) []string { + subj := iri(e.Subject) + obj := objectTerm(e.Predicate, e.Object) + out := []string{subj + " " + expand(rdfPredicate(e.Predicate)) + " " + obj} + if e.Weight != nil { + blank := reifiedNode(e) + out = append(out, + blank+" <"+NSRdf+"subject> "+subj, + blank+" <"+NSRdf+"predicate> "+expand(rdfPredicate(e.Predicate)), + blank+" <"+NSRdf+"object> "+obj, + blank+" "+expand(weightTerm(e.Predicate))+" "+quote(strconv.Itoa(*e.Weight))+"^^"+expand(TypeInteger), + ) + } + if e.At != nil { + out = append(out, reifiedNode(e)+" "+expand("schema:dateCreated")+" "+ + quote(e.At.UTC().Format(time.RFC3339))+"^^"+expand(TypeDateTime)) + } + return out +} + +// weightTerm names what a weight counts. Only contributedTo and reactedWith +// carry one, and calling both of them "count" would throw away the only thing +// that makes the number readable. +func weightTerm(pred string) string { + switch pred { + case PredContributedTo: + return "gh:commitCount" + case PredReactedWith: + return "gh:reactionCount" + default: + return "gh:count" + } +} + +// reifiedNode names the statement itself. The name is derived from the triple, +// so two runs produce the same node and a merge of two exports does not +// duplicate it. +func reifiedNode(e Edge) string { + key := e.Subject + "|" + e.Predicate + "|" + e.Object + return "_:stmt-" + strings.NewReplacer("://", "-", "/", "-", "|", "-", "#", "-", "@", "-", " ", "_").Replace(key) +} + +func factLines(f Fact) []string { + pred, ok := rdfFacts[f.Predicate] + if !ok { + pred = "gh:" + f.Predicate + } + obj := quote(f.Value) + if f.Datatype != "" { + obj += "^^" + expand(f.Datatype) + } + return []string{iri(f.Subject) + " " + expand(pred) + " " + obj} +} + +// rdfPredicate maps a predicate onto its RDF term, defaulting to the gh: +// namespace so a new predicate needs no entry to serialise correctly. +func rdfPredicate(pred string) string { + if p, ok := rdfPredicates[pred]; ok { + return p + } + return "gh:" + pred +} + +// objectTerm renders an edge's object. Most objects are URIs. A language and a +// licence are bare strings on the record plane, and they get synthetic IRIs +// here rather than becoming string literals, because gh:language/go is +// something two exports can join on and "Go" is not. +func objectTerm(pred, object string) string { + if strings.HasPrefix(object, Scheme+"://") { + return iri(object) + } + switch pred { + case PredWrittenIn: + return "<" + NSGH + "language/" + slug(object) + ">" + case PredLicensedUnder: + return "<" + NSGH + "license/" + slug(object) + ">" + case PredReactedWith: + return "<" + NSGH + "reaction/" + slug(object) + ">" + } + return quote(object) +} + +// slug makes a URI path segment out of a rendered name. Spaces and slashes are +// the only characters that actually occur here, in names like "Jupyter Notebook" +// and "BSD 3-Clause", and both have to go. +func slug(s string) string { + s = strings.TrimSpace(s) + var b strings.Builder + for _, r := range s { + switch { + case r == ' ' || r == '/' || r == '\\': + b.WriteByte('-') + case r == '<' || r == '>' || r == '"' || r == '{' || r == '}' || r == '|' || r == '^' || r == '`': + // Characters an IRI may not carry. Dropping them beats escaping + // them, because nobody wants gh:license/BSD%203-Clause. + default: + b.WriteRune(r) + } + } + return b.String() +} + +// iri renders a subject or object. A blank node stays a blank node, and a +// github:// URI becomes the canonical https URL. +func iri(uri string) string { + if strings.HasPrefix(uri, "_:") { + return uri + } + return "<" + IRI(uri) + ">" +} + +// IRI maps a github:// URI to its dereferenceable https form. The github:// form +// stays on the record plane, where it is a stable key rather than a location. +// +// The three derived kinds have no address of their own, so they map into the +// gh: namespace instead of pretending to be a page. +func IRI(uri string) string { + if !strings.HasPrefix(uri, Scheme+"://") { + return uri + } + rest := strings.TrimPrefix(uri, Scheme+"://") + kind, id, ok := strings.Cut(rest, "/") + if !ok { + return uri + } + if u, err := Locate(kind, id); err == nil { + return u + } + return NSGH + kind + "/" + slug(id) +} + +func expand(curie string) string { + prefix, rest, ok := strings.Cut(curie, ":") + if !ok { + return "<" + curie + ">" + } + for _, p := range rdfPrefixes { + if p[0] == prefix { + return "<" + p[1] + rest + ">" + } + } + return "<" + curie + ">" +} + +func quote(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} + +// --- turtle --- + +// writeTurtle groups by subject, which is the whole reason to prefer Turtle: a +// node and everything said about it read as one paragraph. +func writeTurtle(w io.Writer, g *Graph) error { + for _, p := range rdfPrefixes { + if _, err := fmt.Fprintf(w, "@prefix %s: <%s> .\n", p[0], p[1]); err != nil { + return err + } + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + + bySubject := map[string][][2]string{} + var order []string + add := func(subj, pred, obj string) { + if _, seen := bySubject[subj]; !seen { + order = append(order, subj) + } + bySubject[subj] = append(bySubject[subj], [2]string{pred, obj}) + } + // The line renderers already produce N-Triples, and Turtle is the same + // triples with the subject factored out, so this splits each line rather + // than growing a second renderer that could disagree with the first. + collect := func(lines []string) { + for _, l := range lines { + subj, pred, obj, ok := splitTriple(l) + if !ok { + continue + } + add(subj, shorten(pred), shorten(obj)) + } + } + for _, n := range g.Nodes { + collect(nodeLines(n)) + } + for _, e := range g.Edges { + collect(edgeLines(e)) + } + for _, f := range g.Facts { + collect(factLines(f)) + } + + for _, subj := range order { + if _, err := io.WriteString(w, shorten(subj)+"\n"); err != nil { + return err + } + pairs := bySubject[subj] + for i, pair := range pairs { + end := " ;\n" + if i == len(pairs)-1 { + end = " .\n\n" + } + if _, err := io.WriteString(w, " "+pair[0]+" "+pair[1]+end); err != nil { + return err + } + } + } + return nil +} + +// splitTriple pulls a rendered N-Triples line apart. The grammar is regular +// enough for this: the subject and the predicate are always angle-bracketed or +// blank-node terms with no spaces in them, and everything after the second +// space is the object. +func splitTriple(line string) (subj, pred, obj string, ok bool) { + subj, rest, ok := strings.Cut(line, " ") + if !ok { + return "", "", "", false + } + pred, obj, ok = strings.Cut(rest, " ") + if !ok { + return "", "", "", false + } + return subj, pred, obj, true +} + +// shorten turns an expanded IRI back into a CURIE where a prefix covers it, +// which is what makes Turtle readable rather than just grouped. +func shorten(term string) string { + if !strings.HasPrefix(term, "<") { + // A literal, possibly with a datatype that is itself an IRI. + if i := strings.Index(term, "^^<"); i >= 0 { + return term[:i+2] + shorten(term[i+2:]) + } + return term + } + full := strings.TrimSuffix(strings.TrimPrefix(term, "<"), ">") + if full == NSRdf+"type" { + return "a" + } + for _, p := range rdfPrefixes { + // ghr is the whole of github.com, so every subject IRI would collapse + // into it and read as ghr:golang/go, which is not a legal CURIE local + // name once a path has slashes in it. Subjects stay in angle brackets. + if p[0] == "ghr" { + continue + } + if rest, found := strings.CutPrefix(full, p[1]); found && rest != "" && !strings.ContainsAny(rest, "/") { + return p[0] + ":" + rest + } + } + return term +} + +// --- json-ld --- + +// writeJSONLD emits one object per node with its edges and facts folded in, and +// an inline context so the document stands alone. It buffers everything, which +// is why the help text points at it for single records rather than crawls. +func writeJSONLD(w io.Writer, g *Graph) error { + ctx := map[string]any{} + for _, p := range rdfPrefixes { + ctx[p[0]] = p[1] + } + + byURI := map[string]map[string]any{} + var order []string + obj := func(uri string) map[string]any { + o, ok := byURI[uri] + if !ok { + o = map[string]any{"@id": IRI(uri)} + byURI[uri] = o + order = append(order, uri) + } + return o + } + for _, n := range g.Nodes { + o := obj(n.URI) + if types := rdfTypes[n.Kind]; len(types) > 0 { + o["@type"] = types + } + if n.Label != "" { + o["rdfs:label"] = n.Label + } + if n.URL != "" { + o["schema:url"] = n.URL + } + } + for _, e := range g.Edges { + o := obj(e.Subject) + var value any + if strings.HasPrefix(e.Object, Scheme+"://") { + value = map[string]any{"@id": IRI(e.Object)} + } else if term := objectTerm(e.Predicate, e.Object); strings.HasPrefix(term, "<") { + value = map[string]any{"@id": strings.TrimSuffix(strings.TrimPrefix(term, "<"), ">")} + } else { + value = e.Object + } + if e.Weight != nil { + value = map[string]any{"@id": jsonldID(value), weightTerm(e.Predicate): *e.Weight} + } + appendValue(o, rdfPredicate(e.Predicate), value) + } + for _, f := range g.Facts { + pred, ok := rdfFacts[f.Predicate] + if !ok { + pred = "gh:" + f.Predicate + } + appendValue(obj(f.Subject), pred, jsonldLiteral(f)) + } + + graph := make([]map[string]any, 0, len(order)) + for _, uri := range order { + graph = append(graph, byURI[uri]) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{"@context": ctx, "@graph": graph}) +} + +func jsonldID(v any) any { + if m, ok := v.(map[string]any); ok { + return m["@id"] + } + return v +} + +// appendValue keeps repeated predicates as a list rather than letting the last +// one win, because a repository with twelve topics has twelve of them. +func appendValue(o map[string]any, pred string, value any) { + switch cur := o[pred].(type) { + case nil: + o[pred] = value + case []any: + o[pred] = append(cur, value) + default: + o[pred] = []any{cur, value} + } +} + +// jsonldLiteral gives a value its type, so a count arrives as a number and a +// timestamp as a typed value rather than as prose. +func jsonldLiteral(f Fact) any { + switch f.Datatype { + case TypeInteger: + if n, err := strconv.ParseInt(f.Value, 10, 64); err == nil { + return n + } + case TypeDecimal: + if v, err := strconv.ParseFloat(f.Value, 64); err == nil { + return v + } + case TypeBoolean: + return f.Value == "true" + case TypeDateTime: + return map[string]any{"@value": f.Value, "@type": TypeDateTime} + } + return f.Value +} diff --git a/gh/types.go b/gh/types.go index 67dbc49..c921b7f 100644 --- a/gh/types.go +++ b/gh/types.go @@ -861,3 +861,41 @@ type RepoStats struct { PushedAt *time.Time `json:"pushed_at,omitempty" table:"pushed,time"` } + +// Dependency is one row of /network/dependencies: a package this repository +// declares in one of its manifests. +// +// The identity is the repository the package resolves to, because that is the +// only thing on the row with an address on github.com. A package GitHub cannot +// resolve to a repository has no Kind and no ID, and its name is still on the +// record, because a dependency list with the unresolvable rows silently dropped +// is a lie about what the manifest contains. +type Dependency struct { + Base + + Repo string `json:"repo" table:"repo"` + Package string `json:"package" table:"package"` + + SourceRepo string `json:"source_repo,omitempty" table:"source"` + Version string `json:"version,omitempty" table:"version"` + Relationship string `json:"relationship,omitempty" table:"rel"` + Ecosystem string `json:"ecosystem,omitempty" table:"ecosystem"` + Manifest string `json:"manifest,omitempty" table:"manifest"` + License string `json:"license,omitempty" table:"-"` +} + +// Dependent is one row of /network/dependents: a repository that depends on +// this one. The two counts are on the row, so a caller sorting the dependents +// of a popular library by stars does not need a fetch per row. +type Dependent struct { + Base + + Repo string `json:"repo" table:"repo"` + Dependent string `json:"dependent" table:"dependent"` + Owner string `json:"owner" table:"-"` + + Stars *int `json:"stars,omitempty" table:"stars"` + Forks *int `json:"forks,omitempty" table:"forks"` + + AvatarURL string `json:"avatar_url,omitempty" table:"-"` +} diff --git a/pkg/page/selectors.go b/pkg/page/selectors.go index 4e84cb3..f0b5193 100644 --- a/pkg/page/selectors.go +++ b/pkg/page/selectors.go @@ -139,9 +139,9 @@ var ( // organization's readme has no class of its own, so the caller falls back // to the markdown article, which is the same on both. // Verified 2026-07-25 against sindresorhus and github. - ProfileReadme = Sel{Class: "profile-readme"} - ProfileVCardList = Sel{Class: "vcard-details"} - ProfileAchieve = Sel{Class: "js-profile-achievements"} + ProfileReadme = Sel{Class: "profile-readme"} + ProfileVCardList = Sel{Class: "vcard-details"} + ProfileAchieve = Sel{Class: "js-profile-achievements"} ) // --- discussion pages, /{owner}/{repo}/discussions/{n} --- @@ -261,6 +261,38 @@ var ( ReleaseAssetLink = Sel{Tag: "a", Attr: "href", AttrPrefix: "/"} ) +// --- dependency graph, /{owner}/{repo}/network/{dependencies,dependents} --- + +// The two dependency pages are the only keyless source for who depends on whom, +// and they are the most fragile markup this tool reads: the rows are identified +// by test hooks rather than by classes, and one of those hooks carries GitHub's +// own typo, "dependendency". It is spelled here the way the page spells it, and +// a fix on their side will show up as a missing edge rather than a wrong one. +// +// The two pages disagree on everything, including which attribute names a row: +// dependencies uses data-test-selector and dependents uses data-test-id. +// Verified 2026-07-25 against gohugoio/hugo. +var ( + DependencyRow = Sel{Attr: "data-test-selector", AttrValue: "dg-repo-pkg-dependency"} + // The name is an anchor when GitHub resolved the package to a repository + // and a plain span when it did not, so the class is the only thing both + // forms share. + DependencyName = Sel{Class: "h4"} + DependencyLink = Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "dependendency_graph_package"} + DependencyVersion = Sel{Tag: "span", Class: "text-mono"} + DependencyRelation = Sel{Tag: "a", Attr: "data-test-selector", AttrValue: "relationship-label-link"} + DependencyManifest = Sel{Tag: "a", Attr: "data-test-selector", AttrValue: "dg-repo-pkg-manifest"} + + DependentRow = Sel{Attr: "data-test-id", AttrValue: "dg-repo-pkg-dependent"} + DependentRepo = Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "repository"} + DependentUser = Sel{Tag: "a", Attr: "data-hovercard-type", AttrValue: "user"} + DependentStars = Sel{Tag: "span", Class: "text-bold", HasDescendantClass: "octicon-star"} + DependentForks = Sel{Tag: "span", Class: "text-bold", HasDescendantClass: "octicon-repo-forked"} + // The dependents pager is a cursor in a button, not a rel="next" anchor, + // so it needs its own selector and its own token. + DependentNext = Sel{Tag: "a", Class: "BtnGroup-item", Attr: "href", AttrContains: "dependents_after="} +) + // --- gist, gist.github.com/{id} --- // Verified 2026-07-25 against gist.github.com. From a8ab53e2f269467774cbe8c1e6ead07a9304c0b4 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:19:14 +0700 Subject: [PATCH 11/21] gh: the page plane and doctor page prints the whole extraction for one URL and drops nothing. It is the escape hatch for a field no record models yet, it is the way to tell "the page did not carry it" apart from "the decoder dropped it", and it is how a recorded fixture is read back. Every reader in the package now works from the same Client.Page, so what this prints is what they see rather than a second opinion. doctor answers the question people actually ask when a command comes back wrong. The check that matters is the first one: a token in the environment does nothing here, and the failure that causes is invisible, because the tool keeps working and stays exactly as rate limited as it was, so the obvious conclusion is that the token is wrong. Now it says so. --- cli/page.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ cli/root.go | 1 + gh/client.go | 17 +++++ gh/doctor.go | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++ gh/ops.go | 22 +++++++ 5 files changed, 388 insertions(+) create mode 100644 cli/page.go create mode 100644 gh/doctor.go diff --git a/cli/page.go b/cli/page.go new file mode 100644 index 0000000..1c14fb2 --- /dev/null +++ b/cli/page.go @@ -0,0 +1,170 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "os" + "sort" + "strings" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" + "github.com/tamnd/github-cli/gh" + "github.com/tamnd/github-cli/pkg/page" +) + +// page.go is the 1:1 view. Everything else in this tool decides what matters on +// a page and throws the rest away; this command throws nothing away, which makes +// it three things at once. +// +// It is the escape hatch. A consumer who wants a field no record models yet can +// have it today instead of waiting for a release. +// +// It is the debugging tool. When a record comes back thin the first question is +// always whether the data was missing from the page or dropped by the decoder, +// and this is the only way to tell the two apart. +// +// It is how a recorded fixture is read back, since a fixture is the bytes and +// nothing else. +// +// It is a byte-plane command because its output is one document, not a stream of +// records, and pretending otherwise would put a table renderer in front of a +// GraphQL response. + +type pageCmd struct { + section string + query string + raw bool + compact bool +} + +func newPageCmd() kit.Command { + c := &pageCmd{} + return kit.Command{ + Use: "page ", + Short: "Print everything a page carries, organised", + Long: "page fetches one page and prints the whole extraction as JSON: the React\n" + + "route payload, the preloaded Relay queries, GitHub's own schema.org block,\n" + + "the ld+json, the og: and twitter: meta, the microdata, and the deferred\n" + + "fragments the page names for itself.\n\n" + + "--section narrows it to one of payload, queries, structured_data,\n" + + "linked_data, partials, meta, microdata, or fragments. --query prints one\n" + + "preloaded query result by name, which is where issue and pull request pages\n" + + "keep everything. With no argument, --query lists the names.\n\n" + + "--raw writes the original markup instead, which is what you want when the\n" + + "question is about the HTML rather than about the data in it.", + Group: "meta", + Args: kit.ExactArgs(1), + Flags: c.flags, + Run: c.run, + } +} + +func (c *pageCmd) flags(f *kit.FlagSet) { + f.StringVar(&c.section, "section", "", "print one section only") + f.StringVar(&c.query, "query", "", "print one preloaded query by name (empty lists them)") + f.BoolVar(&c.raw, "raw", false, "print the original markup instead of the extraction") + f.BoolVar(&c.compact, "compact", false, "one line of JSON rather than indented") +} + +func (c *pageCmd) run(ctx context.Context, args []string) error { + cl, err := clientFrom(ctx) + if err != nil { + return err + } + url, err := pageURL(args[0]) + if err != nil { + return err + } + p, err := cl.Page(ctx, url) + if err != nil { + return err + } + + w := bufio.NewWriter(os.Stdout) + defer func() { _ = w.Flush() }() + + if c.raw { + _, err := w.Write(p.HTML) + return err + } + + enc := json.NewEncoder(w) + if !c.compact { + enc.SetIndent("", " ") + } + + if c.query != "" { + q, ok := p.Queries[c.query] + if !ok { + // The names are the useful half of this failure. Query names are + // GitHub's internal Relay identifiers, nobody knows them by heart, + // and a bare "not found" would send the reader off to dump the + // whole queries section to find out what to ask for. + // The message leads with a word rather than the URL because the + // error renderer capitalises what it starts with, and a + // title-cased URL reads as a typo. + return errs.NotFound("no query named %q on %s; it has %s", + c.query, url, strings.Join(queryNames(p.Queries), ", ")) + } + return enc.Encode(q) + } + if c.section != "" { + v, err := section(p, c.section) + if err != nil { + return err + } + return enc.Encode(v) + } + return enc.Encode(p) +} + +// pageURL turns anything a person might paste into the page to fetch. A full +// URL is taken as given, including the parts of the site that name no entity, +// like /trending and /explore, because the debugging tool is least useful on +// exactly the pages the model does not cover yet. +func pageURL(ref string) (string, error) { + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + return ref, nil + } + kind, id, err := gh.Classify(ref) + if err != nil { + return "", err + } + return gh.Locate(kind, id) +} + +func section(p *page.Page, name string) (any, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "payload": + return p.Payload, nil + case "queries": + return p.Queries, nil + case "structured_data", "structured-data", "structured": + return p.StructuredData, nil + case "linked_data", "linked-data", "ld", "ld+json": + return p.LinkedData, nil + case "partials": + return p.Partials, nil + case "meta": + return p.Meta, nil + case "microdata": + return p.Microdata, nil + case "fragments": + return p.Fragments, nil + default: + return nil, errs.Usage("unknown --section %q: payload, queries, structured_data, linked_data, partials, meta, microdata, or fragments", name) + } +} + +// queryNames is sorted because the map order would otherwise change between two +// runs against the same bytes, and this output gets diffed. +func queryNames(q map[string]json.RawMessage) []string { + out := make([]string, 0, len(q)) + for k := range q { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cli/root.go b/cli/root.go index 2c78431..e50961c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -38,6 +38,7 @@ func NewApp() *kit.App { app.AddCommand(newReadmeCmd()) app.AddCommand(newArchiveCmd()) app.AddCommand(newDiffCmd()) + app.AddCommand(newPageCmd()) app.AddCommand(newRDFCmd()) app.AddCommand(newExportCmd()) return app diff --git a/gh/client.go b/gh/client.go index 9f28442..8e2d202 100644 --- a/gh/client.go +++ b/gh/client.go @@ -17,6 +17,8 @@ import ( "time" "github.com/tamnd/any-cli/kit/errs" + + "github.com/tamnd/github-cli/pkg/page" ) // Client reads github.com. It is safe for concurrent use: the pacer and the @@ -190,6 +192,21 @@ func (c *Client) GetHTML(ctx context.Context, rawURL string) (*Response, error) return c.Get(ctx, rawURL, SurfaceHTML) } +// Page fetches a URL and hands back the whole extraction, nothing dropped. Every +// reader in the package works from this, and `github page` prints it, which is +// what makes the debugging tool show the same view the readers see rather than a +// second opinion about the page. +// +// The URL is taken as given rather than resolved from an entity, because the +// pages worth inspecting most are the ones the model does not cover yet. +func (c *Client) Page(ctx context.Context, rawURL string) (*page.Page, error) { + res, err := c.GetHTML(ctx, rawURL) + if err != nil { + return nil, err + } + return page.Extract(res.FinalURL, res.Body), nil +} + // Stream opens a body without buffering, retrying, or caching. Release assets // and repository archives go through here: a tarball does not belong in memory // and does not belong in the cache. The caller closes the reader. diff --git a/gh/doctor.go b/gh/doctor.go new file mode 100644 index 0000000..5dcd654 --- /dev/null +++ b/gh/doctor.go @@ -0,0 +1,178 @@ +package gh + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tamnd/github-cli/pkg/page" +) + +// doctor.go answers the question people ask when a command comes back wrong: is +// it me, is it the network, or did GitHub change the page? +// +// Every check is a record rather than a paragraph, so the answer can be read by +// a person and by a script, and so the failing one can be picked out with the +// same --fields and -o json every other command takes. + +// Check is one diagnostic. +type Check struct { + Name string `json:"name" table:"check"` + Status string `json:"status" table:"status"` + Detail string `json:"detail" table:"detail"` +} + +// The three states a check can be in. Warn exists because most of what goes +// wrong here is survivable: a token in the environment, a cache that cannot be +// written, a page that parsed but looks thinner than it should. +const ( + StatusOK = "ok" + StatusWarn = "warn" + StatusFail = "fail" +) + +// tokenVars are the variables people expect to matter and which do not. They are +// checked by name and never read for their value: this file will not put a +// credential in a record, and there is nothing here that would use one. +var tokenVars = []string{"GITHUB_TOKEN", "GH_TOKEN", "GITHUB_API_TOKEN", "GH_ENTERPRISE_TOKEN"} + +// Doctor runs the checks in order and emits one record each. It stops for +// nothing: a failed reachability check makes the page check fail too, and seeing +// both is more useful than seeing the first one alone. +func (c *Client) Doctor(ctx context.Context, emit func(*Check) error) error { + for _, ck := range []func(context.Context) *Check{ + c.checkAuthEnv, + c.checkReach, + c.checkPagePlane, + c.checkCache, + c.checkPacing, + } { + if err := emit(ck(ctx)); err != nil { + return err + } + } + return nil +} + +// checkAuthEnv is the one people need and do not know to ask for. A token in the +// environment does nothing here, and the failure it causes is invisible: the +// tool works, it is just as rate limited as it was before, and the obvious +// conclusion is that the token is wrong. +func (c *Client) checkAuthEnv(context.Context) *Check { + var set []string + for _, v := range tokenVars { + if os.Getenv(v) != "" { + set = append(set, v) + } + } + if len(set) == 0 { + return &Check{Name: "auth", Status: StatusOK, + Detail: "no token in the environment, which is what this tool wants"} + } + return &Check{Name: "auth", Status: StatusWarn, + Detail: fmt.Sprintf("%s is set and ignored: github reads public pages and never sends an Authorization header, so a token changes nothing here. Use gh for the authenticated API", strings.Join(set, " and "))} +} + +// checkReach is one small request to the site. robots.txt is the right target: +// it is a few hundred bytes, it is not behind any of the machinery this tool +// reads, and it comes back the same for everyone. +func (c *Client) checkReach(ctx context.Context) *Check { + start := time.Now() + res, err := c.Get(ctx, BaseURL+"/robots.txt", SurfaceRaw) + if err != nil { + return &Check{Name: "reach", Status: StatusFail, + Detail: fmt.Sprintf("cannot read %s: %v", BaseURL, err)} + } + return &Check{Name: "reach", Status: StatusOK, + Detail: fmt.Sprintf("%s answered %d in %s", BaseURL, res.Status, time.Since(start).Round(time.Millisecond))} +} + +// checkPagePlane reads a repository page and looks for the embedded React +// payload. This is the check that catches the failure this tool cannot survive: +// GitHub reorganising the page. Every structureChanged error in the package +// starts here, so when one fires, this says whether the whole plane moved or +// only the one selector. +func (c *Client) checkPagePlane(ctx context.Context) *Check { + p, err := c.Page(ctx, BaseURL+"/golang/go") + if err != nil { + return &Check{Name: "page", Status: StatusFail, + Detail: fmt.Sprintf("cannot read a repository page: %v", err)} + } + switch { + case p.Plane == page.PlaneReact && len(p.Payload) > 0: + return &Check{Name: "page", Status: StatusOK, + Detail: fmt.Sprintf("the react payload is where it should be, %d keys in %d bytes", len(p.Payload), p.Bytes)} + case len(p.Microdata) > 0 || len(p.Meta) > 0: + return &Check{Name: "page", Status: StatusWarn, + Detail: "no react payload, but the meta and microdata are readable: the records will be thinner than they should be. Run github page golang/go to see what came back"} + default: + return &Check{Name: "page", Status: StatusFail, + Detail: "a repository page carried nothing this understands. Either the request was intercepted or the page changed shape. Run github page golang/go --raw to see the bytes"} + } +} + +// checkCache reports what is on disk and, more to the point, whether it can be +// written. A read-only cache directory turns every run into a cold one, which +// looks like the site being slow rather than like a local problem. +func (c *Client) checkCache(context.Context) *Check { + if c.NoCache { + return &Check{Name: "cache", Status: StatusWarn, + Detail: "the cache is off for this run, so every request goes to the network"} + } + if c.CacheDir == "" { + return &Check{Name: "cache", Status: StatusWarn, Detail: "no cache directory is configured"} + } + if err := os.MkdirAll(c.CacheDir, 0o755); err != nil { + return &Check{Name: "cache", Status: StatusFail, + Detail: fmt.Sprintf("cannot create %s: %v", c.CacheDir, err)} + } + probe := filepath.Join(c.CacheDir, ".doctor") + if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { + return &Check{Name: "cache", Status: StatusFail, + Detail: fmt.Sprintf("%s is not writable: %v", c.CacheDir, err)} + } + _ = os.Remove(probe) + + n, bytes := cacheSize(c.CacheDir) + return &Check{Name: "cache", Status: StatusOK, + Detail: fmt.Sprintf("%s holds %d entries, %s, kept for %s", c.CacheDir, n, humanBytes(bytes), c.CacheTTL)} +} + +// checkPacing prints the numbers a run is using. It is not a test of anything; +// it is here because "why is this slow" and "why did I get rate limited" are +// both answered by these four values and neither is visible otherwise. +func (c *Client) checkPacing(context.Context) *Check { + return &Check{Name: "pacing", Status: StatusOK, + Detail: fmt.Sprintf("%s between requests across %d workers, %s timeout, %d retries, user agent %q", + c.Rate, c.Workers, c.HTTP.Timeout, c.Retries, c.UserAgent)} +} + +func cacheSize(dir string) (entries int, bytes int64) { + _ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil //nolint:nilerr // a directory that cannot be walked is reported as empty + } + if info, err := d.Info(); err == nil { + entries++ + bytes += info.Size() + } + return nil + }) + return entries, bytes +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) +} diff --git a/gh/ops.go b/gh/ops.go index 2160e41..ebd7542 100644 --- a/gh/ops.go +++ b/gh/ops.go @@ -1582,6 +1582,28 @@ func registerMetaOps(app *kit.App) { "answers it best and what to fall back to when that surface declines. A\n" + "route that is not in the table is unsupported rather than guessed at.", }, listRoutes) + + kit.Handle(app, kit.OpMeta{ + Name: "doctor", Group: "meta", List: true, + Summary: "Check the environment, the site, and the cache", + Long: "doctor answers the question people ask when a command comes back wrong: is\n" + + "it me, is it the network, or did GitHub change the page. It reads a small\n" + + "file to check reachability, a repository page to check that the embedded\n" + + "payload is still where every reader expects it, and the cache directory to\n" + + "check that it can be written.\n\n" + + "It also says out loud that GITHUB_TOKEN and GH_TOKEN are ignored, because\n" + + "a token in the environment does nothing here and the failure that causes is\n" + + "invisible: the tool works, it is just as rate limited as before, and the\n" + + "obvious conclusion is that the token is wrong.", + }, runDoctor) +} + +type doctorIn struct { + C *Client `kit:"inject"` +} + +func runDoctor(ctx context.Context, in doctorIn, emit func(*Check) error) error { + return in.C.Doctor(ctx, emit) } type parseIn struct { From 71339d1f3a3fe94e9bd6e3bcd76dd024348a5f6a Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:03 +0700 Subject: [PATCH 12/21] Update kit to v0.4.7 Brings the url output format and the table column tags the records rely on. --- go.mod | 2 +- go.sum | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index eba50f1..762fbfe 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( github.com/charmbracelet/fang v1.0.0 github.com/spf13/cobra v1.10.2 - github.com/tamnd/any-cli v0.4.4 + github.com/tamnd/any-cli v0.4.7 ) require ( diff --git a/go.sum b/go.sum index 3919846..5c1b552 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,12 @@ github.com/tamnd/any-cli v0.4.0 h1:ngyRJBvjZ2X1iBlwlmDLvY2S9aQWlDjVE7CiOwxtt5Y= github.com/tamnd/any-cli v0.4.0/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/tamnd/any-cli v0.4.4 h1:mOo3JJ7M3ZWQtOocYVtMEN7Zhfc3ogVWcVrbarlt9jE= github.com/tamnd/any-cli v0.4.4/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= +github.com/tamnd/any-cli v0.4.5 h1:dEeniLDoneCxK4A9SixIyND2xlS2A/RxUtRdqTWXlQw= +github.com/tamnd/any-cli v0.4.5/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= +github.com/tamnd/any-cli v0.4.6 h1:5GHwOsr8Z9oRYCtFt49Q9bwUKf9NBqkhXUqfOB5So+4= +github.com/tamnd/any-cli v0.4.6/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= +github.com/tamnd/any-cli v0.4.7 h1:aHjifufpIy0M4HQQo+Ex90M4xaHyYoAMNtNPqVWw8ug= +github.com/tamnd/any-cli v0.4.7/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= From ed7a0a46b7a8c54dd08c887fcac322c0bc9bd0b2 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:11 +0700 Subject: [PATCH 13/21] gh: fix package URLs, and reword the reference errors A container package is named after the repository and the thing inside it, so its name carries a slash, and GitHub wants that slash as %2F. Locate was writing it plain, which 404s, and the parser was taking the last path segment as the name, which lost the first half. Both directions round trip now. The rest is wording. Every message here leads with a word rather than with the reference it is about, because the renderer title-cases the first token and a title-cased URL reads like the tool mangled the input. An unknown kind now lists the kinds, which is the difference between an error a reader can act on and one that sends them to the source, and knownKind reads that same list so a kind cannot be accepted here and left out of the list. --- gh/uri.go | 70 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/gh/uri.go b/gh/uri.go index 02d55b2..3ad1153 100644 --- a/gh/uri.go +++ b/gh/uri.go @@ -57,6 +57,17 @@ const ( KindEvent = "event" ) +// Kinds is the whole set, in the order above, for help text and for the error a +// bad kind produces. Listing them is the difference between an error a reader +// can act on and one that sends them to the source. +var Kinds = []string{ + KindRepo, KindUser, KindOrg, KindIssue, KindPR, KindDiscussion, + KindCommit, KindBranch, KindTag, KindRelease, KindFile, KindTree, + KindLabel, KindMilestone, KindTopic, KindGist, KindPackage, KindAction, + KindWiki, KindAdvisory, KindCompare, + KindContributor, KindContribution, KindEvent, +} + // Scheme is the URI scheme this package mints and dereferences. const Scheme = "github" @@ -159,21 +170,21 @@ func parseURI(s string) (kind, id, anchor string, err error) { } kind, id, ok := strings.Cut(rest, "/") if !ok || kind == "" || id == "" { - return "", "", "", errs.Usage("%q is not a %s:// URI", s, Scheme) + return "", "", "", errs.Usage("not a %s:// URI: %q", Scheme, s) } if !knownKind(kind) { - return "", "", "", errs.Usage("%q is not a kind this tool knows", kind) + return "", "", "", errs.Usage("unknown kind %q; the kinds are %s", kind, strings.Join(Kinds, ", ")) } return kind, strings.TrimSuffix(id, "/"), anchor, nil } +// knownKind reads the same list the error message prints, so a kind cannot be +// accepted here and left out of the list a reader is shown. func knownKind(k string) bool { - switch k { - case KindRepo, KindUser, KindOrg, KindIssue, KindPR, KindDiscussion, KindCommit, - KindBranch, KindTag, KindRelease, KindFile, KindTree, KindLabel, KindMilestone, - KindTopic, KindGist, KindPackage, KindAction, KindWiki, KindAdvisory, KindCompare, - KindContributor, KindContribution, KindEvent: - return true + for _, want := range Kinds { + if k == want { + return true + } } return false } @@ -184,7 +195,7 @@ func knownKind(k string) bool { func parseURL(raw string) (kind, id, anchor string, err error) { u, perr := url.Parse(raw) if perr != nil { - return "", "", "", errs.Usage("%q is not a URL: %v", raw, perr) + return "", "", "", errs.Usage("not a URL: %q, %v", raw, perr) } host := strings.ToLower(u.Host) host = strings.TrimPrefix(host, "www.") @@ -196,13 +207,13 @@ func parseURL(raw string) (kind, id, anchor string, err error) { // /{owner}/{repo}/{ref}/{path...} p := strings.Split(path, "/") if len(p) < 4 { - return "", "", "", errs.Usage("%q is not a raw file URL", raw) + return "", "", "", errs.Usage("not a raw file URL: %q", raw) } return KindFile, p[0] + "/" + p[1] + "@" + p[2] + "/" + strings.Join(p[3:], "/"), anchor, nil case "gist.github.com", "gist.githubusercontent.com": p := strings.Split(path, "/") if len(p) == 0 || p[0] == "" { - return "", "", "", errs.Usage("%q names no gist", raw) + return "", "", "", errs.Usage("no gist named in %q", raw) } // A gist URL is either /{id} or /{owner}/{id}. The id is the last // segment that looks like one. @@ -210,13 +221,13 @@ func parseURL(raw string) (kind, id, anchor string, err error) { case "github.com", "codeload.github.com": return classifyPath(path, anchor, raw) default: - return "", "", "", errs.Usage("%q is not a github.com URL", raw) + return "", "", "", errs.Usage("not a github.com URL: %q", raw) } } func classifyPath(path, anchor, raw string) (kind, id, a string, err error) { if path == "" { - return "", "", "", errs.Usage("%q names no resource", raw) + return "", "", "", errs.Usage("no resource named in %q", raw) } p := strings.Split(path, "/") @@ -240,7 +251,7 @@ func classifyPath(path, anchor, raw string) (kind, id, a string, err error) { } } if reserved[p[0]] { - return "", "", "", errs.Usage("%q is a github.com page, not a resource this tool reads", raw) + return "", "", "", errs.Usage("no resource behind %q; it is a github.com page, not a thing this tool reads", raw) } if len(p) == 1 { return KindUser, p[0], anchor, nil @@ -311,9 +322,12 @@ func classifyPath(path, anchor, raw string) (kind, id, a string, err error) { } return KindWiki, repo + "/Home", anchor, nil case "pkgs": - // /{owner}/{repo}/pkgs/{type}/{name} + // /{owner}/{repo}/pkgs/{type}/{name}, where the name is usually the + // repository and the thing inside it and so carries a %2F. Parsing + // decoded that back into a slash before the split, so the name is + // everything from the type onwards rather than the last segment. if len(rest) >= 3 { - return KindPackage, repo + "/" + rest[len(rest)-1], anchor, nil + return KindPackage, repo + "/" + strings.Join(rest[2:], "/"), anchor, nil } case "compare": if len(rest) >= 2 { @@ -341,10 +355,10 @@ func classifyBare(s string) (kind, id string, err error) { } if base, num, ok := strings.Cut(s, "#"); ok { if !isNumber(num) { - return "", "", errs.Usage("%q: the part after # must be a number", s) + return "", "", errs.Usage("the part after # must be a number, in %q", s) } if strings.Count(base, "/") != 1 { - return "", "", errs.Usage("%q: a thread reference looks like owner/name#123", s) + return "", "", errs.Usage("not a thread reference: %q, which should look like owner/name#123", s) } // Bare owner/name#N is an issue, which is the same guess github.com // makes: /issues/N redirects to /pull/N when N is a pull request. @@ -353,7 +367,7 @@ func classifyBare(s string) (kind, id string, err error) { if i := strings.Index(s, "@"); i >= 0 && strings.Count(s[:i], "/") == 1 { repo, rev := s[:i], s[i+1:] if rev == "" { - return "", "", errs.Usage("%q: nothing after @", s) + return "", "", errs.Usage("nothing after the @ in %q", s) } if r, path, ok := strings.Cut(rev, "/"); ok { return KindFile, repo + "@" + r + "/" + path, nil @@ -368,7 +382,7 @@ func classifyBare(s string) (kind, id string, err error) { switch strings.Count(s, "/") { case 0: if reserved[s] { - return "", "", errs.Usage("%q is a github.com page, not an account", s) + return "", "", errs.Usage("no account behind %q; it is a github.com page, not a profile", s) } return KindUser, s, nil case 1: @@ -410,7 +424,7 @@ var routeWord = map[string]bool{ // safe to pipe back into the tool. func Locate(kind, id string) (string, error) { if id == "" { - return "", errs.Usage("%s with no id", kind) + return "", errs.Usage("no id given for a %s", kind) } switch kind { case KindRepo: @@ -420,7 +434,7 @@ func Locate(kind, id string) (string, error) { case KindIssue, KindPR, KindDiscussion: repo, num, ok := strings.Cut(id, "#") if !ok { - return "", errs.Usage("%s id %q is missing its number", kind, id) + return "", errs.Usage("missing number: the %s id %q needs one", kind, id) } seg := map[string]string{KindIssue: "issues", KindPR: "pull", KindDiscussion: "discussions"}[kind] return BaseURL + "/" + repo + "/" + seg + "/" + num, nil @@ -439,13 +453,13 @@ func Locate(kind, id string) (string, error) { case KindTag, KindRelease: repo, tag, ok := cutRev(id) if !ok { - return "", errs.Usage("%s id %q is missing its tag", kind, id) + return "", errs.Usage("missing tag: the %s id %q needs one", kind, id) } return BaseURL + "/" + repo + "/releases/tag/" + tag, nil case KindFile, KindTree: repo, ref, path, ok := SplitPathID(id) if !ok { - return "", errs.Usage("%s id %q is not owner/name@ref/path", kind, id) + return "", errs.Usage("wrong shape: the %s id %q is not owner/name@ref/path", kind, id) } seg := "blob" if kind == KindTree { @@ -478,7 +492,11 @@ func Locate(kind, id string) (string, error) { if !ok { return "", errs.Usage("package id %q is not owner/name/package", id) } - return BaseURL + "/" + repo + "/pkgs/container/" + name, nil + // The name is escaped because a container package is usually called + // after the repository and the thing inside it, so it has a slash in + // it. GitHub wants that slash as %2F: the unescaped form 404s and the + // escaped one is the page. + return BaseURL + "/" + repo + "/pkgs/container/" + url.PathEscape(name), nil case KindTopic: return BaseURL + "/topics/" + id, nil case KindAction: @@ -513,7 +531,7 @@ func Locate(kind, id string) (string, error) { } return BaseURL + "/" + repo + "/compare/" + rng, nil } - return "", errs.Usage("%q is not a kind this tool knows", kind) + return "", errs.Usage("unknown kind %q; the kinds are %s", kind, strings.Join(Kinds, ", ")) } // cutRev splits owner/name@rev. It looks for the `@` after the second slash so From 2d4ff52c1416afbefe786b477a03cc424b58a282 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:19 +0700 Subject: [PATCH 14/21] gh: give every error message a word to lead with The renderer capitalises the first token of an error. A message that started with a path came back as Golang/Go/Blob/Master, which reads like the tool broke the input rather than like the page was missing, and one that started with the argument did the same to whatever was typed. So every message now opens with a plain word and puts the reference after it. Two other things fell out of reading them all at once. Articles did not agree: five of the twenty-four kinds start with a vowel, and "is a repo, not a org" reads like nobody looked. aKind fixes that in the three places it happened. And symbols now says the truth, which is that GitHub serves no symbol list to a signed-out reader on any file tried, rather than suggesting a retry that will not help. doctor's help moved to doctor.go so that the one file allowed to name GITHUB_TOKEN is the file whose job is to say it is ignored. messages_test.go keeps this from drifting back. It parses every source file in the module and fails a message that leads with a format verb, a quote, or a capital, which are the three ways this gets written by accident. --- cli/files.go | 8 ++- cli/page.go | 9 +++- gh/client.go | 4 +- gh/doctor.go | 13 +++++ gh/errors.go | 37 ++++++++------ gh/messages_test.go | 117 ++++++++++++++++++++++++++++++++++++++++++++ gh/ops.go | 81 +++++++++++++++++------------- gh/people.go | 2 +- 8 files changed, 215 insertions(+), 56 deletions(-) create mode 100644 gh/messages_test.go diff --git a/cli/files.go b/cli/files.go index d699bd7..97a354b 100644 --- a/cli/files.go +++ b/cli/files.go @@ -158,7 +158,7 @@ func (c *readmeCmd) run(ctx context.Context, args []string) error { text = r.ReadmeHTML } if text == "" { - return errs.NotFound("%s has no README", id) + return errs.NotFound("no README in %s", id) } _, err = io.WriteString(os.Stdout, text) return err @@ -282,5 +282,9 @@ func diffURL(args []string) (string, error) { case gh.KindCommit, gh.KindCompare, gh.KindPR: return gh.Locate(kind, id) } - return "", errs.Usage("%q is a %s; diff needs a commit, a pull request, a compare URL, or a repository with two refs", args[0], kind) + // Leads with a word, not with the argument. The renderer capitalises the + // first token of an error, and "golang/go" coming back as "Golang/Go" reads + // like the tool mangled the input rather than like the input was the wrong + // kind of thing. + return "", errs.Usage("cannot diff %q, which is a %s; diff needs a commit, a pull request, a compare URL, or a repository with two refs", args[0], kind) } diff --git a/cli/page.go b/cli/page.go index 1c14fb2..d52acf5 100644 --- a/cli/page.go +++ b/cli/page.go @@ -102,9 +102,16 @@ func (c *pageCmd) run(ctx context.Context, args []string) error { // GitHub's internal Relay identifiers, nobody knows them by heart, // and a bare "not found" would send the reader off to dump the // whole queries section to find out what to ask for. - // The message leads with a word rather than the URL because the + // Both messages lead with a word rather than the URL because the // error renderer capitalises what it starts with, and a // title-cased URL reads as a typo. + if len(p.Queries) == 0 { + // Worth saying separately. Most pages preload nothing, so + // listing the names it has would be an empty list, and an empty + // list reads like the lookup broke rather than like the page + // carries no queries at all. + return errs.NotFound("no preloaded queries on %s at all; that is normal, only a few page kinds have them", url) + } return errs.NotFound("no query named %q on %s; it has %s", c.query, url, strings.Join(queryNames(p.Queries), ", ")) } diff --git a/gh/client.go b/gh/client.go index 8e2d202..8b776f6 100644 --- a/gh/client.go +++ b/gh/client.go @@ -181,7 +181,7 @@ func (c *Client) GetJSON(ctx context.Context, rawURL string, s Surface, v any) ( } if v != nil { if err := json.Unmarshal(resp.Body, v); err != nil { - return resp, errs.New(errs.KindNetwork, "%s: %v", shortURL(rawURL), err) + return resp, errs.New(errs.KindNetwork, "cannot decode the json from %s: %v", shortURL(rawURL), err) } } return resp, nil @@ -397,7 +397,7 @@ func (c *Client) Poll(ctx context.Context, rawURL string, s Surface) (*Response, wait *= 2 } } - return nil, errs.Unsupported("%s: github is still computing this statistic, try again shortly", shortURL(rawURL)) + return nil, errs.Unsupported("still computing: github has not finished this statistic yet, try again shortly (%s)", shortURL(rawURL)) } // --- URL building --- diff --git a/gh/doctor.go b/gh/doctor.go index 5dcd654..fbee05d 100644 --- a/gh/doctor.go +++ b/gh/doctor.go @@ -18,6 +18,19 @@ import ( // a person and by a script, and so the failing one can be picked out with the // same --fields and -o json every other command takes. +// doctorLong is the command's help. It lives here rather than beside the +// registration because it names the token variables, and TestNoAuth wants every +// mention of those names in the one file whose job is to talk about them. +const doctorLong = "doctor answers the question people ask when a command comes back wrong: is\n" + + "it me, is it the network, or did GitHub change the page. It reads a small\n" + + "file to check reachability, a repository page to check that the embedded\n" + + "payload is still where every reader expects it, and the cache directory to\n" + + "check that it can be written.\n\n" + + "It also says out loud that GITHUB_TOKEN and GH_TOKEN are ignored, because a\n" + + "token in the environment does nothing here and the failure that causes is\n" + + "invisible: the tool works, it stays exactly as rate limited as before, and\n" + + "the obvious conclusion is that the token is wrong." + // Check is one diagnostic. type Check struct { Name string `json:"name" table:"check"` diff --git a/gh/errors.go b/gh/errors.go index b93d7e4..a316e89 100644 --- a/gh/errors.go +++ b/gh/errors.go @@ -23,6 +23,11 @@ import ( // // Both mean "wrong surface", which is a thing the client can fix by trying the // other one. Turning them into errors here would hide that. +// +// Every message here leads with a word rather than with the path it is about. +// The renderer title-cases whatever a message starts with, and a path that +// comes back as Golang/Go/Blob/Master reads like the tool mangled the input +// rather than like the page was missing. // statusError classifies a non-2xx response. func statusError(rawURL string, status int, body []byte) error { @@ -33,23 +38,23 @@ func statusError(rawURL string, status int, body []byte) error { // public. Saying "pass a token" would be wrong: there is no token to // pass. Saying what is actually true is more useful. if isRateLimitBody(body) { - return errs.RateLimited("%s: github is throttling anonymous reads, try again shortly", where) + return errs.RateLimited("github is throttling anonymous reads, try again shortly (%s)", where) } - return errs.NeedAuth("%s: not public, and this tool reads only public pages (use gh for the rest)", where) + return errs.NeedAuth("not public: %s, and this tool reads only public pages (use gh for the rest)", where) case status == http.StatusNotFound: - return errs.NotFound("%s: not found", where) + return errs.NotFound("not found: %s", where) case status == http.StatusGone: - return errs.NotFound("%s: gone", where) + return errs.NotFound("gone: %s", where) case status == http.StatusTooManyRequests: - return errs.RateLimited("%s: rate limited", where) + return errs.RateLimited("rate limited on %s", where) case status == http.StatusUnavailableForLegalReasons: - return errs.Unsupported("%s: unavailable for legal reasons (DMCA)", where) + return errs.Unsupported("unavailable for legal reasons (DMCA): %s", where) case status == http.StatusBadRequest: - return errs.Usage("%s: bad request", where) + return errs.Usage("bad request: %s", where) case status >= 500: - return errs.New(errs.KindNetwork, "%s: server error %d", where, status) + return errs.New(errs.KindNetwork, "server error %d on %s", status, where) default: - return errs.New(errs.KindGeneric, "%s: http %d", where, status) + return errs.New(errs.KindGeneric, "http %d on %s", status, where) } } @@ -74,9 +79,9 @@ func wrapNetwork(rawURL string, err error) error { } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { - return errs.New(errs.KindNetwork, "%s: cannot resolve %s", shortURL(rawURL), dnsErr.Name) + return errs.New(errs.KindNetwork, "cannot resolve %s, reading %s", dnsErr.Name, shortURL(rawURL)) } - return errs.New(errs.KindNetwork, "%s: %v", shortURL(rawURL), err) + return errs.New(errs.KindNetwork, "reading %s: %v", shortURL(rawURL), err) } // shortURL trims the scheme and the host so an error message reads as a path. @@ -95,13 +100,13 @@ func shortURL(raw string) string { // code search, traffic, clones, referrers. It names what would be needed rather // than being vague, because a vague "unsupported" wastes an afternoon. func notPublic(what, why string) error { - return errs.Unsupported("%s is not available without a session: %s", what, why) + return errs.Unsupported("not available without a session: %s, %s", what, why) } // usageBadID rejects a malformed identifier before a request goes out. Showing // the expected shape saves the round trip and the 404 that would follow it. func usageBadID(kind, got, want string) error { - return errs.Usage("%q is not a %s, expected %s", got, kind, want) + return errs.Usage("expected a %s like %s, got %q", kind, want, got) } // structureChanged is the loud failure from doc 02 section 7: the page came @@ -110,7 +115,7 @@ func usageBadID(kind, got, want string) error { // zero exit code. func structureChanged(what string) error { return errs.New(errs.KindNetwork, - "%s: the page structure changed, none of the expected data was there (run `github page %s` to see what arrived)", + "the page structure changed for %s, none of the expected data was there (run `github page %s` to see what arrived)", what, what) } @@ -119,11 +124,11 @@ func structureChanged(what string) error { // changed means the block is gone, bad payload means the block arrived and no // longer parses, which is usually a type change on one field. func badPayload(what string, err error) error { - return errs.New(errs.KindNetwork, "%s: the payload did not decode: %v", what, err) + return errs.New(errs.KindNetwork, "the payload for %s did not decode: %v", what, err) } // noJSONHere is what a 410 means. It is separated out so the message can say // the useful half: the data is reachable, just on a different surface. func noJSONHere(rawURL string) error { - return errs.Unsupported("%s serves no JSON; this is a page-only route", shortURL(rawURL)) + return errs.Unsupported("no JSON at %s; this is a page-only route", shortURL(rawURL)) } diff --git a/gh/messages_test.go b/gh/messages_test.go new file mode 100644 index 0000000..242b804 --- /dev/null +++ b/gh/messages_test.go @@ -0,0 +1,117 @@ +package gh + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// messages_test.go guards a defect that is invisible in the source and obvious +// on screen. The error renderer capitalises the first token of a message, so a +// message that begins with the thing it is about comes back mangled: +// +// errs.NotFound("%s has no README", id) -> Gohugoio/Hugo has no README. +// errs.Usage("%q is a %s, not a %s", ...) -> "Golang/Go" is a repo, not a user. +// +// A reader who sees that reasonably concludes the tool corrupted their input. +// Every message here therefore leads with a plain lowercase word, and this test +// says so, because the mistake is easy to make and impossible to see in review. + +// errorFuncs are the constructors whose first string argument is shown to a +// person. errs.New takes a kind first, so its message is the second argument. +var errorFuncs = map[string]int{ + "Usage": 0, + "NotFound": 0, + "Unsupported": 0, + "NeedAuth": 0, + "RateLimited": 0, + "NoResults": 0, + "New": 1, +} + +func TestErrorMessagesLeadWithAWord(t *testing.T) { + root := moduleRoot(t) + fset := token.NewFileSet() + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if name := d.Name(); name == ".git" || name == "bin" || name == "dist" || name == "docs" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + t.Errorf("%s: %v", rel, perr) + return nil + } + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := sel.X.(*ast.Ident) + if !ok || pkg.Name != "errs" { + return true + } + at, ok := errorFuncs[sel.Sel.Name] + if !ok || len(call.Args) <= at { + return true + } + lit, ok := call.Args[at].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + msg, uerr := strconv.Unquote(lit.Value) + if uerr != nil || msg == "" { + return true + } + if bad := leadsBadly(msg); bad != "" { + t.Errorf("%s:%d: errs.%s starts with %s: %q\nThe renderer capitalises the first token, so this reaches the reader looking like their input was mangled. Lead with a plain word instead.", + rel, fset.Position(lit.Pos()).Line, sel.Sel.Name, bad, msg) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// leadsBadly names what is wrong with a message's first character, or returns +// empty when there is nothing wrong. Three cases matter: a format verb, because +// whatever fills it gets capitalised; a quote, because the capital lands inside +// it; and an upper-case letter, because a message that already starts capital is +// usually a proper noun that the renderer will then get wrong (GitHub, HTTP). +func leadsBadly(msg string) string { + switch { + case strings.HasPrefix(msg, "%"): + return "a format verb" + case strings.HasPrefix(msg, `"`), strings.HasPrefix(msg, "'"), strings.HasPrefix(msg, "`"): + return "a quote" + case msg[0] >= 'A' && msg[0] <= 'Z': + return "a capital letter" + case strings.HasPrefix(msg, "http://"), strings.HasPrefix(msg, "https://"): + return "a URL" + } + return "" +} diff --git a/gh/ops.go b/gh/ops.go index ebd7542..8b62c3e 100644 --- a/gh/ops.go +++ b/gh/ops.go @@ -58,7 +58,21 @@ func ResolveRef(want, input string) (string, error) { return repo, nil } } - return "", errs.Usage("%q is a %s, not a %s", input, kind, want) + return "", errs.Usage("wrong kind: %q is %s, not %s", input, aKind(kind), aKind(want)) +} + +// aKind puts the right article in front of a kind name. Five of the twenty-four +// start with a vowel, and "not a org" in an error message reads like the tool +// was written in a hurry. +func aKind(kind string) string { + if kind == "" { + return "nothing" + } + switch kind[0] { + case 'a', 'e', 'i', 'o', 'u': + return "an " + kind + } + return "a " + kind } // guessed reports whether Classify was guessing rather than reading. Anything @@ -92,7 +106,7 @@ func resolveThread(want, ref string, num int) (repo string, number int, err erro // is a real error rather than a guess to be forgiven. A bare // owner/name#123 is a guess: nothing in it says which of the two it is. if kind != want && !guessed(ref, kind) { - return "", 0, errs.Usage("%q is a %s, not a %s", ref, kind, want) + return "", 0, errs.Usage("wrong kind: %q is %s, not %s", ref, aKind(kind), aKind(want)) } number, _ = strconv.Atoi(n) return r, number, nil @@ -102,7 +116,7 @@ func resolveThread(want, ref string, num int) (repo string, number int, err erro return "", 0, err } if num <= 0 { - return "", 0, errs.Usage("%s needs a number, either as a second argument or in the URL", want) + return "", 0, errs.Usage("no number given; %s needs one, either as a second argument or in the URL", want) } return repo, num, nil } @@ -117,7 +131,7 @@ func resolveRev(want, ref, rev string) (repo, out string, err error) { } if r, v, ok := cutRev(id); ok && rev == "" { if kind != want && !guessed(ref, kind) { - return "", "", errs.Usage("%q is a %s, not a %s", ref, kind, want) + return "", "", errs.Usage("wrong kind: %q is %s, not %s", ref, aKind(kind), aKind(want)) } return r, v, nil } @@ -456,7 +470,7 @@ func resolveCompare(ref, base, head string) (string, string, string, error) { if kind == KindCompare && base == "" { repo, rng, ok := cutRev(id) if !ok { - return "", "", "", errs.Usage("%q is not a range", ref) + return "", "", "", errs.Usage("not a range: %q", ref) } // Three dots is the merge-base form and two is the direct diff. // github.com accepts both and means different things by them, so the @@ -466,7 +480,7 @@ func resolveCompare(ref, base, head string) (string, string, string, error) { return repo, a, b, nil } } - return "", "", "", errs.Usage("%q has no base...head in it", ref) + return "", "", "", errs.Usage("no base...head in %q", ref) } repo, err := ResolveRepo(ref) if err != nil { @@ -506,7 +520,7 @@ func (c *Client) fetchOne(ctx context.Context, kind, id string) (any, error) { case KindIssue, KindPR, KindDiscussion: repo, n, ok := SplitThreadID(id) if !ok { - return nil, errs.Usage("%q is not a thread id", id) + return nil, errs.Usage("not a thread id: %q", id) } num, _ := strconv.Atoi(n) switch kind { @@ -520,38 +534,38 @@ func (c *Client) fetchOne(ctx context.Context, kind, id string) (any, error) { case KindCommit: repo, sha, ok := cutRev(id) if !ok { - return nil, errs.Usage("%q is not a commit id", id) + return nil, errs.Usage("not a commit id: %q", id) } return c.CommitInfo(ctx, repo, sha, CommitInfoOptions{}) case KindRelease: repo, tag, ok := cutRev(id) if !ok { - return nil, errs.Usage("%q is not a release id", id) + return nil, errs.Usage("not a release id: %q", id) } return c.Release(ctx, repo, tag, ReleaseOptions{Assets: true, Body: true}) case KindBranch, KindTag: repo, name, ok := cutRev(id) if !ok { - return nil, errs.Usage("%q is not a %s id", id, kind) + return nil, errs.Usage("not a %s id: %q", kind, id) } return c.oneRef(ctx, kind, repo, name) case KindCompare: repo, rng, ok := cutRev(id) if !ok { - return nil, errs.Usage("%q is not a range", id) + return nil, errs.Usage("not a range: %q", id) } base, head, found := strings.Cut(rng, "...") if !found { base, head, found = strings.Cut(rng, "..") } if !found { - return nil, errs.Usage("%q has no base...head in it", id) + return nil, errs.Usage("no base...head in %q", id) } return c.CompareRefs(ctx, repo, base, head, CompareOptions{Files: true}) case KindFile: repo, ref, path, ok := SplitPathID(id) if !ok { - return nil, errs.Usage("%q is not a file id", id) + return nil, errs.Usage("not a file id: %q", id) } return c.Blob(ctx, repo, path, BlobOptions{Ref: ref}) case KindTopic: @@ -582,7 +596,7 @@ func (c *Client) oneRef(ctx context.Context, kind, repo, name string) (*GitRef, return nil, err } if found == nil { - return nil, errs.NotFound("%s %s has no %s named %s", repo, kind, kind, name) + return nil, errs.NotFound("no %s named %s in %s", kind, name, repo) } return found, nil } @@ -773,7 +787,7 @@ func (c *Client) searchOne(ctx context.Context, typ, query string, limit int, em case SearchCode: return c.SearchCodeBy(ctx, query, limit, func(f File) error { return any1(&f) }) } - return errs.Usage("%q is not a search type; the types are %s", typ, strings.Join(SearchTypes, ", ")) + return errs.Usage("not a search type: %q; the types are %s", typ, strings.Join(SearchTypes, ", ")) } // --- contents --- @@ -824,11 +838,14 @@ func registerContentOps(app *kit.App) { kit.Handle(app, kit.OpMeta{ Name: "symbols", Group: "contents", Summary: "List the definitions GitHub extracted from a file", - Long: "GitHub runs a symbol extractor over every blob it renders and ships the\n" + - "result in the route payload. There is no unauthenticated REST equivalent\n" + - "anywhere. The extractor is asynchronous, so an empty list can mean the\n" + - "language is unsupported or that the analysis had not finished; the record\n" + - "says which, and this command reports it rather than guessing.", + Long: "GitHub runs a symbol extractor over every blob it renders and used to\n" + + "ship the result in the route payload. There is no unauthenticated REST\n" + + "equivalent anywhere, which is why this command exists.\n\n" + + "As of now it will not return anything. The blob still says symbols are\n" + + "enabled and still renders the button, and the list behind it is empty for\n" + + "a signed-out reader on every file tried. This reports that rather than\n" + + "returning an empty list, and stays here because the field is still in the\n" + + "payload and may fill in again.", Args: []kit.Arg{ {Name: "ref", Help: "owner/name, or a blob URL"}, {Name: "path", Help: "a file inside the repository", Optional: true}, @@ -883,14 +900,18 @@ func listSymbols(ctx context.Context, in symbolIn, emit func(*Symbol) error) err if err != nil { return err } - // The path goes in the middle of these sentences rather than at the front, - // because the CLI title-cases the first word of an error and a path is the - // one thing that must not be title-cased. + // Both messages lead with a plain word. The CLI title-cases whatever an + // error starts with, which turns a path into nonsense and, less obviously, + // turns GitHub into Github. switch f.SymbolsStatus { case "not_analyzed": - return errs.Unsupported("GitHub does not extract symbols from the language %s is written in", path) + return errs.Unsupported("no symbols for %s: GitHub does not extract them from the language it is written in", path) case "unavailable", "timed_out": - return errs.Network("GitHub's symbol analysis for %s had not finished; ask again in a moment", path) + // Unsupported rather than a network kind, because asking again does not + // help. GitHub still renders the symbols button and still sets + // symbolsEnabled on the blob, and the list behind it comes back empty + // for a signed-out reader on every file tried. + return errs.Unsupported("no symbol list for %s: GitHub serves none to a signed-out reader, though it still offers the panel", path) } return emitEach(f.Symbols, emit) } @@ -1586,15 +1607,7 @@ func registerMetaOps(app *kit.App) { kit.Handle(app, kit.OpMeta{ Name: "doctor", Group: "meta", List: true, Summary: "Check the environment, the site, and the cache", - Long: "doctor answers the question people ask when a command comes back wrong: is\n" + - "it me, is it the network, or did GitHub change the page. It reads a small\n" + - "file to check reachability, a repository page to check that the embedded\n" + - "payload is still where every reader expects it, and the cache directory to\n" + - "check that it can be written.\n\n" + - "It also says out loud that GITHUB_TOKEN and GH_TOKEN are ignored, because\n" + - "a token in the environment does nothing here and the failure that causes is\n" + - "invisible: the tool works, it is just as rate limited as before, and the\n" + - "obvious conclusion is that the token is wrong.", + Long: doctorLong, }, runDoctor) } diff --git a/gh/people.go b/gh/people.go index 23f7c42..2723ebe 100644 --- a/gh/people.go +++ b/gh/people.go @@ -621,7 +621,7 @@ func (c *Client) Activity(ctx context.Context, ref string, limit int, emit func( } } if seen == 0 { - return errs.NotFound("%s: the feed carried no entries", shortURL(u)) + return errs.NotFound("empty feed: %s carried no entries", shortURL(u)) } return nil } From d02c05230c7abbb9f146ba96a3007c94d81cff6b Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:41 +0700 Subject: [PATCH 15/21] gh: fail the build if a credential ever appears The one promise this tool makes that a reader cannot check by using it is that nothing here authenticates. You can see that a command works without logging in; you cannot see that no path would send a credential if one happened to be in the environment. So it is asserted. Every source file is parsed with comments dropped and checked for Authorization, GITHUB_TOKEN, GH_TOKEN, and api.github.com. Comments are dropped because this file and the doctor both talk about tokens at length, and a plain grep would have to be switched off the first time someone wrote the rule down. The allow list holds two files and a reason for each, and a second test fails if an entry stops existing, so the list cannot quietly become a place to hide things. --- gh/noauth_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 gh/noauth_test.go diff --git a/gh/noauth_test.go b/gh/noauth_test.go new file mode 100644 index 0000000..65e6ba2 --- /dev/null +++ b/gh/noauth_test.go @@ -0,0 +1,119 @@ +package gh + +import ( + "bytes" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// noauth_test.go is the one promise this tool makes that a reader cannot check +// by using it: that nothing here ever authenticates. A person can see that a +// command works without logging in, but not that no code path would send a +// credential if one happened to be around. +// +// So the promise is asserted instead. The test parses every source file with +// comments dropped and fails on the words that would mean the promise was +// broken. Comments are dropped because this file, the doctor, and the spec all +// talk about tokens at length, and a grep that could not tell prose from code +// would have to be switched off the first time someone documented the rule. + +var forbidden = []string{ + "Authorization", + "GITHUB_TOKEN", + "GH_TOKEN", + "api.github.com", +} + +// allowed lists the files that name a forbidden word in code for a reason. Each +// one is here because saying the word is the point: doctor reads the +// environment to warn that a token is ignored, and this test names all four. +var allowed = map[string]string{ + "gh/doctor.go": "reads the token variables by name to warn that they are ignored", + "gh/noauth_test.go": "is this test", +} + +func TestNoAuth(t *testing.T) { + root := moduleRoot(t) + fset := token.NewFileSet() + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if name := d.Name(); name == ".git" || name == "bin" || name == "dist" || name == "docs" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + if _, ok := allowed[rel]; ok { + return nil + } + + // Parsing without ParseComments and printing the result is how the + // comments come out: the printer only writes what the AST holds. + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + t.Errorf("%s: %v", rel, perr) + return nil + } + var code bytes.Buffer + if perr := (&printer.Config{Mode: printer.RawFormat}).Fprint(&code, fset, file); perr != nil { + t.Errorf("%s: %v", rel, perr) + return nil + } + for _, word := range forbidden { + if bytes.Contains(code.Bytes(), []byte(word)) { + t.Errorf("%s names %q in code. This tool reads public pages and never authenticates; if this is deliberate, the file needs a line in the allowed map saying why", rel, word) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// TestNoAuthCoversItself keeps the allow list honest. A file that stops needing +// its exemption should lose it, otherwise the list grows into a place to hide +// things. +func TestNoAuthCoversItself(t *testing.T) { + root := moduleRoot(t) + for rel, why := range allowed { + if why == "" { + t.Errorf("%s is exempt with no reason given", rel) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Errorf("%s is exempt and does not exist: %v", rel, err) + } + } +} + +func moduleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("no go.mod above the test directory") + } + dir = parent + } +} From e85a209b57cf620e7c64b7eb1fb3cc15010fb5f9 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:50 +0700 Subject: [PATCH 16/21] gh: read a blob from the page, in one request GitHub dropped the metadata block from the blob route JSON. The route now answers with codeViewBlobRoute alone, so asking it for a file's language gets an empty string and asking it for the size and the line counts gets nothing at all. The live test caught this, which is what it is for. The page still carries every block, so the page is now the read. That is one request where the old path was two, since the route was never enough on its own and the page was already being fetched for the styled source. blobRoute is gone. Symbols were a third request. The list used to arrive on the first read sometimes and only on the second other times, so an unavailable one was asked for again before it was believed. It no longer arrives at all: the page still says symbolsEnabled and still renders the button, and the block behind it is null on every file of every repository tried, on both surfaces, with or without a cache, signed out. That retry was spent to learn nothing, so it is gone too. A payload with no blocks in it is now a structure-changed error rather than an empty file, and the routes table says what the blob read actually does, so `github routes` does not lie about it. --- gh/contents.go | 81 ++++++++++++++++++------------------------------- gh/live_test.go | 19 +++++++----- gh/surface.go | 2 +- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/gh/contents.go b/gh/contents.go index 7455ae9..2505439 100644 --- a/gh/contents.go +++ b/gh/contents.go @@ -232,32 +232,29 @@ func (c *Client) Blob(ctx context.Context, repo, path string, opts BlobOptions) f.URL = blobURL(repo, ref, path) f.RawURL = rawURL(repo, ref, path) + // One read, and it is the page. + // + // This used to be the route JSON with the page as a fallback, and it is not + // any more, because the route JSON stopped carrying the half that matters. + // Both halves are on the page: codeViewBlobRoute has the rendered view, the + // table of contents, and the symbol block, and codeViewBlobLayoutRoute has + // the file's own metadata, the language, the size, the line counts. The + // route JSON now answers with codeViewBlobRoute alone, so asking it for the + // language gets an empty string and asking it for both gets two requests + // where the page is one. + // + // Symbols used to be a third request. The list arrived sometimes on the + // first read and sometimes only on the second, so an unavailable one was + // asked for again before it was believed. It no longer arrives at all: the + // page still says symbolsEnabled and still renders the button, and the + // block behind it is null on every file of every repository tried, on both + // surfaces, with or without a cache, signed out. So that retry was spent to + // learn nothing, and it is gone. url := blobURL(repo, ref, path) - final, err := c.blobRoute(ctx, f, url) - if err != nil { + if err := c.readBlobPage(ctx, f, opts.Styled); err != nil { return nil, err } - f.addSource(final) - - // Symbols are served by a background analyser whose result is cached for a - // short while, so the same URL answers with the symbol list one second and - // null the next, on either surface, with any headers. Nothing about the - // request changes it. So an unavailable list is retried: once on the page, - // which is a different cache, and once more on the route with our own cache - // entry dropped. Two extra requests is worth the difference between a - // symbol list and silence, and after that the record says unavailable and - // means it. - if opts.Styled || f.SymbolsStatus == "unavailable" { - if err := c.readBlobPage(ctx, f, opts.Styled); err == nil { - f.addSource(url) - } - } - if f.SymbolsStatus == "unavailable" { - c.cacheDrop(url, SurfaceRouteJSON) - if _, err := c.blobRoute(ctx, f, url); err != nil { - return nil, err - } - } + f.addSource(url) if opts.Content && !f.IsBinary { b, err := c.Raw(ctx, repo, ref, path) if err != nil { @@ -277,28 +274,6 @@ func (c *Client) Blob(ctx context.Context, repo, path string, opts BlobOptions) return f, nil } -// blobRoute fetches and decodes the render half of a blob into f, returning the -// URL it ended up reading. It is a function rather than inline code because the -// page fallback decodes the same block a second time. -func (c *Client) blobRoute(ctx context.Context, f *File, url string) (string, error) { - var env struct { - Payload struct { - Route json.RawMessage `json:"codeViewBlobRoute"` - } `json:"payload"` - } - res, err := c.GetJSON(ctx, url, SurfaceRouteJSON, &env) - if err != nil { - return "", err - } - if len(env.Payload.Route) == 0 { - return "", structureChanged(f.Repo + ":" + f.Path) - } - if err := decodeBlobRoute(f, env.Payload.Route); err != nil { - return "", err - } - return res.FinalURL, nil -} - // blobRouteData is the render half of a blob: what GitHub worked out about the file // while displaying it. The bytes are not in here and that is deliberate on // their side, not an omission on ours. @@ -335,8 +310,8 @@ func decodeBlobRoute(f *File, raw json.RawMessage) error { if err := json.Unmarshal(raw, &v); err != nil { return badPayload(f.Path, err) } - // Assigned, not appended. This block gets decoded twice when the page - // fallback runs, and appending would give a file two of every heading. + // Assigned, not appended, so a second decode of the same block replaces the + // headings rather than giving the file two of each. f.TOC = nil for _, h := range v.HeaderInfo.TOC { f.TOC = append(f.TOC, Heading{Level: h.Level, Text: h.Text, Anchor: h.Anchor}) @@ -381,9 +356,10 @@ func decodeBlobRoute(f *File, raw json.RawMessage) error { return nil } -// readBlobPage reads the page for the three things the route JSON does not -// reliably give: the blob's own metadata, a symbol list that is actually there, -// and, when styled is set, the per-line source with its highlight spans. +// readBlobPage reads a blob page and decodes every block of it: the rendered +// view and the symbols from codeViewBlobRoute, the file's own metadata from +// codeViewBlobLayoutRoute, and, when styled is set, the per-line source with +// its highlight spans. // // The styled key really does contain a dot in its name and really is not // nested. It is payload["codeViewBlobLayoutRoute.StyledBlob"], one key, and a @@ -400,7 +376,10 @@ func (c *Client) readBlobPage(ctx context.Context, f *File, styled bool) error { if err := json.Unmarshal(embeddedPayload(res.Body), &env); err != nil { return badPayload(f.Path, err) } - if raw, ok := env.Payload["codeViewBlobRoute"]; ok && f.SymbolsStatus != "ok" { + if len(env.Payload) == 0 { + return structureChanged(f.Repo + ":" + f.Path) + } + if raw, ok := env.Payload["codeViewBlobRoute"]; ok { if err := decodeBlobRoute(f, raw); err != nil { return err } diff --git a/gh/live_test.go b/gh/live_test.go index 896c3e2..29261e8 100644 --- a/gh/live_test.go +++ b/gh/live_test.go @@ -14,8 +14,11 @@ import ( // for when you want to know whether a surface still looks the way the spec says // it does. // -// `make fixtures` runs these with recording on, which is how the offline -// scenario suite gets its data. +// These are the tests that catch the failure this tool cannot survive: GitHub +// moving something. Nothing offline can see that, because an offline test +// checks the parser against bytes that were already parsed once. So the +// assertions here are deliberately about shape rather than values. A star count +// changes hourly and pinning one turns a test into a clock. func liveClient(t *testing.T) *Client { t.Helper() @@ -564,11 +567,11 @@ func TestLiveContents(t *testing.T) { if f.Lines == nil || *f.Lines < 100 { t.Errorf("line count %v for a 13 KB file", f.Lines) } - // GitHub's symbol analyser answers null about half the time and the - // same list a second later, on both surfaces, with any headers. Blob - // retries twice, and past that the honest report is "unavailable" - // rather than a hard failure here. not_analyzed for a Go file would be - // a real change and does fail. + // GitHub's symbol analyser answers null on every file of every + // repository tried now, on both surfaces, signed out. So "unavailable" + // is the expected answer here rather than a failure, and if the block + // ever comes back this asserts it is shaped right. not_analyzed for a + // Go file would be a real change and does fail. switch f.SymbolsStatus { case "ok": if len(f.Symbols) == 0 { @@ -579,7 +582,7 @@ func TestLiveContents(t *testing.T) { t.Errorf("symbol is half empty: %+v", s) } case "unavailable", "timed_out": - t.Logf("symbols %s after three tries, the analyser was cold", f.SymbolsStatus) + t.Logf("symbols %s, the analyser did not answer", f.SymbolsStatus) default: t.Errorf("symbols status %q for a Go file", f.SymbolsStatus) } diff --git a/gh/surface.go b/gh/surface.go index 8a0c0d3..bbdd552 100644 --- a/gh/surface.go +++ b/gh/surface.go @@ -30,7 +30,7 @@ type RouteInfo struct { var Routes = []RouteInfo{ {"/{owner}/{repo}", "embedded", "route-json", "sidebarAbout lives only in the HTML payload"}, {"/{owner}/{repo}/tree/{ref}/{path}", "route-json", "embedded", ""}, - {"/{owner}/{repo}/blob/{ref}/{path}", "route-json", "raw", "metadata from the route, bytes from raw"}, + {"/{owner}/{repo}/blob/{ref}/{path}", "embedded", "raw", "the route JSON dropped the metadata block, so the page is the read; bytes from raw"}, {"/{owner}/{repo}/branches", "route-json", "xhr", ""}, {"/{owner}/{repo}/refs", "xhr", "git", "names only, 6 KB against 588 KB"}, {"/{owner}/{repo}/commits/{ref}", "route-json", "feed", ""}, From 9f4bbbb75dabb252104b09e5ae7a6b62a0118f5a Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:56 +0700 Subject: [PATCH 17/21] gh: make -o url work, and narrow the author column -o url printed nothing for every record. The renderer takes the URL from the field tagged table:"-,url", and Base had a plain table:"-", so there was no field to take. Nothing failed; it just printed empty lines. Actor gets a String method while the file is open. Without it the renderer falls back to JSON for a struct field, and a cell holding a whole actor pushes every other column off the screen. A login is what an author column is for, and the full record is still there in every other format. --- gh/base.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gh/base.go b/gh/base.go index fdd2bee..e98d115 100644 --- a/gh/base.go +++ b/gh/base.go @@ -28,7 +28,7 @@ type Base struct { Kind string `json:"kind" table:"kind"` ID string `json:"id" table:"id" kit:"id"` URI string `json:"uri,omitempty" table:"-"` - URL string `json:"url,omitempty" table:"-"` + URL string `json:"url,omitempty" table:"-,url"` Sources []string `json:"sources,omitempty" table:"-"` Via map[string]string `json:"via,omitempty" table:"-"` Extra json.RawMessage `json:"extra,omitempty" table:"-"` @@ -107,6 +107,12 @@ type Actor struct { URI string `json:"uri,omitempty" table:"-"` } +// String is the login, which is what an author column in a table is for. The +// renderer would otherwise fall back to JSON for a struct field, and a cell +// holding the whole actor is wide enough to push every other column off the +// screen. The full thing is still there in every other format. +func (a Actor) String() string { return a.Login } + // actor builds an Actor from a login, filling the derived fields. An empty // login gives an empty Actor rather than one with a URL to nowhere. func actor(login string) Actor { From 9114bcc8e0f4d7aa29aeadfcc3f505a87076e3f2 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:28:57 +0700 Subject: [PATCH 18/21] gh: refuse to hand back a page when a diff was asked for A .diff or .patch suffix on something that is not a change gets answered with the page rather than a 404. /golang/go/pull/1000 is an issue, so its .diff is the issue page, 200 and all, and the command was writing a quarter of a megabyte of markup to the terminal or, worse, to the file someone redirected it into. The mistake surfaced later as a patch that would not apply. --- gh/commit.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/gh/commit.go b/gh/commit.go index 3dd4779..91fa584 100644 --- a/gh/commit.go +++ b/gh/commit.go @@ -9,6 +9,7 @@ import ( "golang.org/x/net/html" + "github.com/tamnd/any-cli/kit/errs" "github.com/tamnd/github-cli/pkg/gitproto" "github.com/tamnd/github-cli/pkg/page" ) @@ -1047,7 +1048,7 @@ func (c *Client) Patch(ctx context.Context, url string) (string, error) { if err != nil { return "", err } - return string(res.Body), nil + return plainText(res, url) } // Diff returns the unified diff, which is the patch without the commit @@ -1057,6 +1058,20 @@ func (c *Client) Diff(ctx context.Context, url string) (string, error) { if err != nil { return "", err } + return plainText(res, url) +} + +// plainText refuses to hand back a web page. +// +// A .diff or .patch suffix on something that is not a change gets answered with +// the page instead of a 404. /golang/go/pull/1000 is an issue, so its .diff is +// the issue page, 200 and all. Without this the command writes a quarter of a +// megabyte of markup to a terminal, or worse, to the file someone redirected it +// into, and the mistake surfaces later as a patch that will not apply. +func plainText(res *Response, url string) (string, error) { + if ct := res.Header.Get("Content-Type"); strings.Contains(ct, "text/html") { + return "", errs.NotFound("no diff at %s; github answered with a page, which means the reference names something that is not a change", shortURL(url)) + } return string(res.Body), nil } From 1fe692632727a8fd6ede6d858bccbd0cbd3d9eb8 Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:29:10 +0700 Subject: [PATCH 19/21] Point the release at the right names The binary is github and the package is ./cmd/github, so the Makefile builds those. It also grows a live target, because the suite that talks to github.com is the only one that can tell you the site changed, and it should not need remembering. Three naming decisions in the goreleaser config, each written down with its reason. The image is ghcr.io/tamnd/github-cli, not the short name, because a short name in a user namespace belongs to whichever repository pushed it first and the workflow token can only write packages linked to its own repository, which is how hf-cli's release died at the last step. The cask is github-cli because homebrew-cask already has a github, and it is GitHub Desktop. And the cask strips the quarantine attribute on install, since Gatekeeper kills an ad-hoc signed binary, which is what every cross-compiled Go binary is. --- .goreleaser.yaml | 25 +++++++++++++++++++++++-- Makefile | 15 ++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b7072a2..f888ee5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -71,8 +71,14 @@ nfpms: dst: /usr/share/doc/github-cli/LICENSE dockers_v2: + # The image is named for the repository, not for the binary. A short name in a + # user-owned GHCR namespace belongs to whichever repository pushed it first, + # and the workflow token only has write access to packages linked to the + # repository it is running in. A sibling project taking the short name is how + # a release fails at the last step with a 403 that reads like a permissions + # bug, which already happened once on hf-cli. - images: - - ghcr.io/tamnd/github + - ghcr.io/tamnd/github-cli tags: - "{{ .Version }}" - latest @@ -90,7 +96,13 @@ dockers_v2: org.opencontainers.image.licenses: "Apache-2.0" homebrew_casks: - - name: github-cli-tamnd + # Pushed to the tap repository. It self-disables until + # HOMEBREW_TAP_GITHUB_TOKEN (a PAT with write access to tamnd/homebrew-tap) is + # set, so a tokenless release still writes the cask into dist for inspection. + # + # The cask is github-cli rather than github because homebrew-cask already has + # a cask called github, which is GitHub Desktop. + - name: github-cli repository: owner: tamnd name: homebrew-tap @@ -102,6 +114,15 @@ homebrew_casks: commit_author: name: Duc-Tam Nguyen email: tamnd87@gmail.com + # Homebrew quarantines cask artifacts, and Gatekeeper kills a quarantined + # binary that is only ad-hoc signed, which is what a cross-compiled Go + # binary is. Strip the attribute at install so github runs on the first try. + hooks: + post: + install: | + if system_command("/usr/bin/xattr", args: ["-h"]).exit_status.zero? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/github"] + end scoops: - repository: diff --git a/Makefile b/Makefile index 1de66e5..1bf1ae9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Build into bin/ (gitignored) so the binary never collides with the github/ # source package at the repo root. -BINARY := bin/ghb -PKG := ./cmd/ghb +BINARY := bin/github +PKG := ./cmd/github VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) @@ -10,7 +10,7 @@ LDFLAGS := -s -w \ -X github.com/tamnd/github-cli/cli.Commit=$(COMMIT) \ -X github.com/tamnd/github-cli/cli.Date=$(DATE) -.PHONY: build install test vet fmt clean run +.PHONY: build install test live vet fmt lint clean run build: @mkdir -p $(dir $(BINARY)) @@ -19,15 +19,24 @@ build: install: CGO_ENABLED=0 go install -trimpath -ldflags "$(LDFLAGS)" $(PKG) +# The default run is offline and deterministic. test: go test ./... +# live talks to github.com. It answers the one question no offline test can: +# does the site still look the way the readers think it does. +live: + GITHUB_LIVE=1 go test ./gh/ -run Live -count=1 -v + vet: go vet ./... fmt: gofmt -w -s . +lint: + golangci-lint run + clean: rm -rf bin dist From 64c773e8cc40e15c1ceaa3249ed9540560c1c7cb Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:29:19 +0700 Subject: [PATCH 20/21] Rewrite the README, with a demo The old one described a tool that shelled out to something else. This one opens with what the thing actually is, shows a real session as a gif recorded from the tape in docs/demo, and then answers the two questions people arrive with: what can it read, and why is there no token. The gif is generated, so the tape is in the repository and re-recording is one command rather than a screen capture nobody can reproduce. --- README.md | 342 +++++++++++++++++++++++++++++++++++------- docs/demo/github.tape | 49 ++++++ docs/static/demo.gif | Bin 0 -> 459132 bytes 3 files changed, 338 insertions(+), 53 deletions(-) create mode 100644 docs/demo/github.tape create mode 100644 docs/static/demo.gif diff --git a/README.md b/README.md index a6a1b52..940ec58 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,26 @@ -# github-cli +# github -A command-line for GitHub that reads public data by scraping HTML pages and -Atom feeds. No API key required. No rate limit from the official REST API. +[![ci](https://github.com/tamnd/github-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/tamnd/github-cli/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/tamnd/github-cli)](https://github.com/tamnd/github-cli/releases/latest) +[![Go Reference](https://pkg.go.dev/badge/github.com/tamnd/github-cli.svg)](https://pkg.go.dev/github.com/tamnd/github-cli) +[![Go Report Card](https://goreportcard.com/badge/github.com/tamnd/github-cli)](https://goreportcard.com/report/github.com/tamnd/github-cli) +[![License](https://img.shields.io/github/license/tamnd/github-cli)](./LICENSE) -**Not affiliated with GitHub or Microsoft Corporation.** +**github** reads github.com as data, with no token, ever. +One pure-Go binary turns the site into typed records: every repository, user, organization, issue, pull request, discussion, commit, branch, tag, release, file, topic, gist, package, and marketplace action, each with a canonical `github://` address, every field its page stated, and typed edges to everything it names. +Read one thing, list a million, walk the dependency graph, or export the lot as RDF. + +[Install](#install) • [Quick start](#quick-start) • [Read one thing](#read-one-thing) • [List and search](#list-and-search) • [Contents](#look-inside-a-repository) • [Graph](#walk-the-graph) • [Linked data](#export-linked-data) • [Output](#output) • [No token](#no-token) • [Serve](#serve-it) • [Driver](#use-it-as-a-resource-uri-driver) + +![github reading a repository, listing an account, resolving a dependency graph, printing graph edges, and emitting schema.org triples](docs/static/demo.gif) + +GitHub is already a knowledge graph that happens to be served as a website. +Repositories depend on repositories, issues reference commits, commits belong to people, people belong to organizations, and every one of those relations is written down on a page somewhere. +Most tools hand you back a page, or the subset of fields somebody decided you needed. +`github` reads all of it, keeps all of it, and gives each entity an address. + +Not affiliated with GitHub or Microsoft. +Full docs and guides live at **[tamnd.github.io/github-cli](https://tamnd.github.io/github-cli/)**. ## Install @@ -11,80 +28,299 @@ Atom feeds. No API key required. No rate limit from the official REST API. go install github.com/tamnd/github-cli/cmd/github@latest ``` -Or grab a prebuilt binary from the -[releases](https://github.com/tamnd/github-cli/releases): +Prefer a prebuilt binary? +Grab an archive, a `.deb`/`.rpm`/`.apk`, or a signed checksum from [releases](https://github.com/tamnd/github-cli/releases). +Or let a package manager handle it: + +```bash +# Homebrew (macOS) +brew install --cask tamnd/tap/github-cli + +# Scoop (Windows) +scoop bucket add tamnd https://github.com/tamnd/scoop-bucket +scoop install github-cli + +# apt (Debian, Ubuntu) +curl -fsSL https://tamnd.github.io/linux-repo/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/tamnd.gpg +echo "deb [signed-by=/usr/share/keyrings/tamnd.gpg] https://tamnd.github.io/linux-repo/apt stable main" | sudo tee /etc/apt/sources.list.d/tamnd.list +sudo apt update && sudo apt install github-cli + +# dnf (Fedora, RHEL) +sudo dnf config-manager --add-repo https://tamnd.github.io/linux-repo/dnf/tamnd.repo +sudo dnf install github-cli + +# container +docker run --rm ghcr.io/tamnd/github-cli:latest repo gohugoio/hugo +``` + +The binary is called `github`. +It does not replace the official `gh`, which does the authenticated half of the site far better. +This one does the public half without asking you to log in. + +## Quick start + +```bash +github repo gohugoio/hugo # a record, not a page +github owned torvalds -n 10 # a list, streamed +github get https://github.com/golang/go/pull/1 # paste anything +``` + +`github get` takes a bare id, a URL you copied out of a browser, or a `github://` URI, works out what it points at, and reads it. +Every other read command is the same thing with the kind already decided. + +Output adapts to where it goes: an aligned table on your terminal, JSONL the moment you pipe it somewhere. + +## Read one thing + +```bash +github repo gohugoio/hugo # every field the page states +github repo gohugoio/hugo --deep # plus what only the deferred fragments carry +github user torvalds +github org golang +github issue golang/go 1234 +github pr golang/go 1 +github commit golang/go abc1234 # author, verification, changed files +github release cli/cli # the latest one, or pass a tag +github discussion vercel/next.js 12345 +github compare golang/go go1.22.0 go1.23.0 +``` + +Records carry what the page carried, not a curated subset. +A repository page states its head commit, its commit and release counts, its licence, its topics, its funding and citation flags, and its whole root tree, so the record does too, from one request. + +## List and search ```bash -# Linux/macOS -curl -sSL https://github.com/tamnd/github-cli/releases/latest/download/github_linux_amd64.tar.gz | tar xz -./github --help +github repos --language rust --sort stars -n 100 +github issues "repo:golang/go is:open label:NeedsInvestigation" +github prs --owner golang +github users --language go +github search kubernetes # every entity kind at once +github topics machine-learning +github actions lint # the marketplace +github trending --language go ``` -Or run the container image: +Listing streams. +`-n` stops early without fetching the next page, and no command holds a full result set in memory unless a format forces it to. + +`github code` is the one command that does not work: code search needs a signed-in session, and this tool has none. +It says so rather than returning nothing. + +## Look inside a repository ```bash -docker run --rm ghcr.io/tamnd/github:latest --help +github tree golang/go src/net/http +github tree gohugoio/hugo --recursive +github cat gohugoio/hugo go.mod +github blob golang/go src/runtime/proc.go +github readme gohugoio/hugo +github diff golang/go abc1234 +github archive golang/go master --format tar.gz ``` -## Commands +`archive` is the one to reach for on a whole repository. +One request to codeload streams the entire tree, where `tree --recursive` is one request per directory. -| Command | Description | Source | -|---------|-------------|--------| -| `github trending` | Top trending repositories | HTML | -| `github user ` | User profile | HTML | -| `github repos ` | User's public repositories | HTML | -| `github repo ` | Repository metadata | HTML | -| `github commits ` | Recent commits | Atom feed | -| `github releases ` | Releases | Atom feed | -| `github tags ` | Tags | Atom feed | -| `github issues ` | Issues | HTML | -| `github pulls ` | Pull requests | HTML | -| `github readme ` | README content | raw.githubusercontent.com | -| `github file ` | Any file | raw.githubusercontent.com | -| `github search ` | Search repositories | HTML | -| `github followers ` | User followers | HTML | -| `github following ` | Users followed by user | HTML | -| `github stars ` | Starred repositories | HTML | +There is also `github symbols`, which reads the code navigation index GitHub builds for every file. +It currently returns nothing, and says so: the blob page still renders the symbols button, and the list behind it is empty for a signed-out reader. +The command stays because the field is still in the payload and may fill in again. -## Examples +## History and people ```bash -# Trending Go repos today -github trending --lang go +github commits golang/go -n 50 +github branches golang/go +github tags golang/go +github releases cli/cli +github refs golang/go +github timeline golang/go 1234 # everything that happened on one issue + +github owned torvalds # repositories as the profile shows them +github stars torvalds +github followers torvalds +github members golang +github contributions torvalds # the calendar, one record per day +github gists torvalds +github activity torvalds +``` + +## Walk the graph -# User profile as JSON -github user torvalds -o json +```bash +github graph golang/go # the node, its edges, and its facts +github edges golang/go # just the edges +github deps gohugoio/hugo # what it depends on, with versions and licences +github dependents gohugoio/hugo # the repositories that depend on it +github crawl golang/go --depth 2 +github crawl golang/go --depth 3 --dry-run # size the walk before running it +github contributors golang/go +github forks golang/go +``` -# Recent commits on main -github commits golang/go +Edges come from five places: explicit ids, the embedded React payload, the atom feeds, parsed HTML, and text. +Each edge records which one it came from, so a consumer can decide how much to trust it, and a walk can drop everything below a floor. -# List open issues -github issues golang/go +`deps` and `dependents` read GitHub's dependency graph, which is the part of the site with no API at all. +`deps` gives you the package, the version, the ecosystem, the manifest it came from, the licence, and whether it is direct or transitive. -# Search for HTTP libraries -github search "http client" +## Export linked data -# Fetch the README -github readme torvalds/linux +```bash +github rdf gohugoio/hugo --format ttl +github rdf gohugoio/hugo --format jsonld +github export golang/go --depth 2 --format jsonl > go.jsonl ``` -## Output formats +RDF comes out as N-Triples, Turtle, JSON-LD, or N-Quads, over `schema.org` where a term exists and a `gh:` namespace where none does. +N-Triples and N-Quads stream, so exporting a large organization never needs the graph in memory. -Every command supports `-o table|json|jsonl|csv|tsv|url` and `--fields`. +## The page plane + +Every reader in this tool works from one extraction of the page, and `github page` prints that extraction whole: ```bash -github trending -o jsonl | jq '.full_name' -github repos torvalds --fields full_name,stars +github page gohugoio/hugo # everything, organised +github page gohugoio/hugo --section payload # just the embedded React payload +github page gohugoio/hugo --section meta # just the og: and twitter: tags +github page golang/go#1234 --query IssueViewerViewQuery +github page https://github.com/trending --raw > trending.html ``` -## Notes +This is the debugging tool. +When a field comes back empty, `page` shows you the same view the reader had, so the answer is either "the page stopped carrying it" or "the selector is wrong", and you can tell which in one command. + +## Output + +Every command shares one contract: `-o table|markdown|list|json|jsonl|csv|tsv|url|raw`, `--fields` to pick columns, `--template` for a custom line, `-n` to limit. + +```bash +github repos --language go --fields id,stars,forks +github repos --language go --template '{{.id}} has {{.stars}} stars' +github repo gohugoio/hugo -o json | jq .tree +``` + +`-o url` is the one the others are measured against. +It prints one URL per record and nothing else, so this composes with no glue: + +```bash +github owned torvalds -o url | xargs -n1 github get +``` + +Failures are typed too. +Every surface exits 3 on an empty result, 4 when a page is not public, 5 on a rate limit, 6 on not found, 7 on unsupported, and 8 on a network failure, so a script can branch on the code without reading the message. + +## No token + +There is no API token in this tool and there will not be one. +The unauthenticated REST API allows sixty requests an hour, which is not enough to read one organization, while the pages sit behind a CDN and are faster than the API even where the API would work. + +That is a promise you cannot check by using the tool. +You can see that a command works without logging in, but not that no code path would send a credential if one happened to be lying around. +So it is asserted instead: `gh/noauth_test.go` parses every source file with comments dropped and fails the build if the word `Authorization`, `GITHUB_TOKEN`, `GH_TOKEN`, or `api.github.com` shows up in code anywhere outside the one file whose job is to say those names out loud. + +If you do have a token in your environment, `github doctor` will tell you it is being ignored, because a token that does nothing looks exactly like a token that is wrong: + +```bash +github doctor +``` + +`doctor` checks the environment, whether the site answers, whether the page still carries the payload every reader expects, whether the cache is writable, and what pacing this run is using. +It is the first thing to reach for when something comes back wrong. + +The cost of no token is that this is read-only and public-only. +For anything else, use the official [gh](https://cli.github.com). + +## Serve it + +The same operations are available over HTTP and as an MCP tool set for agents, with no extra code: + +```bash +github serve --addr :7777 # every read verb becomes GET /v1/, streaming NDJSON +github mcp # speak MCP over stdio +``` + +Arguments go in the query string, because most of them contain a slash and a path would swallow it: + +```bash +curl 'localhost:7777/v1/repo?ref=gohugoio/hugo' +curl 'localhost:7777/v1/blob?ref=gohugoio/hugo&path=go.mod' +curl 'localhost:7777/v1/trending?language=go&limit=5' +curl localhost:7777/v1/openapi.json +``` + +## Use it as a resource-URI driver + +`github` registers a `github` domain the way a program registers a database driver with `database/sql`. +A host enables it with one blank import: + +```go +import _ "github.com/tamnd/github-cli/gh" +``` + +Then [ant](https://github.com/tamnd/ant), or any program that links the package, dereferences `github://` URIs without knowing anything about the site: + +```bash +ant get github://repo/gohugoio/hugo +ant cat github://file/gohugoio/hugo@master/go.mod +ant ls github://org/golang +ant url github://pr/golang/go#1 +``` + +## How it works + +One `kit.Handle` registration per operation, and every surface updates itself: + +``` +cmd/github/ thin main: hands cli.NewApp to kit.Run +cli/ assembles the kit App and registers the byte-plane commands +gh/ the library: client, records, graph, RDF, doctor, domain.go +pkg/page/ one extraction of an HTML page, shared by every reader +pkg/gitproto/ the git smart HTTP protocol, for the refs no page lists +docs/ tago documentation site and the demo tape +``` + +That single declaration becomes a CLI command, an HTTP route, an MCP tool, and a URI dereference, so there is no second implementation to keep in step. + +Underneath, seven surfaces answer for different routes: the embedded React payload, the JSON a route returns when asked for JSON, the fragments a page defers to XHR, the search backend, the atom feeds, raw.githubusercontent.com, and the git protocol itself. +`github routes` prints which surface answers for which route and which one it falls back to. + +Responses are cached on disk, keyed by surface and URL, for fifteen minutes. +Anything addressed by a commit SHA is kept forever, because it cannot change. + +## Development + +```bash +make build # ./bin/github +make test # go test ./..., offline and deterministic +make live # the smoke tests that actually talk to github.com +make vet +make lint +``` + +The offline tests check the parsers against bytes that were already parsed once, which is useful but cannot see the failure that matters: GitHub moving something. +`make live` is what sees that, so the assertions there are about shape rather than values. +A star count changes hourly, and pinning one turns a test into a clock. + +The demo above is a tape, not a screen recording. +Regenerate it with [ascii-gif](https://github.com/tamnd/ascii-gif): + +```bash +ascii-gif render docs/demo/github.tape -o docs/static/demo.gif +``` + +## Releasing + +Push a version tag and GitHub Actions runs GoReleaser, which builds the archives, Linux packages, the multi-arch GHCR image, checksums, SBOMs, and a cosign signature: + +```bash +git tag -a v0.2.0 -m "v0.2.0" +git push --tags +``` -- HTML structure can change without notice. Parsers return empty strings on - missing fields rather than crashing. -- Search may return HTTP 429 from datacenter IPs. The binary exits with code 5 - when throttled. Add `--page 1` and wait a moment before retrying. -- The default pacing is 500 ms between requests. Use `--delay` to adjust. +The Homebrew and Scoop steps self-disable until their tokens exist, so a release works with no extra secrets. ## License -Apache-2.0 +Apache-2.0. +See [LICENSE](LICENSE). diff --git a/docs/demo/github.tape b/docs/demo/github.tape new file mode 100644 index 0000000..c680b37 --- /dev/null +++ b/docs/demo/github.tape @@ -0,0 +1,49 @@ +# Demo tape for github. Rendered with ascii-gif (github.com/tamnd/ascii-gif), +# which supplies the window chrome and theme; this file is just the action. +# +# ascii-gif render docs/demo/github.tape -o docs/static/demo.gif +# +# github must be on PATH inside the recording shell, and github.com reachable. + +Hide +Type "export PS1='$ ' PATH=$HOME/bin:$PATH && cd $(mktemp -d) && clear" +Enter +Show + +Sleep 800ms +Type "github repo gohugoio/hugo --fields id,stars,forks,language" +Sleep 700ms +Enter +Sleep 3.5s + +Type "clear" +Enter +Sleep 300ms +Type "github owned torvalds --fields id,stars,forks,language -n 5" +Sleep 700ms +Enter +Sleep 4s + +Type "clear" +Enter +Sleep 300ms +Type "github deps gohugoio/hugo --fields package,version,ecosystem,license -n 6" +Sleep 700ms +Enter +Sleep 4s + +Type "clear" +Enter +Sleep 300ms +Type "github edges golang/go --fields predicate,object,source -n 6" +Sleep 700ms +Enter +Sleep 4s + +Type "clear" +Enter +Sleep 300ms +Type "github rdf gohugoio/hugo --format ttl | grep -m5 schema:" +Sleep 700ms +Enter +Sleep 4.5s diff --git a/docs/static/demo.gif b/docs/static/demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..ca9dc61745f1af73e6d70d7d0b2b5d10e3931e88 GIT binary patch literal 459132 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 z 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%0D=Vh&?8NNMk_3YwA`EA1WEt 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

    s(DqXH&Y;QOTR*4%F?;K` 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�T0JAZHS^rtD5F>d+N=`_Nt+%FD7i`|G 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