diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab5d1d..773f433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **GitLab tokens no longer follow redirects to other hosts.** The token + is sent as `Authorization: Bearer` (dropped on a cross-origin redirect) + instead of a `PRIVATE-TOKEN` header (which was forwarded). +- **`GITLAB_TOKEN` is sent only to gitlab.com**, the host in `GITLAB_HOST`, + or the instance a GitLab CI job runs on — not to whatever GitLab-looking + host a checkout's `origin` names. Self-managed GitLab: set `GITLAB_HOST`. +- **`--api-url` on a host that isn't recognisably github.com or GHE.com** + (an intranet name, `ghes:8080`) now gets the enterprise tokens, never + `GH_TOKEN` / `GITHUB_TOKEN`. + +### Fixed + +- **Incremental fetches no longer lose old comments and reviews** that + were edited, or whose PR was first listed, after the previous fetch + started (including PRs updated while a `--time-budget` fetch was + paused): they keep everything created in the window, not only since the + last fetch. Edited comments are refreshed in the cache. +- GitLab corpora are stamped with the time the fetch started, so notes + written during a long fetch aren't skipped by the next one. +- The MCP server no longer hangs when the background index build dies + (out of memory, killed); the call fails and the next one retries. +- `rules status` no longer flags `no-recent-support` when older matches + crowd recent ones out of the top 50. +- `run --demo` (and `reviewlore --demo`) runs the bundled demo instead of + ignoring `--demo` and starting a real run; other commands refuse it. +- `run --format agents` and `reviewlore export --to agents` keep separate + sections in `AGENTS.md` (the generator's markers are now + `reviewlore:generated:start/end`), and `run --format cursor --force` + never overwrites a file `export` wrote. +- `$` in rule text (`$HOME`, `$&`) is no longer mangled when a managed + block in AGENTS.md, CLAUDE.md, REVIEW.md, CONVENTIONS.md and similar + files is rewritten. +- Rules written with `--llm` keep their cited examples readable (they + used a different heading), so the manifest, `export` and `rules status` + see their evidence. +- A rule file name cut to length no longer ends in `-` (such files + couldn't be read back). +- `generate` records the rules it writes, as `run` does. +- `ssh.github.com` and `github.com:443` remotes are github.com. +- Owner and repository names compare case-insensitively (a committed + corpus for `Acme/App` serves `acme/app`). +- Rule files citing GitHub Enterprise or GitLab links have their sources + read (only github.com links were). +- `package.json`'s `bin` path no longer starts with `./`, which npm 11 + rewrote at publish with a misleading "invalid and removed" warning. + ## [0.3.0] — 2026-10-28 ### Added — plugin preflight diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index d5f114c..95b00c1 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -125,8 +125,10 @@ Create a personal access token with the `read_api` scope (GitLab → Preferences → Access tokens) and export it as `GITLAB_TOKEN`. Public projects need one too: gitlab.com lists merge requests anonymously but answers 401 for their comments. A GitHub -token is never used for GitLab. For a self-managed GitLab whose host -name doesn't contain "gitlab", pass `--provider gitlab --host `. +token is never used for GitLab. For a self-managed GitLab, also set +`GITLAB_HOST=`: `GITLAB_TOKEN` alone is only sent to gitlab.com, so +a cloned repository can't point it at another server. If the host name +doesn't contain "gitlab", pass `--provider gitlab --host ` too. ### GitHub Enterprise: certificate errors ("unable to get local issuer certificate") diff --git a/cli/commands/fetch.mjs b/cli/commands/fetch.mjs index a947a58..35d9827 100644 --- a/cli/commands/fetch.mjs +++ b/cli/commands/fetch.mjs @@ -143,7 +143,7 @@ export async function main(argv) { try { const output = await fetcher.fetch({ - since, monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), + since, keepSince: sinceFromMonths(monthsBack), monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), checkpoint: Boolean(args.cache), budgetMs, }); diff --git a/cli/commands/generate.mjs b/cli/commands/generate.mjs index cb3f95f..19f3121 100644 --- a/cli/commands/generate.mjs +++ b/cli/commands/generate.mjs @@ -9,6 +9,7 @@ import { getRenderer, resolveFormats, rendererSummary } from "../../lib/renderer import { synthesiseAll } from "../../lib/llm.mjs"; import { loadPreviousState, reportDiffSinceLastRun, writeCurrentState } from "../../lib/diff.mjs"; import { applyPreset, presetSummary } from "../../lib/presets.mjs"; +import { recordFiles, urlDates } from "../../lib/rules-manifest.mjs"; const VERSION = "0.3.0"; @@ -268,6 +269,17 @@ export async function main(argv) { // Persist state for next run's diff. writeCurrentState(resolvedTarget, kept); + // Track the rule files written, as `run` does, so `rules status` checks + // them instead of listing them as written before 0.3. + const ruleNames = written.filter((w) => w.format === "claude").map((w) => w.path.split(/[\\/]/).pop()); + if (ruleNames.length && !demoTarget) { + recordFiles(resolvedTarget, ruleNames, { + generator: synth ? "heuristic+llm" : "heuristic", + byUrl: urlDates(data), + ctx: { owner: data.meta?.owner, repo: data.meta?.repo, source: "file", fetchedAt: data.meta?.last_fetched_at ?? data.meta?.fetched_at ?? null }, + }); + } + process.stderr.write(`\n✅ Wrote ${written.length} file(s) [format(s): ${formatNames.join(", ")}] into ${resolvedTarget}\n`); for (const w of written) { process.stderr.write(` - [${w.format}] ${w.path}\n`); diff --git a/cli/reviewlore.mjs b/cli/reviewlore.mjs index 6841f46..e9c0f32 100755 --- a/cli/reviewlore.mjs +++ b/cli/reviewlore.mjs @@ -235,6 +235,18 @@ async function main(argv) { // Subcommands: "init" (wizard), "publish-corpus", "run" (default). const subcommand = args._[0] ?? "run"; + // --demo never touches the user's repo or the network. `run --demo` (and a + // bare `reviewlore --demo`) is `generate --demo`; elsewhere it's refused + // rather than ignored, which used to start a real run. + if (args.demo && subcommand === "run") { + process.stderr.write("Demo: the bundled sample through the heuristic pipeline (`generate --demo`); nothing is fetched or written to your repo.\n\n"); + const { main: runStage } = await import(DELEGATED.generate); + return runStage(["--demo"]); + } + if (args.demo && subcommand !== "candidates") { + process.stderr.write(`Error: --demo works with \`run\`, \`generate\` and \`candidates\`, not \`${subcommand}\`.\n`); + return 1; + } if (subcommand === "init") { try { await runInitWizard({ targetHint: args.target ?? "." }); @@ -396,7 +408,8 @@ async function main(argv) { } // A corpus without `host` is from github.com (written before 0.3). const corpusRepo = corpusOnDisk?.meta?.owner && repoLabel(corpusOnDisk.meta.owner, corpusOnDisk.meta.repo, corpusOnDisk.meta.host ?? "github.com"); - if (corpusRepo && corpusRepo !== repoLabel(owner, repo, host)) { + // GitHub and GitLab names are case-insensitive: Acme/App is acme/app. + if (corpusRepo && corpusRepo.toLowerCase() !== repoLabel(owner, repo, host).toLowerCase()) { process.stderr.write(`Ignoring ${corpusOnDisk.path}: it holds ${corpusRepo}, not ${repoLabel(owner, repo, host)}.\n`); corpusOnDisk = null; } @@ -427,7 +440,7 @@ async function main(argv) { `Topping up local cache from ${host} (since ${since.toISOString()}) so the next \`publish-corpus\` is fresh...\n`, ); const fresh = await fetcher.fetch({ - since, monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), checkpoint: true, + since, keepSince: sinceFromMonths(monthsBack), monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), checkpoint: true, }); const path = mergeIntoCache(owner, repo, fresh, host); process.stderr.write(`📦 Cache refreshed: ${path}\n`); @@ -466,7 +479,7 @@ async function main(argv) { process.stderr.write(`\n[1/2] Fetching ${repoLabel(owner, repo, host)} (range: ${since.toISOString()} → now)...\n\n`); try { data = await fetcher.fetch({ - since, monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), checkpoint: useCache, + since, keepSince: sinceFromMonths(monthsBack), monthsBack, out: process.stderr, mode: process.env.REVIEWLORE_FETCH, concurrency: fetchConcurrency(), checkpoint: useCache, }); if (useCache) { const path = mergeIntoCache(owner, repo, data, host); diff --git a/docs/cli.md b/docs/cli.md index 97133bd..2cf8e65 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -40,7 +40,7 @@ reaches an enterprise server or the reverse: | Host | Order | |---|---| | github.com, GHE.com | `--token`, `GH_TOKEN`, `GITHUB_TOKEN`, `gh auth token --hostname ` | -| GitHub Enterprise Server | `--token`, `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`, `GITHUB_TOKEN` only inside Actions on that same server, `gh auth token --hostname ` | +| GitHub Enterprise Server, or any other host (e.g. an `--api-url` on an intranet name) | `--token`, `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`, `GITHUB_TOKEN` only inside Actions on that same server, `gh auth token --hostname ` | If you use the GitHub CLI, `gh auth login --hostname ` is all it takes. Public repos need the `public_repo` scope; private repos need @@ -59,7 +59,12 @@ Nested groups are fine (`group/sub/project`). The token comes from `--token`, then `GITLAB_TOKEN`, then `GITLAB_ACCESS_TOKEN` — a personal access token with the `read_api` scope, needed even for public projects because GitLab doesn't serve merge-request comments anonymously — and a -GitHub token is never used for GitLab. Merge-request discussions map onto the same comment shape, so +GitHub token is never used for GitLab. `GITLAB_TOKEN` is sent only to +gitlab.com, to the host in `GITLAB_HOST` (glab's variable: set +`GITLAB_HOST=gitlab.acme.corp` for a self-managed instance), or, inside +GitLab CI, to the job's own instance — never to whatever host a +checkout's `origin` names. It goes as an `Authorization: Bearer` header, +which isn't forwarded if the server redirects elsewhere. Merge-request discussions map onto the same comment shape, so everything downstream works unchanged; GitLab has no review bodies or "changes requested" state, so nothing is weighted by review state, and fetches don't resume yet (`--time-budget` is ignored). Bitbucket isn't @@ -319,9 +324,11 @@ the files already in the target) or `all`. | `windsurf` | Managed block in `.windsurfrules` | Managed sections sit between `reviewlore` start/end marker -comments (`` in markdown, `# >>> -reviewlore start` in `.windsurfrules`); text outside the markers -is never touched. +comments (`` in markdown, +`` in `AGENTS.md` so it never +collides with `reviewlore export --to agents`, `# >>> reviewlore start` +in `.windsurfrules`); text outside the markers is never touched, and +nothing `export` wrote is overwritten, even with `--force`. A generated Claude rule: @@ -560,7 +567,8 @@ that refreshes the corpus on a schedule and opens a PR with the | `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN` | Token for GitHub Enterprise Server | | `GH_HOST` | GitHub host when there's no git remote to go by | | `NODE_EXTRA_CA_CERTS` | Extra CA certificates (an Enterprise Server with an internal CA) | -| `GITLAB_TOKEN`, `GITLAB_ACCESS_TOKEN` | Token for GitLab (`read_api` scope) | +| `GITLAB_TOKEN`, `GITLAB_ACCESS_TOKEN` | Token for GitLab (`read_api` scope); sent to gitlab.com or `GITLAB_HOST` only | +| `GITLAB_HOST` | Self-managed GitLab host that may receive `GITLAB_TOKEN` | | `ANTHROPIC_API_KEY` | Anthropic API key for `--llm` | | `REVIEWLORE_MODEL` | Model for `--llm` (default `claude-sonnet-5-5`) | | `REVIEWLORE_CACHE_DIR` | Cache location (default `~/.reviewlore`) | diff --git a/lib/doctor.mjs b/lib/doctor.mjs index 854be58..aedb022 100644 --- a/lib/doctor.mjs +++ b/lib/doctor.mjs @@ -128,7 +128,7 @@ export function runDoctor({ const label = (o, r, h) => `${o}/${r}${!h || h === "github.com" ? "" : ` on ${h}`}`; const corpusRepo = label(info.meta.owner, info.meta.repo, info.meta.host); const repoName = detected?.supported ? label(detected.owner, detected.repo, detected.host) : null; - if (repoName && corpusRepo !== repoName) { + if (repoName && corpusRepo.toLowerCase() !== repoName.toLowerCase()) { // names are case-insensitive checks.push(check("corpus", false, false, `${info.path} holds ${corpusRepo}, not ${repoName}`, "Re-publish it from this repository with `reviewlore publish-corpus --force`.")); } else { diff --git a/lib/export.mjs b/lib/export.mjs index b1ba27c..a9b7b0f 100644 --- a/lib/export.mjs +++ b/lib/export.mjs @@ -83,7 +83,8 @@ const REVIEW_INTRO = "Flag changes that go against these conventions. Each was a function upsertBlock(current, block, { header }) { const re = new RegExp(`${BLOCK_START}[\\s\\S]*?${BLOCK_END}\\n?`); if (current === null) return block ? `${header}${block}\n` : null; - if (re.test(current)) return block ? current.replace(re, `${block}\n`) : current.replace(re, "").replace(/\n{3,}$/, "\n\n"); + // A function replacement: "$" in rule text ($HOME, $&) must stay literal. + if (re.test(current)) return block ? current.replace(re, () => `${block}\n`) : current.replace(re, "").replace(/\n{3,}$/, "\n\n"); return block ? `${current.trimEnd()}\n\n${block}\n` : current; } diff --git a/lib/fetchers.mjs b/lib/fetchers.mjs index e267d52..01de6bb 100644 --- a/lib/fetchers.mjs +++ b/lib/fetchers.mjs @@ -192,10 +192,14 @@ async function listAscending(octokit, method, params, { stream, since, onProgres return state.items; } -export async function fetchIssueComments(octokit, { owner, repo, since, onProgress, state = emptyStreams().comments, onPage, shouldStop, pool, now }) { +// `since` is the cutoff for what to list (updated since); `keepSince`, the +// start of the window, decides what to keep (created since). They differ on +// an incremental fetch, which must still keep an older comment that was +// edited, or a PR first listed, after the previous fetch started. +export async function fetchIssueComments(octokit, { owner, repo, since, keepSince = since, onProgress, state = emptyStreams().comments, onPage, shouldStop, pool, now }) { return listAscending(octokit, octokit.rest.issues.listCommentsForRepo, { owner, repo }, { stream: "comments", since, onProgress, state, onPage, shouldStop, pool, now, - keep: (c) => isWithinRange(c.created_at, since) && !isPlainIssueComment(c), + keep: (c) => isWithinRange(c.created_at, keepSince) && !isPlainIssueComment(c), shape: (c) => ({ type: "issue_comment", comment_id: c.id, @@ -210,10 +214,10 @@ export async function fetchIssueComments(octokit, { owner, repo, since, onProgre }); } -export async function fetchReviewComments(octokit, { owner, repo, since, onProgress, state = emptyStreams().inline, onPage, shouldStop, pool, now }) { +export async function fetchReviewComments(octokit, { owner, repo, since, keepSince = since, onProgress, state = emptyStreams().inline, onPage, shouldStop, pool, now }) { return listAscending(octokit, octokit.rest.pulls.listReviewCommentsForRepo, { owner, repo }, { stream: "inline", since, onProgress, state, onPage, shouldStop, pool, now, - keep: (c) => isWithinRange(c.created_at, since), + keep: (c) => isWithinRange(c.created_at, keepSince), shape: (c) => ({ type: "review_comment", comment_id: c.id, @@ -307,9 +311,10 @@ function reviewRecord(pr, r, idField) { // Resume state: the PR list (`list`, `after`, `listed`) and the PRs whose // reviews are stored (`reviewed`). PRs updated while a fetch is paused are -// picked up by the next incremental fetch, because the cache's cutoff is -// when the first run started (fetchCorpus). -async function fetchReviewsGraphQLWith(octokit, { owner, repo, since, onProgress, state, onPage, shouldStop }, idField) { +// picked up, with all their reviews in the window (`keepSince`), by the +// next incremental fetch, whose cutoff is when the first run started +// (fetchCorpus). +async function fetchReviewsGraphQLWith(octokit, { owner, repo, since, keepSince = since, onProgress, state, onPage, shouldStop }, idField) { const report = (fraction) => onProgress?.({ stream: "reviews", count: state.prs, fraction }); if (state.done) { report(1); @@ -368,7 +373,7 @@ async function fetchReviewsGraphQLWith(octokit, { owner, repo, since, onProgress } const meta = { number: pr.number, title: titles.get(pr.number) }; for (const r of prReviews) { - if (isWithinRange(r.submittedAt, since)) records.push(reviewRecord(meta, r, idField)); + if (isWithinRange(r.submittedAt, keepSince)) records.push(reviewRecord(meta, r, idField)); } } // Commit a batch only once it is complete, so a resume never skips half of it. @@ -406,7 +411,7 @@ export async function fetchReviewSubmissionsGraphQL(octokit, { state = emptyStre // REST: list PRs, then one listReviews per PR, 4 at a time. Used when // GraphQL isn't available (or REVIEWLORE_FETCH=rest). Resumable per PR: // `state.restDone` lists the PRs already fetched. -export async function fetchReviewSubmissionsRest(octokit, { owner, repo, since, onProgress, onPage, shouldStop, concurrency = 4, state = emptyStreams().reviews }) { +export async function fetchReviewSubmissionsRest(octokit, { owner, repo, since, keepSince = since, onProgress, onPage, shouldStop, concurrency = 4, state = emptyStreams().reviews }) { if (state.done) { onProgress?.({ stream: "reviews", count: state.prs, fraction: 1 }); return { reviews: state.items, prAuthors: state.prAuthors }; @@ -437,7 +442,7 @@ export async function fetchReviewSubmissionsRest(octokit, { owner, repo, since, const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, { owner, repo, pull_number: pr.number, per_page: 100, }); - state.items.push(...reviews.filter((r) => isWithinRange(r.submitted_at, since)).map((r) => ({ + state.items.push(...reviews.filter((r) => isWithinRange(r.submitted_at, keepSince)).map((r) => ({ type: "review_submission", review_id: r.id, pr_number: pr.number, @@ -485,8 +490,8 @@ export async function fetchReviewSubmissions(octokit, { mode, onNotice, state = // All three streams, concurrently, each resuming from `streams` (see // emptyStreams). Waits for every stream to settle so their states are // consistent, then rethrows: a real error before an interruption. -export async function fetchAll(octokit, { owner, repo, since, mode, onProgress, onNotice, streams = emptyStreams(), onPage, shouldStop, concurrency = DEFAULT_CONCURRENCY, now = Date.now() }) { - const common = { owner, repo, since, onProgress, onPage, shouldStop }; +export async function fetchAll(octokit, { owner, repo, since, keepSince = since, mode, onProgress, onNotice, streams = emptyStreams(), onPage, shouldStop, concurrency = DEFAULT_CONCURRENCY, now = Date.now() }) { + const common = { owner, repo, since, keepSince, onProgress, onPage, shouldStop }; // The comment listings share one pool of `concurrency` time slices. const pool = createPool(Math.max(1, concurrency)); const sliced = { pool, now }; @@ -525,7 +530,7 @@ const CHECKPOINT_EVERY_MS = 5000; // every 5 s, and whenever the fetch stops early or fails) and an earlier // partial fetch of the same repo is resumed. `budgetMs` stops cleanly // after that long with a PartialFetch error. -export async function fetchCorpus(octokit, { owner, repo, host = "github.com", since, monthsBack, out, mode, checkpoint = false, budgetMs, concurrency = DEFAULT_CONCURRENCY, now = () => Date.now() }) { +export async function fetchCorpus(octokit, { owner, repo, host = "github.com", since, keepSince = since, monthsBack, out, mode, checkpoint = false, budgetMs, concurrency = DEFAULT_CONCURRENCY, now = () => Date.now() }) { const progress = createProgress({ out, now }); installRetry(octokit, { stats: progress.stats, @@ -535,10 +540,12 @@ export async function fetchCorpus(octokit, { owner, repo, host = "github.com", s let partial = checkpoint ? loadPartial(owner, repo, since, now(), host) : null; if (partial) { since = new Date(partial.since); + keepSince = new Date(partial.keep_since ?? partial.since); progress.notice(`Resuming the fetch started ${partial.started_at.slice(0, 16).replace("T", " ")} UTC.`); } else { partial = { - version: PARTIAL_VERSION, owner, repo, since: since.toISOString(), started_at: new Date(now()).toISOString(), streams: emptyStreams(), + version: PARTIAL_VERSION, owner, repo, since: since.toISOString(), keep_since: keepSince.toISOString(), + started_at: new Date(now()).toISOString(), streams: emptyStreams(), }; } const save = () => { if (checkpoint) savePartial(owner, repo, partial, host); }; @@ -554,7 +561,7 @@ export async function fetchCorpus(octokit, { owner, repo, host = "github.com", s let data; try { data = await fetchAll(octokit, { - owner, repo, since, mode, + owner, repo, since, keepSince, mode, streams: partial.streams, onPage, shouldStop: () => now() >= deadline, concurrency, now: now(), onProgress: (e) => progress.update(e), onNotice: (m) => progress.notice(m), diff --git a/lib/github-client.mjs b/lib/github-client.mjs index 883acc1..1ed3079 100644 --- a/lib/github-client.mjs +++ b/lib/github-client.mjs @@ -6,26 +6,31 @@ import { DOTCOM, normalizeHost, hostKind, apiBaseUrl } from "./github-host.mjs"; // another host's: a github.com credential must not reach an enterprise // server, or the reverse. Order, following the gh CLI's conventions: // github.com / GHE.com: --token, GH_TOKEN, GITHUB_TOKEN, `gh auth token --hostname ` -// GitHub Enterprise Server: --token, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, -// GITHUB_TOKEN only inside GitHub Actions running on that same server, -// `gh auth token --hostname ` +// GitHub Enterprise Server, and any host that isn't recognisably github.com +// or GHE.com (an --api-url pointing at an intranet name): --token, +// GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, GITHUB_TOKEN only inside +// GitHub Actions running on that same server, `gh auth token --hostname ` // Returns { token, source, host } or null. The token never goes into messages. export function resolveToken({ token, host = DOTCOM, env = process.env, gh = tryGhAuthToken } = {}) { const h = normalizeHost(host); const found = (value, source) => ({ token: value, source, host: h }); if (token) return found(token, "flag"); - if (hostKind(h) === "ghes") { + if (isDotcomLike(h)) { + if (env.GH_TOKEN) return found(env.GH_TOKEN, "GH_TOKEN"); + if (env.GITHUB_TOKEN) return found(env.GITHUB_TOKEN, "GITHUB_TOKEN"); + } else { if (env.GH_ENTERPRISE_TOKEN) return found(env.GH_ENTERPRISE_TOKEN, "GH_ENTERPRISE_TOKEN"); if (env.GITHUB_ENTERPRISE_TOKEN) return found(env.GITHUB_ENTERPRISE_TOKEN, "GITHUB_ENTERPRISE_TOKEN"); if (env.GITHUB_TOKEN && env.GITHUB_ACTIONS === "true" && actionsHost(env) === h) return found(env.GITHUB_TOKEN, "GITHUB_TOKEN"); - } else { - if (env.GH_TOKEN) return found(env.GH_TOKEN, "GH_TOKEN"); - if (env.GITHUB_TOKEN) return found(env.GITHUB_TOKEN, "GITHUB_TOKEN"); } const ghToken = gh(h); return ghToken ? found(ghToken, "gh-cli") : null; } +// github.com and GHE.com take the gh CLI's general tokens; every other host +// is treated as an enterprise server, so they never reach it. +const isDotcomLike = (host) => ["dotcom", "ghe.com"].includes(hostKind(host)); + // The GitHub instance an Actions run belongs to. function actionsHost(env) { try { @@ -52,7 +57,7 @@ function tryGhAuthToken(host) { export function noTokenMessage(host = DOTCOM) { const h = normalizeHost(host); if (h === DOTCOM) return "No GitHub token found. Either pass --token, set GITHUB_TOKEN, or run `gh auth login`."; - const envVar = hostKind(h) === "ghes" ? "GH_ENTERPRISE_TOKEN" : "GH_TOKEN"; + const envVar = isDotcomLike(h) ? "GH_TOKEN" : "GH_ENTERPRISE_TOKEN"; return `No token for ${h}. Run \`gh auth login --hostname ${h}\`, set ${envVar}, or pass --token.`; } diff --git a/lib/github-host.mjs b/lib/github-host.mjs index af78633..3a1cffe 100644 --- a/lib/github-host.mjs +++ b/lib/github-host.mjs @@ -13,12 +13,14 @@ const NOT_GITHUB = /(^|\.)(bitbucket\.org|dev\.azure\.com|visualstudio\.com|code // --provider gitlab: they look like any other server. export const isGitLabHost = (host) => /(^|\.)gitlab(\.|$)/i.test(String(host ?? "").replace(/:\d+$/, "")); -// A host name as cache keys and messages use it: lower-cased, port kept. -// github.com, www.github.com and SSH aliases like "github.com-work" (a -// common ~/.ssh/config trick for multiple accounts) are all github.com. +// A host name as cache keys and messages use it: lower-cased, port kept +// unless it's the https default (443). github.com, www.github.com, +// ssh.github.com (GitHub's SSH-over-443 host) and SSH aliases like +// "github.com-work" (a common ~/.ssh/config trick for multiple accounts) +// are all github.com. export function normalizeHost(host) { - const h = String(host ?? "").trim().toLowerCase(); - if (h === "www.github.com" || /^github\.com(-[^.:]+)?$/.test(h)) return DOTCOM; + const h = String(host ?? "").trim().toLowerCase().replace(/:443$/, ""); + if (h === "www.github.com" || h === "ssh.github.com" || /^github\.com(-[^.:]+)?$/.test(h)) return DOTCOM; return h; } diff --git a/lib/mcp-tools.mjs b/lib/mcp-tools.mjs index 1ef0bb8..d5e9d9e 100644 --- a/lib/mcp-tools.mjs +++ b/lib/mcp-tools.mjs @@ -127,27 +127,34 @@ export function resolveContext(args = {}, { dir = projectDir() } = {}) { const candidates = []; const cacheFile = cachePath(owner, repo, host); if (fileKey(cacheFile)) candidates.push({ source: "cache", dataPath: cacheFile, fetchedAt: peekFetchedAt(cacheFile) }); - if (corpusMeta?.owner === owner && corpusMeta?.repo === repo && normalizeHost(corpusMeta.host ?? DOTCOM) === host) { + if (sameName(corpusMeta?.owner, owner) && sameName(corpusMeta?.repo, repo) && normalizeHost(corpusMeta.host ?? DOTCOM) === host) { candidates.push({ source: "corpus", dataPath: corpusFile, fetchedAt: corpusMeta.last_fetched_at ?? corpusMeta.fetched_at ?? null }); } candidates.sort((a, b) => (Date.parse(b.fetchedAt ?? 0) || 0) - (Date.parse(a.fetchedAt ?? 0) || 0)); return { dir, owner, repo, host, ...(candidates[0] ?? { source: null, dataPath: null, fetchedAt: null }) }; } +// Owner and repository names are case-insensitive on GitHub and GitLab. +const sameName = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); + // Caches above this size are indexed in a child process (lib/index-builder.mjs): // parsing them in the server would leave it holding a gigabyte or more. const CHILD_BUILD_BYTES = 20 * 1024 * 1024; -export function buildInChild(dataPath, rules) { +// Settles in every case: a child that dies without sending the index (out +// of memory, killed) rejects, so the memo entry is dropped and the next call +// retries instead of waiting forever. `script` is for tests. +export function buildInChild(dataPath, rules, { script = new URL("./index-builder.mjs", import.meta.url) } = {}) { return new Promise((resolve, reject) => { - const child = fork(new URL("./index-builder.mjs", import.meta.url), [], { + const child = fork(script, [], { serialization: "advanced", // keeps the index's Maps and Sets stdio: ["ignore", "ignore", "inherit", "ipc"], // stdout is the MCP channel execArgv: [], }); child.once("message", resolve); child.once("error", reject); - child.once("exit", (code) => { if (code) reject(new Error(`index build failed (exit ${code})`)); }); + // "close" comes after any message the child sent; resolve() already won then. + child.once("close", (code, signal) => reject(new Error(`index build failed (${signal ?? `exit ${code}`})`))); child.send({ dataPath, rules }); }); } @@ -238,7 +245,7 @@ export function listRepos(dir = projectDir()) { if (existsSync(hostsDir)) { for (const h of readdirSync(hostsDir).sort()) repos.push(...cachedRepos(join(hostsDir, h), h.replace(/_(\d+)$/, ":$1"))); } - const same = (r, o, rp, h) => r.owner === o && r.repo === rp && (r.host ?? DOTCOM) === (h ?? DOTCOM); + const same = (r, o, rp, h) => sameName(r.owner, o) && sameName(r.repo, rp) && (r.host ?? DOTCOM) === (h ?? DOTCOM); const corpus = loadCorpus(dir)?.meta; if (corpus?.owner && !repos.some((r) => same(r, corpus.owner, corpus.repo, corpus.host))) { repos.push({ owner: corpus.owner, repo: corpus.repo, ...(corpus.host ? { host: corpus.host } : {}), source: "committed corpus" }); diff --git a/lib/providers/gitlab.mjs b/lib/providers/gitlab.mjs index 61f691e..17097f6 100644 --- a/lib/providers/gitlab.mjs +++ b/lib/providers/gitlab.mjs @@ -12,26 +12,47 @@ // add weight to nothing). // // Tokens: --token, GITLAB_TOKEN, GITLAB_ACCESS_TOKEN (scope read_api), sent -// as PRIVATE-TOKEN. Never read from glab's or any other credential store. +// as `Authorization: Bearer`, which fetch drops on a redirect to another +// host (a custom PRIVATE-TOKEN header would follow it). The environment +// tokens only go to gitlab.com, the host named in GITLAB_HOST (glab's +// variable), or the instance a GitLab CI job runs on — never to whatever +// GitLab-looking host a checkout's origin names. Never read from glab's or +// any other credential store. import { isWithinRange } from "../filters.mjs"; import { retryDecision } from "../rate-limit.mjs"; import { createProgress } from "../progress.mjs"; import { buildOutput } from "../output.mjs"; +import { normalizeHost } from "../github-host.mjs"; const PER_PAGE = 100; export const gitlabApiUrl = (host) => `https://${host}/api/v4`; -export function resolveGitLabToken({ token, env = process.env } = {}) { +const GITLAB_COM = "gitlab.com"; +const bareHost = (value) => normalizeHost(String(value ?? "").replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "")); + +// Hosts the environment's GitLab tokens may be sent to. +function envTokenHosts(env) { + const hosts = new Set([GITLAB_COM]); + if (env.GITLAB_HOST) hosts.add(bareHost(env.GITLAB_HOST)); + if (env.GITLAB_CI === "true" && env.CI_SERVER_HOST) hosts.add(bareHost(env.CI_SERVER_HOST)); + return hosts; +} + +export function resolveGitLabToken({ token, host = GITLAB_COM, env = process.env } = {}) { if (token) return { token, source: "flag" }; + if (!envTokenHosts(env).has(normalizeHost(host))) return null; if (env.GITLAB_TOKEN) return { token: env.GITLAB_TOKEN, source: "GITLAB_TOKEN" }; if (env.GITLAB_ACCESS_TOKEN) return { token: env.GITLAB_ACCESS_TOKEN, source: "GITLAB_ACCESS_TOKEN" }; return null; } export const noGitLabTokenMessage = (host) => - `No token for ${host}. Set GITLAB_TOKEN to a personal access token with the read_api scope, or pass --token. ` + + (normalizeHost(host) === GITLAB_COM + ? `No token for ${host}. Set GITLAB_TOKEN to a personal access token with the read_api scope, or pass --token. ` + : `No token for ${host}. Set GITLAB_TOKEN to a personal access token with the read_api scope and GITLAB_HOST=${host} ` + + "(GITLAB_TOKEN alone is only sent to gitlab.com), or pass --token. ") + "GitLab needs one even for a public project: it doesn't serve merge-request comments anonymously."; // A transport: getPage(path, query) → { data, next } where `next` is the @@ -44,7 +65,7 @@ export function createGitLabTransport({ host, token, baseUrl = gitlabApiUrl(host stats.requests++; let res; try { - res = await fetchImpl(url, { headers: { "PRIVATE-TOKEN": token, Accept: "application/json", "User-Agent": "reviewlore" } }); + res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "User-Agent": "reviewlore" } }); } catch (err) { throw Object.assign(new Error(`fetch failed: ${err.message}`), { cause: err }); } @@ -135,7 +156,9 @@ export function mapDiscussions(mr, discussions, since) { // → { issueComments, reviewComments, reviewSubmissions, prAuthors }, the // arrays buildOutput takes. `project` is the URL-encoded path. -export async function fetchGitLab(transport, { repoPath, since, onProgress, concurrency = 4 }) { +// `keepSince` (the window start) decides which notes to keep; `since`, which +// MRs to list (updated since) — they differ on an incremental fetch. +export async function fetchGitLab(transport, { repoPath, since, keepSince = since, onProgress, concurrency = 4 }) { const project = encodeURIComponent(repoPath); const mrs = (await mergeRequests(transport, project, since)).filter((mr) => isWithinRange(mr.updated_at, since)); onProgress?.({ stream: "comments", count: 0, fraction: 1 }); @@ -153,7 +176,7 @@ export async function fetchGitLab(transport, { repoPath, since, onProgress, conc const mr = mrs[next++]; try { const discussions = await allPages(transport, `/projects/${project}/merge_requests/${mr.iid}/discussions`, {}); - const { issue, review } = mapDiscussions(mr, discussions, since); + const { issue, review } = mapDiscussions(mr, discussions, keepSince); issueComments.push(...issue); reviewComments.push(...review); } catch (err) { @@ -173,14 +196,18 @@ export async function fetchGitLab(transport, { repoPath, since, onProgress, conc // Fetch and shape a GitLab project's review history, like fetchCorpus does // for GitHub (progress on `out`, corpus shape from lib/output.mjs). No // resume yet: a GitLab fetch runs in one go. -export async function fetchGitLabCorpus(transport, { owner, repo, host, since, monthsBack, out, now = () => Date.now() }) { +export async function fetchGitLabCorpus(transport, { owner, repo, host, since, keepSince = since, monthsBack, out, now = () => Date.now() }) { + // The next incremental fetch starts from here: notes written while this + // one runs must fall after it, as in fetchCorpus. + const startedAt = new Date(now()).toISOString(); const progress = createProgress({ out, now, stats: transport.stats }); const data = await fetchGitLab(transport, { - repoPath: `${owner}/${repo}`, since, + repoPath: `${owner}/${repo}`, since, keepSince, onProgress: (e) => progress.update(e), }); progress.finish(); const output = buildOutput({ owner, repo, host, since, monthsBack, ...data }); output.meta.provider = "gitlab"; + output.meta.fetched_at = startedAt; return output; } diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs index 6e7a569..94e02ef 100644 --- a/lib/providers/index.mjs +++ b/lib/providers/index.mjs @@ -17,7 +17,7 @@ export const MISSING_DEPS = // → { fetch(opts) → corpus } | { error } export async function openFetcher(where, { token, env = process.env } = {}) { if (where.provider === "gitlab") { - const resolved = resolveGitLabToken({ token, env }); + const resolved = resolveGitLabToken({ token, host: where.host, env }); if (!resolved) return { error: noGitLabTokenMessage(where.host) }; const transport = createGitLabTransport({ host: where.host, token: resolved.token, baseUrl: where.baseUrl }); return { fetch: (opts) => fetchGitLabCorpus(transport, { ...opts, owner: where.owner, repo: where.repo, host: where.host }) }; diff --git a/lib/renderers/agents.mjs b/lib/renderers/agents.mjs index 727e1af..36ba614 100644 --- a/lib/renderers/agents.mjs +++ b/lib/renderers/agents.mjs @@ -3,7 +3,10 @@ // agent-capable tool reads — OpenAI Codex, Continue, Aider, several // others honour it. // -// Single file at /AGENTS.md with a managed block. +// Single file at /AGENTS.md with a managed block. Its markers +// differ from `reviewlore export --to agents` (), +// so the two never overwrite each other's section; a block from before +// they were split is adopted only if export didn't write it. import { writeFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -12,8 +15,10 @@ import { normalizeAll } from "./_common.mjs"; export const name = "agents"; export const describe = "AGENTS.md (cross-tool standard): managed section in /AGENTS.md"; -const MARKER_START = ""; -const MARKER_END = ""; +const MARKER_START = ""; +const MARKER_END = ""; +const LEGACY_RE = /(?:(?!)[\s\S])*/; +const EXPORTED = "[\s\S]*/)[0]; + writeAgents([cluster], { targetDir: dir, force: true }); + const both = readFileSync(join(dir, "AGENTS.md"), "utf-8"); + assert.ok(both.includes(exported), "the exported section survives"); + assert.match(both, //); + // And the other way round: re-exporting leaves the generated section. + exportTo(dir, ["agents"]); + assert.match(readFileSync(join(dir, "AGENTS.md"), "utf-8"), //); + // A cursor file export wrote is kept even with --force. + const mdc = join(dir, ".cursor/rules/pr-feedback-document-new-options-and-features.mdc"); + const before = readFileSync(mdc, "utf-8"); + const out = writeCursor([cluster], { targetDir: dir, force: true }); + assert.equal(readFileSync(mdc, "utf-8"), before); + assert.equal(out.written.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("re-exporting keeps $ in rule text literal", () => { + const dir = repo(); + try { + const file = join(dir, ".claude/rules/pr-feedback-document-new-options-and-features.md"); + writeFileSync(file, readFileSync(file, "utf-8").replace(/^(When a PR adds [^\n]*)$/m, (line) => `${line} Write \`$$HOME\`, \`$&\` and \`$'\\t'\` literally.`)); + exportTo(dir, ["agents"]); + exportTo(dir, ["agents"]); + assert.match(readFileSync(join(dir, "AGENTS.md"), "utf-8"), /Write `\$\$HOME`, `\$&` and `\$'\\t'` literally\./); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/fetchers.test.mjs b/test/fetchers.test.mjs index 10e20b5..aa3c27d 100644 --- a/test/fetchers.test.mjs +++ b/test/fetchers.test.mjs @@ -8,6 +8,8 @@ import { fetchReviewSubmissionsGraphQL, fetchAll, windowFraction, + emptyStreams, + FetchInterrupted, } from "../lib/fetchers.mjs"; import { fakeGraphQL, isBatch, isList } from "./helpers/fake-graphql.mjs"; @@ -311,3 +313,31 @@ test("GraphQL reviews: batches run in parallel (at most 4), deleted PRs are skip assert.equal(r.reviews.length, 200 - 8, "one skipped PR per batch"); assert.equal(Object.keys(r.prAuthors).length, 200); }); + +test("a PR first listed by an incremental fetch keeps its older in-window reviews", async () => { + const SINCE = new Date("2026-01-01T00:00:00Z"); + const day = (n) => new Date(SINCE.getTime() + n * 86400000).toISOString(); + // 250 PRs, newest-updated first, each with one review on its update day. + let prs = Array.from({ length: 250 }, (_, i) => ({ + number: 250 - i, title: `PR ${250 - i}`, updatedAt: day(200 - i * 0.5), author: { login: "a", __typename: "User" }, + reviews: [{ id: 1000 + i, url: `u${i}`, state: "COMMENTED", body: "please fix", submittedAt: day(200 - i * 0.5), author: { login: "rv", __typename: "User" } }], + })); + const octokit = { graphql: (q, v) => fakeGraphQL(prs).graphql(q, v) }; + // The first fetch lists one page, then its time budget runs out… + const state = emptyStreams().reviews; + await assert.rejects( + fetchReviewSubmissionsGraphQL(octokit, { owner: "o", repo: "r", since: SINCE, state, shouldStop: () => true }), + FetchInterrupted, + ); + // …PR #1, not listed yet, is updated while it's paused, so it jumps ahead + // of the listing cursor and the resumed run never sees it. + const pr1 = prs.find((p) => p.number === 1); + prs = [{ ...pr1, updatedAt: day(202) }, ...prs.filter((p) => p !== pr1)]; + const resumed = await fetchReviewSubmissionsGraphQL(octokit, { owner: "o", repo: "r", since: SINCE, state }); + assert.equal(resumed.reviews.length, 249); + // The next incremental fetch lists it (updated after the first run + // started) and keeps its review from day 75.5, inside the window. + const startedAt = new Date(day(201)); + const next = await fetchReviewSubmissionsGraphQL(octokit, { owner: "o", repo: "r", since: startedAt, keepSince: SINCE, state: emptyStreams().reviews }); + assert.deepEqual(next.reviews.map((r) => r.pr_number), [1]); +}); diff --git a/test/github-client.test.mjs b/test/github-client.test.mjs index 03b752b..39ef4ea 100644 --- a/test/github-client.test.mjs +++ b/test/github-client.test.mjs @@ -49,6 +49,12 @@ test("resolveToken: per-host order, and no cross-host leaks", () => { const actions = { GITHUB_ACTIONS: "true", GITHUB_TOKEN: "actions", GITHUB_API_URL: "https://ghe.corp/api/v3" }; assert.equal(resolveToken({ host: "ghe.corp", env: actions, gh: none }).token, "actions"); assert.equal(resolveToken({ host: "other.corp", env: actions, gh: none }), null); + // Hosts --api-url can name that aren't recognisably GitHub (a dotless + // intranet name, a GitLab-looking host) are enterprise servers too. + for (const host of ["ghes:8080", "git-internal", "gitlab.acme.io"]) { + assert.equal(resolveToken({ host, env: { GITHUB_TOKEN: "dotcom", GH_TOKEN: "dotcom" }, gh: none }), null, host); + assert.equal(resolveToken({ host, env: { GITHUB_TOKEN: "dotcom", GH_ENTERPRISE_TOKEN: "corp" }, gh: none }).token, "corp", host); + } }); test("noTokenMessage names the host and the variable, never a value", async () => { diff --git a/test/github-host.test.mjs b/test/github-host.test.mjs index d5f8bdc..f9c4b88 100644 --- a/test/github-host.test.mjs +++ b/test/github-host.test.mjs @@ -5,6 +5,10 @@ import { normalizeHost, hostKind, apiBaseUrl, resolveHost, hostDirName } from ". test("hosts: kinds and API base URLs", () => { assert.equal(normalizeHost("WWW.GitHub.com"), "github.com"); assert.equal(normalizeHost("github.com-work"), "github.com"); + assert.equal(normalizeHost("ssh.github.com"), "github.com", "GitHub's SSH-over-443 host"); + assert.equal(normalizeHost("github.com:443"), "github.com"); + assert.equal(normalizeHost("ghe.acme.corp:443"), "ghe.acme.corp"); + assert.equal(normalizeHost("ghe.acme.corp:8443"), "ghe.acme.corp:8443"); assert.equal(hostKind("github.com"), "dotcom"); assert.equal(hostKind("acme.ghe.com"), "ghe.com"); assert.equal(hostKind("ghe.acme.corp"), "ghes"); diff --git a/test/gitlab.test.mjs b/test/gitlab.test.mjs index 2efd23b..59f361e 100644 --- a/test/gitlab.test.mjs +++ b/test/gitlab.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, writeFileSync, rmSync, realpathSync, mkdirSync } from "nod import { tmpdir } from "node:os"; import { join, resolve, dirname, delimiter } from "node:path"; import { spawnSync } from "node:child_process"; -import { mapDiscussions, createGitLabTransport, fetchGitLab, resolveGitLabToken } from "../lib/providers/gitlab.mjs"; +import { mapDiscussions, createGitLabTransport, fetchGitLab, fetchGitLabCorpus, resolveGitLabToken, noGitLabTokenMessage } from "../lib/providers/gitlab.mjs"; import { extractCandidates } from "../lib/extract.mjs"; import { buildOutput } from "../lib/output.mjs"; import { rawComments } from "../lib/raw.mjs"; @@ -43,7 +43,7 @@ function fakeFetch() { const calls = []; let limited = false; const fn = async (url, init) => { - calls.push({ url: String(url), token: init.headers["PRIVATE-TOKEN"] }); + calls.push({ url: String(url), auth: init.headers.Authorization }); const u = new URL(url); const json = (body, headers = {}) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", ...headers } }); if (!limited) { @@ -68,7 +68,7 @@ test("GitLab transport: token header, pagination, 429 retry; fetchGitLab end to const transport = createGitLabTransport({ host: "gitlab.com", token: "glpat-xxx", fetchImpl: fn, sleep: async (ms) => waits.push(ms) }); const out = await fetchGitLab(transport, { repoPath: "group/sub/proj", since: SINCE }); assert.deepEqual(waits, [1000]); - assert.ok(calls.every((c) => c.token === "glpat-xxx")); + assert.ok(calls.every((c) => c.auth === "Bearer glpat-xxx")); assert.ok(calls.some((c) => c.url.includes("updated_after=2026-08-01"))); assert.deepEqual(Object.keys(out.prAuthors).sort(), ["7", "8"], "MR 3 is older than the window"); assert.equal(out.issueComments.length, 3); @@ -93,6 +93,48 @@ test("GitLab errors carry the status and GitLab's message, never the token", asy assert.equal(resolveGitLabToken({ env: { GITHUB_TOKEN: "gh" } }), null, "a GitHub token is never used for GitLab"); }); +test("GitLab environment tokens go only to gitlab.com, GITLAB_HOST, or the CI job's own instance", () => { + const env = { GITLAB_TOKEN: "env-token" }; + assert.equal(resolveGitLabToken({ host: "gitlab.com", env }).token, "env-token"); + // A checkout whose origin names some other GitLab-looking host doesn't get it… + assert.equal(resolveGitLabToken({ host: "gitlab.evil.io", env }), null); + assert.match(noGitLabTokenMessage("gitlab.acme.corp"), /GITLAB_HOST=gitlab\.acme\.corp/); + // …unless the user named that host, or a GitLab CI job runs there. + assert.equal(resolveGitLabToken({ host: "gitlab.acme.corp", env: { ...env, GITLAB_HOST: "https://gitlab.acme.corp/" } }).token, "env-token"); + assert.equal(resolveGitLabToken({ host: "git.acme.corp", env: { ...env, GITLAB_CI: "true", CI_SERVER_HOST: "git.acme.corp" } }).token, "env-token"); + assert.equal(resolveGitLabToken({ host: "git.acme.corp", env: { ...env, CI_SERVER_HOST: "git.acme.corp" } }), null, "CI_SERVER_HOST counts only inside GitLab CI"); + assert.equal(resolveGitLabToken({ host: "gitlab.evil.io", token: "flag", env }).token, "flag", "--token is the user's explicit choice"); +}); + +test("a GitLab redirect to another host doesn't carry the token", async () => { + const http = await import("node:http"); + const listen = (s) => new Promise((r) => s.listen(0, "127.0.0.1", () => r(s.address().port))); + let seen = null; + const other = http.createServer((req, res) => { seen = req.headers; res.setHeader("content-type", "application/json"); res.end("[]"); }); + const otherPort = await listen(other); + // An SSO gateway or proxy in front of a self-managed GitLab. + const gitlab = http.createServer((req, res) => { res.statusCode = 302; res.setHeader("location", `http://localhost:${otherPort}/login`); res.end(); }); + const port = await listen(gitlab); + try { + const transport = createGitLabTransport({ host: "x", token: "glpat-redirect-test", baseUrl: `http://127.0.0.1:${port}/api/v4` }); + await transport.getPage("/projects/g%2Fp/merge_requests"); + assert.ok(seen, "the redirect was followed"); + assert.doesNotMatch(JSON.stringify(seen), /glpat-redirect-test/); + } finally { + other.close(); + gitlab.close(); + } +}); + +test("a GitLab corpus is stamped with the time its fetch started", async () => { + let t = Date.parse("2026-10-01T00:00:00Z"); + const now = () => (t += 60_000); + const { fn } = fakeFetch(); + const transport = createGitLabTransport({ host: "gitlab.com", token: "glpat-xxx", fetchImpl: fn, sleep: async () => {} }); + const out = await fetchGitLabCorpus(transport, { owner: "group/sub", repo: "proj", host: "gitlab.com", since: SINCE, monthsBack: 6, out: { write() {}, isTTY: false }, now }); + assert.equal(out.meta.fetched_at, "2026-10-01T00:01:00.000Z"); +}); + test("CLI on a GitLab checkout: committed corpus without a token; clear message without one", () => { const dir = realpathSync(mkdtempSync(join(tmpdir(), "gitlab-cli-"))); try { diff --git a/test/review-index.test.mjs b/test/review-index.test.mjs index 29c3b28..c29d73c 100644 --- a/test/review-index.test.mjs +++ b/test/review-index.test.mjs @@ -72,6 +72,11 @@ test("parseRuleText reads paths, title, body, links and provenance", () => { const hand = parseRuleText("---\npaths: [\"docs/**\", 'site/**']\n---\n# Docs tone\nWrite in second person.\n", "x.md"); assert.deepEqual(hand.paths, ["docs/**", "site/**"]); assert.equal(hand.generated, false); + // PR links on GitHub Enterprise and GitLab count too; other links don't. + const hosts = parseRuleText( + "# T\n\n- > a\n — @x on PR #1: https://ghe.corp/acme/app/pull/1#discussion_r1\n" + + "- > b\n — @y on PR #2: https://gitlab.com/g/sub/p/-/merge_requests/2#note_2\n\nSee https://example.com/docs.\n", "x.md"); + assert.deepEqual(hosts.urls, ["https://ghe.corp/acme/app/pull/1#discussion_r1", "https://gitlab.com/g/sub/p/-/merge_requests/2#note_2"]); }); test("searchIndex: rules first, then comments by BM25, with honest support", () => { @@ -196,6 +201,23 @@ test("an index built in a child process equals one built in-process", async () = } }); +test("a child that dies without sending the index rejects instead of hanging", async () => { + const { buildInChild } = await import("../lib/mcp-tools.mjs"); + const dir = mkdtempSync(join(tmpdir(), "child-dies-")); + try { + // As when the builder runs out of memory or is killed: no message, a signal. + const killed = join(dir, "killed.mjs"); + writeFileSync(killed, 'process.on("message", () => process.kill(process.pid, "SIGKILL"));\n'); + await assert.rejects(buildInChild("unused.json", [], { script: killed }), /index build failed \(SIGKILL\)/); + // A clean exit with no message too. + const quits = join(dir, "quits.mjs"); + writeFileSync(quits, 'process.on("message", () => process.exit(0));\n'); + await assert.rejects(buildInChild("unused.json", [], { script: quits }), /index build failed \(exit 0\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("replies in threads a bot started are not review feedback", () => { const data = corpus(); const bot = rc("coderabbitai[bot]", 4, "src/api/users.ts", "Consider extracting this into a helper function for reuse.", 10, { author_type: "Bot" }); diff --git a/test/rules.test.mjs b/test/rules.test.mjs index f5de6e5..af71bbe 100644 --- a/test/rules.test.mjs +++ b/test/rules.test.mjs @@ -113,6 +113,26 @@ test("status: refresh old quotes, paths gone, deleted, edited, unmanaged, contra } }); +test("status: older matches outside the window don't crowd out recent support", () => { + const dir = mkdtempSync(join(tmpdir(), "rules-crowd-")); + try { + const comment = (i, date, body) => ({ type: "review_comment", comment_id: i, pr_number: i, author: `rv${i % 9}`, author_type: "User", body, path: "src/a.ts", line: 1, created_at: date, html_url: `https://github.com/o/r/pull/${i}#discussion_r${i}` }); + // 60 short, high-scoring matches from long ago; 3 longer recent ones. + const old = Array.from({ length: 60 }, (_, i) => comment(i + 1, "2025-01-10T00:00:00Z", "Please add a changeset here.")); + const recent = [100, 101, 102].map((i) => comment(i, "2026-10-01T00:00:00Z", `Please add a changeset for this package change, the release tooling in this monorepo needs one for every published package ${i}.`)); + const index = buildIndex({ meta: { pr_authors: {} }, issue_comments: [], review_comments: [...old, ...recent], review_submissions: [] }); + const file = "pr-feedback-add-a-changeset.md"; + mkdirSync(join(dir, ".claude/rules"), { recursive: true }); + writeFileSync(join(dir, ".claude/rules", file), "\n\n# Add a changeset\n\nAdd one.\n"); + const manifest = { version: 1, rules: { [file]: { title: "Add a changeset", sha256: "x", evidence: [], generator: "learn-from-reviews", query: "changeset" } }, dismissed: [] }; + const [r] = ruleStatus({ targetDir: dir, manifest, index, tracked: ["src/a.ts"], months: 6, now: Date.parse("2026-10-28T00:00:00Z") }).rules; + assert.equal(r.recent_support.prs, 3); + assert.ok(!r.flags.includes("no-recent-support")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("reviewlore rules: record --all, status exits 0, dismiss and retire need the file gone", () => { const dir = repo({ ".claude/rules/pr-feedback-add-a-changeset.md": rule("Add a changeset", null, [["Please add a changeset for this.", "alice", 1, 1], ["This needs a changeset too.", "bob", 2, 2]]) }); const cache = mkdtempSync(join(tmpdir(), "rules-cache-")); diff --git a/test/skill-writer.test.mjs b/test/skill-writer.test.mjs index d560759..3d93a8a 100644 --- a/test/skill-writer.test.mjs +++ b/test/skill-writer.test.mjs @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { renderRule, writeSkills, renderSummary, yamlQuote } from "../lib/skill-writer.mjs"; +import { renderRule, renderLLMRule, writeSkills, renderSummary, yamlQuote } from "../lib/skill-writer.mjs"; +import { parseRuleFile } from "../lib/rule-files.mjs"; const sampleCluster = { label: ["test", "missing", "edge"], @@ -125,3 +126,20 @@ test("writeSkills never overwrites a plugin-written rule, even with force", () = rmSync(target, { recursive: true, force: true }); } }); + +test("--llm rules keep their examples readable, like heuristic ones", () => { + const items = [1, 2, 3].map((n) => ({ sentence: `Please add a changeset for this change ${n}.`, author: `rv${n}`, pr: n, url: `https://github.com/o/r/pull/${n}#discussion_r${n}`, path: "src/a.ts", createdAt: "2026-05-01T00:00:00Z" })); + const cluster = { label: ["changeset"], items, paths: ["src/a.ts"], occurrences: 3, distinctAuthors: 3, distinctPRs: 3, authors: ["rv1", "rv2", "rv3"], kind: "request" }; + const synth = { name: "Add a changeset", rule: "Add a changeset.", why: "Releases.", when_to_apply: "Always." }; + for (const { slug, body } of [renderRule(cluster), renderLLMRule(cluster, synth)]) { + assert.equal(parseRuleFile(body, `pr-feedback-${slug}.md`).examples.length, 3, slug); + } +}); + +test("a file name cut to length never ends in a dash", () => { + const cluster = { label: ["x"], items: [{ sentence: "x", author: "a", pr: 1, url: "u" }], paths: [], occurrences: 3, distinctAuthors: 2, distinctPRs: 2, authors: ["a", "b"] }; + // The 60-character cut lands right after "translation". + const { slug, body } = renderLLMRule(cluster, { name: "Internationalization placeholders need reviewed translation everywhere", rule: "r", why: "w", when_to_apply: "a" }); + assert.doesNotMatch(slug, /-$/); + assert.ok(parseRuleFile(body, `pr-feedback-${slug}.md`)); +});