diff --git a/.changeset/ls-and-directories-commands.md b/.changeset/ls-and-directories-commands.md new file mode 100644 index 0000000..f0b156b --- /dev/null +++ b/.changeset/ls-and-directories-commands.md @@ -0,0 +1,15 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Add `ls` and `directories` commands to browse a repository's tree with quality +metrics. `ls` lists the directories and files at a path — showing Grade, Issues, +Complexity, Duplication, and Coverage per row — and `directories` (alias `dirs`) +lists folders only, with `--plus-children` to also show one level of +sub-directories as a `└─` tree. Both auto-detect the provider/organization/repository +from the git remote and the path from your current directory (relative to the +repo root); override with positional args, `--path`, and `--branch`. Sort with +`--sort ` (`name`, `issues`, `grade`, `duplication`, `complexity`, +`coverage`) and `--direction asc|desc`. `codacy ls --search ` finds files +at any depth under the path. Folders and files are marked with `▸` and `·` (no +emojis). Both commands fetch every page of results, so nothing is truncated. diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 1d67bc1..07fe7d2 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -5,11 +5,15 @@ Commander.js CLI). Please weigh these before flagging style or correctness concerns. ## Testing -- Tests mutate `process.env` directly: assign the variable in the test and - `delete` it in `beforeEach`/`afterEach` for isolation. This is the repo-wide - convention (see `src/utils/auth.test.ts` and ~10 other test files). Do **not** - suggest `vi.stubEnv` / `vi.unstubAllEnvs` — the codebase deliberately does not - use them, and consistency across the suite is preferred. +- Tests mutate `process.env` directly: set the variable in `beforeEach` (which + re-assigns it before every test, so leakage between tests is a non-issue) and, + when a specific test needs it unset, `delete` it inside that test. An + `afterEach` cleanup block is **not** required and is **not** the convention — + ~13 of the 14 test files (`repositories.test.ts`, `issue.test.ts`, + `findings.test.ts`, …) use `beforeEach` only. Do **not** suggest adding + `afterEach`, and do **not** suggest `vi.stubEnv` / `vi.unstubAllEnvs` — the + codebase deliberately does not use them, and consistency across the suite is + preferred. - API service calls are mocked with `vi.mock(...)`; tests are co-located as `.test.ts` next to the source. diff --git a/README.md b/README.md index 13f90b8..c155403 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Supported providers: GitHub (`gh`), GitLab (`gl`), Bitbucket (`bb`). | `info` | Show authenticated user info and their organizations | | `repositories ` | List repositories for an organization | | `repository [provider] [org] [repo]` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it (optionally waiting for results) | +| `ls [provider] [org] [repo]` | List (or search) directories and files at a path in a repository, with quality metrics and sorting | +| `directories [provider] [org] [repo]` | List directories at a path in a repository (optionally one level of sub-directories), with quality metrics | | `issues [provider] [org] [repo]` | Search issues in a repository with filters | | `issue [provider] [org] [repo] ` | Show details for a single issue, or ignore/unignore it | | `findings [provider] [org] [repo]` | Show security findings for a repository or organization | diff --git a/SPECS/README.md b/SPECS/README.md index fa84a98..923b881 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -15,6 +15,8 @@ _No pending tasks._ All commands implemented. | `info` | `inf` | ✅ Done | [info.md](commands/info.md) | | `repositories` | `repos` | ✅ Done | [repositories.md](commands/repositories.md) | | `repository` | `repo` | ✅ Done (actions added) | [repository.md](commands/repository.md) | +| `ls` | N/A | ✅ Done | [ls.md](commands/ls.md) | +| `directories` | `dirs` | ✅ Done | [directories.md](commands/directories.md) | | `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary added) | [pull-request.md](commands/pull-request.md) | | `issues` | `is` | ✅ Done | [issues.md](commands/issues.md) | | `issue` | `iss` | ✅ Done | [issue.md](commands/issue.md) | @@ -71,3 +73,4 @@ _No pending tasks._ All commands implemented. | 2026-06-18 | `repo --output json` now includes `repository.fileCount`, plucked from `coverage.numberTotalFiles` on the existing `getRepositoryWithAnalysis` response (present even without coverage data — no extra API call). Unlocks repo-size visibility for downstream consumers like the `configure-codacy-cloud` skill (1 new test, 373 total) | | 2026-06-24 | `findings` and `finding` now surface the vulnerable dependency's import chain from the new `dependencyChains` field: Direct (`Update to `) vs Transitive (` (Fixed in )`), with the middle collapsed to `... N more ...` for 4+ packages. List shows the first chain + `... and X more`; detail shows all chains aligned under a single label. New helpers in `utils/formatting.ts` (`formatDependencyChain`, `formatDependencyChainsLine`, `formatDependencyChainsBlock`); `dependencyChains` added to both JSON projections (17 new tests, 390 total) | | 2026-06-30 | npm-style "update available" notice via `update-notifier@5`: one-time stderr hint when a newer version is published, gated to `--output table` (suppressed for `json`, when piped, in CI, under `npx`). Non-blocking daily background check; never auto-updates. New `src/version.ts` (single source of name/version) + `src/utils/update-check.ts` (`maybeNotifyUpdate`); `preAction` hook + `--no-update-notifier` flag wired in `index.ts`. Opt-outs: `CODACY_DISABLE_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `--no-update-notifier`. `package.json` `overrides` pin transitive `got@^11.8.6`/`package-json@^7` (CVE-2022-33987) (7 new tests, 409 total) | +| 2026-07-07 | `ls` and `directories` commands: browse a repository's folders/files with quality metrics (Grade/Issues/Complexity/Duplication/Coverage). Auto-detect provider/org/repo and the cwd-relative path; `--path`/`--branch` options; `directories --plus-children` shows one extra level as a `└─` tree (header adds `, M subdirectories`). `--sort`/`--direction` (server-side; in `ls`, directories and files sorted independently), and `ls --search ` (files only; folds the path into the search as `/%` and shows full paths). Duplication uses `numberOfClones`; Complexity uses `complexity` (hotspots). Both fetch **all** pages (no pagination warning) via new `src/utils/repo-tree.ts` (path resolution + `resolveSort`/`resolveDirection` + `fetchAllDirectories`/`fetchAllFiles`). Row markers `▸` folder / dim `·` file (no emojis). Promoted `formatGrade` to `utils/formatting.ts` (now also colors E red) and added `formatCountCell`/`formatCoverageCell` (48 new tests, 457 total) | diff --git a/SPECS/commands/directories.md b/SPECS/commands/directories.md new file mode 100644 index 0000000..f67f608 --- /dev/null +++ b/SPECS/commands/directories.md @@ -0,0 +1,57 @@ +# `directories` Command Spec + +**Status:** ✅ Done (2026-07-07) + +## Purpose + +List the immediate directories at a path in a repository, each with quality metrics — like `ls` but folders only. With `--plus-children`, also lists each directory's immediate sub-directories (one extra level) so you can see a bigger picture of the folder structure and its metrics. + +## Usage + +``` +codacy directories # current directory's folders (auto-detected) +codacy directories --plus-children # also one more level of sub-folders +codacy directories --path src/website +codacy directories --sort issues --direction desc +codacy directories gh my-org my-repo --plus-children +codacy dirs --output json +``` + +## API Endpoints + +- [`listDirectories`](https://api.codacy.com/api/api-docs#listdirectories) — `RepositoryService.listDirectories(provider, org, repo, branch, path, sort, direction, cursor, limit)` + +Cursor-paginated; **all pages are fetched** (`fetchAllDirectories` in `utils/repo-tree.ts`) with no pagination warning. `--plus-children` issues one additional (fully paginated) `listDirectories` call per directory, concurrently, listing that directory's `path`. + +## Options + +| Option | Short | Description | +|---|---|---| +| `--path ` | `-p` | Repository-relative folder to list (defaults to the cwd relative to the repo root) | +| `--branch ` | `-b` | Branch name (defaults to the main branch) | +| `--plus-children` | `-c` | Also list each directory's immediate sub-directories | +| `--sort ` | `-S` | Sort by `name`, `issues`, `grade`, `duplication`, `complexity`, or `coverage` | +| `--direction ` | `-d` | `asc`/`ascending` or `desc`/`descending` | + +`--sort`/`--direction`, when given, are applied server-side to both the top-level listing and each `--plus-children` listing; otherwise directories are ordered by name. + +## Output + +Bold header `// — N directories` (with `--plus-children`, also `, M subdirectories` — the total children across all directories), then a columnar table (same columns as `ls`). Parent rows are prefixed `▸ `; with `--plus-children`, each child is rendered on its own indented row ` └─ ` under its parent. + +| Column | Source | Notes | +|---|---|---| +| Name | `▸ ` / ` └─ ` | `▸` parent folder, `└─` child connector | +| Grade | `gradeLetter` | A/B green, C yellow, D/E/F red | +| Issues | `totalIssues` | Abbreviated (`formatCount`) | +| Complexity | `complexity` | Highest file complexity under the path (hotspots) | +| Duplication | `numberOfClones` | Number of cloned code blocks (matches Codacy's UI) | +| Coverage | `coverageWithDecimals` | e.g. `76.3%` | + +Missing metrics render as a dim `-`. Only the Grade letter is colored. No pagination warning. Empty result prints "No directories found at …". + +JSON output: `{ path, directories: [ { …, children?: [...] } ] }`, each item projected via `pickDeep`; `children` present only with `--plus-children`. + +## Tests + +File: `src/commands/directories.test.ts` — 8 tests. Shared helpers covered in `src/utils/repo-tree.test.ts`. diff --git a/SPECS/commands/ls.md b/SPECS/commands/ls.md new file mode 100644 index 0000000..289ca64 --- /dev/null +++ b/SPECS/commands/ls.md @@ -0,0 +1,61 @@ +# `ls` Command Spec + +**Status:** ✅ Done (2026-07-07) + +## Purpose + +List the immediate directories **and** files at a path in a repository (like Unix `ls`), each with quality metrics. Repository is auto-detected from the git remote; the path is auto-detected from the current working directory relative to the repo root, or given via `--path`, or defaults to the repo root. + +## Usage + +``` +codacy ls # current directory (auto-detected) +codacy ls --path src/website # a specific folder +codacy ls gh my-org my-repo # root of an explicit repository +codacy ls gh my-org my-repo --path src/website +codacy ls --sort issues --direction desc # worst files/folders first +codacy ls --path app/client --search config # search files under a path +codacy ls --output json +``` + +## API Endpoints + +- [`listDirectories`](https://api.codacy.com/api/api-docs#listdirectories) — `RepositoryService.listDirectories(provider, org, repo, branch, path, sort, direction, cursor, limit)` +- [`listFiles`](https://api.codacy.com/api/api-docs#listfiles) — `RepositoryService.listFiles(provider, org, repo, branch, path, search, sort, direction, cursor, limit)` + +Both endpoints are cursor-paginated. This command **fetches every page** (via `fetchAllDirectories` / `fetchAllFiles` in `utils/repo-tree.ts`) — it does **not** show a pagination warning. The `path` argument is always passed as an explicit string (`""` = repo root, non-recursive); it is never `undefined`, which would make `listFiles` recursive. + +## Options + +| Option | Short | Description | +|---|---|---| +| `--path ` | `-p` | Repository-relative folder to list (defaults to the cwd relative to the repo root) | +| `--branch ` | `-b` | Branch name (defaults to the main branch) | +| `--search ` | `-s` | Search files (any depth) under the path instead of listing immediate children | +| `--sort ` | `-S` | Sort by `name`, `issues`, `grade`, `duplication`, `complexity`, or `coverage` | +| `--direction ` | `-d` | `asc`/`ascending` or `desc`/`descending` | + +**Sorting.** With no `--sort`/`--direction`, results are ordered by name client-side. When either is given, ordering is delegated to the API (`sort`/`direction` params) — directories and files are sorted **independently** (each fully paginated, then concatenated), never merged. The CLI `name` field maps to the API's `filename` for files. + +**Search.** `--search` lists **files only** (the directories endpoint has no search). The folder scope is folded into the search term (`/%`, or just `` at the root) and `path` is **not** sent, so matches are found at any depth under the path. Results show each file's full repository-relative path. + +## Output + +Bold header `// — N directories, M files`, then a columnar table with directories first (each sorted by name), then files. + +| Column | Source (directory / file) | Notes | +|---|---|---| +| Name | `▸ ` / `· ` | `▸` folder, dim `·` file (no emojis); in search mode the file's full path is shown | +| Grade | `gradeLetter` | A/B green, C yellow, D/E/F red | +| Issues | `totalIssues` | Abbreviated (`formatCount`, e.g. `1.2k`) | +| Complexity | `complexity` | Highest file complexity under the path (hotspots); files show the file value | +| Duplication | `numberOfClones` | Number of cloned code blocks (matches Codacy's UI, not duplicated lines) | +| Coverage | `coverageWithDecimals` | e.g. `76.3%` | + +Missing metrics render as a dim `-`. Only the Grade letter is colored (directory/file items carry no goal thresholds). No pagination warning (all pages fetched). Empty result prints "Nothing found at …". + +JSON output: `{ path, directories: [...], files: [...] }`, each item projected via `pickDeep`. + +## Tests + +File: `src/commands/ls.test.ts` — 14 tests. Shared helpers (path/sort/direction resolution, fetch-all) covered in `src/utils/repo-tree.test.ts`. diff --git a/package.json b/package.json index 739a8f7..71e86a3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepublishOnly": "npm run update-api && npm run build", "start": "npx ts-node src/index.ts", "start:dist": "node dist/index.js", - "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/56.1.1/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/56.2.9/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", "generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch", "update-api": "npm run fetch-api && npm run generate-api", "check-types": "tsc --noEmit" diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 00ddc5e..7550e0a 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -109,6 +109,65 @@ Instead of a dedicated "Visibility" column (wastes horizontal space), public rep - **`--reanalyze` mode** (`-R`): fetches HEAD commit SHA, calls `RepositoryService.reanalyzeCommitById`; early return - **`--reanalyze-and-wait` mode** (`-w`): blocking variant — see "Reanalyze and wait" below. Baseline comes from `issuesOverview`; polling reads the repo's first commit via `listRepositoryCommits(limit=1)` analysis timestamps +## ls command (`ls.ts`) and directories command (`directories.ts`) + +Repository-level "file browser" commands built on `RepositoryService.listFiles` +and `listDirectories`. `ls` lists directories **and** files at a path; +`directories` (alias `dirs`) lists folders only, with `-c, --plus-children` to +also pull each folder's immediate sub-folders (one extra level). + +- **Repo + path resolution**: optional `[provider] [organization] [repository]` + positionals via `resolveRepoArgs(..., 0, ...)` (auto-detected from the git + remote when omitted). The listed path comes from `resolveListingPath(options.path, autoDetected)` + in `utils/repo-tree.ts`: an explicit `--path` wins; otherwise, when the repo + was auto-detected, the current working directory relative to the git root + (`getCwdRepoRelativePath` → `git rev-parse --show-toplevel` + `path.relative`); + otherwise the repo root. `autoDetected = !(provider && organization && repository)`. +- **Fetch-all, no pagination warning (deliberate deviation)**: unlike every other + paginated command, these two fetch *all* pages (`fetchAllDirectories` / + `fetchAllFiles` loop over `pagination.cursor`) and do **not** call + `printPaginationWarning` — the whole listing must be shown. Do not "fix" this + by adding a warning. +- **Explicit empty-string path**: the repo root is listed by passing `path = ""` + (not `undefined`). The generated client serializes `""` as `path=`, which the + API treats as "root, non-recursive"; `undefined` would omit the param and make + `listFiles` recursive across the whole repo. `fetchAll*` always take a `string`. +- **Row markers (no emojis)**: `▸` (U+25B8) for folders, dim `·` for files. + `directories --plus-children` renders each child on its own indented row with a + `└─` connector under its parent (`DirectoryNode.children`). +- **Metric columns**: Name, Grade, Issues, Complexity, Duplication, Coverage. + Grade via `formatGrade` (A/B green, C yellow, D/E/F red — Codacy folder/file + grades can be E). Issues/Complexity/Duplication via `formatCountCell` + (abbreviated, dim `-` when absent). Complexity is `complexity` (for a folder, + the *highest* file complexity under it — surfaces hotspots — not the + `complexitySum` total). Duplication is `numberOfClones` (number of cloned + code blocks, matching Codacy's UI — not `duplication`, which is duplicated + *lines*). Coverage via `formatCoverageCell` (`coverageWithDecimals`). Only the + Grade is colored — directory/file items carry no `goals` thresholds, so + metrics are not threshold-colored. +- **Ordering / `--sort` + `--direction`**: with no sort flags, results are + sorted client-side by name (directories) / basename (files), ascending. When + `--sort` or `--direction` is given, ordering is delegated to the API (`sort`/ + `direction` params, order preserved across pages) and the client sort is + skipped. `resolveSort`/`resolveDirection` (in `repo-tree.ts`) validate the + values and map them: CLI `name` → API `filename` for the files endpoint; + `ascending`/`descending` (and `asc`/`desc`) → `asc`/`desc`. In `ls`, + directories and files are always sorted **independently** and never merged + (dirs fully paginated + sorted, then files) so the type grouping holds. In + `directories`, the same sort is applied to the top-level and each + `--plus-children` listing. +- **`ls --search ` (files only)**: `listDirectories` has no `search`, so + search mode lists files only (directories skipped). The folder scope is folded + into the search string as `/%` (just `` at the repo root) and + `path` is **not** sent — otherwise the API would restrict to immediate + children. Results show each file's full repository-relative path (not just the + basename), since matches span folders. +- **`directories --plus-children` header**: appends `, M subdirectories` where M + is the summed count of children across all listed directories. +- **Aliases**: `directories` → `dirs`; `ls` has no alias (already the minimal + form). +- JSON: `ls` → `{ path, directories, files }`; `directories` → `{ path, directories: [{…, children?}] }`, each item projected via `pickDeep`. + ## Shared Formatting Utilities (`utils/formatting.ts`) Several helpers are shared between `repository.ts` and `pull-request.ts` via `utils/formatting.ts`: diff --git a/src/commands/directories.test.ts b/src/commands/directories.test.ts new file mode 100644 index 0000000..17177fc --- /dev/null +++ b/src/commands/directories.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { registerDirectoriesCommand } from "./directories"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { consoleOutput } from "../test-support"; + +vi.mock("../api/client/services/RepositoryService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); +vi.mock("../utils/git-remote", () => ({ + detectRepoContext: vi.fn(() => ({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + })), +})); +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +function createProgram(): Command { + const program = new Command(); + program.option("-o, --output ", "output format", "table"); + registerDirectoriesCommand(program); + return program; +} + +function dir(name: string, path = name) { + return { + path, + name, + nrFiles: 5, + totalIssues: 10, + grade: 85, + gradeLetter: "B", + complexity: 42, + numberOfClones: 12, + coverageWithDecimals: 70, + }; +} + +describe("directories command", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CODACY_API_TOKEN = "test-token"; + }); + + it("lists directories with the ▸ marker and no files", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [dir("pages"), dir("components")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + ]); + + const out = consoleOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("▸ components"); + expect(out).toContain("2 directories"); + // files endpoint must not be called + expect(RepositoryService.listFiles).not.toHaveBeenCalled(); + }); + + it("--plus-children fetches each directory's children and renders a tree", async () => { + vi.mocked(RepositoryService.listDirectories) + // root call + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + // children of "pages" + .mockResolvedValueOnce({ + data: [dir("account", "pages/account"), dir("admin", "pages/admin")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + // one root call + one per directory + expect(RepositoryService.listDirectories).toHaveBeenCalledTimes(2); + // child call lists the parent's path + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[1][4], + ).toBe("pages"); + + const out = consoleOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("└─ account"); + expect(out).toContain("└─ admin"); + }); + + it("shows a subdirectory count in the header with --plus-children", async () => { + vi.mocked(RepositoryService.listDirectories) + // root: two directories + .mockResolvedValueOnce({ + data: [dir("pages", "pages"), dir("src", "src")], + } as any) + // children of "pages" (fetched first in map order) + .mockResolvedValueOnce({ + data: [dir("a", "pages/a"), dir("b", "pages/b")], + } as any) + // children of "src" + .mockResolvedValueOnce({ data: [dir("c", "src/c")] } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + expect(consoleOutput()).toContain("2 directories, 3 subdirectories"); + }); + + it("forwards --sort/--direction to the root and children listings", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + .mockResolvedValueOnce({ + data: [dir("account", "pages/account")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--plus-children", + "--sort", + "issues", + "--direction", + "desc", + ]); + + const root = vi.mocked(RepositoryService.listDirectories).mock.calls[0]; + expect(root[5]).toBe("issues"); // sort + expect(root[6]).toBe("desc"); // direction + const child = vi.mocked(RepositoryService.listDirectories).mock.calls[1]; + expect(child[5]).toBe("issues"); + expect(child[6]).toBe("desc"); + }); + + it("passes a normalized --path through to the endpoint", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + "--path", + "src/website/", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe("src/website"); + }); + + it("includes children in JSON output", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ data: [dir("pages", "pages")] } as any) + .mockResolvedValueOnce({ + data: [dir("account", "pages/account")], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "--output", + "json", + "directories", + "gh", + "org", + "repo", + "--plus-children", + ]); + + const out = consoleOutput(); + expect(out).toContain('"children"'); + expect(out).toContain('"name": "account"'); + }); + + it("shows a message when no directories are found", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "directories", + "gh", + "org", + "repo", + ]); + + const out = consoleOutput(); + expect(out).toContain("No directories found"); + }); + + it("fails when CODACY_API_TOKEN is not set", async () => { + delete process.env.CODACY_API_TOKEN; + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const program = createProgram(); + await expect( + program.parseAsync(["node", "test", "directories", "gh", "org", "repo"]), + ).rejects.toThrow("process.exit called"); + + mockExit.mockRestore(); + }); +}); diff --git a/src/commands/directories.ts b/src/commands/directories.ts new file mode 100644 index 0000000..5f136de --- /dev/null +++ b/src/commands/directories.ts @@ -0,0 +1,236 @@ +import { Command } from "commander"; +import ora from "ora"; +import ansis from "ansis"; +import pluralize from "pluralize"; +import { checkApiToken } from "../utils/auth"; +import { handleError } from "../utils/error"; +import { createTable, getOutputFormat, printJson } from "../utils/output"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { + fetchAllDirectories, + mapWithConcurrency, + resolveListingPath, + resolveSort, + resolveDirection, + SORT_FIELDS, +} from "../utils/repo-tree"; +import { + FOLDER_GLYPH, + CHILD_CONNECTOR, + TABLE_HEAD, + displayPath, + metricCells, + projectDir, +} from "./tree-view"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; + +// Cap on concurrent children fetches (--plus-children) so a directory with many +// sub-folders doesn't flood the API with unbounded parallel requests. +const CHILDREN_CONCURRENCY = 10; + +// A directory optionally carrying its immediate sub-directories (--plus-children). +interface DirectoryNode extends DirectoryWithAnalysisInfo { + children?: DirectoryWithAnalysisInfo[]; +} + +interface DirOptions { + path?: string; + branch?: string; + plusChildren?: boolean; + sort?: string; + direction?: string; +} + +interface DirContext { + provider: string; + organization: string; + repository: string; + targetPath: string; + branch?: string; + sort?: string; + direction?: string; + useServerSort: boolean; + plusChildren: boolean; +} + +function resolveDirContext( + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: DirOptions, +): DirContext { + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "directories", + [], + ); + return { + provider, + organization, + repository, + targetPath: resolveListingPath(options.path, autoDetected), + branch: options.branch, + sort: resolveSort(options.sort, "directory"), + direction: resolveDirection(options.direction), + useServerSort: options.sort !== undefined || options.direction !== undefined, + plusChildren: !!options.plusChildren, + }; +} + +/** + * Fetch the directories at the path (all pages), and — with --plus-children — + * each one's immediate sub-directories (bounded concurrency). Ordering is by + * name unless a server-side sort was requested. + */ +async function fetchDirTree(ctx: DirContext): Promise { + const sortByName = (arr: DirectoryWithAnalysisInfo[]) => { + if (!ctx.useServerSort) arr.sort((a, b) => a.name.localeCompare(b.name)); + }; + + const dirs: DirectoryNode[] = await fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.targetPath, + ctx.sort, + ctx.direction, + ); + sortByName(dirs); + + if (ctx.plusChildren) { + await mapWithConcurrency(dirs, CHILDREN_CONCURRENCY, async (d) => { + const children = await fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + d.path, + ctx.sort, + ctx.direction, + ); + sortByName(children); + d.children = children; + }); + } + return dirs; +} + +function dirJson(ctx: DirContext, dirs: DirectoryNode[]): Record { + return { + path: ctx.targetPath, + directories: dirs.map((d) => { + const projected = projectDir(d); + if (d.children) projected.children = d.children.map(projectDir); + return projected; + }), + }; +} + +function dirHeader(ctx: DirContext, dirs: DirectoryNode[]): string { + let header = + `${displayPath(ctx.repository, ctx.targetPath)} — ` + + `${dirs.length} ${pluralize("directory", dirs.length)}`; + if (ctx.plusChildren) { + const subdirs = dirs.reduce((n, d) => n + (d.children?.length ?? 0), 0); + header += `, ${subdirs} ${pluralize("subdirectory", subdirs)}`; + } + return header; +} + +function renderDir(ctx: DirContext, dirs: DirectoryNode[]): void { + if (dirs.length === 0) { + console.log( + ansis.dim( + `\nNo directories found at ${displayPath(ctx.repository, ctx.targetPath)}.`, + ), + ); + return; + } + console.log(ansis.bold(`\n${dirHeader(ctx, dirs)}\n`)); + const table = createTable({ head: TABLE_HEAD }); + for (const d of dirs) { + table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]); + for (const child of d.children ?? []) { + table.push([ + `${ansis.dim(CHILD_CONNECTOR)}${child.name}`, + ...metricCells(child), + ]); + } + } + console.log(table.toString()); +} + +export function registerDirectoriesCommand(program: Command) { + program + .command("directories") + .alias("dirs") + .description( + "List directories at a path in a repository, with quality metrics", + ) + .argument( + "[provider]", + "git provider (gh, gl, or bb) — auto-detected from git remote if omitted", + ) + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .option( + "-p, --path ", + "repository-relative folder to list (defaults to the current directory relative to the repo root)", + ) + .option("-b, --branch ", "branch name (defaults to the main branch)") + .option( + "-c, --plus-children", + "also list each directory's immediate sub-directories", + ) + .option("-S, --sort ", `sort by one of: ${SORT_FIELDS.join(", ")}`) + .option( + "-d, --direction ", + "sort direction: asc (ascending) or desc (descending)", + ) + .addHelpText( + "after", + ` +Examples: + $ codacy directories list the current directory's folders (auto-detected) + $ codacy directories --plus-children also show one more level of sub-folders + $ codacy directories --path src/website + $ codacy directories --sort issues --direction desc + $ codacy directories gh my-org my-repo --plus-children + $ codacy dirs --output json`, + ) + .action(async function ( + this: Command, + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: DirOptions, + ) { + try { + checkApiToken(); + const format = getOutputFormat(this); + const ctx = resolveDirContext( + providerArg, + organizationArg, + repositoryArg, + options, + ); + + const spinner = ora( + `Listing directories in ${displayPath(ctx.repository, ctx.targetPath)}...`, + ).start(); + const dirs = await fetchDirTree(ctx); + spinner.stop(); + + if (format === "json") { + printJson(dirJson(ctx, dirs)); + return; + } + renderDir(ctx, dirs); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/ls.test.ts b/src/commands/ls.test.ts new file mode 100644 index 0000000..e2e298a --- /dev/null +++ b/src/commands/ls.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { registerLsCommand } from "./ls"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { consoleOutput } from "../test-support"; + +vi.mock("../api/client/services/RepositoryService"); +vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); +vi.mock("../utils/git-remote", () => ({ + detectRepoContext: vi.fn(() => ({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + })), +})); +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +function createProgram(): Command { + const program = new Command(); + program.option("-o, --output ", "output format", "table"); + registerLsCommand(program); + return program; +} + +const mockDirs = [ + { + path: "pages", + name: "pages", + nrFiles: 12, + totalIssues: 1200, + grade: 90, + gradeLetter: "A", + complexity: 45, + numberOfClones: 234, + coverageWithDecimals: 76.3, + }, +]; + +const mockFiles = [ + { + fileId: 1, + branchId: 1, + path: "src/common.js", + totalIssues: 2, + grade: 70, + gradeLetter: "C", + coverageWithDecimals: 34.2, + numberOfMethods: 3, + // complexity + numberOfClones intentionally absent -> rendered as "-" + }, +]; + +function mockList(dirs: unknown[], files: unknown[]) { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: dirs, + } as any); + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ + data: files, + } as any); +} + +describe("ls command", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CODACY_API_TOKEN = "test-token"; + }); + + it("lists directories before files with the ▸ / · markers", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = consoleOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("· common.js"); + // directory row appears before the file row + expect(out.indexOf("▸ pages")).toBeLessThan(out.indexOf("· common.js")); + // header with combined totals + expect(out).toContain("/repo — 1 directory, 1 file"); + // absent metrics render as a dim "-" + expect(out).toContain("-"); + }); + + it("passes an explicit empty-string path when listing the repo root", async () => { + mockList([], []); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe(""); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][4]).toBe(""); + }); + + it("passes a normalized --path through to both endpoints", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--path", + "/src/website/", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][4], + ).toBe("src/website"); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][4]).toBe( + "src/website", + ); + }); + + it("forwards the --branch option", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--branch", + "develop", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][3], + ).toBe("develop"); + }); + + it("auto-detects the repository from the git remote when no positionals are given", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls"]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0].slice(0, 3), + ).toEqual(["gh", "auto-org", "auto-repo"]); + }); + + it("aggregates across multiple pages", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ + data: [{ ...mockDirs[0], name: "pages" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ + data: [{ ...mockDirs[0], name: "components" }], + } as any); + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ data: [] } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = consoleOutput(); + expect(out).toContain("▸ pages"); + expect(out).toContain("▸ components"); + expect(out).toContain("2 directories"); + }); + + it("forwards --sort/--direction to both endpoints (server-side)", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--sort", + "issues", + "--direction", + "desc", + ]); + + const dcall = vi.mocked(RepositoryService.listDirectories).mock.calls[0]; + expect(dcall[5]).toBe("issues"); // sort + expect(dcall[6]).toBe("desc"); // direction + const fcall = vi.mocked(RepositoryService.listFiles).mock.calls[0]; + expect(fcall[6]).toBe("issues"); // sort + expect(fcall[7]).toBe("desc"); // direction + }); + + it("maps --sort name to 'filename' for files but 'name' for directories", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--sort", + "name", + ]); + + expect( + vi.mocked(RepositoryService.listDirectories).mock.calls[0][5], + ).toBe("name"); + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][6]).toBe( + "filename", + ); + }); + + it("search mode lists files only, folds the path into the search, and omits path", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--path", + "app/client", + "--search", + "config", + ]); + + // directories are not searched + expect(RepositoryService.listDirectories).not.toHaveBeenCalled(); + // files: path omitted (undefined), search = "/%" + const fcall = vi.mocked(RepositoryService.listFiles).mock.calls[0]; + expect(fcall[4]).toBeUndefined(); // path + expect(fcall[5]).toBe("app/client/%config"); // search + }); + + it("shows full file paths and a 'matching' header in search mode", async () => { + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ + data: [ + { + fileId: 1, + branchId: 1, + path: "src/commands/finding.ts", + totalIssues: 4, + grade: 20, + gradeLetter: "F", + numberOfMethods: 1, + }, + ], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--search", + "find", + ]); + + const out = consoleOutput(); + expect(out).toContain("· src/commands/finding.ts"); + expect(out).toContain('matching "find"'); + // at repo root the search term is sent as-is (no path prefix) + expect(vi.mocked(RepositoryService.listFiles).mock.calls[0][5]).toBe("find"); + }); + + it("shows a message when a search finds nothing", async () => { + vi.mocked(RepositoryService.listFiles).mockResolvedValue({ data: [] } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "ls", + "gh", + "org", + "repo", + "--search", + "nope", + ]); + + expect(consoleOutput()).toContain('No files matching "nope"'); + }); + + it("outputs JSON with a { path, directories, files } shape", async () => { + mockList(mockDirs, mockFiles); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "--output", + "json", + "ls", + "gh", + "org", + "repo", + ]); + + const out = consoleOutput(); + expect(out).toContain('"directories"'); + expect(out).toContain('"files"'); + expect(out).toContain('"name": "pages"'); + expect(out).toContain('"path": "src/common.js"'); + }); + + it("shows a message when nothing is found", async () => { + mockList([], []); + + const program = createProgram(); + await program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]); + + const out = consoleOutput(); + expect(out).toContain("Nothing found"); + }); + + it("fails when CODACY_API_TOKEN is not set", async () => { + delete process.env.CODACY_API_TOKEN; + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const program = createProgram(); + await expect( + program.parseAsync(["node", "test", "ls", "gh", "org", "repo"]), + ).rejects.toThrow("process.exit called"); + + mockExit.mockRestore(); + }); +}); diff --git a/src/commands/ls.ts b/src/commands/ls.ts new file mode 100644 index 0000000..f7ddcf1 --- /dev/null +++ b/src/commands/ls.ts @@ -0,0 +1,271 @@ +import { Command } from "commander"; +import ora from "ora"; +import ansis from "ansis"; +import pluralize from "pluralize"; +import { checkApiToken } from "../utils/auth"; +import { handleError } from "../utils/error"; +import { createTable, getOutputFormat, printJson } from "../utils/output"; +import { + fetchAllDirectories, + fetchAllFiles, + resolveListingPath, + resolveSort, + resolveDirection, + SORT_FIELDS, +} from "../utils/repo-tree"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { + FOLDER_GLYPH, + FILE_GLYPH, + TABLE_HEAD, + basename, + displayPath, + metricCells, + projectDir, + projectFile, +} from "./tree-view"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +interface LsOptions { + path?: string; + branch?: string; + search?: string; + sort?: string; + direction?: string; +} + +interface LsContext { + provider: string; + organization: string; + repository: string; + targetPath: string; + branch?: string; + dirSort?: string; + fileSort?: string; + direction?: string; + useServerSort: boolean; + searching: boolean; + searchTerm?: string; + searchValue?: string; +} + +/** Resolve repo, path, sort/direction, and search from the args + options. */ +function resolveLsContext( + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: LsOptions, +): LsContext { + // When the repo is auto-detected (not all three positionals given), the path + // defaults to the current working directory; when explicit, no cwd inference. + const autoDetected = !(providerArg && organizationArg && repositoryArg); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "ls", + [], + ); + const targetPath = resolveListingPath(options.path, autoDetected); + const searching = !!options.search; + // Search folds the folder scope into the term (`/%`) so matches + // are found at any depth; `path` is not sent (see fetchLsEntries). + const searchValue = searching + ? targetPath + ? `${targetPath}/%${options.search}` + : options.search + : undefined; + return { + provider, + organization, + repository, + targetPath, + branch: options.branch, + dirSort: resolveSort(options.sort, "directory"), + fileSort: resolveSort(options.sort, "file"), + direction: resolveDirection(options.direction), + useServerSort: options.sort !== undefined || options.direction !== undefined, + searching, + searchTerm: options.search, + searchValue, + }; +} + +/** The file label: full path in search mode (results span folders), else basename. */ +function fileLabel(ctx: LsContext, filePath: string): string { + return ctx.searching ? filePath : basename(filePath); +} + +/** + * Fetch every page of directories and files (no pagination warning, by design). + * Directories are skipped in search mode (only the files endpoint supports + * search) and the two lists are ordered independently — never merged. + */ +async function fetchLsEntries(ctx: LsContext): Promise<{ + dirs: DirectoryWithAnalysisInfo[]; + files: FileWithAnalysisInfo[]; +}> { + const [dirs, files] = await Promise.all([ + ctx.searching + ? Promise.resolve([]) + : fetchAllDirectories( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.targetPath, + ctx.dirSort, + ctx.direction, + ), + fetchAllFiles( + ctx.provider, + ctx.organization, + ctx.repository, + ctx.branch, + ctx.searching ? undefined : ctx.targetPath, + ctx.searchValue, + ctx.fileSort, + ctx.direction, + ), + ]); + // With no explicit --sort/--direction, order deterministically by name. + if (!ctx.useServerSort) { + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => + fileLabel(ctx, a.path).localeCompare(fileLabel(ctx, b.path)), + ); + } + return { dirs, files }; +} + +function lsJson( + ctx: LsContext, + dirs: DirectoryWithAnalysisInfo[], + files: FileWithAnalysisInfo[], +): Record { + return { + path: ctx.targetPath, + directories: dirs.map(projectDir), + files: files.map(projectFile), + }; +} + +function lsHeader( + ctx: LsContext, + dirCount: number, + fileCount: number, +): string { + const where = displayPath(ctx.repository, ctx.targetPath); + if (ctx.searching) { + return `${where} — ${fileCount} ${pluralize("file", fileCount)} matching "${ctx.searchTerm}"`; + } + return ( + `${where} — ${dirCount} ${pluralize("directory", dirCount)}, ` + + `${fileCount} ${pluralize("file", fileCount)}` + ); +} + +function renderLs( + ctx: LsContext, + dirs: DirectoryWithAnalysisInfo[], + files: FileWithAnalysisInfo[], +): void { + if (dirs.length === 0 && files.length === 0) { + const where = displayPath(ctx.repository, ctx.targetPath); + console.log( + ansis.dim( + ctx.searching + ? `\nNo files matching "${ctx.searchTerm}" under ${where}.` + : `\nNothing found at ${where}.`, + ), + ); + return; + } + console.log(ansis.bold(`\n${lsHeader(ctx, dirs.length, files.length)}\n`)); + const table = createTable({ head: TABLE_HEAD }); + for (const d of dirs) { + table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]); + } + for (const f of files) { + table.push([ + `${ansis.dim(FILE_GLYPH)} ${fileLabel(ctx, f.path)}`, + ...metricCells(f), + ]); + } + console.log(table.toString()); +} + +export function registerLsCommand(program: Command) { + program + .command("ls") + .description( + "List directories and files at a path in a repository, with quality metrics", + ) + .argument( + "[provider]", + "git provider (gh, gl, or bb) — auto-detected from git remote if omitted", + ) + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .option( + "-p, --path ", + "repository-relative folder to list (defaults to the current directory relative to the repo root)", + ) + .option("-b, --branch ", "branch name (defaults to the main branch)") + .option( + "-s, --search ", + "search files (at any depth) under the path, instead of listing immediate children", + ) + .option("-S, --sort ", `sort by one of: ${SORT_FIELDS.join(", ")}`) + .option( + "-d, --direction ", + "sort direction: asc (ascending) or desc (descending)", + ) + .addHelpText( + "after", + ` +Examples: + $ codacy ls list the current directory (auto-detected) + $ codacy ls --path src/website list a specific folder + $ codacy ls gh my-org my-repo list the root of an explicit repository + $ codacy ls gh my-org my-repo --path src/website + $ codacy ls --sort issues --direction desc worst files/folders first + $ codacy ls --path app/client --search config find files matching "config" under app/client + $ codacy ls --output json`, + ) + .action(async function ( + this: Command, + providerArg: string | undefined, + organizationArg: string | undefined, + repositoryArg: string | undefined, + options: LsOptions, + ) { + try { + checkApiToken(); + const format = getOutputFormat(this); + const ctx = resolveLsContext( + providerArg, + organizationArg, + repositoryArg, + options, + ); + + const where = displayPath(ctx.repository, ctx.targetPath); + const spinner = ora( + ctx.searching + ? `Searching "${ctx.searchTerm}" under ${where}...` + : `Listing ${where}...`, + ).start(); + const { dirs, files } = await fetchLsEntries(ctx); + spinner.stop(); + + if (format === "json") { + printJson(lsJson(ctx, dirs, files)); + return; + } + renderLs(ctx, dirs, files); + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/repositories.ts b/src/commands/repositories.ts index feb5ac6..788e471 100644 --- a/src/commands/repositories.ts +++ b/src/commands/repositories.ts @@ -12,7 +12,7 @@ import { printPaginationWarning, } from "../utils/output"; import { AnalysisService } from "../api/client/services/AnalysisService"; -import { formatCount } from "../utils/formatting"; +import { formatCount, formatGrade } from "../utils/formatting"; import pluralize from "pluralize"; /** @@ -35,19 +35,6 @@ function formatMetric( return value < threshold ? ansis.red(display) : ansis.green(display); } -function formatGrade(gradeLetter: string | undefined): string { - if (!gradeLetter) return "N/A"; - const colors: Record string> = { - A: ansis.green, - B: ansis.green, - C: ansis.yellow, - D: ansis.red, - F: ansis.red, - }; - const colorFn = colors[gradeLetter] || ((s: string) => s); - return colorFn(gradeLetter); -} - export function registerRepositoriesCommand(program: Command) { program .command("repositories") diff --git a/src/commands/tree-view.ts b/src/commands/tree-view.ts new file mode 100644 index 0000000..a9ac0cf --- /dev/null +++ b/src/commands/tree-view.ts @@ -0,0 +1,80 @@ +import { pickDeep } from "../utils/output"; +import { + formatGrade, + formatCountCell, + formatCoverageCell, +} from "../utils/formatting"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +/** + * Shared presentation helpers for the `ls` and `directories` commands: row + * markers, the metric columns, and JSON projections. Kept in one place so the + * two commands render identically and don't duplicate this logic. + */ + +// Row markers (no emojis, for terminal safety — same Unicode family as the ⊙ +// the CLI already ships): ▸ folder, dim · file; children use a └─ connector. +export const FOLDER_GLYPH = "▸"; +export const FILE_GLYPH = "·"; +export const CHILD_CONNECTOR = " └─ "; + +export const TABLE_HEAD = [ + "Name", + "Grade", + "Issues", + "Complexity", + "Duplication", + "Coverage", +]; + +// Metric fields common to directories and files; directories add name/nrFiles. +const METRIC_JSON_FIELDS = [ + "path", + "gradeLetter", + "totalIssues", + "complexity", + "numberOfClones", + "coverageWithDecimals", +]; +export const DIR_JSON_FIELDS = [...METRIC_JSON_FIELDS, "name", "nrFiles"]; +export const FILE_JSON_FIELDS = METRIC_JSON_FIELDS; + +/** Last path segment of a repository-relative path. */ +export function basename(filePath: string): string { + const segments = filePath.split("/"); + return segments[segments.length - 1] || filePath; +} + +/** Header display path, e.g. "/my-repo/src/website" ("/my-repo" at the root). */ +export function displayPath(repository: string, targetPath: string): string { + return "/" + [repository, targetPath].filter(Boolean).join("/"); +} + +/** + * The five metric cells shared by directory and file rows: Grade, Issues, + * Complexity, Duplication, Coverage. Complexity is the highest under the path + * (surfaces hotspots); Duplication is the number of cloned blocks — matching + * Codacy's UI. Missing metrics render as a dim "-". + */ +export function metricCells( + item: DirectoryWithAnalysisInfo | FileWithAnalysisInfo, +): string[] { + return [ + formatGrade(item.gradeLetter), + formatCountCell(item.totalIssues), + formatCountCell(item.complexity), + formatCountCell(item.numberOfClones), + formatCoverageCell(item.coverageWithDecimals), + ]; +} + +/** JSON projection of a directory item (fields shown in the table). */ +export function projectDir(d: DirectoryWithAnalysisInfo): Record { + return pickDeep(d, DIR_JSON_FIELDS); +} + +/** JSON projection of a file item (fields shown in the table). */ +export function projectFile(f: FileWithAnalysisInfo): Record { + return pickDeep(f, FILE_JSON_FIELDS); +} diff --git a/src/index.ts b/src/index.ts index 0c67f7f..8a0576f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { maybeNotifyUpdate } from "./utils/update-check"; import { registerInfoCommand } from "./commands/info"; import { registerRepositoriesCommand } from "./commands/repositories"; import { registerRepositoryCommand } from "./commands/repository"; +import { registerLsCommand } from "./commands/ls"; +import { registerDirectoriesCommand } from "./commands/directories"; import { registerPullRequestCommand } from "./commands/pull-request"; import { registerIssuesCommand } from "./commands/issues"; import { registerIssueCommand } from "./commands/issue"; @@ -49,6 +51,8 @@ program.hook("preAction", (_thisCommand, actionCommand) => { registerInfoCommand(program); registerRepositoriesCommand(program); registerRepositoryCommand(program); +registerLsCommand(program); +registerDirectoriesCommand(program); registerPullRequestCommand(program); registerIssuesCommand(program); registerIssueCommand(program); diff --git a/src/test-support.ts b/src/test-support.ts new file mode 100644 index 0000000..19fbd1d --- /dev/null +++ b/src/test-support.ts @@ -0,0 +1,18 @@ +/** + * Test-only helpers. Excluded from the published build via tsconfig.build.json. + */ + +/** + * Joined `console.log` output with ANSI color/style codes stripped, so + * assertions can match plain text — `dim()` otherwise splits a glyph from its + * name with a reset sequence. Assumes `console.log` has been replaced with a + * spy/mock (e.g. `vi.spyOn(console, "log").mockImplementation(() => {})`). + */ +export function consoleOutput(): string { + const calls = (console.log as unknown as { mock: { calls: unknown[][] } }) + .mock.calls; + return calls + .map((c) => c[0]) + .join("\n") + .replace(/\x1b\[[0-9;]*m/g, ""); +} diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index 1a1c854..85022c4 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -8,6 +8,9 @@ import { formatDependencyChain, formatDependencyChainsLine, formatDependencyChainsBlock, + formatGrade, + formatCountCell, + formatCoverageCell, } from "./formatting"; // Mock ansis to return raw text for easier testing @@ -193,6 +196,44 @@ describe("formatDuration", () => { }); }); +describe("formatGrade", () => { + // ansis is mocked to identity, so we assert on the letter and N/A fallback. + it("returns the grade letter for A–F including E", () => { + for (const g of ["A", "B", "C", "D", "E", "F"]) { + expect(formatGrade(g)).toBe(g); + } + }); + + it("returns N/A when the grade is missing", () => { + expect(formatGrade(undefined)).toBe("N/A"); + expect(formatGrade("")).toBe("N/A"); + }); +}); + +describe("formatCountCell", () => { + it("abbreviates a count", () => { + expect(formatCountCell(1200)).toBe("1.2k"); + expect(formatCountCell(0)).toBe("0"); + }); + + it("renders a dash when the value is absent (undefined or null)", () => { + expect(formatCountCell(undefined)).toBe("-"); + expect(formatCountCell(null)).toBe("-"); + }); +}); + +describe("formatCoverageCell", () => { + it("renders a one-decimal percentage", () => { + expect(formatCoverageCell(76.3)).toBe("76.3%"); + expect(formatCoverageCell(0)).toBe("0.0%"); + }); + + it("renders a dash when coverage is absent (undefined or null)", () => { + expect(formatCoverageCell(undefined)).toBe("-"); + expect(formatCoverageCell(null)).toBe("-"); + }); +}); + describe("isBeingAnalyzed", () => { it("is true when started but never finished", () => { expect(isBeingAnalyzed("2025-06-15T10:00:00Z", undefined)).toBe(true); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 5adc7da..f2cc113 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -240,6 +240,43 @@ export function formatCount(n: number): string { return numeral(n).format("0.[0]a"); } +/** + * Color a quality grade letter: A/B green, C yellow, D/E/F red, anything else + * uncolored. Returns "N/A" when no grade is available. Codacy folder/file + * grades can be E (not just A–D/F), so it is colored red like D/F. + */ +export function formatGrade(gradeLetter: string | undefined): string { + if (!gradeLetter) return "N/A"; + const colors: Record string> = { + A: ansis.green, + B: ansis.green, + C: ansis.yellow, + D: ansis.red, + E: ansis.red, + F: ansis.red, + }; + const colorFn = colors[gradeLetter] || ((s: string) => s); + return colorFn(gradeLetter); +} + +/** + * Render a numeric metric as a table cell: abbreviated via `formatCount` + * ("1.2k"), or a dim "-" when the value is absent (e.g. complexity/duplication + * that Codacy didn't compute for a file or folder). Handles both `undefined` + * and `null` — the API may return either for an uncomputed metric. + */ +export function formatCountCell(n: number | undefined | null): string { + return n === undefined || n === null ? ansis.dim("-") : formatCount(n); +} + +/** + * Render a coverage percentage as a table cell ("76.3%"), or a dim "-" when no + * coverage data is available (`undefined` or `null`). + */ +export function formatCoverageCell(pct: number | undefined | null): string { + return pct === undefined || pct === null ? ansis.dim("-") : `${pct.toFixed(1)}%`; +} + /** * Print a bold section header, optionally with a total count. * e.g. printSection("Issues", 45000, "issue") → "Issues — Found 45k issues" diff --git a/src/utils/repo-tree.test.ts b/src/utils/repo-tree.test.ts new file mode 100644 index 0000000..cf2d00c --- /dev/null +++ b/src/utils/repo-tree.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execFileSync } from "child_process"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { + normalizeRepoPath, + resolveListingPath, + getCwdRepoRelativePath, + fetchAllDirectories, + fetchAllFiles, + resolveSort, + resolveDirection, + mapWithConcurrency, +} from "./repo-tree"; + +vi.mock("child_process", () => ({ execFileSync: vi.fn() })); +vi.mock("../api/client/services/RepositoryService"); + +const mockExec = vi.mocked(execFileSync); + +describe("normalizeRepoPath", () => { + it("strips a leading ./", () => { + expect(normalizeRepoPath("./src/website")).toBe("src/website"); + }); + + it("strips leading and trailing slashes", () => { + expect(normalizeRepoPath("/src/website/")).toBe("src/website"); + }); + + it("leaves a clean path unchanged", () => { + expect(normalizeRepoPath("src/website")).toBe("src/website"); + }); + + it("trims whitespace and treats an empty string as the root", () => { + expect(normalizeRepoPath(" ")).toBe(""); + expect(normalizeRepoPath("")).toBe(""); + }); + + it("treats '.' and './' as the repository root", () => { + expect(normalizeRepoPath(".")).toBe(""); + expect(normalizeRepoPath("./")).toBe(""); + }); +}); + +describe("getCwdRepoRelativePath", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the cwd relative to the git root", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo/src/website"); + expect(getCwdRepoRelativePath()).toBe("src/website"); + cwdSpy.mockRestore(); + }); + + it("returns '' when the cwd is the repo root", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); + expect(getCwdRepoRelativePath()).toBe(""); + cwdSpy.mockRestore(); + }); + + it("returns '' when not inside a git repository", () => { + mockExec.mockImplementation(() => { + throw new Error("not a git repository"); + }); + expect(getCwdRepoRelativePath()).toBe(""); + }); +}); + +describe("resolveListingPath", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses and normalizes an explicit path (wins over auto-detection)", () => { + expect(resolveListingPath("/src/website/", true)).toBe("src/website"); + expect(resolveListingPath("src", false)).toBe("src"); + }); + + it("defaults to the repo root when no path and repo is explicit", () => { + expect(resolveListingPath(undefined, false)).toBe(""); + }); + + it("uses the cwd-relative path when auto-detected and no explicit path", () => { + mockExec.mockReturnValue("/repo\n" as any); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo/src"); + expect(resolveListingPath(undefined, true)).toBe("src"); + cwdSpy.mockRestore(); + }); +}); + +describe("resolveSort", () => { + it("returns undefined when no sort is requested", () => { + expect(resolveSort(undefined, "file")).toBeUndefined(); + expect(resolveSort(undefined, "directory")).toBeUndefined(); + }); + + it("maps 'name' to 'filename' for files but keeps it for directories", () => { + expect(resolveSort("name", "file")).toBe("filename"); + expect(resolveSort("name", "directory")).toBe("name"); + }); + + it("passes other fields through and is case-insensitive", () => { + expect(resolveSort("issues", "file")).toBe("issues"); + expect(resolveSort("Coverage", "directory")).toBe("coverage"); + }); + + it("throws on an invalid field", () => { + expect(() => resolveSort("bogus", "file")).toThrow(/Invalid --sort/); + }); +}); + +describe("resolveDirection", () => { + it("returns undefined when no direction is requested", () => { + expect(resolveDirection(undefined)).toBeUndefined(); + }); + + it("maps ascending/descending and short forms", () => { + expect(resolveDirection("asc")).toBe("asc"); + expect(resolveDirection("ascending")).toBe("asc"); + expect(resolveDirection("desc")).toBe("desc"); + expect(resolveDirection("DESCENDING")).toBe("desc"); + }); + + it("throws on an invalid direction", () => { + expect(() => resolveDirection("sideways")).toThrow(/Invalid --direction/); + }); +}); + +describe("mapWithConcurrency", () => { + it("runs every item while never exceeding the concurrency limit", async () => { + const order: number[] = []; + let inFlight = 0; + let maxInFlight = 0; + const items = [0, 1, 2, 3, 4, 5, 6]; + + await mapWithConcurrency(items, 2, async (item) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + order.push(item); + inFlight--; + }); + + expect(order.sort((a, b) => a - b)).toEqual(items); + expect(maxInFlight).toBeLessThanOrEqual(2); + }); + + it("handles an empty list without invoking the worker", async () => { + const fn = vi.fn(async () => {}); + await mapWithConcurrency([], 4, fn); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe("fetchAllDirectories", () => { + beforeEach(() => vi.clearAllMocks()); + + it("follows pagination cursors and aggregates every page", async () => { + vi.mocked(RepositoryService.listDirectories) + .mockResolvedValueOnce({ + data: [{ name: "a" }, { name: "b" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ data: [{ name: "c" }] } as any); + + const result = await fetchAllDirectories("gh", "org", "repo", "main", "src"); + + expect(result.map((d) => d.name)).toEqual(["a", "b", "c"]); + expect(RepositoryService.listDirectories).toHaveBeenCalledTimes(2); + // First call: no cursor; path passed through as the explicit string. + expect(RepositoryService.listDirectories).toHaveBeenNthCalledWith( + 1, + "gh", + "org", + "repo", + "main", + "src", + undefined, + undefined, + undefined, + 100, + ); + // Second call: passes the cursor from the first page. + expect(RepositoryService.listDirectories).toHaveBeenNthCalledWith( + 2, + "gh", + "org", + "repo", + "main", + "src", + undefined, + undefined, + "c1", + 100, + ); + }); + + it("passes an explicit empty-string path for the repo root", async () => { + vi.mocked(RepositoryService.listDirectories).mockResolvedValue({ + data: [], + } as any); + + await fetchAllDirectories("gh", "org", "repo", undefined, ""); + + expect(RepositoryService.listDirectories).toHaveBeenCalledWith( + "gh", + "org", + "repo", + undefined, + "", + undefined, + undefined, + undefined, + 100, + ); + }); +}); + +describe("fetchAllFiles", () => { + beforeEach(() => vi.clearAllMocks()); + + it("follows pagination cursors and aggregates every page", async () => { + vi.mocked(RepositoryService.listFiles) + .mockResolvedValueOnce({ + data: [{ path: "a.ts" }], + pagination: { cursor: "c1" }, + } as any) + .mockResolvedValueOnce({ data: [{ path: "b.ts" }] } as any); + + const result = await fetchAllFiles("gh", "org", "repo", undefined, ""); + + expect(result.map((f) => f.path)).toEqual(["a.ts", "b.ts"]); + expect(RepositoryService.listFiles).toHaveBeenCalledTimes(2); + // Root listing must send an explicit "" (non-recursive), never undefined. + expect(RepositoryService.listFiles).toHaveBeenNthCalledWith( + 1, + "gh", + "org", + "repo", + undefined, + "", + undefined, + undefined, + undefined, + undefined, + 100, + ); + }); +}); diff --git a/src/utils/repo-tree.ts b/src/utils/repo-tree.ts new file mode 100644 index 0000000..d0011ca --- /dev/null +++ b/src/utils/repo-tree.ts @@ -0,0 +1,225 @@ +import { execFileSync } from "child_process"; +import * as path from "path"; +import { RepositoryService } from "../api/client/services/RepositoryService"; +import { DirectoryWithAnalysisInfo } from "../api/client/models/DirectoryWithAnalysisInfo"; +import { FileWithAnalysisInfo } from "../api/client/models/FileWithAnalysisInfo"; + +/** + * Helpers for the `ls` and `directories` commands: figuring out *where* in the + * repository to list (path resolution) and fetching *all* children of a folder + * (the list endpoints are cursor-paginated, and these commands intentionally + * fetch every page rather than showing a "first N results" warning). + */ + +/** + * Absolute path of the current git repository's root, or null when not inside a + * git repository. Mirrors `getGitRemoteUrl` in git-remote.ts (shells out, never + * throws). + */ +export function getGitRepoRoot(): string | null { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +/** + * Repository-relative path of the current working directory, using forward + * slashes. Returns "" (the repository root) when not inside a git repository or + * when the cwd is the repository root itself. + */ +export function getCwdRepoRelativePath(): string { + const root = getGitRepoRoot(); + if (!root) return ""; + const relative = path.relative(root, process.cwd()); + // path.relative returns "" when cwd === root; normalize Windows separators. + return relative.split(path.sep).join("/"); +} + +/** + * Normalize a user-supplied path into the form the API expects: no leading + * "./", no leading or trailing slashes. The repository root is represented as + * an empty string. + */ +export function normalizeRepoPath(input: string): string { + const trimmed = input.trim(); + // "." / "./" mean "here" — i.e. the current scope, which for the API is root. + if (trimmed === "." || trimmed === "./") return ""; + return trimmed + .replace(/^\.\/+/, "") // strip a leading "./" + .replace(/^\/+/, "") // strip leading slashes + .replace(/\/+$/, ""); // strip trailing slashes +} + +/** + * Run `fn` over `items` with at most `limit` promises in flight at once — a + * bounded alternative to `Promise.all(items.map(...))` that avoids flooding the + * API with unbounded concurrent requests (each item may itself paginate). + */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next++; + await fn(items[index]); + } + }; + const size = Math.min(limit, items.length); + await Promise.all(Array.from({ length: size }, () => worker())); +} + +/** + * Decide which repository folder to list: + * - an explicit `--path` always wins (normalized), + * - otherwise, when the repo was auto-detected from the git remote, use the + * current working directory relative to the repo root, + * - otherwise (repo passed explicitly, no `--path`), the repository root (""). + */ +export function resolveListingPath( + explicitPath: string | undefined, + autoDetected: boolean, +): string { + if (explicitPath !== undefined) return normalizeRepoPath(explicitPath); + if (autoDetected) return getCwdRepoRelativePath(); + return ""; +} + +// Sort fields accepted at the CLI (--sort). The same vocabulary is used for +// both files and folders; the files endpoint calls the "name" field "filename", +// which resolveSort maps for us. +export const SORT_FIELDS = [ + "name", + "issues", + "grade", + "duplication", + "complexity", + "coverage", +] as const; + +/** + * Validate a --sort value and map it to the API field for the given endpoint. + * Returns undefined when no sort was requested; throws on an invalid value. + */ +export function resolveSort( + cliSort: string | undefined, + kind: "file" | "directory", +): string | undefined { + if (cliSort === undefined) return undefined; + const field = cliSort.toLowerCase(); + if (!(SORT_FIELDS as readonly string[]).includes(field)) { + throw new Error( + `Invalid --sort value '${cliSort}'. Valid values: ${SORT_FIELDS.join(", ")}.`, + ); + } + // The files endpoint names the file field "filename" rather than "name". + if (kind === "file" && field === "name") return "filename"; + return field; +} + +const DIRECTIONS: Record = { + asc: "asc", + ascending: "asc", + desc: "desc", + descending: "desc", +}; + +/** + * Validate a --direction value and map it to the API's "asc"/"desc". Returns + * undefined when no direction was requested; throws on an invalid value. + */ +export function resolveDirection( + cliDirection: string | undefined, +): string | undefined { + if (cliDirection === undefined) return undefined; + const dir = DIRECTIONS[cliDirection.toLowerCase()]; + if (!dir) { + throw new Error( + `Invalid --direction value '${cliDirection}'. Valid values: asc (ascending), desc (descending).`, + ); + } + return dir; +} + +/** + * Fetch every folder directly inside `path`, following pagination cursors until + * the API returns no more pages. `path` is always a string ("" = repo root); + * passing it explicitly (never undefined) keeps the listing non-recursive. + * Optional `sort`/`direction` are applied server-side (order is preserved + * across pages). + */ +export async function fetchAllDirectories( + provider: string, + organization: string, + repository: string, + branch: string | undefined, + path: string, + sort?: string, + direction?: string, +): Promise { + const all: DirectoryWithAnalysisInfo[] = []; + let cursor: string | undefined; + do { + const response = await RepositoryService.listDirectories( + provider, + organization, + repository, + branch, + path, + sort, + direction, + cursor, + 100, // limit + ); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} + +/** + * Fetch every file matching the request, following pagination cursors until the + * API returns no more pages. For a plain listing, `path` is a string ("" = repo + * root, non-recursive). For a search, pass `path = undefined` (recursive) and a + * `search` term — the caller folds the folder scope into the search string, so + * matches are found at any depth under it. Optional `sort`/`direction` are + * applied server-side. + */ +export async function fetchAllFiles( + provider: string, + organization: string, + repository: string, + branch: string | undefined, + path: string | undefined, + search?: string, + sort?: string, + direction?: string, +): Promise { + const all: FileWithAnalysisInfo[] = []; + let cursor: string | undefined; + do { + const response = await RepositoryService.listFiles( + provider, + organization, + repository, + branch, + path, + search, + sort, + direction, + cursor, + 100, // limit + ); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 84010de..ba38d04 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,7 @@ "node_modules", "dist", "**/*.test.ts", + "src/test-support.ts", "vitest.config.mts" ] }