diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 39022e8..dcb1cf0 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -72,7 +72,51 @@ devtools desloppify --repo . --profile ci --output desloppify-results.json **Creating Multica issues from findings:** -Use the `desloppify-issues` skill (see `skills/desloppify-issues/`) to parse the JSON output and create structured Multica issues for T1/T2 findings. +Use [`devtools create-issues`](#devtools-create-issues) to parse the JSON output and create structured Multica issues for T1/T2 findings. + +### `devtools create-issues` + +Reads a desloppify scan JSON output file and creates Multica child issues for T1 (critical) and T2 (high) findings. Findings are grouped by detector within each package to keep related issues together. + +```bash +devtools create-issues --input [options] +``` + +**Options:** + +| Flag | Description | Default | +|---|---|---| +| `--input ` | Path to desloppify scan JSON output file | *(required)* | +| `--parent ` | Parent Multica issue UUID (e.g. APR-65 / Code Quality Sentinel) | — | +| `--project ` | Multica project UUID to assign issues to | — | +| `--max-tier ` | Maximum tier to include (`1` = T1 only, `2` = T1+T2) | `2` | +| `--dry-run` | Print what would be created without calling `multica` | `false` | + +**Examples:** + +```bash +# Preview what issues would be created +devtools create-issues --input desloppify-results.json --dry-run + +# Create issues as children of APR-65 (Code Quality Sentinel) +devtools create-issues --input desloppify-results.json \ + --parent 0d0853e2-b0e0-45ce-a52e-d30066fe2d1d \ + --project 3fa22da9-5597-468e-87d9-089f96fdc7d8 + +# T1 (critical) only +devtools create-issues --input desloppify-results.json --max-tier 1 +``` + +**Typical two-step workflow:** + +```bash +# Step 1: scan +devtools desloppify --repo . --profile ci --output desloppify-results.json + +# Step 2: create issues for T1/T2 findings +devtools create-issues --input desloppify-results.json \ + --parent --project +``` ### `devtools git-refresh` @@ -95,3 +139,89 @@ const result = await runDesloppifyScan({ console.log(result.packages[0].score); ``` + +## CI Integration + +Wire desloppify into GitHub Actions with a score-threshold gate and optional Multica issue creation. + +### Basic CI gate (fail on score regression) + +```yaml +# .github/workflows/desloppify.yml +name: desloppify + +on: + push: + branches: [main] + pull_request: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Run desloppify scan + run: | + pnpm exec devtools desloppify \ + --repo . \ + --profile ci \ + --output desloppify-results.json + + - name: Check score threshold + run: | + node -e " + const r = JSON.parse(require('fs').readFileSync('desloppify-results.json','utf8')); + const fail = r.packages.filter(p => p.score.objective < 70); + if (fail.length) { + console.error('Score below threshold in:', fail.map(p=>p.name).join(', ')); + process.exit(1); + } + " + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: desloppify-results + path: desloppify-results.json +``` + +### With Multica issue creation on merge to main + +Add a second job that runs only on `main` pushes to file Multica issues for any T1/T2 findings: + +```yaml + - name: Create Multica issues for T1/T2 findings + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + MULTICA_TOKEN: ${{ secrets.MULTICA_TOKEN }} + run: | + pnpm exec devtools create-issues \ + --input desloppify-results.json \ + --parent ${{ vars.APR_65_UUID }} \ + --project ${{ vars.MULTICA_PROJECT_ID }} +``` + +> **Secrets / vars to configure:** +> - `MULTICA_TOKEN` — API token for `multica` CLI authentication +> - `APR_65_UUID` — UUID of the Code Quality Sentinel initiative (APR-65) +> - `MULTICA_PROJECT_ID` — UUID of the target Multica project + +### Profile notes + +| Profile | Description | +|---|---| +| `objective` | Default; scores against objective mechanical rules only | +| `ci` | Stricter thresholds; recommended for PR gates | +| `full` | All rules including subjective checks; best for periodic scans | diff --git a/packages/devtools/src/__tests__/issue-creator.test.ts b/packages/devtools/src/__tests__/issue-creator.test.ts new file mode 100644 index 0000000..3493f73 --- /dev/null +++ b/packages/devtools/src/__tests__/issue-creator.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi } from "vitest"; +import { createIssuesFromScanResult } from "../multica/issue-creator.js"; +import type { ScanResult } from "../desloppify/types.js"; + +const baseScanResult: ScanResult = { + repo: "core", + profile: "objective", + timestamp: "2026-01-01T00:00:00.000Z", + packages: [ + { + name: "@aprovan/api", + path: "packages/api", + score: { overall: 72, objective: 75, strict: 65 }, + issues: [ + { + tier: 1, + category: "no-input-validation", + message: "Handler accepts unvalidated body", + file: "src/routes/users.ts", + line: 42, + detector: "no-input-validation", + id: "abc1", + status: "open", + confidence: "high", + }, + { + tier: 1, + category: "no-input-validation", + message: "Handler accepts unvalidated query", + file: "src/routes/posts.ts", + line: 18, + detector: "no-input-validation", + id: "abc2", + status: "open", + confidence: "high", + }, + { + tier: 2, + category: "missing-tests", + message: "No test file for src/services/auth.ts", + file: "src/services/auth.ts", + line: null, + detector: "missing-tests", + id: "abc3", + status: "open", + confidence: "medium", + }, + { + tier: 3, + category: "style", + message: "Missing trailing newline", + file: "src/index.ts", + line: 1, + detector: "style", + id: "abc4", + status: "open", + confidence: "low", + }, + ], + }, + ], +}; + +describe("createIssuesFromScanResult", () => { + it("returns dry-run results without calling the executor", () => { + const executor = vi.fn(); + const result = createIssuesFromScanResult(baseScanResult, { + dryRun: true, + executor, + }); + + expect(executor).not.toHaveBeenCalled(); + expect(result.created).toHaveLength(2); + expect(result.errors).toHaveLength(0); + expect(result.created.every((c) => c.dryRun)).toBe(true); + }); + + it("groups findings by detector in dry-run mode", () => { + const result = createIssuesFromScanResult(baseScanResult, { dryRun: true }); + + const t1Group = result.created.find((c) => c.detector === "no-input-validation"); + expect(t1Group).toBeDefined(); + expect(t1Group!.findingCount).toBe(2); + expect(t1Group!.tier).toBe(1); + }); + + it("respects maxTier filter in dry-run mode", () => { + const result = createIssuesFromScanResult(baseScanResult, { + dryRun: true, + maxTier: 1, + }); + + expect(result.created).toHaveLength(1); + expect(result.created[0]!.tier).toBe(1); + }); + + it("excludes T3 findings when maxTier is 2 (default)", () => { + const result = createIssuesFromScanResult(baseScanResult, { dryRun: true }); + + const t3 = result.created.find((c) => c.tier === 3); + expect(t3).toBeUndefined(); + }); + + it("builds issue title with repo, detector, package", () => { + const result = createIssuesFromScanResult(baseScanResult, { dryRun: true }); + + const t1 = result.created.find((c) => c.detector === "no-input-validation"); + expect(t1!.title).toMatch(/\[desloppify\]/); + expect(t1!.title).toMatch(/\[T1\]/); + expect(t1!.title).toMatch(/no-input-validation/); + expect(t1!.title).toMatch(/@aprovan\/api/); + expect(t1!.title).toMatch(/core/); + }); + + it("calls the executor once per finding group when not dry-run", () => { + const executor = vi.fn().mockReturnValue( + "Created issue 11111111-2222-3333-4444-555555555555", + ); + + const result = createIssuesFromScanResult(baseScanResult, { executor }); + + expect(executor).toHaveBeenCalledTimes(2); + expect(result.created).toHaveLength(2); + expect(result.errors).toHaveLength(0); + const cmd = executor.mock.calls[0]![0] as string; + expect(cmd).toMatch(/multica issue create/); + expect(cmd).toMatch(/--title/); + expect(cmd).toMatch(/--priority critical/); + }); + + it("includes --parent when parentIssueId is provided", () => { + const executor = vi.fn().mockReturnValue("Created issue abc"); + + createIssuesFromScanResult(baseScanResult, { + parentIssueId: "0d0853e2-b0e0-45ce-a52e-d30066fe2d1d", + executor, + }); + + const cmd = executor.mock.calls[0]![0] as string; + expect(cmd).toMatch(/--parent 0d0853e2-b0e0-45ce-a52e-d30066fe2d1d/); + }); + + it("includes --project when projectId is provided", () => { + const executor = vi.fn().mockReturnValue("Created issue abc"); + + createIssuesFromScanResult(baseScanResult, { + projectId: "proj-uuid-123", + executor, + }); + + const cmd = executor.mock.calls[0]![0] as string; + expect(cmd).toMatch(/--project proj-uuid-123/); + }); + + it("uses priority high for T2 issues", () => { + const executor = vi.fn().mockReturnValue("Created issue abc"); + + createIssuesFromScanResult(baseScanResult, { executor }); + + const t2Call = executor.mock.calls.find((c: unknown[]) => + (c[0] as string).includes("--priority high"), + ); + expect(t2Call).toBeDefined(); + }); + + it("captures executor errors without throwing", () => { + const executor = vi.fn().mockImplementation(() => { + throw new Error("multica CLI not found"); + }); + + const result = createIssuesFromScanResult(baseScanResult, { executor }); + + expect(result.errors).toHaveLength(2); + expect(result.created).toHaveLength(0); + expect(result.errors[0]!.error).toMatch(/multica CLI not found/); + }); + + it("handles a scan result with no T1/T2 issues", () => { + const emptyResult: ScanResult = { + ...baseScanResult, + packages: [ + { + ...baseScanResult.packages[0]!, + issues: baseScanResult.packages[0]!.issues.filter((i) => i.tier === 3), + }, + ], + }; + + const result = createIssuesFromScanResult(emptyResult, { dryRun: true }); + + expect(result.created).toHaveLength(0); + }); + + it("handles a scan result with no packages", () => { + const noPackages: ScanResult = { ...baseScanResult, packages: [] }; + + const result = createIssuesFromScanResult(noPackages, { dryRun: true }); + + expect(result.created).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/packages/devtools/src/cli.ts b/packages/devtools/src/cli.ts index a3b34ae..5208513 100644 --- a/packages/devtools/src/cli.ts +++ b/packages/devtools/src/cli.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { bootstrap } from "./commands/bootstrap.js"; +import { makeCreateIssuesCommand } from "./commands/create-issues.js"; import { makeDesloppifyCommand } from "./commands/desloppify.js"; import { gitRefresh } from "./commands/git-refresh.js"; import { makeQualityCommand } from "./commands/quality.js"; @@ -24,6 +25,7 @@ program .action(bootstrap); program.addCommand(makeDesloppifyCommand()); +program.addCommand(makeCreateIssuesCommand()); program.addCommand(makeQualityCommand()); program.parse(); diff --git a/packages/devtools/src/commands/create-issues.ts b/packages/devtools/src/commands/create-issues.ts new file mode 100644 index 0000000..7e69b57 --- /dev/null +++ b/packages/devtools/src/commands/create-issues.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { Command } from "commander"; +import { createIssuesFromScanResult } from "../multica/issue-creator.js"; +import type { ScanResult } from "../desloppify/types.js"; + +export function makeCreateIssuesCommand(): Command { + return new Command("create-issues") + .description( + "Read a desloppify scan JSON output and create Multica issues for T1/T2 findings", + ) + .requiredOption("--input ", "Path to desloppify scan JSON output file") + .option( + "--parent ", + "Parent Multica issue ID (e.g. APR-65 UUID for Code Quality Sentinel)", + ) + .option("--project ", "Multica project ID to assign issues to") + .option( + "--max-tier ", + "Maximum tier to include (1 = T1 only, 2 = T1+T2)", + "2", + ) + .option("--dry-run", "Print what would be created without calling multica", false) + .action((opts) => { + const inputPath = resolve(opts.input); + let scanResult: ScanResult; + + try { + const raw = readFileSync(inputPath, "utf-8"); + scanResult = JSON.parse(raw) as ScanResult; + } catch (err) { + console.error(`Failed to read input file: ${String(err)}`); + process.exit(1); + } + + const maxTier = parseInt(opts.maxTier, 10); + if (isNaN(maxTier) || maxTier < 1 || maxTier > 3) { + console.error("--max-tier must be 1, 2, or 3"); + process.exit(1); + } + + const result = createIssuesFromScanResult(scanResult, { + parentIssueId: opts.parent, + projectId: opts.project, + maxTier, + dryRun: opts.dryRun, + }); + + if (opts.dryRun) { + console.log(`\nDry run — would create ${result.created.length} issue(s):\n`); + } else { + console.log(`\nCreated ${result.created.length} issue(s):\n`); + } + + for (const issue of result.created) { + const id = issue.issueId ? ` [${issue.issueId}]` : ""; + console.log( + ` T${issue.tier} | ${issue.detector} | ${issue.packageName} | ${issue.findingCount} finding(s)${id}`, + ); + console.log(` ${issue.title}`); + } + + if (result.errors.length > 0) { + console.error(`\n${result.errors.length} error(s):`); + for (const err of result.errors) { + console.error(` [ERROR] ${err.title}: ${err.error}`); + } + } + + if (result.skipped > 0) { + console.log(`\nSkipped ${result.skipped} finding(s) below T${maxTier} threshold.`); + } + + if (result.errors.length > 0) { + process.exit(1); + } + }); +} diff --git a/packages/devtools/src/multica/issue-creator.ts b/packages/devtools/src/multica/issue-creator.ts new file mode 100644 index 0000000..32536e1 --- /dev/null +++ b/packages/devtools/src/multica/issue-creator.ts @@ -0,0 +1,163 @@ +import { execSync } from "node:child_process"; +import type { DesloppifyIssue, PackageResult, ScanResult } from "../desloppify/types.js"; + +export type Executor = (cmd: string) => string; + +export interface CreateIssuesOptions { + parentIssueId?: string; + projectId?: string; + maxTier?: number; + dryRun?: boolean; + executor?: Executor; +} + +export interface CreatedIssue { + title: string; + tier: number; + detector: string; + packageName: string; + findingCount: number; + issueId?: string; + dryRun: boolean; +} + +export interface CreateIssuesResult { + created: CreatedIssue[]; + skipped: number; + errors: { title: string; error: string }[]; +} + +interface FindingGroup { + tier: number; + detector: string; + packageName: string; + packagePath: string; + findings: DesloppifyIssue[]; +} + +function groupFindings( + pkg: PackageResult, + maxTier: number, +): FindingGroup[] { + const map = new Map(); + + for (const issue of pkg.issues) { + if (issue.tier > maxTier) continue; + const key = `${issue.tier}::${issue.detector}`; + if (!map.has(key)) { + map.set(key, { + tier: issue.tier, + detector: issue.detector, + packageName: pkg.name, + packagePath: pkg.path, + findings: [], + }); + } + map.get(key)!.findings.push(issue); + } + + return Array.from(map.values()).sort((a, b) => a.tier - b.tier || a.detector.localeCompare(b.detector)); +} + +function buildTitle(group: FindingGroup, repo: string): string { + return `[desloppify] [T${group.tier}] ${group.detector} in ${group.packageName} (${repo})`; +} + +function buildDescription(group: FindingGroup, repo: string): string { + const tierLabel = group.tier === 1 ? "Critical" : "High"; + const lines: string[] = [ + `## desloppify ${tierLabel} Finding: \`${group.detector}\``, + "", + `**Repo:** ${repo}`, + `**Package:** ${group.packageName} (\`${group.packagePath}\`)`, + `**Tier:** T${group.tier} (${tierLabel})`, + `**Detector:** ${group.detector}`, + `**Findings:** ${group.findings.length}`, + "", + "### Issues", + "", + ]; + + for (const f of group.findings) { + const location = f.line != null ? `${f.file}:${f.line}` : f.file; + lines.push(`- **${location}** — ${f.message} *(confidence: ${f.confidence})*`); + } + + lines.push("", "---", "_Created by `devtools create-issues` from a desloppify scan._"); + return lines.join("\n"); +} + +function defaultExecutor(cmd: string): string { + return execSync(cmd, { encoding: "utf-8", shell: "/bin/bash" }).trim(); +} + +function runMulticaCreate(args: string[], executor: Executor): string { + const cmd = ["multica issue create", ...args].join(" "); + return executor(cmd); +} + +function extractIssueId(output: string): string | undefined { + // multica issue create prints the issue ID or URL containing it + const match = output.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); + return match?.[1]; +} + +export function createIssuesFromScanResult( + result: ScanResult, + options: CreateIssuesOptions = {}, +): CreateIssuesResult { + const { parentIssueId, projectId, maxTier = 2, dryRun = false, executor = defaultExecutor } = options; + const summary: CreateIssuesResult = { created: [], skipped: 0, errors: [] }; + + for (const pkg of result.packages) { + const groups = groupFindings(pkg, maxTier); + + for (const group of groups) { + const title = buildTitle(group, result.repo); + const description = buildDescription(group, result.repo); + const priority = group.tier === 1 ? "critical" : "high"; + + if (dryRun) { + summary.created.push({ + title, + tier: group.tier, + detector: group.detector, + packageName: group.packageName, + findingCount: group.findings.length, + dryRun: true, + }); + continue; + } + + try { + const args = [ + `--title ${JSON.stringify(title)}`, + `--description ${JSON.stringify(description)}`, + `--priority ${priority}`, + ]; + if (parentIssueId) args.push(`--parent ${parentIssueId}`); + if (projectId) args.push(`--project ${projectId}`); + + const output = runMulticaCreate(args, executor); + const issueId = extractIssueId(output); + + summary.created.push({ + title, + tier: group.tier, + detector: group.detector, + packageName: group.packageName, + findingCount: group.findings.length, + issueId, + dryRun: false, + }); + } catch (err) { + summary.errors.push({ title, error: String(err) }); + } + } + + const packageIssueCount = pkg.issues.filter((i) => i.tier > maxTier).length; + summary.skipped += packageIssueCount; + } + + return summary; +}