diff --git a/.changeset/auto-detect-repo-from-git-remote.md b/.changeset/auto-detect-repo-from-git-remote.md new file mode 100644 index 0000000..8105fb8 --- /dev/null +++ b/.changeset/auto-detect-repo-from-git-remote.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Auto-detect provider, organization, and repository from the git remote origin URL. All repository-scoped commands now work without explicitly passing ` ` — just run them inside a git repo with an `origin` remote pointing at GitHub, GitLab, or Bitbucket. diff --git a/README.md b/README.md index d690e94..cd3d5c5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,22 @@ codacy --help # Detailed usage for any command | `-V, --version` | Show version | | `-h, --help` | Show help | +### Repository Auto-Detection + +When you run a command inside a git repository, the CLI automatically detects the **provider**, **organization**, and **repository** from the `origin` remote URL. This means you can skip those arguments entirely: + +```bash +# Inside a GitHub repo — auto-detects provider/org/repo +codacy issues +codacy pull-request 42 +codacy tools + +# Or specify them explicitly +codacy issues gh my-org my-repo +``` + +Supported providers: GitHub (`gh`), GitLab (`gl`), Bitbucket (`bb`). + ### Commands | Command | Description | @@ -61,18 +77,16 @@ codacy --help # Detailed usage for any command | `logout` | Remove stored Codacy API token | | `info` | Show authenticated user info and their organizations | | `repositories ` | List repositories for an organization | -| `repository ` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it | -| `issues ` | Search issues in a repository with filters | -| `issue ` | Show details for a single issue, or ignore/unignore it | -| `findings [repo]` | Show security findings for a repository or organization | +| `repository [provider] [org] [repo]` | Show metrics for a repository, or add/remove/follow/unfollow/reanalyze it | +| `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 | | `finding ` | Show details for a single security finding, or ignore/unignore it | -| `pull-request ` | Show PR analysis, issues, diff coverage, and changed files; or reanalyze it | -| `tools ` | List analysis tools configured for a repository | -| `tool ` | Enable, disable, or configure an analysis tool | -| `patterns ` | List patterns for a tool, or bulk enable/disable them | -| `pattern ` | Enable, disable, or set parameters for a pattern | - -Provider shortcodes: `gh` (GitHub), `gl` (GitLab), `bb` (Bitbucket). +| `pull-request [provider] [org] [repo] ` | Show PR analysis, issues, diff coverage, and changed files; or reanalyze it | +| `tools [provider] [org] [repo]` | List analysis tools configured for a repository | +| `tool [provider] [org] [repo] ` | Enable, disable, or configure an analysis tool | +| `patterns [provider] [org] [repo] ` | List patterns for a tool, or bulk enable/disable them | +| `pattern [provider] [org] [repo] ` | Enable, disable, or set parameters for a pattern | Run `codacy --help` for full argument and option details for any command. diff --git a/package-lock.json b/package-lock.json index c7cda9a..86e3040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codacy/codacy-cloud-cli", - "version": "1.0.5", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codacy/codacy-cloud-cli", - "version": "1.0.5", + "version": "1.1.1", "license": "ISC", "dependencies": { "@codacy/tooling": "0.1.0", @@ -15,7 +15,7 @@ "commander": "14.0.0", "date-fns": "4.1.0", "gitdiff-parser": "0.3.1", - "lodash": "4.17.23", + "lodash": "4.18.1", "numeral": "2.0.6", "ora": "7.0.1", "pluralize": "8.0.0" @@ -2549,9 +2549,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.clonedeep": { diff --git a/package.json b/package.json index b385fd8..c4a38e6 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/55.6.4/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/55.12.1/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" @@ -52,7 +52,7 @@ "commander": "14.0.0", "date-fns": "4.1.0", "gitdiff-parser": "0.3.1", - "lodash": "4.17.23", + "lodash": "4.18.1", "numeral": "2.0.6", "ora": "7.0.1", "pluralize": "8.0.0" diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index e72b567..37238b2 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -5,7 +5,15 @@ import { SecurityService } from "../api/client/services/SecurityService"; vi.mock("../api/client/services/SecurityService"); 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(); @@ -633,4 +641,47 @@ describe("findings command", () => { mockExit.mockRestore(); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when no positional args are provided", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: mockFindings, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "findings"]); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "auto-org", + undefined, + 100, + "Status", + "asc", + { + repositories: ["auto-repo"], + statuses: ["Overdue", "OnTrack", "DueSoon"], + }, + ); + }); + + it("should use explicit provider/org for org-wide view (no repo filter)", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "findings", "gh", "my-org"]); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "my-org", + undefined, + 100, + "Status", + "asc", + { statuses: ["Overdue", "OnTrack", "DueSoon"] }, + ); + }); + }); }); diff --git a/src/commands/findings.ts b/src/commands/findings.ts index 46a7371..ad1ab78 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { detectRepoContext } from "../utils/git-remote"; import { getOutputFormat, pickDeep, @@ -153,8 +154,8 @@ export function registerFindingsCommand(program: Command) { .command("findings") .alias("find") .description("Show security findings for a repository or an organization") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") .argument( "[repository]", "repository name (omit to show organization-wide findings)", @@ -182,8 +183,9 @@ export function registerFindingsCommand(program: Command) { "after", ` Examples: + $ codacy findings # auto-detect from git remote $ codacy findings gh my-org my-repo - $ codacy findings gh my-org + $ codacy findings gh my-org # organization-wide findings $ codacy findings gh my-org --severities Critical,High $ codacy findings gh my-org my-repo --statuses Overdue,DueSoon $ codacy findings gh my-org my-repo --limit 500 @@ -191,12 +193,48 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string | undefined, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, ) { try { checkApiToken(); + + const argCount = [providerArg, organizationArg, repositoryArg].filter( + (v) => v !== undefined, + ).length; + let provider: string; + let organization: string; + let repository: string | undefined; + + if (argCount === 3) { + provider = providerArg!; + organization = organizationArg!; + repository = repositoryArg; + } else if (argCount === 2) { + provider = providerArg!; + organization = organizationArg!; + repository = undefined; + } else if (argCount === 0) { + const ctx = detectRepoContext(); + console.error( + ansis.dim( + ` Using ${ctx.provider} / ${ctx.organization} / ${ctx.repository} (from git remote)`, + ), + ); + provider = ctx.provider; + organization = ctx.organization; + repository = ctx.repository; + } else { + throw new Error( + "Ambiguous arguments for 'findings'. Expected 0, 2, or 3 positional arguments.\n\n" + + "Usage:\n" + + " codacy findings (auto-detect from git remote)\n" + + " codacy findings (organization-wide)\n" + + " codacy findings (repo-specific)", + ); + } + const opts = this.opts(); const format = getOutputFormat(this); diff --git a/src/commands/issue.test.ts b/src/commands/issue.test.ts index ad93f50..417d642 100644 --- a/src/commands/issue.test.ts +++ b/src/commands/issue.test.ts @@ -10,6 +10,13 @@ vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/FileService"); 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(() => {}); @@ -426,4 +433,18 @@ describe("issue command", () => { expect(AnalysisService.updateIssueState).not.toHaveBeenCalled(); }); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when only issueId is provided", async () => { + const program = createProgram(); + await program.parseAsync(["node", "test", "issue", "42"]); + + expect(AnalysisService.getIssue).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + 42, + ); + }); + }); }); diff --git a/src/commands/issue.ts b/src/commands/issue.ts index bb3d948..d412f43 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { getOutputFormat, pickDeep, printJson } from "../utils/output"; import { printIssueDetail } from "../utils/formatting"; import { AnalysisService } from "../api/client/services/AnalysisService"; @@ -15,10 +16,10 @@ export function registerIssueCommand(program: Command) { .command("issue") .alias("iss") .description("Show full details of a single quality issue") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") - .argument("", "issue ID (shown at the bottom of each issue card)") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .argument("[issueId]", "issue ID (shown at the bottom of each issue card)") .option("-I, --ignore", "ignore this issue") .option( "-R, --ignore-reason ", @@ -31,6 +32,7 @@ export function registerIssueCommand(program: Command) { "after", ` Examples: + $ codacy issue 12345 # auto-detect from git remote $ codacy issue gh my-org my-repo 12345 $ codacy issue gh my-org my-repo 12345 --output json $ codacy issue gh my-org my-repo 12345 --ignore @@ -39,13 +41,21 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, - issueIdStr: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, + issueIdArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository, trailingArgs } = + resolveRepoArgs( + [providerArg, organizationArg, repositoryArg, issueIdArg], + 1, + "issue", + ["issueId"], + ); + const issueIdStr = trailingArgs[0]; const format = getOutputFormat(this); const issueId = parseInt(issueIdStr, 10); const shouldIgnore: boolean = !!this.opts().ignore; diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index f166f2a..cdd096a 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -7,6 +7,13 @@ import { ToolsService } from "../api/client/services/ToolsService"; vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); 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(() => {}); function createProgram(): Command { @@ -76,6 +83,10 @@ const mockOverview = { { id: "no-undef", title: "No Undefined Variables", total: 3 }, ], authors: [{ name: "dev@example.com", total: 4 }], + potentialFalsePositives: [ + { name: "equalOrAboveThreshold", total: 2 }, + { name: "belowThreshold", total: 6 }, + ], }, }, }; @@ -182,6 +193,9 @@ describe("issues command", () => { expect(output).toContain("sql-injection"); expect(output).toContain("Author"); expect(output).toContain("dev@example.com"); + expect(output).toContain("False Positives"); + expect(output).toContain("equalOrAboveThreshold"); + expect(output).toContain("belowThreshold"); }); it("should pass filter options to the API body", async () => { @@ -1128,4 +1142,49 @@ describe("issues command", () => { ); }); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect repo when no positional args are provided", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: mockIssues, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "issues"]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + undefined, + 100, + {}, + ); + }); + + it("should still work with explicit args", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "gl", + "explicit-org", + "explicit-repo", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gl", + "explicit-org", + "explicit-repo", + undefined, + 100, + {}, + ); + }); + }); }); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 1b765e1..f592fe6 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { createTable, getOutputFormat, @@ -131,6 +132,7 @@ function printOverview(counts: { tags: Count[]; patterns: PatternsCount[]; authors: Count[]; + potentialFalsePositives: Count[]; }): void { printSection("Issues Overview"); const hasData = @@ -139,7 +141,8 @@ function printOverview(counts: { counts.languages.length > 0 || counts.tags.length > 0 || counts.patterns.length > 0 || - counts.authors.length > 0; + counts.authors.length > 0 || + counts.potentialFalsePositives.length > 0; if (!hasData) { console.log(ansis.dim(" No issues data available.")); @@ -157,6 +160,9 @@ function printOverview(counts: { printPatternsTable(counts.patterns); if (counts.patterns.length > 0 && counts.authors.length > 0) console.log(); printCountTable("Author", counts.authors); + if (counts.authors.length > 0 && counts.potentialFalsePositives.length > 0) + console.log(); + printCountTable("False Positives", counts.potentialFalsePositives); } /** @@ -284,9 +290,9 @@ export function registerIssuesCommand(program: Command) { .command("issues") .alias("is") .description("Search for issues in a repository") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") .option( "-b, --branch ", "branch name (defaults to the main branch)", @@ -338,6 +344,7 @@ export function registerIssuesCommand(program: Command) { "after", ` Examples: + $ codacy issues # auto-detect from git remote $ codacy issues gh my-org my-repo $ codacy issues gh my-org my-repo --branch main --severities Critical,Medium $ codacy issues gh my-org my-repo --categories Security --overview @@ -352,12 +359,18 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "issues", + [], + ); const opts = this.opts(); const format = getOutputFormat(this); const isOverview = !!opts.overview; @@ -414,6 +427,7 @@ Examples: "overview.tags", "overview.patterns", "overview.authors", + "overview.potentialFalsePositives", ]), ); return; @@ -426,6 +440,7 @@ Examples: tags: counts.tags, patterns: counts.patterns, authors: counts.authors, + potentialFalsePositives: counts.potentialFalsePositives, }); } else { const pageSize = Math.min(limit, 100); diff --git a/src/commands/pattern.test.ts b/src/commands/pattern.test.ts index 0059432..bc507c2 100644 --- a/src/commands/pattern.test.ts +++ b/src/commands/pattern.test.ts @@ -5,6 +5,13 @@ import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); 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(() => {}); @@ -325,4 +332,33 @@ describe("pattern command", () => { mockExit.mockRestore(); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when only toolName and patternId are provided", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", "test", "pattern", "eslint", "no-unused-vars", "--enable", + ]); + + expect(AnalysisService.listRepositoryTools).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + ); + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + "uuid-eslint", + { + patterns: [ + { + id: "no-unused-vars", + enabled: true, + }, + ], + }, + ); + }); + }); }); diff --git a/src/commands/pattern.ts b/src/commands/pattern.ts index 6fbaef5..557801f 100644 --- a/src/commands/pattern.ts +++ b/src/commands/pattern.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { findToolByName } from "../utils/formatting"; import { ConfigureToolBody } from "../api/client/models/ConfigureToolBody"; @@ -13,14 +14,14 @@ export function registerPatternCommand(program: Command) { .command("pattern") .alias("pat") .description("Enable, disable, or set parameters for a specific pattern") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") .argument( - "", + "[toolName]", "tool name (use hyphens for spaces, e.g. eslint-(deprecated))", ) - .argument("", "pattern ID") + .argument("[patternId]", "pattern ID") .option("-e, --enable", "enable the pattern") .option("-d, --disable", "disable the pattern") .option( @@ -33,6 +34,7 @@ export function registerPatternCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli pattern eslint some-pattern-id --enable # auto-detect from git remote $ codacy-cloud-cli pattern gh my-org my-repo eslint some-pattern-id --enable $ codacy-cloud-cli pattern gh my-org my-repo eslint some-pattern-id --disable $ codacy-cloud-cli pattern gh my-org my-repo eslint some-pattern-id --parameter maxParams=3 @@ -40,14 +42,22 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, - toolName: string, - patternId: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, + toolNameArg?: string, + patternIdArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository, trailingArgs } = + resolveRepoArgs( + [providerArg, organizationArg, repositoryArg, toolNameArg, patternIdArg], + 2, + "pattern", + ["toolName", "patternId"], + ); + const [toolName, patternId] = trailingArgs; const opts = this.opts(); if (!opts.enable && !opts.disable && opts.parameter.length === 0) { diff --git a/src/commands/patterns.test.ts b/src/commands/patterns.test.ts index ba829d9..573370d 100644 --- a/src/commands/patterns.test.ts +++ b/src/commands/patterns.test.ts @@ -5,6 +5,13 @@ import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); 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(() => {}); @@ -719,4 +726,30 @@ describe("patterns command", () => { expect(call).toHaveLength(11); }); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when only toolName is provided", async () => { + const program = createProgram(); + await program.parseAsync(["node", "test", "patterns", "eslint"]); + + expect(AnalysisService.listRepositoryTools).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + ); + expect(AnalysisService.listRepositoryToolPatterns).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + "uuid-eslint", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + }); + }); }); diff --git a/src/commands/patterns.ts b/src/commands/patterns.ts index 47ac891..1eada9a 100644 --- a/src/commands/patterns.ts +++ b/src/commands/patterns.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { getOutputFormat, pickDeep, @@ -222,10 +223,10 @@ export function registerPatternsCommand(program: Command) { .command("patterns") .alias("pats") .description("List patterns for a specific tool in a repository") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") - .argument("", "tool name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .argument("[toolName]", "tool name") .option("-l, --languages ", "comma-separated list of languages") .option( "-C, --categories ", @@ -246,6 +247,7 @@ export function registerPatternsCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli patterns eslint # auto-detect from git remote $ codacy-cloud-cli patterns gh my-org my-repo eslint $ codacy-cloud-cli patterns gh my-org my-repo eslint --severities Critical,High $ codacy-cloud-cli patterns gh my-org my-repo eslint --enabled --categories Security @@ -255,13 +257,21 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, - toolName: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, + toolNameArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository, trailingArgs } = + resolveRepoArgs( + [providerArg, organizationArg, repositoryArg, toolNameArg], + 1, + "patterns", + ["toolName"], + ); + const toolName = trailingArgs[0]; const format = getOutputFormat(this); const opts = this.opts(); diff --git a/src/commands/pull-request.test.ts b/src/commands/pull-request.test.ts index 38e23ec..ad4a345 100644 --- a/src/commands/pull-request.test.ts +++ b/src/commands/pull-request.test.ts @@ -13,6 +13,13 @@ vi.mock("../api/client/services/RepositoryService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/FileService"); 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(() => {}); function createProgram(): Command { @@ -1566,4 +1573,28 @@ describe("pull-request command", () => { // Should NOT contain old "Head Commit" label expect(allOutput).not.toContain("Head Commit"); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when only prNumber is provided", async () => { + vi.mocked(AnalysisService.getRepositoryPullRequest).mockResolvedValue( + mockPrData as any, + ); + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ analyzed: true, data: [] } as any) + .mockResolvedValueOnce({ analyzed: true, data: [] } as any); + vi.mocked(AnalysisService.listPullRequestFiles).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "pull-request", "42"]); + + expect(AnalysisService.getRepositoryPullRequest).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + 42, + ); + }); + }); }); diff --git a/src/commands/pull-request.ts b/src/commands/pull-request.ts index 2751c3e..f73a577 100644 --- a/src/commands/pull-request.ts +++ b/src/commands/pull-request.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { createTable, formatFriendlyDate, @@ -657,10 +658,10 @@ export function registerPullRequestCommand(program: Command) { .command("pull-request") .alias("pr") .description("Show details and analysis for a specific pull request") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") - .argument("", "pull request number") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") + .argument("[prNumber]", "pull request number") .option( "-i, --issue ", "show full details for a specific issue in this PR (use the #id shown on issue cards)", @@ -695,6 +696,7 @@ export function registerPullRequestCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli pull-request 42 # auto-detect from git remote $ codacy-cloud-cli pull-request gh my-org my-repo 42 $ codacy-cloud-cli pull-request gh my-org my-repo 42 --output json $ codacy-cloud-cli pull-request gh my-org my-repo 42 --issue 9901 @@ -707,13 +709,21 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, - prNumberStr: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, + prNumberArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository, trailingArgs } = + resolveRepoArgs( + [providerArg, organizationArg, repositoryArg, prNumberArg], + 1, + "pull-request", + ["prNumber"], + ); + const prNumberStr = trailingArgs[0]; const prNumber = parseInt(prNumberStr, 10); if (isNaN(prNumber)) { console.error(ansis.red("Error: prNumber must be a number.")); diff --git a/src/commands/repository.test.ts b/src/commands/repository.test.ts index d193bd2..e67b52f 100644 --- a/src/commands/repository.test.ts +++ b/src/commands/repository.test.ts @@ -9,6 +9,13 @@ vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/RepositoryService"); vi.mock("../api/client/services/CodingStandardsService"); 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(() => {}); // Default mocks for analysis status API calls (overridden in specific tests) @@ -136,6 +143,7 @@ const mockIssuesCounts = { tags: [], patterns: [], authors: [], + potentialFalsePositives: [], }; describe("repository command", () => { @@ -259,6 +267,7 @@ describe("repository command", () => { tags: [], patterns: [], authors: [], + potentialFalsePositives: [], }, }, }); @@ -311,6 +320,7 @@ describe("repository command", () => { tags: [], patterns: [], authors: [], + potentialFalsePositives: [], }, }, }); @@ -404,6 +414,7 @@ describe("repository command", () => { tags: [], patterns: [], authors: [], + potentialFalsePositives: [], }; vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ @@ -633,7 +644,7 @@ describe("repository command", () => { data: [] as any, }); vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ - data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [] } }, + data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [], potentialFalsePositives: [] } }, }); // Head commit with finished analysis vi.mocked(AnalysisService.listRepositoryCommits).mockResolvedValue({ @@ -684,7 +695,7 @@ describe("repository command", () => { data: [] as any, }); vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ - data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [] } }, + data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [], potentialFalsePositives: [] } }, }); const program = createProgram(); @@ -749,7 +760,7 @@ describe("repository command", () => { data: [] as any, }); vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ - data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [] } }, + data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [], potentialFalsePositives: [] } }, }); const program = createProgram(); @@ -767,4 +778,27 @@ describe("repository command", () => { expect(parsed.repository.grade).toBeUndefined(); expect(parsed.repository.repository.repositoryId).toBeUndefined(); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when no positional args are provided", async () => { + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: mockRepoData as any, + }); + vi.mocked(AnalysisService.listRepositoryPullRequests).mockResolvedValue({ + data: [] as any, + }); + vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ + data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [], potentialFalsePositives: [] } }, + }); + + const program = createProgram(); + await program.parseAsync(["node", "test", "repository"]); + + expect(AnalysisService.getRepositoryWithAnalysis).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + ); + }); + }); }); diff --git a/src/commands/repository.ts b/src/commands/repository.ts index c087252..5ed7992 100644 --- a/src/commands/repository.ts +++ b/src/commands/repository.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { createTable, formatFriendlyDate, @@ -213,9 +214,9 @@ export function registerRepositoryCommand(program: Command) { .command("repository") .alias("repo") .description("Show details, status, and metrics for a specific repository") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") .option("-a, --add", "add this repository to Codacy") .option("-r, --remove", "remove this repository from Codacy") .option("-f, --follow", "follow this repository on Codacy") @@ -227,6 +228,7 @@ export function registerRepositoryCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli repository # auto-detect from git remote $ codacy-cloud-cli repository gh my-org my-repo $ codacy-cloud-cli repository gh my-org my-repo --output json $ codacy-cloud-cli repository gh my-org my-repo --add @@ -239,12 +241,18 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "repository", + [], + ); const opts = this.opts(); // ── Action: add ────────────────────────────────────────────────── diff --git a/src/commands/tool.test.ts b/src/commands/tool.test.ts index 40ca390..855a7f7 100644 --- a/src/commands/tool.test.ts +++ b/src/commands/tool.test.ts @@ -5,6 +5,13 @@ import { AnalysisService } from "../api/client/services/AnalysisService"; vi.mock("../api/client/services/AnalysisService"); 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(() => {}); @@ -277,4 +284,24 @@ describe("tool command", () => { mockExit.mockRestore(); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when only toolName and option are provided", async () => { + const program = createProgram(); + await program.parseAsync(["node", "test", "tool", "eslint", "--enable"]); + + expect(AnalysisService.listRepositoryTools).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + ); + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + "uuid-eslint", + { enabled: true }, + ); + }); + }); }); diff --git a/src/commands/tool.ts b/src/commands/tool.ts index 5f420c0..0ce16c3 100644 --- a/src/commands/tool.ts +++ b/src/commands/tool.ts @@ -3,6 +3,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { findToolByName } from "../utils/formatting"; import { ConfigureToolBody } from "../api/client/models/ConfigureToolBody"; @@ -12,11 +13,11 @@ export function registerToolCommand(program: Command) { .command("tool") .alias("tl") .description("Enable, disable, or configure a tool for a repository") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") .argument( - "", + "[toolName]", "tool name (use hyphens for spaces, e.g. eslint-(deprecated))", ) .option("-e, --enable", "enable the tool") @@ -29,6 +30,7 @@ export function registerToolCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli tool eslint --enable # auto-detect from git remote $ codacy-cloud-cli tool gh my-org my-repo eslint --enable $ codacy-cloud-cli tool gh my-org my-repo eslint --disable $ codacy-cloud-cli tool gh my-org my-repo eslint --configuration-file true @@ -36,13 +38,21 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, - toolName: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, + toolNameArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository, trailingArgs } = + resolveRepoArgs( + [providerArg, organizationArg, repositoryArg, toolNameArg], + 1, + "tool", + ["toolName"], + ); + const toolName = trailingArgs[0]; const opts = this.opts(); if ( diff --git a/src/commands/tools.test.ts b/src/commands/tools.test.ts index 9021814..caa0ac0 100644 --- a/src/commands/tools.test.ts +++ b/src/commands/tools.test.ts @@ -12,6 +12,13 @@ vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/CodingStandardsService"); vi.mock("../api/client/services/ToolsService"); 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(() => {}); @@ -411,4 +418,17 @@ describe("tools command", () => { expect(output).toContain("error"); }); }); + + describe("auto-detect from git remote", () => { + it("should auto-detect provider/org/repo when no positional args are provided", async () => { + const program = createProgram(); + await program.parseAsync(["node", "test", "tools"]); + + expect(AnalysisService.listRepositoryTools).toHaveBeenCalledWith( + "gh", + "auto-org", + "auto-repo", + ); + }); + }); }); diff --git a/src/commands/tools.ts b/src/commands/tools.ts index 2999ee8..309fb51 100644 --- a/src/commands/tools.ts +++ b/src/commands/tools.ts @@ -4,6 +4,7 @@ import ora from "ora"; import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; +import { resolveRepoArgs } from "../utils/resolve-repo-args"; import { createTable, getOutputFormat, pickDeep, printJson } from "../utils/output"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { AnalysisTool } from "../api/client/models/AnalysisTool"; @@ -77,9 +78,9 @@ export function registerToolsCommand(program: Command) { .command("tools") .alias("tls") .description("List all tools for a repository and their status") - .argument("", "git provider (gh, gl, or bb)") - .argument("", "organization name") - .argument("", "repository name") + .argument("[provider]", "git provider (gh, gl, or bb) — auto-detected from git remote if omitted") + .argument("[organization]", "organization name") + .argument("[repository]", "repository name") .option("--import [path]", "import tool configuration from a file (default: .codacy/codacy.config.json)") .option("-y, --skip-approval", "skip confirmation prompt during import") .option("--force", "unlink all coding standards before importing") @@ -87,6 +88,7 @@ export function registerToolsCommand(program: Command) { "after", ` Examples: + $ codacy-cloud-cli tools # auto-detect from git remote $ codacy-cloud-cli tools gh my-org my-repo $ codacy-cloud-cli tools gh my-org my-repo --output json $ codacy-cloud-cli tools gh my-org my-repo --import @@ -96,12 +98,18 @@ Examples: ) .action(async function ( this: Command, - provider: string, - organization: string, - repository: string, + providerArg?: string, + organizationArg?: string, + repositoryArg?: string, ) { try { checkApiToken(); + const { provider, organization, repository } = resolveRepoArgs( + [providerArg, organizationArg, repositoryArg], + 0, + "tools", + [], + ); const opts = this.opts(); // ── Mode: import ──────────────────────────────────────────────── diff --git a/src/utils/git-remote.test.ts b/src/utils/git-remote.test.ts new file mode 100644 index 0000000..fef75c9 --- /dev/null +++ b/src/utils/git-remote.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { parseGitRemoteUrl, detectRepoContext } from "./git-remote"; + +describe("parseGitRemoteUrl", () => { + it("should parse GitHub SSH URL", () => { + expect(parseGitRemoteUrl("git@github.com:codacy/codacy-cloud-cli.git")).toEqual({ + provider: "gh", + organization: "codacy", + repository: "codacy-cloud-cli", + }); + }); + + it("should parse GitHub HTTPS URL", () => { + expect(parseGitRemoteUrl("https://github.com/codacy/codacy-cloud-cli.git")).toEqual({ + provider: "gh", + organization: "codacy", + repository: "codacy-cloud-cli", + }); + }); + + it("should parse GitHub HTTPS URL without .git suffix", () => { + expect(parseGitRemoteUrl("https://github.com/codacy/codacy-cloud-cli")).toEqual({ + provider: "gh", + organization: "codacy", + repository: "codacy-cloud-cli", + }); + }); + + it("should parse GitLab SSH URL", () => { + expect(parseGitRemoteUrl("git@gitlab.com:my-org/my-repo.git")).toEqual({ + provider: "gl", + organization: "my-org", + repository: "my-repo", + }); + }); + + it("should parse GitLab HTTPS URL", () => { + expect(parseGitRemoteUrl("https://gitlab.com/my-org/my-repo.git")).toEqual({ + provider: "gl", + organization: "my-org", + repository: "my-repo", + }); + }); + + it("should parse Bitbucket SSH URL", () => { + expect(parseGitRemoteUrl("git@bitbucket.org:team/project.git")).toEqual({ + provider: "bb", + organization: "team", + repository: "project", + }); + }); + + it("should parse Bitbucket HTTPS URL", () => { + expect(parseGitRemoteUrl("https://bitbucket.org/team/project.git")).toEqual({ + provider: "bb", + organization: "team", + repository: "project", + }); + }); + + it("should return null for unknown host", () => { + expect(parseGitRemoteUrl("git@custom-git.example.com:org/repo.git")).toBeNull(); + }); + + it("should return null for invalid format", () => { + expect(parseGitRemoteUrl("not-a-url")).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(parseGitRemoteUrl("")).toBeNull(); + }); + + it("should parse SSH URL without .git suffix", () => { + expect(parseGitRemoteUrl("git@github.com:org/repo")).toEqual({ + provider: "gh", + organization: "org", + repository: "repo", + }); + }); + + it("should parse repo names containing dots", () => { + expect(parseGitRemoteUrl("git@github.com:org/my.repo.git")).toEqual({ + provider: "gh", + organization: "org", + repository: "my.repo", + }); + expect(parseGitRemoteUrl("https://github.com/org/my.dotted.repo")).toEqual({ + provider: "gh", + organization: "org", + repository: "my.dotted.repo", + }); + }); + + it("should strip credentials from HTTPS URL for provider lookup", () => { + expect(parseGitRemoteUrl("https://token@github.com/org/repo.git")).toEqual({ + provider: "gh", + organization: "org", + repository: "repo", + }); + expect(parseGitRemoteUrl("https://user:pass@github.com/org/repo")).toEqual({ + provider: "gh", + organization: "org", + repository: "repo", + }); + }); +}); + +vi.mock("child_process", () => ({ + execFileSync: vi.fn(), +})); + +describe("detectRepoContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should detect repo context from git remote", async () => { + const { execFileSync } = await import("child_process"); + vi.mocked(execFileSync).mockReturnValue("git@github.com:codacy/codacy-cloud-cli.git\n"); + + const result = detectRepoContext(); + expect(result).toEqual({ + provider: "gh", + organization: "codacy", + repository: "codacy-cloud-cli", + }); + }); + + it("should throw when git remote is not available", async () => { + const { execFileSync } = await import("child_process"); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error("not a git repo"); + }); + + expect(() => detectRepoContext()).toThrow("Could not detect repository from git remote"); + }); + + it("should throw when remote URL has unknown provider", async () => { + const { execFileSync } = await import("child_process"); + vi.mocked(execFileSync).mockReturnValue("git@custom-host.com:org/repo.git\n"); + + expect(() => detectRepoContext()).toThrow("Could not determine provider"); + }); + + it("should redact credentials in error messages", async () => { + const { execFileSync } = await import("child_process"); + vi.mocked(execFileSync).mockReturnValue("https://secret-token@custom-host.com/org/repo.git\n"); + + expect(() => detectRepoContext()).toThrow("***@custom-host.com"); + expect(() => detectRepoContext()).not.toThrow("secret-token"); + }); +}); diff --git a/src/utils/git-remote.ts b/src/utils/git-remote.ts new file mode 100644 index 0000000..0db802a --- /dev/null +++ b/src/utils/git-remote.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "child_process"; + +export interface RepoContext { + provider: string; + organization: string; + repository: string; +} + +const HOST_TO_PROVIDER: Record = { + "github.com": "gh", + "gitlab.com": "gl", + "bitbucket.org": "bb", +}; + +const SSH_REGEX = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/; +const HTTPS_REGEX = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/; + +function stripUserinfo(host: string): string { + const atIndex = host.indexOf("@"); + return atIndex >= 0 ? host.slice(atIndex + 1) : host; +} + +export function parseGitRemoteUrl(url: string): RepoContext | null { + const match = url.match(SSH_REGEX) || url.match(HTTPS_REGEX); + if (!match) return null; + + const [, rawHost, org, repo] = match; + const host = stripUserinfo(rawHost); + const provider = HOST_TO_PROVIDER[host]; + if (!provider) return null; + + return { provider, organization: org, repository: repo }; +} + +export function getGitRemoteUrl(remoteName = "origin"): string | null { + try { + return execFileSync("git", ["remote", "get-url", remoteName], { + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +function redactUrl(url: string): string { + return url.replace(/\/\/[^@]+@/, "//***@"); +} + +export function detectRepoContext(): RepoContext { + const url = getGitRemoteUrl(); + if (!url) { + throw new Error( + "Could not detect repository from git remote. " + + "Specify explicitly, " + + "or ensure you are inside a git repository with an 'origin' remote.", + ); + } + + const parsed = parseGitRemoteUrl(url); + if (!parsed) { + const supported = Object.entries(HOST_TO_PROVIDER) + .map(([host, code]) => `${host} (${code})`) + .join(", "); + throw new Error( + `Could not determine provider from git remote URL '${redactUrl(url)}'. ` + + `Supported providers: ${supported}.`, + ); + } + + return parsed; +} diff --git a/src/utils/resolve-repo-args.test.ts b/src/utils/resolve-repo-args.test.ts new file mode 100644 index 0000000..851f23f --- /dev/null +++ b/src/utils/resolve-repo-args.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveRepoArgs } from "./resolve-repo-args"; + +vi.mock("./git-remote", () => ({ + detectRepoContext: vi.fn(() => ({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + })), +})); + +vi.spyOn(console, "error").mockImplementation(() => {}); + +describe("resolveRepoArgs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("0 trailing args (e.g. issues, repository, tools)", () => { + it("should return explicit args when all 3 are provided", () => { + const result = resolveRepoArgs(["gh", "my-org", "my-repo"], 0, "issues", []); + expect(result).toEqual({ + provider: "gh", + organization: "my-org", + repository: "my-repo", + trailingArgs: [], + }); + }); + + it("should auto-detect when no args are provided", async () => { + const { detectRepoContext } = await import("./git-remote"); + const result = resolveRepoArgs([undefined, undefined, undefined], 0, "issues", []); + expect(detectRepoContext).toHaveBeenCalled(); + expect(result).toEqual({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + trailingArgs: [], + }); + }); + + it("should throw for 1 arg (ambiguous)", () => { + expect(() => + resolveRepoArgs(["gh", undefined, undefined], 0, "issues", []), + ).toThrow("Ambiguous arguments"); + }); + + it("should throw for 2 args (ambiguous)", () => { + expect(() => + resolveRepoArgs(["gh", "my-org", undefined], 0, "issues", []), + ).toThrow("Ambiguous arguments"); + }); + }); + + describe("1 trailing arg (e.g. issue, tool, pull-request)", () => { + it("should return explicit args when all 4 are provided", () => { + const result = resolveRepoArgs(["gh", "my-org", "my-repo", "12345"], 1, "issue", ["issueId"]); + expect(result).toEqual({ + provider: "gh", + organization: "my-org", + repository: "my-repo", + trailingArgs: ["12345"], + }); + }); + + it("should auto-detect when only trailing arg is provided", async () => { + const { detectRepoContext } = await import("./git-remote"); + const result = resolveRepoArgs(["12345", undefined, undefined, undefined], 1, "issue", ["issueId"]); + expect(detectRepoContext).toHaveBeenCalled(); + expect(result).toEqual({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + trailingArgs: ["12345"], + }); + }); + + it("should throw when no args are provided (missing trailing)", () => { + expect(() => + resolveRepoArgs([undefined, undefined, undefined, undefined], 1, "issue", ["issueId"]), + ).toThrow("Missing required argument: issueId"); + }); + + it("should throw for 2 args (ambiguous)", () => { + expect(() => + resolveRepoArgs(["gh", "org", undefined, undefined], 1, "issue", ["issueId"]), + ).toThrow("Ambiguous arguments"); + }); + + it("should throw for 3 args (ambiguous)", () => { + expect(() => + resolveRepoArgs(["gh", "org", "repo", undefined], 1, "issue", ["issueId"]), + ).toThrow("Ambiguous arguments"); + }); + }); + + describe("2 trailing args (e.g. pattern)", () => { + it("should return explicit args when all 5 are provided", () => { + const result = resolveRepoArgs( + ["gh", "my-org", "my-repo", "eslint", "no-undef"], + 2, + "pattern", + ["toolName", "patternId"], + ); + expect(result).toEqual({ + provider: "gh", + organization: "my-org", + repository: "my-repo", + trailingArgs: ["eslint", "no-undef"], + }); + }); + + it("should auto-detect when only trailing args are provided", async () => { + const { detectRepoContext } = await import("./git-remote"); + const result = resolveRepoArgs( + ["eslint", "no-undef", undefined, undefined, undefined], + 2, + "pattern", + ["toolName", "patternId"], + ); + expect(detectRepoContext).toHaveBeenCalled(); + expect(result).toEqual({ + provider: "gh", + organization: "auto-org", + repository: "auto-repo", + trailingArgs: ["eslint", "no-undef"], + }); + }); + + it("should throw when no args are provided (missing trailing)", () => { + expect(() => + resolveRepoArgs( + [undefined, undefined, undefined, undefined, undefined], + 2, + "pattern", + ["toolName", "patternId"], + ), + ).toThrow("Missing required arguments: toolName, patternId"); + }); + + it("should throw for 1 arg (ambiguous)", () => { + expect(() => + resolveRepoArgs( + ["eslint", undefined, undefined, undefined, undefined], + 2, + "pattern", + ["toolName", "patternId"], + ), + ).toThrow("Ambiguous arguments"); + }); + }); + + describe("error messages include usage examples", () => { + it("should show usage for commands with 0 trailing args", () => { + try { + resolveRepoArgs(["gh", undefined, undefined], 0, "issues", []); + } catch (e: any) { + expect(e.message).toContain("codacy issues"); + expect(e.message).toContain(" "); + } + }); + + it("should show usage for commands with 1 trailing arg", () => { + try { + resolveRepoArgs(["gh", "org", undefined, undefined], 1, "issue", ["issueId"]); + } catch (e: any) { + expect(e.message).toContain("codacy issue "); + expect(e.message).toContain(" "); + } + }); + }); +}); diff --git a/src/utils/resolve-repo-args.ts b/src/utils/resolve-repo-args.ts new file mode 100644 index 0000000..9c3a35b --- /dev/null +++ b/src/utils/resolve-repo-args.ts @@ -0,0 +1,76 @@ +import ansis from "ansis"; +import { detectRepoContext } from "./git-remote"; + +export interface ResolvedArgs { + provider: string; + organization: string; + repository: string; + trailingArgs: string[]; +} + +export function resolveRepoArgs( + rawArgs: (string | undefined)[], + trailingCount: number, + commandName: string, + trailingNames: string[], +): ResolvedArgs { + const defined = rawArgs.filter((v): v is string => v !== undefined); + const fullCount = 3 + trailingCount; + + if (defined.length === fullCount) { + return { + provider: defined[0], + organization: defined[1], + repository: defined[2], + trailingArgs: defined.slice(3), + }; + } + + if (defined.length === trailingCount && trailingCount > 0) { + const ctx = detectRepoContext(); + printAutoDetected(ctx); + return { + ...ctx, + trailingArgs: defined, + }; + } + + if (defined.length === 0 && trailingCount === 0) { + const ctx = detectRepoContext(); + printAutoDetected(ctx); + return { ...ctx, trailingArgs: [] }; + } + + const trailDesc = + trailingNames.length > 0 + ? " " + trailingNames.map((n) => `<${n}>`).join(" ") + : ""; + const autoExample = `codacy ${commandName}${trailDesc}`; + const explicitExample = `codacy ${commandName} ${trailDesc}`; + + let message: string; + if (defined.length === 0 && trailingCount > 0) { + message = + `Missing required argument${trailingCount > 1 ? "s" : ""}: ${trailingNames.join(", ")}.\n\n` + + `Usage:\n ${autoExample} (auto-detect repo from git remote)\n ${explicitExample}`; + } else { + message = + `Ambiguous arguments for '${commandName}'. ` + + `Expected ${trailingCount > 0 ? trailingCount : "0"} or ${fullCount} positional arguments, got ${defined.length}.\n\n` + + `Usage:\n ${autoExample} (auto-detect repo from git remote)\n ${explicitExample}`; + } + + throw new Error(message); +} + +function printAutoDetected(ctx: { + provider: string; + organization: string; + repository: string; +}): void { + console.error( + ansis.dim( + ` Using ${ctx.provider} / ${ctx.organization} / ${ctx.repository} (from git remote)`, + ), + ); +}