blog
typescriptclitooling

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

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/react
stdout
Packed 47 issues and 12 PRs

  Issues:  47
  PRs:     12
  Tokens:  ~28,400

  repissue-output.md

Set 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#

StyleWhat it looks likeReach for it when
markdownCollapsible <details> per item, label badges, inline images strippedDefault. A human might read it too
plainASCII dividers, nothing a renderer can reinterpretThe consuming tool is fussy about markup
xmlEscaped, nested, machine shapedPairing with a Repomix XML snapshot
--style xml
<?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.xml

The 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.

StageDoesLives in
1. FetchIssues and PRs concurrently, then commentscore/github/*
2. FilterDrops bots and reaction-only commentscore/filter/noiseFilter.ts
3. SortLabel priority, then activitycore/filter/labelSort.ts
4. EnrichAttaches comments and cross referencescore/github/crossRefParse.ts
5. RenderHandlebars, one of three stylescore/output/outputGenerate.ts
6. WriteFile, stdout, append, or splitcore/output/*
7. Counttiktoken cl100k_base in a workercore/metrics/TokenCounter.ts
8. Scan14 secret patterns, opt incore/security/securityScan.ts
9. CopyNative clipboard tool, opt incore/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:

src/core/github/commentsFetch.ts
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:

src/core/filter/noiseFilter.ts
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:

src/core/filter/labelSort.ts
[...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 breakIssuesPull requests
FirstLabel score, descendingLabel score, descending
ThenComment count, descendingNon-draft before draft
ThenCreated, ascendingUpdated, 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:

src/core/output/appendOutput.ts
// 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 fallback

It 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:

src/core/security/securityScan.ts
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:

src/core/github/rateLimitHandle.ts
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:

tests/core/github/commentsFetch.test.ts
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:

src/config/configLoad.ts
...(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:

tests/fixtures/githubFixtures.ts
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 /issues endpoint returns pull requests too. You filter them out by checking each item for a pull_request field, which is documented nowhere near where you would look for it.
  • /pulls does not accept a since parameter, so date filtering for merged PRs happens client side, after you have already paid for the pages.
  • The /pulls list response has no additions, deletions or changed_files. They default to 0, so the templates guard the diff line rather than print +0 / -0 and lie.
  • Handlebars has noEscape: true precisely 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 /gu and 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