@smwb/onreel-git-mcp
v0.24.0
Published
Библиотека модулей, выделенных из onreel: git-операции и безопасные пути, список файлов worktree, жизненный цикл worktree против порта WorktreeStore, адаптеры трекеров GitHub/GitLab/Jira/SourceCraft/GitVerse/Linear/локального, MCP-сервер на stdio и готовы
Maintainers
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 и GitVersetarget— ровно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
