Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions lib/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`.
Expand Down Expand Up @@ -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`
)
);
}

Expand All @@ -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 (
Expand Down
70 changes: 70 additions & 0 deletions lib/src/http/scan.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
56 changes: 50 additions & 6 deletions lib/src/http/scan.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Comment thread
laurakoye marked this conversation as resolved.
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<IInitiateScanResponse> {
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)) {
Expand Down
5 changes: 5 additions & 0 deletions lib/src/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ export type IExportSwiftFileRequest = z.infer<typeof ZExportSwiftFileRequest>;

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<typeof ZInitiateScanBodySchema>;
export const ZInitiateScanResponse = z.object({
record: z.object({ _id: z.string() }),
candidatesSignedS3Url: z.string(),
Expand Down
Loading