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
26 changes: 26 additions & 0 deletions fixtures/github-events/merge_group.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"action": "checks_requested",
"merge_group": {
"head_sha": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"head_ref": "refs/heads/gh-readonly-queue/main/pr-42-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"base_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"base_ref": "refs/heads/main",
"head_commit": {
"id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"timestamp": "2026-10-01T10:00:00Z",
"message": "Merge pull request #42"
}
},
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"sender": {
"login": "octocat"
}
}
33 changes: 33 additions & 0 deletions fixtures/github-events/pull_request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"action": "synchronize",
"number": 42,
"pull_request": {
"number": 42,
"head": {
"ref": "feature/login",
"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"repo": {
"full_name": "octo-org/widgets"
}
},
"base": {
"ref": "main",
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"repo": {
"full_name": "octo-org/widgets"
}
}
},
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"sender": {
"login": "octocat"
}
}
33 changes: 33 additions & 0 deletions fixtures/github-events/pull_request_target.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"action": "opened",
"number": 43,
"pull_request": {
"number": 43,
"head": {
"ref": "patch-1",
"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"repo": {
"full_name": "someone/widgets"
}
},
"base": {
"ref": "main",
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"repo": {
"full_name": "octo-org/widgets"
}
}
},
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"sender": {
"login": "someone"
}
}
25 changes: 25 additions & 0 deletions fixtures/github-events/push-new-branch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"ref": "refs/heads/feature/new",
"before": "0000000000000000000000000000000000000000",
"after": "cccccccccccccccccccccccccccccccccccccccc",
"created": true,
"deleted": false,
"forced": false,
"head_commit": {
"id": "cccccccccccccccccccccccccccccccccccccccc",
"timestamp": "2026-10-01T09:30:00+10:00",
"message": "start"
},
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"sender": {
"login": "octocat"
}
}
28 changes: 28 additions & 0 deletions fixtures/github-events/push.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"ref": "refs/heads/main",
"before": "dddddddddddddddddddddddddddddddddddddddd",
"after": "cccccccccccccccccccccccccccccccccccccccc",
"created": false,
"deleted": false,
"forced": false,
"head_commit": {
"id": "cccccccccccccccccccccccccccccccccccccccc",
"timestamp": "2026-10-01T09:30:00+10:00",
"message": "fix: things"
},
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"pusher": {
"name": "octocat"
},
"sender": {
"login": "octocat"
}
}
17 changes: 17 additions & 0 deletions fixtures/github-events/workflow_dispatch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"inputs": {},
"ref": "refs/heads/main",
"repository": {
"full_name": "octo-org/widgets",
"name": "widgets",
"owner": {
"login": "octo-org"
},
"default_branch": "main",
"html_url": "https://github.com/octo-org/widgets"
},
"sender": {
"login": "octocat"
},
"workflow": ".github/workflows/vcqa.yml"
}
51 changes: 49 additions & 2 deletions src/detect.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { detectStack, detectWorkspace, parseYamlList } from "./detect.js";
import { detectRepoUrl, detectStack, detectWorkspace, parseYamlList } from "./detect.js";

const TMP = join(import.meta.dirname!, "__test_fixture__");

Expand Down Expand Up @@ -576,3 +578,48 @@ describe("parseYamlList", () => {
expect(parseYamlList(yaml, "packages")).toEqual(["packages/*", "apps/*"]);
});
});

describe("detectRepoUrl", () => {
const git = (cwd: string, ...args: string[]) =>
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const repos: string[] = [];
const makeRepo = () => {
const dir = mkdtempSync(join(tmpdir(), "vcqa-detect-git-"));
repos.push(dir);
git(dir, "init", "-q", "-b", "trunk");
writeFileSync(join(dir, "a.txt"), "a\n");
git(dir, "add", "a.txt");
git(dir, "commit", "-q", "-m", "init");
git(dir, "remote", "add", "origin", "git@github.com:octo-org/widgets.git");
return dir;
};
afterEach(() => {
for (const dir of repos.splice(0)) rmSync(dir, { recursive: true, force: true });
});

it("reports the checked-out branch and an https repo url", () => {
expect(detectRepoUrl(makeRepo())).toEqual({ repoUrl: "https://github.com/octo-org/widgets", branch: "trunk" });
});

it("never guesses main on a detached HEAD", () => {
const dir = makeRepo();
git(dir, "checkout", "-q", "--detach");
expect(detectRepoUrl(dir)).toEqual({ repoUrl: "https://github.com/octo-org/widgets", branch: "" });
});

it("keeps the branch when there is no remote", () => {
const dir = makeRepo();
git(dir, "remote", "remove", "origin");
expect(detectRepoUrl(dir)).toEqual({ repoUrl: null, branch: "trunk" });
});

it("returns an empty branch outside git", () => {
const dir = mkdtempSync(join(tmpdir(), "vcqa-detect-nogit-"));
repos.push(dir);
expect(detectRepoUrl(dir)).toEqual({ repoUrl: null, branch: "" });
});
});
26 changes: 21 additions & 5 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,21 +1139,37 @@ function shouldSkipConventionEntry(entry: string): boolean {
return discoveryConventions.ignoredConventionEntries.includes(entry) || entry.startsWith(".");
}

/** Detect GitHub/GitLab repo URL from git remote. */
/** Detect GitHub/GitLab repo URL from git remote, and the checked-out branch.
*
* `branch` is "" when it is not known — a detached HEAD (which is how
* `actions/checkout` leaves a pull_request run), or no git at all. It is never
* guessed: a guessed "main" mislabels every PR scan and points file links at
* the wrong tree. */
export function detectRepoUrl(cwd: string): { repoUrl: string | null; branch: string } {
return { repoUrl: detectRemoteUrl(cwd), branch: detectLocalBranch(cwd) ?? "" };
}

