Repissue: the half of a repo an agent never sees
A CLI that packs a repository's open issues and pull requests into one file you can hand to a model. The pipeline, the code, and the parts of GitHub's API that fought back.
contents
- One command
- Three shapes of the same data
- The pipeline
- Five requests in flight, no semaphore
- The character class that ate real comments
- Sorting so the important thing is first
- Appending to a file you did not write
- Warning about a secret without leaking it
- Decisions I would defend
- Dependency injection, only where it buys something
- Zod at every boundary
- Config layers in one direction
- Extensions resolve themselves
- A token estimate can never fail the run
- Tests
- What GitHub taught me on the way
- Where it landed
I use coding agents every day, and the same thing kept bothering me. They know the code down to the line and nothing at all about the project. The forty-seven open bugs, the twelve pull requests in review, the argument in issue #312 that explains why a function looks the way it does: none of that lives in the source tree, so none of it reaches the model.
The cost shows up fast. The agent proposes a fix for a bug someone is already fixing in an open PR, or recommends the exact approach the team rejected last month with reasons.
Repomix already solved the code half. It packs a repository into one structured file you drop into a context window. Repissue is the other half.
repomix output→ everything the code is
repissue output→ everything that needs to change
One command#
npx repissue facebook/reactPacked 47 issues and 12 PRs
Issues: 47
PRs: 12
Tokens: ~28,400
repissue-output.mdSet a token first
Unauthenticated GitHub allows 60 requests per hour, shared across your whole machine. One run
against a repo with 50 open issues eats all of it. export GITHUB_TOKEN=... raises the ceiling to
5,000 and the problem disappears.
Three shapes of the same data#
| Style | What it looks like | Reach for it when |
|---|---|---|
markdown | Collapsible <details> per item, label badges, inline images stripped | Default. A human might read it too |
plain | ASCII dividers, nothing a renderer can reinterpret | The consuming tool is fussy about markup |
xml | Escaped, nested, machine shaped | Pairing with a Repomix XML snapshot |
<?xml version="1.0" encoding="UTF-8"?>
<repissue>
<metadata>
<repository>facebook/react</repository>
<open_issues>47</open_issues>
<open_prs>12</open_prs>
</metadata>
<issues count="47">
<issue number="312">
<title>Auth token not refreshed on 401</title>
...
</issue>
</issues>
</repissue>The pairing it was built for
--append-to puts the issues block inside a file Repomix already wrote, so the agent gets one
file instead of two.
repomix && npx repissue owner/repo --append-to repomix-output.xmlThe pipeline#
pack() runs every call through nine stages, sequential and explicit. Each does one thing and hands
its output to the next, so when something comes out wrong you know which file to open.
| Stage | Does | Lives in |
|---|---|---|
| 1. Fetch | Issues and PRs concurrently, then comments | core/github/* |
| 2. Filter | Drops bots and reaction-only comments | core/filter/noiseFilter.ts |
| 3. Sort | Label priority, then activity | core/filter/labelSort.ts |
| 4. Enrich | Attaches comments and cross references | core/github/crossRefParse.ts |
| 5. Render | Handlebars, one of three styles | core/output/outputGenerate.ts |
| 6. Write | File, stdout, append, or split | core/output/* |
| 7. Count | tiktoken cl100k_base in a worker | core/metrics/TokenCounter.ts |
| 8. Scan | 14 secret patterns, opt in | core/security/securityScan.ts |
| 9. Copy | Native clipboard tool, opt in | core/clipboard/clipboardCopy.ts |
Most of those are twenty boring lines. The five below are the ones I actually had to think about.
Five requests in flight, no semaphore#
Comments are a request per item, so a repo with 47 issues is 47 round trips. Firing them all at once gets you rate limited; firing them one at a time takes a minute. I wanted exactly five in flight at all times, which is a queue and some workers draining it:
const runBounded = async <T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<T[]> => {
const results: T[] = new Array(tasks.length);
const queue = tasks.map((task, index) => ({ task, index }));
const worker = async (): Promise<void> => {
let item = queue.shift();
while (item !== undefined) {
const { task, index } = item;
results[index] = await task();
item = queue.shift();
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
return results;
};Fifteen lines, no dependency, and the index carried through the queue keeps results in the order
they were requested instead of the order they came back. Every semaphore library I looked at was
solving a harder problem than the one I had.
The character class that ate real comments#
Raw GitHub threads are mostly noise. Dependabot and renovate post more than the humans do, and a comment that says "+1" costs tokens while carrying nothing. So: strip every reaction token, then check whether anything substantive is left.
My first version put the vote shorthand in a character class. It looked fine and it was quietly wrong:
const cleaned = body
.replace(/[+\-1]/g, '')
.replace(/\+1|-1/g, '')
.replace(/[\u{1F000}-\u{1FFFF}]/gu, '') // emoji (supplementary plane)
.replace(/[\u{2600}-\u{27FF}]/gu, '') // misc symbols & dingbats
.replace(/[\u{FE00}-\u{FEFF}]/gu, '') // variation selectors
.replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '') // skin-tone modifiers
.trim();
return cleaned === '';Why the first line was a bug
[+\-1] matches those characters anywhere, individually. A comment reading 111 or +++
reduces to an empty string, so a real report gets dropped as a reaction. Alternation matches the
two tokens I actually meant.
Bot detection has a similar shape, where the general rule beats the list:
if (login.endsWith('[bot]')) return true;
return knownBots.includes(login);knownBots defaults to dependabot, renovate and github-actions, but every GitHub bot account ends
in [bot], so the suffix check catches the ones nobody listed.
Sorting so the important thing is first#
A model reads top down and a context window runs out. Order is not cosmetic here: whatever is at the bottom of the file is what effectively gets skimmed.
Labels decide the tier, and position in labelPriority decides the score, so bug outranks
security outranks P0 by default and a config change reorders them without touching code. Then
the tie breaks:
[...issues].sort((a, b) => {
const scoreDiff = computeLabelScore(b.labels, labelPriority) - computeLabelScore(a.labels, labelPriority);
if (scoreDiff !== 0) return scoreDiff;
const commentDiff = b.comments - a.comments;
if (commentDiff !== 0) return commentDiff;
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});| Tier break | Issues | Pull requests |
|---|---|---|
| First | Label score, descending | Label score, descending |
| Then | Comment count, descending | Non-draft before draft |
| Then | Created, ascending | Updated, descending |
Comment count descending because the thread nobody has settled is the one carrying the argument. Then oldest first, because an issue that survived a year is a different signal from one opened yesterday. PRs invert that last one: a stale PR is noise, a PR touched this morning is in flight. And the sort spreads into a new array, so nothing downstream sees a reordered input.
Appending to a file you did not write#
--append-to drops the issues block into an existing Repomix output. For markdown that is a
concatenation. For XML, appending after the closing root tag gives you a document with two roots,
which is not XML anymore. So find the last closing tag and insert in front of it:
// Match the last closing XML tag in the document
const lastClosingTagMatch = existing.match(/(<\/\w[\w.-]*>\s*)$/);
if (lastClosingTagMatch !== null) {
const insertionIndex = existing.lastIndexOf(lastClosingTagMatch[0]);
// insert the append block here
}
// No closing root tag found, plain append as safe fallbackIt is a heuristic and the comment in the source says so. A regex is not an XML parser, and pulling one in to find a single tag position would have been the wrong trade. When the pattern misses, the fallback is a plain append: the same result the naive version would have given, never a crash.
--split-output has the other awkward shape. Chunking runs in two passes, because "Part 3 of 7"
cannot be written until you know there are seven: bin the items using an estimated preamble size,
then build the real content once the count is known.
Warning about a secret without leaking it#
--security-check walks the rendered output line by line against 14 patterns: GitHub tokens classic
and fine grained, OAuth and server-to-server tokens, AWS keys, PEM headers, sk- style keys, Slack,
Stripe, SendGrid, bearer headers, and generic password assignments. Every pattern has exactly one
capture group, which is the part that gets hidden:
export const redact = (value: string): string => {
const visibleChars = value.length > 10 ? 6 : 2;
return value.slice(0, visibleChars) + '***';
};Enough prefix to recognise which key it is, never enough to use it. A security warning that pastes the credential into your terminal scrollback is not a security warning.
The rate limiter is the other place that has to behave under pressure. It reads what GitHub tells it rather than guessing:
const jitter = Math.random() * JITTER_MAX_MS;
// Always wait at least BASE_BACKOFF_MS so we never hammer the API
const waitMs = Math.max(resetAt - Date.now() + jitter, BASE_BACKOFF_MS);Wait until the reset, plus up to two seconds of jitter so parallel runs do not all wake at once, and never less than a second even if the clock disagrees. Three retries, then a readable error.
Decisions I would defend#
Dependency injection, only where it buys something#
Every function that touches the network takes
a deps argument defaulted to the real implementation. That one seam makes the whole fetch path
testable with no network and no global mocking:
const fetchPage = makeFetchPage([makeComment({ id: 1, body: 'Hello' })]);
const result = await fetchComments(REPO, [42, 99], defaultConfig, () => {}, { fetchPage });
expect(result.has(42)).toBe(true);Nothing else in the codebase has an injection point, because nothing else needed one.
Zod at every boundary#
GitHub responses are validated before they reach application code, the
config file is validated on load, and the merged config is validated again after CLI overrides. When
GitHub changes a response shape I want an error naming the field, not an undefined travelling four
stages down the pipeline and surfacing as a blank section.
Config layers in one direction#
Defaults, then config file, then CLI flags, and only defined flags win:
...(cli.style !== undefined && { style: cli.style }),
...(cli.labelPriority !== undefined && { labelPriority: cli.labelPriority }),
...(cli.maxComments !== undefined && { maxCommentsPerItem: cli.maxComments }),Without the !== undefined guard, Commander's absent options spread undefined over the config
file and silently reset it to the defaults. This is the difference between --no-prs overriding
includePRs: true and an omitted flag doing the same thing behind your back.
Extensions resolve themselves#
--style xml with no --output writes repissue-output.xml.
Pass --output my-file.md and your path is left exactly as you typed it. Small, and it removes a
whole category of "why is my XML in a markdown file".
A token estimate can never fail the run#
Counting happens in a Tinypool worker running tiktoken,
which keeps the main thread free while WASM initialises. If the worker dies for any reason, missing
native module, WASM error, a locked down CI box, the count falls back to chars / 4. The file is the
product. Nothing about counting characters gets a vote on whether the run succeeds.
Tests#
Fixture factories build typed GitHub objects with sensible defaults and per property overrides, so a test states the one field it cares about and nothing else:
export const makeIssue = (overrides: Partial<GitHubIssue> = {}): GitHubIssue => ({
number: 1,
title: 'Test issue',
labels: [],
user: makeUser('alice'),
comments: 0,
created_at: '2026-01-01T10:00:00Z',
...overrides,
});The unit suite covers label scoring and sort stability, the noise filter edge cases (unicode emoji, skin-tone modifiers, null users), cross reference parsing across all nine closing keywords with deduplication and cross-repo references, pagination, comment concurrency, split file naming, XML append insertion, the token counter contract, and all 14 secret patterns.
Integration tests hit the real API against elysiajs/elysia-jwt, a small stable repo, and assert on
structure rather than exact counts, because the counts change the moment someone opens an issue. They
share one pack() call across every assertion so CI does not spend its rate limit proving the same
fetch works nine times.
What GitHub taught me on the way#
- The
/issuesendpoint returns pull requests too. You filter them out by checking each item for apull_requestfield, which is documented nowhere near where you would look for it. /pullsdoes not accept asinceparameter, so date filtering for merged PRs happens client side, after you have already paid for the pages.- The
/pullslist response has noadditions,deletionsorchanged_files. They default to0, so the templates guard the diff line rather than print+0 / -0and lie. - Handlebars has
noEscape: trueprecisely so you can escape per helper instead of globally, which is the only way the XML style works. - A character class of emoji does not reliably match every representation of those emoji.
Supplementary plane code points need
/guand a pass of their own.
Where it landed#
One command, one file, and an agent that knows what the code is and what the people around it have been arguing about. That is the whole idea, and it turned out to be a few hundred lines of pipeline plus a lot of regexes I now have opinions about.
Repissue is on GitHub if you want to read it or break it.
TypeScript · Node.js 18+ · Vitest · April 2026