npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smwb/onreel-git-mcp

v0.24.0

Published

Библиотека модулей, выделенных из onreel: git-операции и безопасные пути, список файлов worktree, жизненный цикл worktree против порта WorktreeStore, адаптеры трекеров GitHub/GitLab/Jira/SourceCraft/GitVerse/Linear/локального, MCP-сервер на stdio и готовы

Readme

@smwb/onreel-git-mcp

Библиотека модулей, выделенных из onreel в отдельно версионируемый npm-пакет:

  • git-обвязка — запуск git дочерним процессом через пул с таймаутами и ограничением параллелизма, операции над worktree, ветками, статусом, diff, коммитом и rebase, и безопасные пути (проверка confinement по realpath, отказ на симлинк);
  • листинги — список записей одного каталога worktree для ревью;
  • жизненный цикл worktree — замки, двухфазное создание и очистка, сверка при старте, свип и корзина — против портов хранения, которые реализует потребитель;
  • адаптеры трекеров — единая модель задач и семь адаптеров: GitHub, GitLab, Jira, SourceCraft, GitVerse, Linear и локальный трекер поверх хранилища потребителя; у GitHub, GitLab, SourceCraft и GitVerse есть разделы pull request-ов и CI (D-005) и раздел репозитория — карточка и ветки (D-008); метки, вехи, связи и управление задачами — такие же необязательные разделы (D-021); свои трекеры пользователя — декларативным конфигом без кода (D-010, D-015);
  • MCP stdio-сервер — JSON-RPC 2.0 поверх stdin/stdout, инструменты строятся из таблицы команд вызывающего;
  • готовый MCP-сервер трекеров — npx -y @smwb/onreel-git-mcp: провайдер и репозиторий по git remote, таблица инструментов на адаптерах библиотеки, запись только по --write (D-020).

Пакет ESM, рантайм-зависимости — zod и yaml (разбор конфигов своих трекеров). Публичная поверхность держится документированной здесь: новый субпуть или публичный символ добавляют вместе с записью в этом README.

Установка

npm i @smwb/onreel-git-mcp

Требуется Node.js >= 24 (в package.json — engines) и Linux: код рассчитывает на маску прав сессии (umask), групповой SIGKILL процесса git и чтение /proc/<pid>/environ в выборе принципала MCP. CI и тесты идут на образе node:24-bookworm; в репозиторий git должен быть доступен как команда — своих git-библиотек в зависимостях нет.

Импортируют по субпутям, ядро (src/kernel/) своего субпути не имеет — его типы достаются из тех субпутей, где они нужны вызывающему.

| Субпуть | Что отдаёт | Ссылка | |---|---|---| | @smwb/onreel-git-mcp | срез контракта: константы env, zod-схемы и типы (Issue, Worktree, ContractError), чистые помощники (contractError, sessionUmask), VERSION | Контракт | | …/git | runGit, GitError, управление пулом, операции над репозиторием и worktree, слияние в ветку без checkout, безопасные пути, маскирование секретов | git | | …/files | listWorktreeDir, listDirectory, DirRefusal | files | | …/worktree | замки, finishPreparing/finishCleaning, reconciler, корзина — против порта WorktreeStore | worktree | | …/tracker | типы портов, реестр, registerBuiltinTrackers(), семь фабрик адаптеров, разделы меток, вех, связей и управления задачами, сводки CI и релиза, zod-схемы запросов, свои трекеры из конфига (definitionFromConfig, setUserTrackers), файловое хранилище токенов | tracker | | …/mcp | serveMcp, mcpToolSpecs, сборщики инструментов, выбор принципала, zodInputSchema | mcp | | …/package.json | манифест пакета | package.json | | bin: onreel-git-mcp | готовый MCP-сервер трекеров — не субпуть, а точка входа dist/cli/main.js: npx -y @smwb/onreel-git-mcp | Сервер MCP через npx |

Побочных эффектов при импорте нет нигде (R-001): регистрации, пулы и фоновые циклы запускаются только явными вызовами — registerBuiltinTrackers(), startWorktreeReconciler(), serveMcp().

Дальше — рабочие примеры; их же компилируемая версия лежит в examples/ и проверяется npm run typecheck в CI. Примеры импортируют src/ напрямую, а не субпути пакета: в CI линтер и typecheck идут до npm run build, когда dist/ ещё нет. Node 24 выполняет TypeScript напрямую, поэтому каждый пример запускается и без сборки: node examples/git.lib.ts.

Контракт

Корневой субпуть — срез контракта onreel: константы имён переменных окружения, схемы и выведенные из них типы, чистые помощники ошибок. Имена ONREEL_* заморожены как есть: их смена ломает interop onreel ↔ библиотека.

import { ENV_TOKEN, HOST_CONTRACT_VERSION, VERSION, contractError, formatVersion } from '@smwb/onreel-git-mcp';

const version = formatVersion(HOST_CONTRACT_VERSION); // '1.14' — версия контракта host
console.log(`библиотека ${VERSION}, контракт host ${version}, токен читают из ${ENV_TOKEN}`);

// Ошибку собирают здесь и отдают наружу: код из каталога, непустое сообщение и обязательные nextSteps.
const error = contractError('not_found', 'Задачи 42 нет.', ['Откройте список задач.']);
console.log(error.code, error.message, error.nextSteps.join(' '));

У ошибки контракта есть необязательное поле i18n — ключи и параметры message и каждого шага из nextSteps ({message: {key, params?}, nextSteps: [{key, params?}, …]}, шагов столько же, сколько строк nextSteps). Клиент со словарём рендерит их на своей локали, клиент без словаря читает message и nextSteps. Ключ — слова из строчных латинских букв, цифр и _ через точки (MESSAGE_KEY_PATTERN), параметры — строки до MESSAGE_PARAM_MAX (2000) символов или числа: данные, а не готовые фразы. Пятый параметр contractError(code, message, nextSteps, data, i18n) кладёт i18n в ошибку; без него поле не появляется. Схемы — contractErrorSchema, errorTextSchema, localizedTextSchema, messageKeySchema, messageParamsSchema, типы — ErrorText и LocalizedText.

git

Каждая git-команда — дочерний процесс git: аргументы массивом (без shell), не больше четырёх одновременно, таймаут 120 с (GIT_TIMEOUT_MS), для worktree add — 600 с (GIT_CHECKOUT_TIMEOUT_MS), вывод не больше 16 МиБ. Всё, что приходит от клиента, git получает только после --end-of-options или уже резолвленным в sha. Окружение ребёнка очищено от унаследованных GIT_*, права создаваемых файлов берутся из маски сессии.

import {
  addWorktree,
  changedFiles,
  closeGitPool,
  commitChanges,
  currentBranch,
  GitError,
  headOf,
  readWorktreeFile,
  repoToplevel,
  resolveCommit,
  runGit,
} from '@smwb/onreel-git-mcp/git';

const repo = await repoToplevel('/home/user/project');
const branch = await currentBranch(repo); // detached HEAD — undefined
const baseSha = await headOf(repo);

await addWorktree(repo, '/home/user/.onreel/workspaces/project-1a2b3c/run-1/task-1', 'onreel/run-1/task-1', baseSha);

const wt = '/home/user/.onreel/workspaces/project-1a2b3c/run-1/task-1';
const outcome = await commitChanges(wt, '#12 Правка по задаче'); // committed: false, если коммитить нечего
const changes = await changedFiles(wt, baseSha);                 // отслеживаемые против базы; без неотслеживаемых
const file = await readWorktreeFile(wt, 'src/main.ts', 64 * 1024); // {size, binary, truncated, text?}
console.log(branch, outcome.committed, changes.length, file?.text === undefined);

// Нештатный код, таймаут или переполнение вывода — GitError.
const missing = await resolveCommit(repo, 'refs/heads/нет-такой-ветки'); // undefined, не ошибка
try {
  await runGit(['rev-parse', '--verify', 'HEAD^1'], repo, { okCodes: [] });
} catch (error) {
  if (error instanceof GitError) console.log(error.exitCode, error.timedOut, error.stderr);
}

Остановка процесса. closeGitPool() вызывает потребитель в своей точке остановки host'а: очередь отклоняется, запущенные git убиваются групповым SIGKILL. isGitPoolClosed() позволяет отличить сбой «пул закрыт, мы останавливаемся» от настоящей ошибки git — в последнем случае запись failed не пишут. reopenGitPool() — только для тестов.

