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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>`.
token is never used for GitLab. For a self-managed GitLab, also set
`GITLAB_HOST=<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 <host>` too.

### GitHub Enterprise: certificate errors ("unable to get local issuer certificate")

Expand Down
2 changes: 1 addition & 1 deletion cli/commands/fetch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
12 changes: 12 additions & 0 deletions cli/commands/generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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`);
Expand Down
19 changes: 16 additions & 3 deletions cli/reviewlore.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "." });
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 14 additions & 6 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` |
| GitHub Enterprise Server | `--token`, `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`, `GITHUB_TOKEN` only inside Actions on that same server, `gh auth token --hostname <host>` |
| 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 <host>` |

If you use the GitHub CLI, `gh auth login --hostname <host>` is all it
takes. Public repos need the `public_repo` scope; private repos need
Expand All @@ -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
Expand Down Expand Up @@ -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 (`<!-- reviewlore:start -->` in markdown, `# >>>
reviewlore start` in `.windsurfrules`); text outside the markers
is never touched.
comments (`<!-- reviewlore:start -->` in markdown,
`<!-- reviewlore:generated:start -->` 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:

Expand Down Expand Up @@ -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`) |
Expand Down
2 changes: 1 addition & 1 deletion lib/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion lib/export.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading