diff --git a/lib/src/commands/scan.ts b/lib/src/commands/scan.ts index a8dd935..1a53cb3 100644 --- a/lib/src/commands/scan.ts +++ b/lib/src/commands/scan.ts @@ -4,15 +4,12 @@ import path from "path"; import { prompt } from "enquirer"; import open from "open"; -import logger from "../utils/logger"; import { DittoScanCandidate, DittoScanExtractSummary, runExtract, } from "@dittowords/text-extract"; -import { quit } from "../utils/quit"; -import initAPIToken from "../services/apiToken/initAPIToken"; -import appContext from "../utils/appContext"; +import chalk from "chalk"; import { asScanLimitInfo, initiateClassify, @@ -25,8 +22,12 @@ import { formatDirectoryBreakdown, formatOverLimitMessage, } from "../scan/analyzeDirectories"; +import { readGitContext } from "../scan/git"; +import initAPIToken from "../services/apiToken/initAPIToken"; +import appContext from "../utils/appContext"; import DittoError, { ErrorType } from "../utils/DittoError"; -import chalk from "chalk"; +import logger from "../utils/logger"; +import { quit } from "../utils/quit"; // Yarn sets INIT_CWD to the directory the user invoked yarn from, which // matters when we proxy via `cd product-text-detection && yarn ptd`. @@ -161,8 +162,10 @@ export const scan = async ( }); if (candidates.length === 0) { - logger.warnText( - `[ditto scan] no candidates extracted; writing empty classify output\n` + logger.writeLine( + logger.warnText( + `[ditto scan] no candidates extracted; writing empty classify output\n` + ) ); } @@ -188,13 +191,31 @@ export const scan = async ( await writeCandidatesNdjson(candidates, candidatesPath); logExtractSummary(extractSummary, candidatesPath); } else { + const gitContext = await readGitContext(resolvedInput); + if (!gitContext) { + logger.writeLine( + logger.warnText( + "[ditto scan] not a git repository - this scan can be imported but not re-synced\n" + ) + ); + } else if (gitContext.dirty) { + logger.writeLine( + logger.warnText( + `[ditto scan] uncommitted changes present; recording ${gitContext.commitSha.slice( + 0, + 7 + )} as an approximate commit\n` + ) + ); + } + const token = await initAPIToken(); appContext.setAuthToken(token); const { candidatesSignedS3Url, record: { _id: recordId }, planLimit, - } = await initiateScan(resolvedInput); + } = await initiateScan(resolvedInput, gitContext); // Fail before the wasted upload when the candidates we already extracted exceed it. if ( diff --git a/lib/src/http/scan.test.ts b/lib/src/http/scan.test.ts new file mode 100644 index 0000000..c084f80 --- /dev/null +++ b/lib/src/http/scan.test.ts @@ -0,0 +1,70 @@ +import { GitContext } from "../scan/git"; +import { buildInitiateScanBody } from "./scan"; +import { ZInitiateScanBodySchema } from "./types"; + +const context: GitContext = { + repoKey: "github.com/dittowords/cli", + repoRoot: "/Users/laura/cli", + commitSha: "d3c1a8148580e1869c91ee6caadda17165ceb0ea", + branch: "master", + dirty: false, +}; + +describe("buildInitiateScanBody", () => { + test("sends only path when there is no git context", () => { + expect(buildInitiateScanBody("/repo", null)).toEqual({ path: "/repo" }); + }); + + test("sends repo, sha, branch and repo-relative root when there is", () => { + expect(buildInitiateScanBody("/Users/laura/cli/lib/src", context)).toEqual({ + path: "/Users/laura/cli/lib/src", + repoKey: "github.com/dittowords/cli", + gitCommitSha: "d3c1a8148580e1869c91ee6caadda17165ceb0ea", + gitBranch: "master", + repoRelativeRoot: "lib/src", + }); + }); + + test("sends an empty repo-relative root when scanning the repo root", () => { + const body = buildInitiateScanBody("/Users/laura/cli", context); + expect(body.repoRelativeRoot).toBe(""); + }); + + test("omits the repo-relative root when the path is outside the repo", () => { + const body = buildInitiateScanBody("/elsewhere/src", context); + expect(body).not.toHaveProperty("repoRelativeRoot"); + }); + + test("sends a null branch on a detached HEAD", () => { + const body = buildInitiateScanBody("/Users/laura/cli", { + ...context, + branch: null, + }); + expect(body.gitBranch).toBeNull(); + expect(body.gitCommitSha).toBe(context.commitSha); + }); + + test("never sends repoRoot or dirty", () => { + const body = buildInitiateScanBody("/Users/laura/cli/lib", { + ...context, + dirty: true, + }); + expect(Object.keys(body).sort()).toEqual([ + "gitBranch", + "gitCommitSha", + "path", + "repoKey", + "repoRelativeRoot", + ]); + }); + + test("every body satisfies the request schema", () => { + for (const c of [null, context, { ...context, branch: null }]) { + expect(() => + ZInitiateScanBodySchema.parse( + buildInitiateScanBody("/Users/laura/cli/lib", c) + ) + ).not.toThrow(); + } + }); +}); diff --git a/lib/src/http/scan.ts b/lib/src/http/scan.ts index 87338d6..e0a2557 100644 --- a/lib/src/http/scan.ts +++ b/lib/src/http/scan.ts @@ -1,9 +1,15 @@ -import axios, { AxiosError } from "axios"; -import getHttpClient from "./client"; -import { IInitiateScanResponse, ZInitiateScanResponse } from "./types"; import { DittoScanCandidate } from "@dittowords/text-extract"; -import DittoError, { ErrorType } from "../utils/DittoError"; +import axios, { AxiosError } from "axios"; import { Blob } from "buffer"; +import { relative, sep } from "node:path"; +import { GitContext } from "../scan/git"; +import DittoError, { ErrorType } from "../utils/DittoError"; +import getHttpClient from "./client"; +import { + IInitiateScanBody, + IInitiateScanResponse, + ZInitiateScanResponse, +} from "./types"; // Structured details from the classify step's SCAN_CANDIDATE_LIMIT_EXCEEDED // response. @@ -68,12 +74,50 @@ export function scanLimitError( }); } +/** + * Where the scanned directory sits inside the repo, as a forward-slash path the + * server prefixes onto candidate paths to build code links (`toRepoRelativePath` + * in ditto-app `services/ai/productTextDetection/codeLinks.ts`). `""` when the + * scan is the repo root, and `undefined` when the scanned path is somehow + * outside the repo, so a bad value never becomes a wrong link. + */ +function repoRelativeRoot( + scannedPath: string, + repoRoot: string +): string | undefined { + const rel = relative(repoRoot, scannedPath); + if (rel.startsWith("..")) return undefined; + return sep === "/" ? rel : rel.split(sep).join("/"); +} + +/** + * Builds the `POST /v2/scan` body. Without git context the body is exactly what + * the CLI has always sent, so a scan outside a repo is unaffected. + */ +export function buildInitiateScanBody( + path: string, + gitContext?: GitContext | null +): IInitiateScanBody { + if (!gitContext) return { path }; + const root = repoRelativeRoot(path, gitContext.repoRoot); + return { + path, + repoKey: gitContext.repoKey, + gitCommitSha: gitContext.commitSha, + gitBranch: gitContext.branch, + ...(root === undefined ? {} : { repoRelativeRoot: root }), + }; +} + export async function initiateScan( - path: string + path: string, + gitContext?: GitContext | null ): Promise { + const body = buildInitiateScanBody(path, gitContext); + try { const httpClient = getHttpClient({}); - const response = await httpClient.post("/v2/scan", { path }); + const response = await httpClient.post("/v2/scan", body); return ZInitiateScanResponse.parse(response.data); } catch (e) { if (!(e instanceof AxiosError)) { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index d5171a7..71d802d 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -180,7 +180,12 @@ export type IExportSwiftFileRequest = z.infer; export const ZInitiateScanBodySchema = z.object({ path: z.string(), + repoKey: z.string().optional(), + gitCommitSha: z.string().optional(), + gitBranch: z.string().nullable().optional(), + repoRelativeRoot: z.string().optional(), }); +export type IInitiateScanBody = z.infer; export const ZInitiateScanResponse = z.object({ record: z.object({ _id: z.string() }), candidatesSignedS3Url: z.string(),