Маскирование секретов (D-272, D-194). GitError маскирует аргументы и stderr до обрезки, иначе срез оставил бы префикс секрета, который шаблон не узнаёт; stdout маскируется при первом чтении. Шаблонов R1–R8 (URL с user:pass@, Authorization:, известные ключи доступа, JWT, PEM, onr_…-токен, присваивания вида token = …, пароли в CLI-флагах) бывает мало — значение, не похожее ни на один шаблон, регистрируют в приёмнике секретов:

import { defaultSecretSink, redactSecretText, setSecretSink, type SecretSink } from '@smwb/onreel-git-mcp/git';

defaultSecretSink.register(process.env.MYopaqueValue ?? ''); // короче 8 символов — игнорируется
console.log(redactSecretText('клон https://user:секрет@git.example/o/r и MYopaqueValue'));

// Потребитель со своим журналом подставляет свой приёмник — тем же типом SecretSink.
const known = new Set<string>();
const sink: SecretSink = {
  register(secret: string): void {
    known.add(secret);
  },
};

// Одна точка на процесс: после этого вызова известные секреты получает и sink потребителя.
setSecretSink(sink);
setSecretSink(undefined); // убирает приёмник потребителя: redactor ядра эти значения уже знает

Приёмник потребителя (D-009). setSecretSink(sink) задаёт приёмник секретов потребителя на весь процесс: значения, которые встречают адаптеры трекеров и хранилище токенов, библиотека пишет и в свой redactor, и в sink — регистрировать их второй раз не нужно. Реестр ядра при этом не отключается: тексты, которые библиотека отдаёт наружу сама (JSON-RPC-ошибка serveMcp, GitError, TrackerError с телом ответа, окно лога CI), замаскированы и тогда, когда журнал потребителя их не видит. Новый sink сразу получает значения, которые библиотека встретила до вызова, поэтому порядок «сначала токен, потом setSecretSink» не оставляет секрета незамаскированным у потребителя. setSecretSink(undefined) убирает приёмник потребителя; обратно переносить нечего — redactor ядра значения уже знает. Порог 8 символов держит ядро, а в sink значение уходит как есть: свой порог потребитель задаёт сам. sink вправе делегировать значение обратно в defaultSecretSink — так потребитель зеркалит секрет в реестр библиотеки: внутри register самого приёмника библиотека обратный вызов пропускает, поэтому рекурсии нет, а значение в реестре ядра оказывается до вызова приёмника. Вызов явный (R-001), состояние модульное — как redactor и пул git, на процесс одно.

Значения SecretSink, defaultSecretSink, redactSecretText и setSecretSink реэкспортированы из …/git; setSecretSink и тип SecretSink — также из …/tracker, рядом с хранилищем токенов. Отдельного субпути …/kernel у пакета нет.

