Skip to content
Open
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
132 changes: 131 additions & 1 deletion packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> [options]
```

**Options:**

| Flag | Description | Default |
|---|---|---|
| `--input <path>` | Path to desloppify scan JSON output file | *(required)* |
| `--parent <issue-id>` | Parent Multica issue UUID (e.g. APR-65 / Code Quality Sentinel) | — |
| `--project <project-id>` | Multica project UUID to assign issues to | — |
| `--max-tier <number>` | 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 <APR-65-uuid> --project <project-uuid>
```

### `devtools git-refresh`

Expand All @@ -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 |
202 changes: 202 additions & 0 deletions packages/devtools/src/__tests__/issue-creator.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions packages/devtools/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ program
.action(bootstrap);

program.addCommand(makeDesloppifyCommand());
program.addCommand(makeCreateIssuesCommand());
program.addCommand(makeQualityCommand());

program.parse();
Loading