diff --git a/CLAUDE.md b/CLAUDE.md index 6126b51..eb0d0f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ The application follows a command-line interface pattern using the Cobra library - **auth.go**: OAuth2 device flow authentication with JWT token handling - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration - **registries.go**: Registry operations (list, config, add, update, registrator) with REST API integration -- **packages.go**: Package operations (search, dependency) with REST API primary path (`/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) +- **packages.go**: Package operations (search, dependency) with REST API primary path (`/api/v1/ui/packages/info`), GraphQL fallback, and documentation API (`/docs/{registry}/{package}/stable/pkg.json`) - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API and REST API for listing users - **tokens.go**: Token management operations (list) with REST API integration @@ -36,7 +36,7 @@ The application follows a command-line interface pattern using the Cobra library - Stores tokens securely in `~/.juliahub` with 0600 permissions 2. **API Integration**: - - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), registry credential management (old API tried first, new API fallback), landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE), and vulnerability scanning (`/api/v1/ui/vulnerabilities/packages/{name}`) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/api/v1/ui/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), registry credential management (old API tried first, new API fallback), landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE), and vulnerability scanning (`/api/v1/ui/vulnerabilities/packages/{name}`) - **GraphQL API**: Used for projects, user info, user list (`public_users`), group list, and package search/info fallback (`/v1/graphql`) - **Headers**: All GraphQL requests require `Authorization: Bearer `, `X-Hasura-Role: jhuser`, and `X-Juliahub-Ensure-JS: true` - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -48,7 +48,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` - `jh registry permission`: Registry permission management (list, set, remove) - `jh registry registrator`: Show registrator config by name; subcommand update accepts JSON via stdin or `--file` - - `jh package`: Package search and dependency (REST primary via `/packages/info`, GraphQL fallback; dependency data from `/docs/{registry}/{package}/stable/pkg.json`) + - `jh package`: Package search and dependency (REST primary via `/api/v1/ui/packages/info`, GraphQL fallback; dependency data from `/docs/{registry}/{package}/stable/pkg.json`) - `jh project`: Project management (list with GraphQL, supports user filtering) - `jh user`: User information (info, list via GraphQL `public_users`) - `jh group`: Group information (list via GraphQL) @@ -122,6 +122,10 @@ go run . package info Plots --registries General go run . package dependency DataFrames go run . package dependency DataFrames --indirect go run . package dependency CSV --registry General + +# Get package dependents (reverse dependencies) +go run . package dependents JuMP +go run . package dependents JuMP --indirect ``` ### Test registry operations @@ -327,7 +331,7 @@ The application uses OAuth2 device flow: - **User management**: `/app/config/features/manage` endpoint for listing all users - **Token management**: `/app/token/activelist` endpoint for listing all API tokens - **Registry credentials**: `GET /api/v1/sysconfig/credentials` to fetch (returns `CredentialsInfo` object directly); `POST` to add tokens/apps; `PUT` to update tokens/apps or replace all SSH credentials; `DELETE` with `{tokens:[...], githubApps:[...]}` to delete -- **Package search/info primary**: `/packages/info` endpoint with `name`, `registries`, `tags`, `licenses`, `limit`, `offset` query params; returns `{packages: [...], meta: {total: N}}` +- **Package search/info primary**: `/api/v1/ui/packages/info` (the web UI's package listing) with `name`, `registries` (comma-separated names), `tags`, `licenses`, `sorts[0]` (`-score` when searching by name) and page-based pagination `pagination[type]=offset&pagination[page]=N&pagination[per_page]=M` (M ≤ 100); returns `{packages: [...], meta: {total: N}}`. The CLI's `--limit`/`--offset` window is mapped onto pages by `planRESTPages`. (The older `/packages/info` path is not routed by current JuliaHub: it falls through to the git `/packages/` handler and 404s.) - **Package dependencies**: `/docs/{registry}/{package}/stable/pkg.json` for dependency information - **Authentication**: Bearer token with ID token - **Upload workflow**: 3-step process (request presigned URL, upload to URL, close upload) @@ -507,7 +511,7 @@ jh run setup - Landing page commands (`jh admin landing-page`) use REST API: GET `/app/homepage` (show), POST `/app/config/homepage` (update), DELETE `/app/config/homepage` (remove); require appropriate permissions - Landing page `update` command accepts content inline as an argument, from a file via `--file`, or piped via stdin (priority: `--file` > arg > stdin) - Landing page response uses custom JSON unmarshaling (`homepageResponse`) to handle `message` being either an object or a string -- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/packages/info`) first, then fall back to GraphQL (`FilteredPackages` / `FilteredPackagesCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used +- Package search (`jh package search`) and info (`jh package info`) both try REST API (`/api/v1/ui/packages/info`) first, then fall back to GraphQL (`FilteredPackages` / `FilteredPackagesCount` via `/v1/graphql`) on failure; a warning is printed to stderr when the fallback is used - REST API passes `--registries` as comma-separated registry names to the `registries` query param; GraphQL fallback passes registry IDs to the `registries` variable - `fetchRegistries` in `registries.go` is used by `listRegistries`, `packageSearchCmd`, `packageInfoCmd`, and `packageDependencyCmd` to resolve registry names to IDs (for GraphQL) and names (for REST) - Both REST and GraphQL package search/info paths produce identical output columns (Registry and Owner); GraphQL resolves registry names from the `registryIDs`/`registryNames` already in `PackageSearchParams` — no extra API call needed @@ -515,7 +519,8 @@ jh run setup - GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests - `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers) - `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries` -- `getPackageDependencies` uses GraphQL (`fetchGraphQLPackages`) to locate the package, then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) +- `jh package dependents` reads `reversedeps` from the same `pkg.json` (`fetchPackageDocs`). Entries carry `version` (singular), and a dependent can appear once as direct and once as indirect; `splitDependents` collapses them to one per UUID (direct wins), sorted by name +- `getPackageDependencies` locates the package's registry with `findPackageRegistry` (REST listing, GraphQL fallback), then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) - `jh vuln` uses two REST endpoints: vulnerabilities at `/api/v1/ui/vulnerabilities/packages/{name}?version=` and latest version at `/docs///versions.json` (first entry is latest); no GraphQL fallback - When no `--version` is given, `fetchLatestVersion` calls the versions.json endpoint (registry defaults to `General`, overridable with `--registry`) - By default only advisories where `is_affected == true` are shown; `--all` overrides this diff --git a/README.md b/README.md index d7a9912..3cf929a 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,10 @@ go build -o jh . - Default: Shows up to 10 direct dependencies (NAME, REGISTRY, UUID, VERSIONS) - `jh package dependency --indirect` - Include both direct and indirect dependencies - `jh package dependency --registry General` - Specify registry to use +- `jh package dependents ` - List packages that depend on a package (reverse dependencies) + - Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID) + - `jh package dependents --indirect` - Also list indirect dependents + - `jh package dependents --registry General` - Specify registry to use ### Registry Management (`jh registry`) @@ -347,6 +351,10 @@ jh package info Plots --registries General jh package dependency DataFrames # Shows up to 10 direct dependencies jh package dependency DataFrames --indirect # Includes indirect dependencies jh package dependency CSV --registry General # Use specific registry + +# List reverse dependencies (dependents) +jh package dependents JuMP # Counts, plus the direct dependents +jh package dependents JuMP --indirect # Also lists indirect dependents ``` ### Registry Operations diff --git a/main.go b/main.go index 0171561..f4f7bd7 100644 --- a/main.go +++ b/main.go @@ -1096,6 +1096,39 @@ the --registry flag.`, }, } +var packageDependentsCmd = &cobra.Command{ + Use: "dependents ", + Short: "List packages that depend on a package", + Long: `List the reverse dependencies of a Julia package: the packages that +depend on it. + +Prints the number of direct and indirect dependents and lists the direct +ones. Use --indirect to list the indirect dependents as well. + +The data comes from the same package documentation JSON endpoint as +jh package dependency, for the package's stable version. If a package +exists in multiple registries, it uses the first registry by default. +You can specify a different registry using the --registry flag.`, + Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + packageName := args[0] + registryName, _ := cmd.Flags().GetString("registry") + showIndirect, _ := cmd.Flags().GetBool("indirect") + + if err := getPackageDependents(server, packageName, registryName, showIndirect); err != nil { + fmt.Printf("Failed to get package dependents: %v\n", err) + os.Exit(1) + } + }, +} + var registryCmd = &cobra.Command{ Use: "registry", Short: "Registry management commands", @@ -2458,6 +2491,9 @@ func init() { packageDependencyCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies") + packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") + packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents") registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryListCmd.Flags().Bool("verbose", false, "Show detailed registry information") projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -2500,7 +2536,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") diff --git a/packages.go b/packages.go index f005350..cdc70e3 100644 --- a/packages.go +++ b/packages.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "sort" "strings" "time" ) @@ -60,10 +62,21 @@ type PackageDependency struct { Slug string `json:"slug"` } +// PackageDependent is one entry of pkg.json's `reversedeps`. A dependent can +// appear twice, once with direct=true and once with direct=false. +type PackageDependent struct { + Direct bool `json:"direct"` + Name string `json:"name"` + UUID string `json:"uuid"` + Registry string `json:"registry"` + Slug string `json:"slug"` +} + type PackageDocsResponse struct { Name string `json:"name"` Version string `json:"version"` Dependencies []PackageDependency `json:"deps"` + Dependents []PackageDependent `json:"reversedeps"` } type Package struct { @@ -264,59 +277,136 @@ func buildRegistryIDToName(ids []int, names []string) map[int]string { return m } -// fetchRESTPackages calls the /packages/info endpoint and returns raw results and total count. +// packagesInfoPath is the package listing endpoint behind the web UI's package +// pages. It pages with pagination[page]/[per_page] (1-based pages, at most +// packagesInfoMaxPerPage rows each) rather than limit/offset. +const ( + packagesInfoPath = "/api/v1/ui/packages/info" + packagesInfoMaxPerPage = 100 +) + +// restPage is one page request against packagesInfoPath. +type restPage struct { + Page int + PerPage int +} + +// planRESTPages maps the CLI's limit/offset window onto the pages that cover +// it, all of one size: min(limit, packagesInfoMaxPerPage). The window starts +// skip rows into the first page; an offset that is not a multiple of the page +// size costs one extra page. +func planRESTPages(limit, offset int) (pages []restPage, skip int) { + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + per := min(limit, packagesInfoMaxPerPage) + first := offset / per + last := (offset + limit - 1) / per + for p := first; p <= last; p++ { + pages = append(pages, restPage{Page: p + 1, PerPage: per}) + } + return pages, offset - first*per +} + +// buildPackagesInfoQuery returns the query string for one page. With a search +// term the results are ordered by the server's relevance score (exact name > +// prefix > substring, then stars), like the GraphQL search; without one, by +// stars (the server's default). Name breaks ties: packages of one repository +// share a score, and without a tiebreak their order changes between requests, +// so consecutive pages could repeat or skip rows. +// +// Every sort key carries a direction sign: the API rejects unsigned keys +// (`^[+-].+$`), and servers only honour the `-` form, so the tiebreak is +// descending. Any fixed direction keeps the paging stable. +func buildPackagesInfoQuery(search string, registryNames []string, page restPage) url.Values { + q := url.Values{} + q.Set("sorts[0]", "-stargazers_count") + if search != "" { + q.Set("name", search) + q.Set("sorts[0]", "-score") + } + q.Set("sorts[1]", "-name") + if len(registryNames) > 0 { + q.Set("registries", strings.Join(registryNames, ",")) + } + q.Set("pagination[type]", "offset") + q.Set("pagination[page]", fmt.Sprintf("%d", page.Page)) + q.Set("pagination[per_page]", fmt.Sprintf("%d", page.PerPage)) + return q +} + +// fetchRESTPackages lists packages matching search via packagesInfoPath and +// returns the limit/offset window and the total match count. func fetchRESTPackages(server, search string, limit, offset int, registryNames []string) ([]RESTPackage, int, error) { token, err := ensureValidToken() if err != nil { return nil, 0, fmt.Errorf("authentication required: %w", err) } + client := &http.Client{Timeout: 30 * time.Second} + return fetchRESTPackagesFrom(client, "https://"+server, token.IDToken, search, limit, offset, registryNames) +} - url := fmt.Sprintf("https://%s/packages/info", server) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, 0, fmt.Errorf("failed to create request: %w", err) +// fetchRESTPackagesFrom is the testable core of fetchRESTPackages (client, +// base URL and token are injected; an empty token sends no Authorization). +func fetchRESTPackagesFrom(client *http.Client, baseURL, token, search string, limit, offset int, registryNames []string) ([]RESTPackage, int, error) { + if limit <= 0 { + limit = 10 + } + pages, skip := planRESTPages(limit, offset) + var all []RESTPackage + total := 0 + for _, page := range pages { + pkgs, t, err := fetchPackagesInfoPage(client, baseURL, token, buildPackagesInfoQuery(search, registryNames, page)) + if err != nil { + return nil, 0, err + } + total = t + all = append(all, pkgs...) + if len(pkgs) < page.PerPage { + break // last page + } } - - q := req.URL.Query() - if search != "" { - q.Add("name", search) + if skip >= len(all) { + return []RESTPackage{}, total, nil } - if limit > 0 { - q.Add("limit", fmt.Sprintf("%d", limit)) + all = all[skip:] + if len(all) > limit { + all = all[:limit] } - if offset > 0 { - q.Add("offset", fmt.Sprintf("%d", offset)) + return all, total, nil +} + +func fetchPackagesInfoPage(client *http.Client, baseURL, token string, q url.Values) ([]RESTPackage, int, error) { + req, err := http.NewRequest("GET", baseURL+packagesInfoPath+"?"+q.Encode(), nil) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) } - if len(registryNames) > 0 { - q.Add("registries", strings.Join(registryNames, ",")) + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } - req.URL.RawQuery = q.Encode() - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) req.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, 0, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, 0, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) - } - body, err := io.ReadAll(resp.Body) if err != nil { return nil, 0, fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } var response PackageRESTListResponse if err := json.Unmarshal(body, &response); err != nil { return nil, 0, fmt.Errorf("failed to parse response: %w", err) } - return response.Packages, response.Meta.Total, nil } @@ -562,38 +652,52 @@ func getPackageInfoGraphQL(server, packageName string, registryIDs []int, regist return nil } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { - var targetRegistry string - if registryName != "" { - targetRegistry = registryName - } else { - allRegistries, err := fetchRegistries(server) - if err != nil { - return fmt.Errorf("failed to fetch registries: %w", err) - } - var registryIDs []int - for _, reg := range allRegistries { - registryIDs = append(registryIDs, reg.RegistryID) - } - gqlPkgs, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) - if err != nil { - return fmt.Errorf("failed to search for package %q: %w", packageName, err) +// findPackageRegistry returns the registry of the package named packageName +// (exact, case-insensitive): from the REST package listing, or, when that is +// unavailable, from the GraphQL package search. +func findPackageRegistry(server, packageName string) (string, error) { + pkgs, _, restErr := fetchRESTPackages(server, packageName, 100, 0, nil) + if restErr == nil { + for _, p := range pkgs { + if strings.EqualFold(p.Name, packageName) && p.Registry != "" { + return p.Registry, nil + } } - for i := range gqlPkgs { - if strings.EqualFold(gqlPkgs[i].Name, packageName) { - if gqlPkgs[i].RegistryMap != nil { - for _, reg := range allRegistries { - if reg.RegistryID == gqlPkgs[i].RegistryMap.RegistryID { - targetRegistry = reg.Name - break - } - } + return "", fmt.Errorf("package not found: %s", packageName) + } + + allRegistries, err := fetchRegistries(server) + if err != nil { + return "", fmt.Errorf("failed to search for package %q: %v; failed to fetch registries: %w", packageName, restErr, err) + } + var registryIDs []int + for _, reg := range allRegistries { + registryIDs = append(registryIDs, reg.RegistryID) + } + gqlPkgs, err := fetchGraphQLPackages(server, packageName, 100, 0, registryIDs) + if err != nil { + return "", fmt.Errorf("failed to search for package %q: %v; GraphQL fallback: %w", packageName, restErr, err) + } + for i := range gqlPkgs { + if strings.EqualFold(gqlPkgs[i].Name, packageName) && gqlPkgs[i].RegistryMap != nil { + for _, reg := range allRegistries { + if reg.RegistryID == gqlPkgs[i].RegistryMap.RegistryID { + return reg.Name, nil } - break } } - if targetRegistry == "" { - return fmt.Errorf("package not found: %s", packageName) + } + return "", fmt.Errorf("package not found: %s", packageName) +} + +// fetchPackageDocs returns the stable version's pkg.json of packageName in +// registryName, or in the registry findPackageRegistry picks when it is empty. +func fetchPackageDocs(server, packageName, registryName string) (*PackageDocsResponse, string, error) { + targetRegistry := registryName + if targetRegistry == "" { + var err error + if targetRegistry, err = findPackageRegistry(server, packageName); err != nil { + return nil, "", err } } @@ -601,12 +705,12 @@ func getPackageDependencies(server string, packageName string, registryName stri token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, "", fmt.Errorf("authentication required: %w", err) } req, err := http.NewRequest("GET", docsURL, nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) @@ -615,23 +719,31 @@ func getPackageDependencies(server string, packageName string, registryName stri client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to fetch package documentation: %w", err) + return nil, "", fmt.Errorf("failed to fetch package documentation: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + return nil, "", fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return nil, "", fmt.Errorf("failed to read response: %w", err) } var docsResp PackageDocsResponse if err := json.Unmarshal(body, &docsResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, "", fmt.Errorf("failed to parse response: %w", err) + } + return &docsResp, targetRegistry, nil +} + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err } var deps []PackageDependency @@ -694,3 +806,70 @@ func getPackageDependencies(server string, packageName string, registryName stri return nil } + +// splitDependents collapses entries to one per UUID, sorted by name. A +// dependent listed as direct in any entry is direct; the rest are indirect. +func splitDependents(entries []PackageDependent) (direct, indirect []PackageDependent) { + byUUID := make(map[string]PackageDependent) + for _, e := range entries { + if prev, ok := byUUID[e.UUID]; ok { + e.Direct = e.Direct || prev.Direct + } + byUUID[e.UUID] = e + } + for _, e := range byUUID { + if e.Direct { + direct = append(direct, e) + } else { + indirect = append(indirect, e) + } + } + byName := func(s []PackageDependent) { + sort.Slice(s, func(i, j int) bool { + if a, b := strings.ToLower(s[i].Name), strings.ToLower(s[j].Name); a != b { + return a < b + } + return s[i].UUID < s[j].UUID + }) + } + byName(direct) + byName(indirect) + return direct, indirect +} + +func printDependentsTable(title string, deps []PackageDependent) { + fmt.Printf("%s (%d):\n", title, len(deps)) + fmt.Printf("%-35s %-15s %s\n", "NAME", "REGISTRY", "UUID") + fmt.Printf("%-35s %-15s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 36)) + for _, d := range deps { + fmt.Printf("%-35s %-15s %s\n", d.Name, d.Registry, d.UUID) + } +} + +func getPackageDependents(server, packageName, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err + } + + direct, indirect := splitDependents(docsResp.Dependents) + if len(direct)+len(indirect) == 0 { + fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version) + return nil + } + + fmt.Printf("Dependents of %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry) + fmt.Printf("%d direct, %d indirect, %d total\n\n", len(direct), len(indirect), len(direct)+len(indirect)) + if len(direct) > 0 { + printDependentsTable("Direct dependents", direct) + } + if showIndirect && len(indirect) > 0 { + if len(direct) > 0 { + fmt.Println() + } + printDependentsTable("Indirect dependents", indirect) + } else if len(indirect) > 0 { + fmt.Printf("\nUse --indirect to list the %d indirect dependents\n", len(indirect)) + } + return nil +} diff --git a/packages_test.go b/packages_test.go index c75b1ef..7dce95d 100644 --- a/packages_test.go +++ b/packages_test.go @@ -1,7 +1,14 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "reflect" + "regexp" + "strconv" + "strings" "testing" ) @@ -101,3 +108,141 @@ func TestBuildGraphQLPackageVariables(t *testing.T) { } }) } + +func TestPlanRESTPages(t *testing.T) { + cases := []struct { + limit, offset int + pages []restPage + skip int + }{ + {10, 0, []restPage{{1, 10}}, 0}, + {10, 20, []restPage{{3, 10}}, 0}, + {10, 15, []restPage{{2, 10}, {3, 10}}, 5}, + {250, 0, []restPage{{1, 100}, {2, 100}, {3, 100}}, 0}, + {0, 0, []restPage{{1, 10}}, 0}, + } + for _, c := range cases { + pages, skip := planRESTPages(c.limit, c.offset) + if !reflect.DeepEqual(pages, c.pages) || skip != c.skip { + t.Errorf("planRESTPages(%d, %d) = %v, %d; want %v, %d", c.limit, c.offset, pages, skip, c.pages, c.skip) + } + } +} + +func TestBuildPackagesInfoQuery(t *testing.T) { + q := buildPackagesInfoQuery("plots", []string{"General", "MyReg"}, restPage{Page: 2, PerPage: 25}) + want := "name=plots&pagination%5Bpage%5D=2&pagination%5Bper_page%5D=25&pagination%5Btype%5D=offset®istries=General%2CMyReg&sorts%5B0%5D=-score&sorts%5B1%5D=-name" + if got := q.Encode(); got != want { + t.Errorf("query = %s\nwant %s", got, want) + } + // Without a search term: most-starred first, still with the name tiebreak. + q = buildPackagesInfoQuery("", nil, restPage{1, 10}) + if q.Has("name") || q.Get("sorts[0]") != "-stargazers_count" || q.Get("sorts[1]") != "-name" { + t.Errorf("unexpected params without a search term: %s", q.Encode()) + } + // The API's Sorts schema requires a direction sign on every key. + signed := regexp.MustCompile(`^[+-].+$`) + for _, search := range []string{"plots", ""} { + q = buildPackagesInfoQuery(search, nil, restPage{1, 10}) + for key, vals := range q { + if !strings.HasPrefix(key, "sorts[") { + continue + } + for _, v := range vals { + if !signed.MatchString(v) { + t.Errorf("search %q: %s=%q does not match the API's sort pattern ^[+-].+$", search, key, v) + } + } + } + } +} + +func TestFetchRESTPackagesFromWindow(t *testing.T) { + // 23 packages P00..P22 served in pages; ask for 10 starting at offset 15. + var gotAuth []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != packagesInfoPath { + http.NotFound(w, r) + return + } + gotAuth = append(gotAuth, r.Header.Get("Authorization")) + page, _ := strconv.Atoi(r.URL.Query().Get("pagination[page]")) + per, _ := strconv.Atoi(r.URL.Query().Get("pagination[per_page]")) + var pkgs []RESTPackage + for i := (page - 1) * per; i < page*per && i < 23; i++ { + pkgs = append(pkgs, RESTPackage{Name: fmt.Sprintf("P%02d", i)}) + } + json.NewEncoder(w).Encode(PackageRESTListResponse{Packages: pkgs, Meta: struct { + Total int `json:"total"` + }{23}}) + })) + defer srv.Close() + + pkgs, total, err := fetchRESTPackagesFrom(srv.Client(), srv.URL, "", "", 10, 15, nil) + if err != nil { + t.Fatal(err) + } + var names []string + for _, p := range pkgs { + names = append(names, p.Name) + } + want := []string{"P15", "P16", "P17", "P18", "P19", "P20", "P21", "P22"} + if !reflect.DeepEqual(names, want) || total != 23 { + t.Errorf("got %v (total %d), want %v (total 23)", names, total, want) + } + for _, a := range gotAuth { + if a != "" { + t.Errorf("empty token should send no Authorization header, got %q", a) + } + } +} + +func TestFetchRESTPackagesFromHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + if _, _, err := fetchRESTPackagesFrom(srv.Client(), srv.URL, "t", "x", 10, 0, nil); err == nil || !strings.Contains(err.Error(), "status 401") { + t.Errorf("want a status 401 error, got %v", err) + } +} + +func TestSplitDependents(t *testing.T) { + var docs PackageDocsResponse + body := `{"name":"JuMP","version":"1.32.1","reversedeps":[ + {"direct":false,"name":"zeta","uuid":"u3","registry":"General","slug":"c","version":["1.0.0"]}, + {"direct":true,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"alpha","uuid":"u1","registry":"General","slug":"a","version":["2.0.0"]}, + {"direct":true,"name":"Alpha","uuid":"u0","registry":"DyadRegistry","slug":"d","version":["3.0.0"]} + ]}` + if err := json.Unmarshal([]byte(body), &docs); err != nil { + t.Fatal(err) + } + + direct, indirect := splitDependents(docs.Dependents) + + names := func(ds []PackageDependent) []string { + var out []string + for _, d := range ds { + out = append(out, d.Name+"/"+d.UUID) + } + return out + } + if got, want := names(direct), []string{"Alpha/u0", "Beta/u2"}; !reflect.DeepEqual(got, want) { + t.Errorf("direct = %v, want %v", got, want) + } + if got, want := names(indirect), []string{"alpha/u1", "zeta/u3"}; !reflect.DeepEqual(got, want) { + t.Errorf("indirect = %v, want %v", got, want) + } + if direct[0].Registry != "DyadRegistry" { + t.Errorf("direct[0].Registry = %q, want DyadRegistry", direct[0].Registry) + } +} + +func TestSplitDependentsEmpty(t *testing.T) { + direct, indirect := splitDependents(nil) + if len(direct) != 0 || len(indirect) != 0 { + t.Errorf("splitDependents(nil) = %v, %v; want empty", direct, indirect) + } +}