Remote-ы репозитория (#30). remotesOf(repoRoot) читает git remote -v: список { name, url } с адресами fetch, origin первым, остальные в порядке git; репозиторий без remote — пустой список, не репозиторий — GitError. Адрес отдаётся как записан в конфиге git, вместе с учётными данными (https://user:token@host/…): в тексты и ответы его пускают только через displayRemoteUrl из …/tracker. Разбор адреса и вид по хосту — там же (раздел «Провайдер по git remote» в разделе tracker). Тип записи — GitRemote.

Слияние в ветку без checkout (#34). mergeIntoBranch(repoPath, { target, source, createAt, message }) сливает коммит source в локальную ветку target, которая не выложена ни в каком worktree: git merge-tree --write-tree строит дерево, git commit-tree — коммит слияния с родителями (вершина target, source), git update-ref с прежним значением переставляет ветку. Рабочие деревья и индекс не трогаются. Нужен git 2.38 или новее — на более старом вызов отклоняется GitError. Автор и коммиттер — из конфига репозитория, как у commitChanges. Исход — BranchMergeOutcome:

  • merged { sha, created } — новый коммит слияния; он создаётся всегда, даже когда возможна перемотка;
  • already { sha, created } — source уже достижим из вершины target (или из createAt, если ветки не было);
  • conflict { conflicts, targetSha } — пути с конфликтами; ничего не записано, ветка не создана;
  • checked_out { worktreePath } — ветка выложена в worktree репозитория (по git worktree list); ничего не сделано;
  • moved { targetSha } — вершина сдвинулась между чтением и записью и после одного повтора; ничего не записано;
  • foreign { targetSha } — ветка есть, а createAt не её предок; ничего не сделано.

Ветки нет — она создаётся на createAt вместе с успешным исходом (created: true). Вызовы по одной паре (repoPath, target) идут по очереди внутри процесса; между процессами ветку стережёт update-ref с прежним значением. ensureBranch(repoPath, branch, sha) создаёт ветку на sha с нулевым прежним значением и отдаёт EnsureBranchOutcome { created, sha }: ветка уже есть — её вершина и created: false. isAncestor(repoPath, ancestor, descendant) — git merge-base --is-ancestor. Ref'ы от клиента git получает после --end-of-options, неизвестный ref и неверное имя ветки — GitError.

import { ensureBranch, isAncestor, mergeIntoBranch } from '@smwb/onreel-git-mcp/git';

const repo = '/home/user/project';
const outcome = await mergeIntoBranch(repo, {
  target: 'onreel/run/r1',
  source: 'onreel/run-1/task-1',
  createAt: 'main',
  message: 'Слияние задачи task-1',
});
if (outcome.kind === 'conflict') console.log(outcome.conflicts); // ветка не изменилась
const branch = await ensureBranch(repo, 'onreel/run/r1', 'main'); // { created, sha }
console.log(await isAncestor(repo, 'main', branch.sha));

files

listWorktreeDir отдаёт записи одного каталога worktree: не глубже одного уровня, симлинки не разыменовываются (symlink остаётся листом), служебный .git пропускается без учёта регистра, каталог и путь запроса проверяются на confinement по realpath. truncated ставится, если просмотрели больше scanMax записей или обрезали по maxEntries; имя не в UTF-8 тоже обрезает список с этим признаком.

import { DirRefusal, listWorktreeDir } from '@smwb/onreel-git-mcp/files';

try {
  const root = await listWorktreeDir(dataDir, worktreePath, undefined);
  const nested = await listWorktreeDir(dataDir, worktreePath, 'src', { maxEntries: 200, scanMax: 1000 });
  console.log(root.truncated, root.entries.map((entry) => `${entry.kind} ${entry.path}`), nested.entries.length);
} catch (error) {
  // Причина отказа — часть контракта: её контроллер переводит в ошибку worktree.files по матрице D-367.
  if (error instanceof DirRefusal) console.log(error.reason); // 'outside_worktree' | 'excluded' | 'not_found' | …
  else throw error;
}

listDirectory(base, path, limits) — тот же список для любого каталога: пределы, исключение .git, truncated и причины отказов общие с listWorktreeDir, а корень не обязан лежать под каталогом данных host. listWorktreeDir построен на ней и добавляет к ней проверку управляемого пути (checkManagedPath), поэтому её поведение не изменилось. Корень и каталог запроса резолвятся realpath, и realpath каталога обязан совпадать с realpath корня или лежать внутри него; выход через .. и симлинк за корень — DirRefusal('outside_worktree'), путь в .git — DirRefusal('excluded'), путь не каталог — DirRefusal('not_directory'). Проверку того, что корень вообще принадлежит host, делает вызывающий: самостоятельный MCP-сервер передаёт туда корень репозитория или его worktree (инструмент dir_list).

import { DirRefusal, listDirectory } from '@smwb/onreel-git-mcp/files';

// Корень — обычный каталог: список доступен и вне каталога данных host.
const listing = await listDirectory(repoRoot, 'src', { maxEntries: 200, scanMax: 1000 });
console.log(listing.truncated, listing.entries.map((entry) => `${entry.kind}:${entry.path}`).join(' '));

worktree

Двухфазные создание и очистка (onreel D-050): receipt-транзакция потребителя записала preparing или cleaning, библиотека доводит состояние до ready, cleaned или failed, работая с git и диском вне транзакции. Состояние читается и пишется через порт WorktreeStore — его реализует вызывающий (D-003); в пакете нет ни SQLite, ни ledger, ни другой конкретной БД.

import {
  finishPreparing,
  isWorktreeBusy,
  startWorktreeReconciler,
  withWorktreeLock,
  type WorktreeLifecycleServices,
  type WorktreeStore,
} from '@smwb/onreel-git-mcp/worktree';

const services: WorktreeLifecycleServices = {
  store: myStore satisfies WorktreeStore,
  dataDir: '/home/user/.onreel',
  log: (line) => console.error(line),
};

await finishPreparing(services, 'wt-9'); // preparing → ready с HEAD; сбой → failed и сразу очистка остатков

const reconciler = startWorktreeReconciler(services); // 30 с по умолчанию (WORKTREE_RECONCILE_INTERVAL_MS)
await reconciler.kick(); // свип вне таймера: после мержа, после stop воркера
await reconciler.stop();

// Замок — один на worktree в пределах процесса: создание, очистка, коммит и rebase не перекрываются.
console.log(isWorktreeBusy('wt-9'));
await withWorktreeLock('wt-9', async () => {
  /* работа с этим worktree */
});

Стартует startWorktreeReconciler — он же делает сверку при старте (reconcileWorktreesAtStart: осиротевший preparing → failed и в очистку) и доочищает корзину (emptyTrash). sweepWorktrees отдельно готовит к очистке ready-worktree завершённых задач и доводит начатые cleaning. Незакоммиченные изменения без явного решения вызывающего (discardChanges) очистка не выбрасывает: worktree уходит в failed и ждёт решения.

Порт WorktreeStore раскрывает пять методов и явную единицу работы — inTransaction(fn), которой вызывают все переходы (markWorktreeReady, markWorktreeFailed, requestWorktreeCleanup, completeWorktreeCleanup); вложенность и асинхронные обратные вызовы не поддерживаются. Эталон реализации — test/helpers/memory-store.lib.ts в тестовом дереве пакета (D-003): он же образец семантики для потребителей, включая копию-на-коммит в inTransaction и отказ броска как откат. Свои переходы библиотека пишет с actor: 'host' — процесс, который её держит (D-012); реализация порта отображает это значение в лексику потребителя.

Замки worktree — одна таблица на процесс. Субпуть, которому нужны замки, импортирует модуль жизненного цикла напрямую: модуль двух и более точек входа tsup выносит в общий chunk, поэтому определение таблицы в собранном пакете остаётся одно (D-013). Инвариант держит test/worktree/locks-bundle.test.ts — он собирает пакет конфигурацией проекта во временный каталог и требует ровно одного определения.

tracker

Единая модель задач поверх внешних трекеров и локального хранилища. Адаптер — TrackerAdapter (list/get/create/ update/comments/comment), подключаемый трекер — TrackerDefinition (descriptor для интерфейса + фабрика). fetch приходит в контексте, а не берётся из глобальной области: тесты подставляют свою функцию, потребитель — свою обёртку с прокси.

import {
  findTracker,
  localTracker,
  registerBuiltinTrackers,
  TrackerError,
  type AdapterContext,
  type TrackerAdapter,
  type TrackerFetch,
} from '@smwb/onreel-git-mcp/tracker';

// Явный вызов, а не побочный эффект импорта (R-001); повтор — no-op.
registerBuiltinTrackers();
console.log(findTracker('github')?.descriptor.title); // 'GitHub'; реестр пуст без этого вызова

// Обёртка передаёт init целиком: redirect: 'manual' от библиотеки должен дойти до fetch (раздел «Безопасность»).
const fetch: TrackerFetch = (input, init) => globalThis.fetch(input, init);

const context: AdapterContext = {
  projectId: 'p1',
  connection: { target: 'o/r', token: 'секрет-только-в-памяти' }, // расшифровку даёт хранилище вызывающего
  fetch,
  store: myLocalIssueStore, // порт LocalIssueStore — нужен только локальному трекеру
  author: 'owner',
};

const adapter: TrackerAdapter = (findTracker('github') ?? localTracker()).create(context);
const issue = await adapter.get('7');
try {
  await adapter.update('7', { status: 'in_progress' });
} catch (error) {
  // 'failed' | 'unknown_issue' | 'invalid_request' | 'not_found' | 'unsupported' | 'conflict' | 'rate_limited'
  if (error instanceof TrackerError) console.log(error.failure);
  else throw error;
}

Фабрики: localTracker(), sourcecraftTracker(), githubTracker(), gitlabTracker(), jiraTracker(), gitverseTracker(), linearTracker() (подробнее о GitVerse — в подразделе «GitVerse» ниже). Сопоставление статусов и приоритетов — открытые таблицы (SOURCECRAFT_STATUSES, JIRA_PRIORITIES, GITHUB_PRIORITY_LABELS, GITLAB_PRIORITY_LABELS, …), чтобы потребитель видел, во что именно преводится его статус. Свои трекеры добавляют в реестр явным registerTracker(definition); повторный kind — исключение, а не молчаливая замена. Трекеры пользователя из конфигов — отдельный заменяемый набор setUserTrackers(definitions) (подраздел «Свои трекеры из конфига»). Типы порта LocalIssueStore (и его строк) реэкспортированы из …/tracker — реализацию, как и для WorktreeStore, пишет потребитель; эталон в памяти — test/helpers/tracker-fixtures.lib.ts. Порт требует: комментарий — только к существующей задаче, иначе отказ с кодом unknown_issue (сиротских комментариев хранилище не держит, D-011); limit страницы — целое от 1 до 500, по умолчанию 100, значение вне диапазона прижимается к границам; снятый исполнитель (assignee: null) убирает ключ из строки задачи, а не оставляет его со значением undefined. Нецелый limit до хранилища не доходит: адаптер отказывает invalid_request ещё в проверке запроса, а чем на него отвечает сама реализация порта, не предписано — эталон в памяти отвечает invalid_request, ledger падает ошибкой SQLite.

Таймаут запроса к трекеру — TRACKER_TIMEOUT_MS (15 с), тело ответа ошибки обрезают до TRACKER_ERROR_BODY_MAX (300 символов) и маскируют; 404 с missingIsIssue — unknown_issue, а не «неверный адрес». 404 с заданным missing отдаёт его код и текст (not_found), статус из conflictOn — conflict, 429 и 403, который выглядит лимитом частоты (x-ratelimit-remaining: 0, gitverse-ratelimit-remaining: 0, gitverse-ratelimit-user-remaining: 0, retry-after или gitverse-ratelimit-retry-after) — rate_limited (подробнее — в подразделе ниже). Заголовок accept запроса задаёт поле accept у JsonRequest (по умолчанию application/json) и TextRequest (по умолчанию text/plain): он же уходит на переход на чужой origin.

Origin API подключения отдаёт необязательная функция определения origin(connection): у sourcecraftTracker(), githubTracker(), gitlabTracker(), jiraTracker() и gitverseTracker() она есть, у localTracker() — нет (сети и токена у него нет). requestJson и requestText принимают origin в запросе и сами проходят редиректы, не больше TRACKER_REDIRECT_MAX (5), поэтому обёртка fetch потребителя передаёт init.redirect дальше; подробнее — в разделе «Безопасность».

Провайдер по git remote

Разбор адреса remote и вид трекера по хосту — те же функции, что выбирают провайдера у готового MCP-сервера (раздел «Определение провайдера»); потребитель библиотеки собирает из них предложение подключения без своего разбора. Чтение remote-ов репозитория — remotesOf из …/git.

import { displayRemoteUrl, parseRemoteUrl, providerOfRemote, REMOTE_HOSTS } from '@smwb/onreel-git-mcp/tracker';
import { remotesOf, type GitRemote } from '@smwb/onreel-git-mcp/git';

const remotes: GitRemote[] = await remotesOf('/home/user/project');
const origin = remotes.find((remote) => remote.name === 'origin');
const location = origin === undefined ? undefined : parseRemoteUrl(origin.url);
// ssh://ssh.sourcecraft.dev/elfenmail/onreel.git → { host: 'ssh.sourcecraft.dev', path: 'elfenmail/onreel' }
const provider = location === undefined ? undefined : providerOfRemote(location, {});
// → { kind: 'sourcecraft', target: 'elfenmail/onreel' }; незнакомый хост — undefined
console.log(provider, origin === undefined ? '' : displayRemoteUrl(origin.url)); // адрес без учётных данных
  • parseRemoteUrl(url) — RemoteLocation { host, path, origin? } или undefined. Форматы: https://[user[:pass]@]host[:port]/a/b(.git), ssh://, git+ssh://, git:// и scp-подобный [user@]host:a/b(.git); file://, другие схемы и локальный путь дают undefined. Хост — в нижнем регистре и без порта, регистр пути сохраняется, .git, ведущая и замыкающая косая черта снимаются. origin (схема, хост, порт не по умолчанию) задан только у http(s). Учётные данные в результат не попадают.
  • providerOfRemote(location, env) — RemoteProvider { kind, target, url? } по таблице REMOTE_HOSTS: github.com — github, gitlab.com — gitlab, git.sourcecraft.dev и ssh.sourcecraft.dev — sourcecraft, gitverse.ru — gitverse. Хост сравнивается целиком: evilgithub.com и github.com.evil.example не узнаются. У GitHub, SourceCraft и GitVerse target — ровно org/repo, у GitLab — весь путь с подгруппами. Свои хосты GitLab берутся из env.GITLAB_HOSTS (через запятую; в таком случае заполнен url — адрес API): со своим окружением функция детерминирована, с пустым {} узнаются только облачные хосты. Незнакомый хост, чужое число сегментов — undefined.
  • displayRemoteUrl(url) — адрес без логина и пароля, для текстов и журналов.

Нормализация данных подключения

Владельцы вводят elfenmail/onreel.git вместо elfenmail/onreel и адрес сайта (https://sourcecraft.dev/) вместо адреса API: адаптер принимает первое и падает на запросе, второе уходит в сеть. Необязательная функция определения normalize(data) приводит данные к нормальному виду, поэтому вызывающий применяет её до проверки полей, origin(), пробы и записи подключения и хранит результат, а не введённое:

import { findTracker, TrackerError, type TrackerConnectionData } from '@smwb/onreel-git-mcp/tracker';

const definition = findTracker('sourcecraft');
const input: TrackerConnectionData = { target: ' elfenmail/onreel.git/ ', url: '', token: 'секрет' };
const data = definition?.normalize?.(input) ?? input; // { target: 'elfenmail/onreel', token: 'секрет' }

try {
  definition?.normalize?.({ url: 'https://sourcecraft.dev/', target: 'elfenmail/onreel' });
} catch (error) {
  // TrackerError 'invalid_request': «Адрес https://sourcecraft.dev/ — страница сайта SourceCraft, а не API.
  // Оставьте поле пустым: по умолчанию используется https://api.sourcecraft.tech.»
  if (error instanceof TrackerError) console.log(error.failure, error.message);
}
  • target: без пробелов по краям, замыкающей / и одного суффикса .git (o/r.git/ → o/r, group/sub/p.git → group/sub/p); пустой — отсутствующий.
  • url: без пробелов по краям; пустой — отсутствующий. Адрес сайта площадки отклоняется TrackerError('invalid_request'), в тексте — введённый адрес (без логина, пароля, запроса и фрагмента) и адрес API по умолчанию. Сравнение по хосту целиком: sourcecraft.dev.evil.example и api.sourcecraft.tech проходят. Строка, не являющаяся URL, проходит как есть: её отклоняет адаптер при создании.
  • Хосты сайта: SourceCraft — sourcecraft.dev, git.sourcecraft.dev; GitHub — github.com, www.github.com; GitVerse — gitverse.ru, www.gitverse.ru. У GitLab сайт и есть адрес API, поэтому его url не отклоняется (в target суффикс .git и косая черта снимаются так же).
  • user и token не меняются; исходный объект тоже, функция возвращает новый.

normalize есть у githubTracker(), gitlabTracker(), sourcecraftTracker() и gitverseTracker(). У jiraTracker(), localTracker() и трекеров из конфига его нет (definition.normalize === undefined): ?. оставляет данные как есть.

Повторы запросов

Запросы адаптеров падают на временных сетевых ошибках (обрыв соединения, таймаут, сброс сокета) и временных ответах сервера. retryFetch(fetch, options?) оборачивает TrackerFetch в повторы с экспоненциальными паузами и ставится потребителем в AdapterContext.fetch — адаптеры и requestJson/requestText не меняются, редиректы по-прежнему проходит библиотека. Побочных эффектов при импорте нет (R-001): таймер создаёт только sleep во время паузы, и её можно заменить своей (в тестах — разрешающейся сразу).

import { retryFetch, type TrackerFetch } from '@smwb/onreel-git-mcp/tracker';

// 3 повтора, паузы 1 с, 2 с, 4 с; паузу прерывает сигнал запроса.
const fetch: TrackerFetch = retryFetch((input, init) => globalThis.fetch(input, init));

Правило идемпотентности: повторяются чтение, PUT и DELETE (IDEMPOTENT_METHODS) и любой метод с заголовком ключа идемпотентности (idempotency-key по умолчанию, сравнение ключей без регистра) непустым значением; POST и PATCH повторяются, только если ошибка возникла до отправки запроса (нет соединения — ECONNREFUSED и др. из BEFORE_SEND_ERROR_CODES), а сбои после отправки (ECONNRESET и др. из AFTER_SEND_ERROR_CODES) и временные статусы 502/503/504 для них — сразу наружу. Коды вне обеих таблиц (в том числе ENOTFOUND — адрес не найден, чаще опечатка) — fatal: повтора нет; свою классификацию задайте через classify. Тело отброшенного ответа отменяется (body.cancel()); ответ последней попытки возвращается как есть, последняя ошибка перебрасывается без изменений — кроме истёкшего attemptTimeoutMs: наружу идёт AttemptTimeoutError с исходной ошибкой в cause (имя класса — стабильная проверка). 500, 429 и прочие статусы вне списка не повторяются.

| Опция | По умолчанию | Значение | |---|---|---| | retries | 3 (RETRY_DEFAULTS) | повторов после первой попытки; 0 — без повторов | | delayMs | 1000 | первая пауза, мс: пауза перед повтором n (с нуля) — delayMs * factor ** n | | factor | 2 | множитель паузы: 1000, 2000, 4000 мс при значениях по умолчанию | | statuses | [502, 503, 504] | временные ответы сервера; повторяются только идемпотентные запросы | | idempotencyHeaders | ['idempotency-key'] | заголовки ключа идемпотентности, без учёта регистра | | attemptTimeoutMs | нет | потолок времени до заголовков одной попытки, мс; чтение тела граничится сигналом вызывающего | | sleep | setTimeout | пауза (ms, signal?) => Promise<void>; сигнал отменяет паузу | | classify | networkErrorStage | этап сбоя попытки: 'before-send', 'after-send' или 'fatal' |

Невалидные retries (не целое ≥ 0), delayMs (не конечное ≥ 0), factor (не конечный ≥ 1), attemptTimeoutMs (не целое от 1 до 2³¹−1) и самая большая пауза delayMs * factor ** (retries - 1) больше 2³¹−1 мс отклоняются TrackerError('invalid_request') синхронно. Бюджет повторов ограничен таймаутом вызывающего: по умолчанию TRACKER_TIMEOUT_MS (15 с) на JSON-запрос и CI_LOG_TIMEOUT_MS (60 с) на чтение лога, поэтому паузы по умолчанию (7 с на все повторы) укладываются в него; сигнал, снятый до или во время паузы, бросает signal.reason. Ограничение: сбой чтения тела после заголовков ответа (response.text() в requestJson, поток лога в requestText) повторами не покрывается.

Помимо retryFetch из субпути экспортируются: retryDelays(options?) — список пауз ([1000, 2000, 4000] по умолчанию), networkErrorStage(error) — классификация по коду ошибки (error.code или error.cause.code), константы RETRY_DEFAULTS, IDEMPOTENT_METHODS, BEFORE_SEND_ERROR_CODES, AFTER_SEND_ERROR_CODES и типы RetryOptions, NetworkErrorStage.

Фильтры, сортировка и страницы

IssueQuery — PageQuery плюс необязательные фильтры (D-004): status (категории статуса), labels (должны присутствовать все перечисленные), assignee (в том же виде, что в Issue.assignee; null — задачи без исполнителя), author (логин или slug трекера), search (как ищет сам трекер: у SourceCraft — префикс заголовка, у локального — подстрока заголовка и описания), createdAfter/createdBefore/updatedAfter/ updatedBefore (мс Unix epoch, границы включительно) и sort — ListSort<IssueSortField> ({ field, direction? }, где direction — SortDirection). Без sort — прежний порядок трекера: GitHub, GitLab, Jira и локальный отдают новые первыми, SourceCraft — свои (старые первыми). Без direction — desc. comments принимает CommentQuery: updatedAfter (мс) и order — SortDirection (asc по умолчанию). Трекеры ограничивают размер страницы (GitHub и GitLab — до 100): limit выше потолка вернёт страницу размером потолка.

Что умеет каждый список, описывают IssueCapabilities в разделе issues определения трекера: { list: ListCapabilities<IssueFilter, IssueSortField>, comments: CommentCapabilities }. ListCapabilities перечисляет filters — фильтры, которые адаптер применяет (любой другой — отказ unsupported до запроса), clientSide — часть filters, которую адаптер проверяет по полученной странице: такая страница бывает короче limit и даже пустой при живом nextCursor, и sorts — поля сортировки. CommentCapabilities описывает список комментариев так же: filters и clientSide по updatedAfter и order вместо sorts — допустимые порядки, первый из них по умолчанию. Возможности статичны и сериализуются в JSON. У определения без раздела читаются LEGACY_ISSUE_CAPABILITIES — через issueCapabilitiesOf(definition):

| Трекер | list.filters | list.clientSide | list.sorts | comments | |---|---|---|---|---| | GitHub | все ISSUE_FILTERS | status | created, updated, comments | updatedAfter; порядок asc | | GitLab | все ISSUE_FILTERS | status | created, updated, priority, title | updatedAfter (на странице); asc, desc | | Jira | все ISSUE_FILTERS | — | created, updated, priority, title | как у GitLab | | SourceCraft | все ISSUE_FILTERS | assignee | created, updated, priority, title | как у GitLab | | GitVerse | все ISSUE_FILTERS | status, labels, assignee, search, createdAfter, createdBefore | created, updated | updatedAfter (на странице); asc, desc; страницы — адаптер | | Linear | все ISSUE_FILTERS | — | — | updatedAfter; порядок desc | | локальный | без author | без status | — | updatedAfter (на странице); asc |

У GitHub фильтры search и диапазоны created*/updated* идут через Search API (30 запросов/мин, не больше 1000 результатов). Границы времени там — квалификаторы q без кавычек: одна граница — created:>=<ISO> или created:<=<ISO>, обе границы одного поля — один диапазон created:<ISO>..<ISO> (два отдельных квалификатора GitHub объединяет через OR, и вместо фильтра он отдал бы почти всё, а кавычки в дате не читает вовсе — отдал бы пустой список). У SourceCraft статус уходит в QL-фильтр (SOURCECRAFT_STATUS_FILTER), но всё равно перепроверяется на странице. Правила модели, которые держат тесты:

  • R1 — проверка до запроса. Метод списка сначала зовёт assertListQuery/assertCommentQuery: фильтр или сортировка вне возможностей — TrackerError('unsupported') без единого запроса, а неверное значение (limit < 1, время не целое ≥ 0, пустая строка, метка с , там, где трекер ждёт CSV) — invalid_request.
  • R2 — время. Время в мс Unix epoch, границы включительно; наверх уходят как isoTime(ms) (у Jira — UTC с округлением минут наружу: after вниз, before вверх).
  • R3 — значения. labels — все должны присутствовать, фильтрация по имени метки, как в Issue.labels; совпадение имени решает сервер у GitHub, GitLab и Jira (правила регистра у них серверные), а GitVerse и SourceCraft сравнивают имена без учёта регистра на клиенте. У SourceCraft серверный фильтр QL принимает метки только слагами (label_slug), поэтому адаптер перед запросом читает список меток репозитория (отдельный запрос, не больше 20 страниц по 100) и переводит имя в slug; строка, равная slug метки, принимается как есть, имя без метки в репозитории даёт пустой результат без запроса задач, а список меток, непрочитанный до конца за потолок страниц, — отказ failed. У GitHub и GitLab тот же пустой результат даёт их серверный фильтр по имени. Фильтр labels списка PR клиентский у GitVerse и SourceCraft — там сравнение тоже без учёта регистра; assignee — как в Issue.assignee, null — без исполнителя; author — логин или slug трекера; search — как ищет сам трекер.
  • R4 — умолчания. Без sort — прежний порядок трекера: GitHub, GitLab, Jira и локальный отдают новые первыми, у SourceCraft — свой порядок по умолчанию (старые первыми); без direction — desc, у комментариев без order — asc.
  • R5 — страницы. clientSide-фильтр применяется к элементам каждой полученной страницы, nextCursor — курсор трекера; страницы бывают короче limit и пустыми.
  • R6 — экранирование. Каждое значение пользователя, попадающее в JQL, QL SourceCraft или q поиска GitHub, проходит quoted() (\ и " экранируются); свободный текст GitHub уходит одной фразой в кавычках, так что repo:чужой/репозиторий из ввода не добавит чужой репозиторий в выдачу. Даты isoTime(ms) в q — исключение: их кладут без кавычек (значение машинное, [0-9TZ:.-]), иначе GitHub не найдёт ни одной задачи.
  • R7 — прежние трекеры. Определение без раздела issues читается как LEGACY_ISSUE_CAPABILITIES через issueCapabilitiesOf() — свои трекеры потребителя продолжают работать без изменений.

Помощники фильтров — публичные символы субпути: usedFilters (имена заполненных фильтров в порядке констант), assertListQuery/assertCommentQuery (проверка запроса по возможностям), issueCapabilitiesOf, isoTime (мс → ISO 8601 UTC), inTimeRange (включительные границы, заданная граница и неизвестное время — не проходит), containsText (подстрока без учёта регистра), quoted (экранирование для JQL/QL/q), keysFor (обратный поиск по таблице сопоставления). Имена фильтров и полей сортировки — константы ISSUE_FILTERS, ISSUE_SORT_FIELDS, COMMENT_FILTERS и выведенные из них типы IssueFilter, IssueSortField, CommentFilter. За модель отвечают запись D-004 и правило R-002.

Новые коды отказа. TrackerFailure расширен четырьмя кодами; requestJson выставляет их по ответу, а потребитель сопоставляет со своим контрактом:

| TrackerFailure | Когда возникает | Код контракта потребителя | |---|---|---| | not_found | 404 сущности, заданный через missing (PR, прогон, задание) | not_found | | unsupported | фильтр/сортировка вне возможностей, неподдержанная операция | capability_unsupported | | conflict | статус из conflictOn (405/409/422): переход сейчас невозможен | lifecycle_conflict | | rate_limited | 429, 403 с x-ratelimit-remaining: 0 (или gitverse-ratelimit-remaining/gitverse-ratelimit-user-remaining: 0 у GitVerse) и 403 с retry-after (вторичные лимиты GitHub: Search API — 30 запросов/мин); задержка — из retry-after или gitverse-ratelimit-retry-after, иначе x-ratelimit-reset (GitHub), ratelimit-reset (GitLab) или gitverse-ratelimit-reset | rate_limited |

Все четыре кода уже есть в каталоге ошибок контракта (errorCatalog), срез контракта не меняется. Исчерпывающий switch по TrackerFailure у потребителя после обновления нужно дополнить.

Pull request-ы

Pull request-ы — раздел pulls определения трекера (D-005), а не отдельный реестр и не новые методы TrackerAdapter: адаптер PR живёт на том же подключении (target, token, url) и в том же AdapterContext, что и задачи. Раздел ведут GitHub, GitLab, SourceCraft и GitVerse; у локального трекера, Jira и Linear его нет, и pullRequestAdapterOf(definition, context) для них бросает TrackerError('unsupported'), не сделав ни одного запроса. Фабрики разделов — githubPulls(), gitlabPulls(), sourcecraftPulls() и gitversePulls(): каждая возвращает PullRequestDefinition (capabilities + create(context)), и определение трекера получает раздел полем pulls.

import { findTracker, pullRequestAdapterOf } from '@smwb/onreel-git-mcp/tracker';

const definition = findTracker('github');
if (definition === undefined) throw new Error('Встроенные адаптеры не зарегистрированы');
const pulls = pullRequestAdapterOf(definition, context);

const page = await pulls.list({ limit: 50, status: ['open'], target: 'main', sort: { field: 'updated' } });
const pr = page.items[0];
if (pr !== undefined) await pulls.merge(pr.key, { method: 'squash', headSha: pr.headSha });

PullRequestAdapter — семь методов: list, get, create, update, merge, comments и comment, плюс необязательный changes (изменённые файлы, ниже). Список и комментарии листаются и фильтруются так же, как задачи (D-004, R-002): PullRequestQuery — это PageQuery плюс необязательные status, author, source, target, labels, search, createdAfter/createdBefore/updatedAfter/updatedBefore и sort; имена фильтров перечисляет pullRequestFilterSchema (тип PullRequestFilter), сортировка — ListSort<PullRequestSortField> с полями created, updated, title (pullRequestSortFieldSchema). comments принимает тот же CommentQuery, что и адаптер задач.

Модель PR описывает pullRequestSchema (тип PullRequest): key — номер в своём трекере (GitHub number, GitLab iid, SourceCraft slug), для клиента непрозрачная строка; title, необязательное description; status — { category, name }, где category из pullRequestStatusCategorySchema (draft, open, merged, closed), а name — как состояние называет сам трекер; author; source и target — имена веток без refs/heads/; headSha — голова источника; labels; необязательные reviewers (трекер их не отдаёт — поля нет); links и времена createdAt/updatedAt/mergedAt/closedAt.

Что умеет менять адаптер, описывают PullRequestCapabilities — они статичны и сериализуются в JSON: list и comments как у задач, плюс create, createDraft, update (поля, которые меняет update), statuses (категории, в которые переводит update({ status })), comment, merge (способы слияния, пусто — merge не поддерживается), deleteSourceOnMerge и необязательный changes (адаптер отдаёт изменённые файлы PR). merge принимает MergeInput: method из mergeMethodSchema (merge, squash, rebase), способ вне capabilities.merge — unsupported до запроса; без method способ по настройке репозитория (у GitHub это слияние коммитом merge, у GitLab при явном method: 'merge' действует squash-настройка MR или проекта), headSha — голова, которую видел вызывающий (другая — отказ conflict; где сверки сервером нет, push, проскочивший между чтением и запросом, обнаруживается перечитыванием после слияния только если уже виден там — тогда вызов завершается тем же отказом conflict вместо успеха), message — сообщение коммита слияния, deleteSource — удалить ветку-источник. create принимает NewPullRequestInput (title, source, target, description?, draft?), update — PullRequestPatchInput (title?, description?, target?, status? — в merged переводит только merge). Вне объявленных возможностей — unsupported до запроса, как и у списков задач.

| Трекер | list.clientSide | list.sorts | create / createDraft | update | statuses | merge | deleteSourceOnMerge | |---|---|---|---|---|---|---|---| | GitHub | status, author, labels, search, created*/updated* | created, updated | true / true | title, description, target | open, closed | merge, squash, rebase | false | | GitLab | status | created, updated, title | true / true | те же | draft, open, closed | merge, squash | true | | SourceCraft | labels, search, created*/updated* | created, updated, title | true / true | те же | draft, open, closed | merge, squash, rebase | true | | GitVerse | все фильтры | created, updated | true / true | те же | open, closed | — (нет в API) | false |

У всех четырёх list.filters — весь pullRequestFilterSchema. Комментарии: у GitHub updatedAfter фильтрует сам трекер и порядок только asc; у GitLab и SourceCraft updatedAfter проверяется по странице, порядки asc и desc; у GitVerse маршрут комментариев не принимает ни параметров страниц, ни updatedAfter, поэтому и страницы, и updatedAfter, и порядки asc/desc — на стороне адаптера (курсор — смещение в списке). comment — true у всех.

Изменённые файлы PR

changes(key, query) (D-026) — страница файлов PR: PageQuery на входе (limit, cursor), PullRequestChangesPage на выходе (TrackerPage<PullRequestFile> плюс необязательное truncated, D-027). Файл описывает pullRequestFileSchema (тип PullRequestFile): path — путь после изменения; previousPath — прежний путь, только у renamed; status из pullRequestFileStatusSchema — added, removed, modified, renamed (копию GitHub отдаёт как added, смену режима — как modified); additions и deletions — суммы строк как их посчитал хостинг; patch — изменения файла в unified diff (ханки с @@, без заголовков файла); patchOmitted из pullRequestPatchOmissionSchema — почему патча нет: binary, too_large (хостинг не отдал патч по размеру или он длиннее 1 МиБ — суммы тогда остаются) или unavailable (хостинг не смог построить патч). Нет additions/deletions — хостинг строки не посчитал, нулями они не подменяются: итог «+N −M» по такой странице неполон, и потребитель обязан это показать. Страница бывает короче limit: размер страницы у всех трёх хостингов не больше 100, курсор ведёт дальше. Запрос страницы — pullRequestChangesQuerySchema (то же, что pageQuerySchema).

Обрезанный список (D-027). Хостинги ограничивают число файлов и обрезку не помечают: последняя страница без nextCursor выглядит как полный список. Поэтому на последней странице адаптер сверяет длину списка с данными самого хостинга и при расхождении ставит truncated: true — итог «+N −M» по страницам неполон, потребитель обязан это показать. Потребитель собирает truncated по страницам как ИЛИ. Нет поля — хостинг сигнала не дал, это не доказательство полноты. GitHub (до 3000 файлов): сверка с changed_files PR только если список дошёл до потолка. GitLab (потолок — настройка хостинга, плюс лимиты строк и размера): сверка с changes_count MR на каждой последней странице, одним лишним запросом; «1000+» или число больше списка — truncated. SourceCraft лимита в документации не описывает, поле не ставит. Проверка GitHub и GitLab — по документации, живых прогонов нет. Курсор GitHub и GitLab — номер страницы: любой другой, чем выдал адаптер, — invalid_request, а limit посреди обхода не меняют (сдвигаются границы страниц).

Возможность задаёт capabilities.changes (поле необязательно, чтобы определения трекеров потребителя, D-005, не ломались; не true — возможности нет). У GitHub, GitLab и SourceCraft она true, у GitVerse (в API нет) и декларативных трекеров (в языке конфигурации нет) — false, и changes отвечает unsupported до запроса (R-002). Адаптер потребителя может не иметь метода вовсе: pullRequestChangesOf(adapter, key, query) в этом случае тоже отвечает unsupported.

| Трекер | Конечная точка | Откуда суммы строк | Когда сумм и патча нет | |---|---|---|---| | GitHub | GET /repos/{owner}/{repo}/pulls/{n}/files | additions/deletions хостинга | патча нет у бинарных и слишком больших правок: при ненулевых суммах patchOmitted: 'too_large', при нулевых причина не называется (GitHub бинарный файл от пустого не отличает); хостинг отдаёт не больше 3000 файлов — обрезку показывает truncated | | GitLab | GET /projects/{id}/merge_requests/{iid}/diffs (GitLab 15.7+) | считаются по тексту патча: строки ханков с + и - | collapsed и too_large (GitLab 18.4+) — без сумм и патча, patchOmitted: 'too_large'; до 18.4 исключённый патч приходит пустым и от пустого не отличается — суммы нулевые; обрезку списка лимитами показывает truncated | | SourceCraft | GET /repos/{org}/{repo}/pulls/{slug}/files | additions/deletions хостинга | при patch_error (diff_limit_exceeded, too_large, timeout, error) хостинг отдаёт нули вместо сумм — адаптер их не передаёт; у binary нули верны, patchOmitted: 'binary' |

Сопоставление состояний — открытые таблицы, как у задач: GITLAB_MERGE_REQUEST_STATES (locked — слияние идёт, MR ещё открыт, поэтому open) и SOURCECRAFT_PULL_STATUSES (merging — open с именем merging, discarded — closed). Черновик у GitLab — префикс заголовка, а не поле: его ставит и снимает адаптер (GITLAB_DRAFT_PREFIX и все формы префикса — GITLAB_DRAFT_PATTERN). Слияние SourceCraft асинхронно: merge вернёт PR с категорией open (status.name open или merging) до завершения операции, либо уже merged; операция, отклонённая трекером (failed/cancel в ответе 202), — отказ conflict, и следующий get потребуется, только чтобы дождаться результата. headSha на всех трёх трекерах защищает от слияния чужой головы двумя способами. Серверное предусловие атомарно: GitHub и GitLab передают sha в самом запросе слияния, а у декларативного трекера (D-010) сервером сверяется headSha, подставленный в тело pulls.merge как {{headSha}}. Предварительное чтение с перечитыванием после запроса — у SourceCraft и у декларативного трекера без {{headSha}} в pulls.merge: чтение перед слиянием лишь сужает короткое окно гонки, а push, проскочивший между чтением и POST /merge, перечитывание после слияния уже не предотвращает, а обнаруживает, только если виден при перечитывании: тогда такой вызов бросает conflict вместо успеха, хотя слияние уже запущено — проверьте целевую ветку. У асинхронного слияния SourceCraft операция может запуститься позже перечитывания, и push в этом промежутке останется незамеченным. Головы нет в PR после слияния (например, ветку удалили) — сверить нечего, merge возвращает PR как есть. Сверка — по равенству полного sha, поэтому headSha передают полным (штатно — в нижнем регистре).

Фильтры списка работают по-разному, и это видно по пустым страницам. search: у GitHub — подстрока без учёта регистра в заголовке и описании, проверяется на странице; у GitLab — серверный параметр search по заголовку и описанию; у SourceCraft — подстрока на странице. У GitHub source уходит параметром head=<владелец>:<ветка> с владельцем целевого репозитория, поэтому PR из форков этим фильтром не находятся; author и labels там же сверяются точным совпадением на странице (регистр имеет значение). У GitLab status: ['open'] шлёт state=opened и потому не возвращает locked MR, хотя их категория — open. У SourceCraft неизвестный author — 400 «user not found» от самого трекера, то есть failed, а не пустая страница. status из неизвестной категории адаптер отклоняет invalid_request до запроса, пустой массив status: [] равен его отсутствию. Переход update({ status }) «в то же состояние» ни у кого не пишет повторно: GitLab не шлёт PUT, GitHub — PATCH, SourceCraft не дёргает эндпоинт состояния.

Публичные символы раздела — схемы pullRequestSchema, pullRequestStatusCategorySchema, pullRequestFilterSchema, pullRequestSortFieldSchema и mergeMethodSchema с выведенными типами PullRequest, PullRequestStatusCategory, PullRequestFilter, PullRequestSortField и MergeMethod; типы PullRequestQuery, NewPullRequestInput, PullRequestPatchInput, MergeInput, PullRequestAdapter, PullRequestCapabilities и PullRequestDefinition; схемы файлов PR pullRequestFileSchema, pullRequestFileStatusSchema, pullRequestPatchOmissionSchema и pullRequestChangesQuerySchema с типами PullRequestFile, PullRequestFileStatus и PullRequestPatchOmission; помощники pullRequestAdapterOf, pullRequestChangesOf и, для адаптеров потребителя, pullRequestFileOf и lineCount; фабрики разделов githubPulls(), gitlabPulls(), sourcecraftPulls() и gitversePulls(). Всё это выходит из субпути …/tracker — нового субпути нет (D-001, D-005). За модель отвечают таблицы сопоставления и запись D-005, отказ до запроса — правило R-002.

CI

Прогоны CI — необязательный раздел ci того же TrackerDefinition, на том же подключении, что задачи (D-005): возможности CiCapabilities плюс фабрика create(context) на том же AdapterContext. Раздел строят фабрики githubCi(), gitlabCi(), sourcecraftCi() и gitverseCi(); у локального трекера, Jira и Linear раздела нет. Адаптер раздела — ciAdapterOf(definition, context); у трекера без ci это TrackerError('unsupported', 'Трекер <title> не ведёт CI.'). CiAdapter умеет: listWorkflows, listRuns, getRun, listJobs, getJob, log, trigger, rerun, rerunJob, cancel, cancelJob. Каждый возвращённый объект проходит parse своей схемы zod (ciWorkflowSchema, ciRunSchema, ciJobSchema, ciLogSchema); состояние — категория CiStatusCategory (queued, running, waiting, success, failed, canceled, skipped) плюс сырое имя трекера в status.name, событие — категория CiEventCategory плюс сырое имя. Метод вне возможностей своего трекера — отказ unsupported до запроса.

Сопоставление сырых статусов и событий с единой моделью — открытые таблицы, как у задач, чтобы потребитель видел, во что именно преводится значение его трекера. Правила для неизвестного: статус → running (прогон ещё не закончен, и тот, кто ждёт конца, продолжает опрос), законченный прогон GitHub с незнакомым conclusion → failed (незнакомое заключение не бывает зелёным), событие → other.

GitHub Actions (GITHUB_RUN_STATUSES — status, пока он не completed; GITHUB_RUN_CONCLUSIONS — conclusion при status: completed; GITHUB_RUN_EVENTS — event прогона):

  • queued/requested/pending → queued, waiting → waiting, in_progress → running;
  • success/neutral → success, failure/timed_out/startup_failure → failed, cancelled/stale → canceled, skipped → skipped, action_required → waiting;
  • push → push, pull_request/pull_request_target/merge_group → pull_request, workflow_dispatch → manual, schedule → schedule, repository_dispatch/workflow_run/workflow_call → trigger.

GitLab (GITLAB_CI_STATUSES — status пайплайна и задания; GITLAB_PIPELINE_SOURCES — source пайплайна):

  • created/waiting_for_resource/preparing/pending → queued, running/canceling → running, waiting_for_callback/manual/scheduled → waiting, success/failed/canceled/skipped → одноимённые категории;
  • push → push, merge_request_event/external_pull_request_event → pull_request, web/api/chat/webide → manual, schedule/pipeline_execution_policy_schedule → schedule, trigger/pipeline/parent_pipeline → trigger.

SourceCraft (SOURCECRAFT_CI_STATUSES — status прогона, workflow, задачи и куба; SOURCECRAFT_RUN_EVENTS — event_type прогона):

  • created/prepared → queued, processing → running, awaiting_approval → waiting, success/failed/skipped → одноимённые, timeout → failed, canceled/rejected → canceled;
  • push → push, pr_update → pull_request, manual → manual, restart → retry, schedule → schedule, repository_event → trigger.

Что умеет каждый трекер, описывает раздел ci определения (R11 — все фильтры ciRunFilterSchema: status, ref, sha, event, workflow, actor, pullRequest, createdAfter, createdBefore, updatedAfter, updatedBefore):

| Поле | GitHub Actions | GitLab | SourceCraft | GitVerse | |---|---|---|---|---| | workflows | да (listWorkflows) | нет | нет | да (listWorkflows) | | runs.filters | все | все | кроме ref и sha | кроме created* и updated* (нет в API) | | runs.clientSide | status, event, updatedAfter, updatedBefore | status, event | все фильтры | status, ref, sha, event, pullRequest | | runs.sorts | — | created, updated | — | — | | jobs | фильтры status, allAttempts; status на странице | фильтры status, allAttempts | фильтр status; status на странице | фильтр status; status на странице | | log | live: false, курсор есть | live: true, курсор есть | live: true, курсор есть | live: false, курсор есть | | trigger | ровно один workflow, inputs, теги | без workflow, inputs, теги | хотя бы один workflow, inputs, теги | ровно один workflow, inputs, теги (экспериментально) | | rerun | всё, включая задание и debug | только failedOnly и задание | не поддерживается | прогон и задание (экспериментально) | | cancel | прогон | прогон и задание | не поддерживается | прогон (экспериментально) |

Запросы списков — CiRunQuery (PageQuery плюс фильтры из таблицы и sort по created/updated) и CiJobQuery (status, allAttempts — задания прежних попыток); имена допустимых фильтров и полей сортировки выведены в типы CiRunFilter, CiRunSortField и CiJobFilter из схем ciRunFilterSchema, ciRunSortFieldSchema и ciJobFilterSchema — теми же перечислениями оперирует CiCapabilities. У GitLab список прогонов с pullRequest идёт через пайплайны MR: остальные фильтры тогда проверяются по странице. Запуск — CiTriggerInput (ref, refType, workflows, inputs): у GitHub ровно один workflow (иначе invalid_request, входов не больше 25), у GitLab workflows не задаётся (непустой — unsupported), у SourceCraft нужен хотя бы один workflow. rerun/rerunJob возвращают свежее состояние прогона, cancel/cancelJob — свежее состояние (оно может быть ещё running).

Лог окном с курсором (D-006). log(jobKey, CiLogQuery) читает лог задания окном: maxBytes от 1 до CI_LOG_MAX_BYTES (1 МиБ), по умолчанию CI_LOG_DEFAULT_BYTES (16 КиБ) — конец лога, где обычно причина падения; from: 'head' — начало. Тело читается потоком до CI_LOG_READ_MAX_BYTES (64 МиБ), удерживается кольцевым буфером, таймаут загрузки — CI_LOG_TIMEOUT_MS (60 с). ANSI-последовательности и перерисовки строки вычищаются (cleanLogText), если не raw: true; маскирование секретов идёт всегда. Ответ CiLog: text (окно), truncated, complete (задание закончилось), size (если тело прочитано целиком) и nextCursor — его нет, когда дальше не будет ни байта. Курсор — c:<страница>:<байт> (logCursor); страница 0 у GitHub и GitLab, у SourceCraft — номер страницы его пагинации (за один вызов читается до CI_LOG_PAGES_MAX страниц). Окно из запроса разбирает logWindow(query), ответ собирает logOf(...); isFinished(category) — категория из CI_FINISHED_STATUSES.

Края окна — по целым строкам. Секрет или ANSI-последовательность, разрезанные краем окна, не узнаются ни в одном из окон, а два склеенных ответа отдают их целиком — поэтому окно выравнивается по строкам внутри logOf: окно с курсором кончается после последнего \n (курсор указывает на начало следующей строки), а окно с отрезанным началом — флаг startCut: true, по умолчанию truncated && !more — начинается после первого \n. Строка длиннее окна режется по слову: конец — перед последним словом вместе с ключом присваивания перед ним, начало — после первого пробела; без пробелов окно режется по байту, и тогда откладываются незаконченная ANSI-последовательность и \r. Отдельно добирается хвост приватного ключа: logOf маскирует строки тела PEM, на которых окно начинается, и откладёт строку -----BEGIN … PRIVATE KEY-----, если парного END в этом окне нет. Так замаскированный и очищенный текст склеенных окон совпадает с текстом лога, прочитанного целиком, а nextCursor уходит на начало следующей строки — кроме случая, когда окно упёрлось в потолок maxBytes посреди единственной строки длиннее окна либо в границу страницы SourceCraft.

Дочитывание лога идущего задания — поллинг по nextCursor:

let cursor: string | undefined;
for (;;) {
  const chunk = await ci.log(job.key, cursor === undefined ? {} : { cursor });
  process.stdout.write(chunk.text);
  cursor = chunk.nextCursor;
  if (cursor === undefined) break;
  await new Promise((resolve) => setTimeout(resolve, 5000));
}

Пределы трекеров: у GitHub лог задания доступен только после завершения (log.live: false): идущему заданию отвечают пустым текстом и курсором c:0:0, не скачивая ничего. Сам лог GitHub отдаёт 302 на подписанный URL (живёт около минуты) — fetch в контексте должен следовать редиректам. У SourceCraft нет перезапуска и отмены (rerun/cancel — unsupported), а listJobs стоит 1 + N запросов (прогон и каждый его workflow); список прогонов не фильтрует сервером вообще — все фильтры на странице, листайте nextCursor. Логи служебных кубов прогона (#git-clone, #create-environment) через API не читаются: слаг с # площадка не принимает (400 на шаблон имени), а без # куб не находится, поэтому log() на таком задании — unsupported до запроса; в listJobs эти кубы остаются. Хвост SourceCraft дочитывает не больше CI_LOG_PAGES_MAX страниц за вызов: у более длинного лога это конец первой прочитанной пачки, а не всего лога — остальное добирается по nextCursor.

Символы субпути: фабрики githubCi(), gitlabCi(), sourcecraftCi(), gitverseCi(), ciAdapterOf, типы CiAdapter, CiCapabilities, CiDefinition, CiWorkflow, CiRun, CiJob, CiLog, CiStatus, CiStatusCategory, CiEventCategory, CiRunQuery, CiJobQuery, CiLogQuery, CiLogWindow, CiTriggerInput, CiTriggerResult, CiRerunInput, CiJobRerunInput, наборы значений запроса — типы CiRunFilter, CiRunSortField, CiJobFilter, схемы ciStatusCategorySchema, ciEventCategorySchema, ciRunFilterSchema, ciRunSortFieldSchema, ciJobFilterSchema, ciStatusSchema, ciWorkflowSchema, ciRunSchema, ciJobSchema, ciLogSchema, помощники logWindow, logCursor, logOf (у его входа — необязательное поле startCut: начало окна отрезано краем, первая неполная строка отбрасывается), cleanLogText, isFinished и константы CI_LOG_DEFAULT_BYTES, CI_LOG_MAX_BYTES, CI_LOG_READ_MAX_BYTES, CI_LOG_TIMEOUT_MS, CI_LOG_PAGES_MAX, CI_FINISHED_STATUSES. Таблицы сопоставления — GITHUB_RUN_STATUSES, GITHUB_RUN_CONCLUSIONS, GITHUB_RUN_EVENTS, GITLAB_CI_STATUSES, GITLAB_PIPELINE_SOURCES, SOURCECRAFT_CI_STATUSES, SOURCECRAFT_RUN_EVENTS, GITVERSE_CI_STATUSES, GITVERSE_RUN_EVENTS (содержание — выше и в подразделе «GitVerse»). Загрузку текста наружу держит requestText(url, TextRequest): TextResponse — тело читается потоком, skip пропускает байты от начала (дочитывание по курсору), keep: { bytes, from: 'head' | 'tail' } удерживает окно кольцевым буфером, readMax и timeoutMs перекрывают потолки по умолчанию. Модель лога — запись D-006.

Репозиторий: карточка и ветки

Карточка репозитория и список веток — раздел repo того же TrackerDefinition, на том же подключении, что и задачи (D-005, D-008): возможности RepoCapabilities плюс фабрика create(context) на том же AdapterContext. Раздел строят фабрики githubRepo(), gitlabRepo(), sourcecraftRepo() и gitverseRepo(); у локального трекера, Jira и Linear его нет, и repoAdapterOf(definition, context) для них бросает TrackerError('unsupported', 'Трекер <title> не ведёт репозиторий.'), не сделав ни одного запроса.

import { findTracker, repoAdapterOf } from '@smwb/onreel-git-mcp/tracker';

const definition = findTracker('github');
if (definition === undefined) throw new Error('Встроенные адаптеры не зарегистрированы');
const repo = repoAdapterOf(definition, context);

const card = await repo.get(); // Repo: имя, ветка по умолчанию, описание, видимость, ссылки
console.log(card.name, card.defaultBranch ?? '—', card.visibility ?? '—');

// Ветки для диалога «Новый PR»: страница с курсором трекера и единственный фильтр search.
const page = await repo.branches({ limit: 50, search: 'onreel/' });
for (const branch of page.items) console.log(branch.name, branch.sha ?? '?', branch.protected === true);

RepoAdapter — два метода. get() отдаёт карточку Repo (repoSchema): name — имя репозитория, как его называет трекер (GitHub name, GitLab name проекта, SourceCraft slug), необязательные defaultBranch (у пустого репозитория её нет), description и visibility (repoVisibilitySchema — public, internal, private; значение трекера вне перечисления поле не заполняет), links — страница репозитория в трекере первой. branches(BranchQuery) отдаёт страницу Branch (branchSchema): name — имя ветки без refs/heads/, необязательные sha её головы, protected и isDefault.

Единственный фильтр списка — search (подстрока имени ветки), сортировки нет. Возможности описывает RepoCapabilities.branches как у других списков (D-004): фильтр вне них — отказ unsupported до запроса (R-002), limit вне целых от 1 и пустой search — invalid_request тоже до запроса. У GitHub параметра поиска в /branches нет, поэтому search попадает в clientSide и проверяется по полученной странице: страница бывает короче limit и пустой при живом nextCursor. GitLab и SourceCraft фильтруют сервером (параметр search и filter соответственно), их clientSide пуст.

GitLab читает ^ в начале и $ в конце search как якоря, а * — как шаблон, тогда как единая модель обещает подстроку. Адаптер снимает эти якоря, и search: '^rel' уходит как rel — так же, как у GitHub и SourceCraft. Экранировать якоря нельзя: \^ GitLab ищет буквально и не находит ничего. * остаётся шаблоном GitLab — в имени ветки git её не бывает.

| Трекер | Карточка | Ветки | search | Курсор страницы | protected | isDefault | |---|---|---|---|---|---|---| | GitHub | GET /repos/{owner}/{repo} | GET /repos/{owner}/{repo}/branches | на странице | номер страницы по Link | да | — | | GitLab | GET /api/v4/projects/{id} | GET /api/v4/projects/{id}/repository/branches | параметр сервера, якоря снимаются | `x-nex