/** The current local branch, or null on a detached HEAD / outside git. */
function detectLocalBranch(cwd: string): string | null {
try {
return execSync("git branch --show-current", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim() || null;
} catch {
return null;
}
}

function detectRemoteUrl(cwd: string): string | null {
try {
const remote = execSync("git remote get-url origin", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
const branch = execSync("git branch --show-current", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim() || "main";
// Convert SSH to HTTPS
const url = remote
return remote
.replace(/^git@github\.com:/, "https://github.com/")
.replace(/^git@gitlab\.com:/, "https://gitlab.com/")
.replace(/^git@bitbucket\.org:/, "https://bitbucket.org/")
.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/")
.replace(/^ssh:\/\/git@gitlab\.com\//, "https://gitlab.com/")
.replace(/\.git$/, "");
return { repoUrl: url, branch };
} catch {
return { repoUrl: null, branch: "main" };
return null;
}
}
26 changes: 25 additions & 1 deletion src/report/components.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { e, fileLink, gc, pc } from "./components.js";
import { e, fileLink, gc, linkRef, pc } from "./components.js";

describe("e (HTML escape)", () => {
it("escapes all dangerous characters", () => {
Expand Down Expand Up @@ -37,12 +37,36 @@ describe("fileLink", () => {
expect(result).toContain(">src/foo.ts:10</a>"); // display text preserves original
});

it("links to a commit sha as a permalink", () => {
const sha = "a".repeat(40);
expect(fileLink("src/foo.ts", 3, "https://github.com/org/repo", sha)).toContain(
`https://github.com/org/repo/blob/${sha}/src/foo.ts#L3`,
);
});

it("renders plain text instead of a /blob// link when there is no ref", () => {
const result = fileLink("src/foo.ts", 3, "https://github.com/org/repo", "");
expect(result).toBe("src/foo.ts");
expect(result).not.toContain("/blob//");
});

it("escapes special characters in path", () => {
const result = fileLink("src/a&b.ts", undefined, "https://github.com/org/repo", "main");
expect(result).toContain("&amp;");
});
});

describe("linkRef", () => {
it("prefers the scanned commit sha over the branch", () => {
expect(linkRef({ branch: "main", git: { sha: "abc123" } })).toBe("abc123");
});

it("falls back to the branch, then to no ref", () => {
expect(linkRef({ branch: "main", git: { sha: null } })).toBe("main");
expect(linkRef({ branch: "" })).toBe("");
});
});

describe("gc (grade color)", () => {
it("returns green for A", () => {
expect(gc("A")).toBe("#22c55e");
Expand Down
17 changes: 13 additions & 4 deletions src/report/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,23 @@ export function e(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}

/** Make a file path a clickable GitHub link if repoUrl is available. */
export function fileLink(path: string, line: number | undefined, repoUrl: string | null, branch: string): string {
/** The tree file links should point at: the scanned commit when the report
* knows it (a permalink that stays correct after the branch moves), else the
* branch, else "" — meaning no link at all. */
export function linkRef(meta: { branch?: string | null; git?: { sha?: string | null } | null }): string {
return meta.git?.sha || meta.branch || "";
}

/** Make a file path a clickable link into the repo at `ref` (a commit sha or a
* branch). Plain text when there is no http(s) repoUrl or no ref — a link with
* an empty ref would be `/blob//…`, which resolves nowhere. */
export function fileLink(path: string, line: number | undefined, repoUrl: string | null, ref: string): string {
const clean = path.split(":")[0]!;
if (!repoUrl || !/^https?:\/\//.test(repoUrl)) return e(path);
if (!repoUrl || !/^https?:\/\//.test(repoUrl) || !ref) return e(path);
// Encode path segments for URL safety (spaces, #, ?, etc.)
const encodedPath = clean.split("/").map(encodeURIComponent).join("/");
const safeRepoUrl = repoUrl.replace(/ /g, "%20");
const href = `${safeRepoUrl}/blob/${encodeURIComponent(branch)}/${encodedPath}${line ? `#L${line}` : ""}`;
const href = `${safeRepoUrl}/blob/${encodeURIComponent(ref)}/${encodedPath}${line ? `#L${line}` : ""}`;
return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
}

Expand Down
6 changes: 3 additions & 3 deletions src/report/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { getCheckMeta } from "../check-meta.js";
import { computeDelta } from "../delta.js";
import type { CheckResult, VibeReport } from "../types.js";
import { det, e, fileLink, gc } from "./components.js";
import { det, e, fileLink, gc, linkRef } from "./components.js";
import { FAVICON_SVG } from "./favicon.js";
import {
actionsPage,
Expand Down Expand Up @@ -55,8 +55,8 @@ export function generatePages(report: VibeReport, historyDir?: string, prevRepor
const checkMap = new Map(allChecks.map((c) => [c.name, c]));
const active = allChecks.filter((c) => !det(c).skipped && !det(c).comingSoon);
const ru = report.meta.repoUrl;
const br = report.meta.branch;
const fl = (path: string, line?: number) => fileLink(path, line, ru, br);
const ref = linkRef(report.meta);
const fl = (path: string, line?: number) => fileLink(path, line, ru, ref);
const totalIssues = allChecks.reduce((s, c) => s + c.issues.length, 0);
const proj = report.meta.cwd.split("/").pop() || "project";

Expand Down
Loading