From 3ea22fddff7e8ae3ca2bbfac28d02c0033395688 Mon Sep 17 00:00:00 2001 From: Alex Macdonald-Smith Date: Fri, 8 May 2026 00:35:35 -0400 Subject: [PATCH] feat(cli): add `gitnexus publish` for opt-in understand-quickly registry Adds a small, opt-in command that fires a single `repository_dispatch` event at `looptech-ai/understand-quickly` to ask the registry for an instant resync of the current repo's entry. No graph file is uploaded; the registry pulls from raw.githubusercontent.com per the protocol at https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md. - Pure helpers (id parsing, payload construction, validation) live in `gitnexus-shared/src/integrations/understand-quickly.ts` so the package stays Node-free and the same logic is testable in isolation. - The CLI command lives in `gitnexus/src/cli/publish.ts`. Without `UNDERSTAND_QUICKLY_TOKEN` it is a no-op (exits 0 with one informational line); with the token it POSTs the dispatch and surfaces 204 / 401 / 404 / 5xx distinctly. - The id defaults to `/` parsed from the `origin` remote and can be overridden with `--id`. - Refuses to publish when no `.gitnexus/` index exists, with a `gitnexus analyze` hint. Tests: a new vitest unit covers the pure helpers (8 + 8 + 2 cases) and the no-token no-op path with a `fetch` spy that fails the test if the network is touched. README gets a one-paragraph "Publishing to understand-quickly" section near the existing CLI docs. --- README.md | 7 + gitnexus-shared/src/index.ts | 11 ++ .../src/integrations/understand-quickly.ts | 105 +++++++++++ gitnexus/src/cli/index.ts | 12 ++ gitnexus/src/cli/publish.ts | 164 ++++++++++++++++++ gitnexus/test/unit/publish.test.ts | 105 +++++++++++ 6 files changed, 404 insertions(+) create mode 100644 gitnexus-shared/src/integrations/understand-quickly.ts create mode 100644 gitnexus/src/cli/publish.ts create mode 100644 gitnexus/test/unit/publish.test.ts diff --git a/README.md b/README.md index f5f3c5a88..6beadb63d 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate repository wiki from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) gitnexus wiki --base-url # Wiki with custom LLM API base URL +gitnexus publish # Notify the understand-quickly registry (opt-in, see below) # Repository groups (multi-repo / monorepo service tracking) gitnexus group create # Create a repository group @@ -228,6 +229,12 @@ gitnexus group status # Check staleness of repos in a group If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. +#### Publishing to understand-quickly (opt-in) + +[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job. + +It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract. + ### What Your AI Agent Gets **16 tools** exposed via MCP (11 per-repo + 5 group): diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index ea66c3855..0c1f41766 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -143,6 +143,17 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js'; export { buildPositionIndex } from './scope-resolution/position-index.js'; export type { PositionIndex } from './scope-resolution/position-index.js'; +// Understand-Quickly registry integration (opt-in) +export { + UNDERSTAND_QUICKLY_DISPATCH_URL, + UNDERSTAND_QUICKLY_EVENT_TYPE, + UNDERSTAND_QUICKLY_TOKEN_ENV, + buildUqDispatchPayload, + isValidOwnerRepo, + parseOwnerRepoFromRemote, +} from './integrations/understand-quickly.js'; +export type { UqDispatchPayload } from './integrations/understand-quickly.js'; + // Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) export { diffResolutions } from './scope-resolution/shadow/diff.js'; export type { diff --git a/gitnexus-shared/src/integrations/understand-quickly.ts b/gitnexus-shared/src/integrations/understand-quickly.ts new file mode 100644 index 000000000..de7fa9736 --- /dev/null +++ b/gitnexus-shared/src/integrations/understand-quickly.ts @@ -0,0 +1,105 @@ +/** + * Understand-Quickly registry integration helpers. + * + * Pure, runtime-agnostic logic for opting in to publishing a GitNexus + * index to the [`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) + * registry. Lives in `gitnexus-shared` so both the Node CLI and any + * future browser-side surface can construct identical dispatch payloads. + * + * Network I/O lives in the CLI command (`gitnexus/src/cli/publish.ts`) + * to keep this module free of Node-only imports — see the comment at + * the top of `gitnexus-shared/src/graph/types.ts`. + * + * The protocol contract (single dispatch event, no graph upload) is + * documented at: + * https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md + */ + +/** + * URL of the registry repo's repository_dispatch endpoint. Hardcoded + * because the registry is the canonical home for this integration — + * users who want a private registry can fork and patch. + */ +export const UNDERSTAND_QUICKLY_DISPATCH_URL = + 'https://api.github.com/repos/looptech-ai/understand-quickly/dispatches'; + +/** + * Event type the registry's sync workflow listens for. + * See `looptech-ai/understand-quickly/.github/workflows/sync.yml`. + */ +export const UNDERSTAND_QUICKLY_EVENT_TYPE = 'sync-entry'; + +/** Environment variable that gates the dispatch. */ +export const UNDERSTAND_QUICKLY_TOKEN_ENV = 'UNDERSTAND_QUICKLY_TOKEN'; + +export interface UqDispatchPayload { + event_type: typeof UNDERSTAND_QUICKLY_EVENT_TYPE; + client_payload: { + /** `/` shape — must match the registered entry. */ + id: string; + }; +} + +/** + * Build the JSON body for the `repository_dispatch` ping. Pure — no + * env reads, no network. Validates that `id` looks like `owner/repo` + * (one slash, no whitespace, both halves non-empty) so a misconfigured + * caller fails loudly before the round-trip. + */ +export function buildUqDispatchPayload(id: string): UqDispatchPayload { + if (!isValidOwnerRepo(id)) { + throw new Error( + `[understand-quickly] expected id of the form "owner/repo", got "${id}". ` + + `The registry uses this string to look up your entry in registry.json — ` + + `it must match the GitHub owner/repo of the source code, not a local path.`, + ); + } + return { + event_type: UNDERSTAND_QUICKLY_EVENT_TYPE, + client_payload: { id }, + }; +} + +/** + * `owner/repo` validation. Conservative on purpose: GitHub's actual + * naming rules are looser, but we want to catch local paths + * (`/Users/...`), bare slugs (`my-repo`), and accidental whitespace. + */ +export function isValidOwnerRepo(id: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/.test(id); +} + +/** + * Parse `owner/repo` out of a git remote URL. Mirrors the heuristic in + * `gitnexus/src/storage/git.ts:parseRepoNameFromUrl` but keeps both + * halves so we can build a registry id. Returns `null` on shapes we + * don't recognise. + * + * Examples: + * git@github.com:looptech-ai/understand-quickly.git + * https://github.com/looptech-ai/understand-quickly + * ssh://git@github.com/looptech-ai/understand-quickly.git + */ +export function parseOwnerRepoFromRemote(url: string | null | undefined): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + // Strip a trailing `.git` (case-insensitive) and any trailing slashes + // so https://h/o/r and https://h/o/r.git collapse to the same id. + const stripped = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, ''); + + // SCP-form SSH (`git@host:owner/repo`). + const ssh = stripped.match(/^[^@]+@[^:]+:([^/]+)\/([^/]+)$/); + if (ssh) return `${ssh[1]}/${ssh[2]}`; + + // URL forms (https://, ssh://, git://, file://) — last two path segments. + const url2 = stripped.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+\/(.+)$/); + if (url2) { + const segments = url2[1].split('/').filter(Boolean); + if (segments.length >= 2) { + const [owner, repo] = segments.slice(-2); + return `${owner}/${repo}`; + } + } + return null; +} diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index b89b40db0..e4a455d40 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -160,6 +160,18 @@ program .description('Augment a search pattern with knowledge graph context (used by hooks)') .action(createLazyAction(() => import('./augment.js'), 'augmentCommand')); +program + .command('publish [path]') + .description( + 'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' + + 'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' + + '`Repository dispatches: write` on looptech-ai/understand-quickly). ' + + 'No-op without the token. See https://github.com/looptech-ai/understand-quickly.', + ) + .option('--id ', 'Override the registry id (defaults to the origin remote)') + .option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery') + .action(createLazyAction(() => import('./publish.js'), 'publishCommand')); + // ─── Direct Tool Commands (no MCP overhead) ──────────────────────── // These invoke LocalBackend directly for use in eval, scripts, and CI. diff --git a/gitnexus/src/cli/publish.ts b/gitnexus/src/cli/publish.ts new file mode 100644 index 000000000..3281619a6 --- /dev/null +++ b/gitnexus/src/cli/publish.ts @@ -0,0 +1,164 @@ +/** + * `gitnexus publish` — opt-in ping to the understand-quickly registry. + * + * Fires a single `repository_dispatch` event at + * `looptech-ai/understand-quickly` so the registry knows to refresh its + * entry for the current repo. Does NOT upload anything: per the + * understand-quickly protocol, the registry pulls the graph from a + * raw-GitHub URL the user controls. + * + * https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md + * + * Defaults: + * - Without `UNDERSTAND_QUICKLY_TOKEN` in the env, this is a no-op + * (prints one informational line, exit 0). Same shape as the + * `--publish` patterns in sibling tools. + * - With the token, fires the dispatch and reports the response code. + * + * The `id` is derived from the repo's `origin` remote unless the caller + * passes `--id ` explicitly. We deliberately do NOT auto-add + * the repo to the registry — registration is one-time and uses the + * `npx @understand-quickly/cli add` path documented in the protocol. + */ + +import path from 'path'; +import { + UNDERSTAND_QUICKLY_DISPATCH_URL, + UNDERSTAND_QUICKLY_TOKEN_ENV, + buildUqDispatchPayload, + isValidOwnerRepo, + parseOwnerRepoFromRemote, +} from 'gitnexus-shared'; +import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js'; +import { hasIndex } from '../storage/repo-manager.js'; +import { cliInfo, cliError } from './cli-message.js'; + +export interface PublishOptions { + /** Override the auto-derived `owner/repo` id. */ + id?: string; + /** Treat the cwd as the repo root (skip git-root walk). */ + skipGit?: boolean; +} + +const REGISTER_HINT = + 'Register your repo once with: npx @understand-quickly/cli add\n' + + 'Or use the wizard: https://looptech-ai.github.io/understand-quickly/add.html'; + +export const publishCommand = async ( + inputPath?: string, + options: PublishOptions = {}, +): Promise => { + // ── 1. Resolve the repo root (same precedence as `analyze`) ────────── + let repoPath: string; + if (inputPath) { + repoPath = path.resolve(inputPath); + } else if (options.skipGit) { + repoPath = path.resolve(process.cwd()); + } else { + const gitRoot = getGitRoot(process.cwd()); + if (!gitRoot) { + cliError( + '[understand-quickly] not inside a git repository.\n' + + 'Run from a repo, or pass --skip-git to publish from the current directory.', + ); + process.exitCode = 1; + return; + } + repoPath = gitRoot; + } + + // ── 2. Confirm a GitNexus index exists ─────────────────────────────── + // Publishing without an index is almost always a mistake — the + // registry's nightly sync would fetch a stale or missing graph file + // and mark the entry `missing`. Refuse loudly with a fix-it hint. + if (!(await hasIndex(repoPath))) { + cliError( + `[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` + + 'Run `gitnexus analyze` first, then re-run `gitnexus publish`.', + ); + process.exitCode = 1; + return; + } + + // ── 3. Derive the registry id ───────────────────────────────────────── + const id = + options.id ?? parseOwnerRepoFromRemote(getRemoteOriginUrl(repoPath) ?? undefined) ?? null; + if (!id || !isValidOwnerRepo(id)) { + cliError( + `[understand-quickly] could not derive a registry id from this repo.\n` + + `Pass --id explicitly (e.g. --id looptech-ai/${path.basename(repoPath)}).\n` + + REGISTER_HINT, + ); + process.exitCode = 1; + return; + } + + // ── 4. Token gate: no token → informational no-op (exit 0) ─────────── + const token = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV]; + if (!token) { + cliInfo( + `[understand-quickly] ${UNDERSTAND_QUICKLY_TOKEN_ENV} is not set — skipping dispatch.\n` + + `Set it to a fine-grained PAT with "Repository dispatches: write" on ` + + `looptech-ai/understand-quickly to enable instant resync.\n` + + `(Without the token, the registry's nightly sync still picks up ${id}.)`, + { id, skipped: 'no-token' }, + ); + return; + } + + // ── 5. Fire the dispatch ───────────────────────────────────────────── + const payload = buildUqDispatchPayload(id); + const commit = getCurrentCommit(repoPath); + let response: Response; + try { + response = await fetch(UNDERSTAND_QUICKLY_DISPATCH_URL, { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + cliError(`[understand-quickly] dispatch network error: ${msg}`, { id }); + process.exitCode = 1; + return; + } + + // GitHub returns 204 on success, 404 when the token can't reach the + // registry repo, 401 when the token is invalid. Surface these + // distinctly so users debug without checking the docs. + if (response.status === 204) { + cliInfo( + `[understand-quickly] dispatched sync-entry for ${id}` + + (commit ? ` @ ${commit.slice(0, 7)}` : '') + + '.\n' + + `View the workflow run: ` + + `https://github.com/looptech-ai/understand-quickly/actions/workflows/sync.yml`, + { id, commit, status: response.status }, + ); + return; + } + + if (response.status === 404) { + cliError( + `[understand-quickly] dispatch returned 404 — the token cannot reach ` + + `looptech-ai/understand-quickly. Verify the PAT has Repository access ` + + `to that repo and the "Repository dispatches: write" permission.`, + { id, status: response.status }, + ); + process.exitCode = 1; + return; + } + + // 401, 403, 422, 5xx → bubble up the body so the user can act. + const body = await response.text().catch(() => ''); + cliError( + `[understand-quickly] dispatch failed with HTTP ${response.status}: ${body || '(empty body)'}`, + { id, status: response.status }, + ); + process.exitCode = 1; +}; diff --git a/gitnexus/test/unit/publish.test.ts b/gitnexus/test/unit/publish.test.ts new file mode 100644 index 000000000..9d8dc4a07 --- /dev/null +++ b/gitnexus/test/unit/publish.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { + buildUqDispatchPayload, + isValidOwnerRepo, + parseOwnerRepoFromRemote, + UNDERSTAND_QUICKLY_TOKEN_ENV, +} from 'gitnexus-shared'; + +describe('understand-quickly helpers (gitnexus-shared)', () => { + describe('isValidOwnerRepo', () => { + it.each([ + ['looptech-ai/understand-quickly', true], + ['abhigyanpatwari/GitNexus', true], + ['Some_Org/Some.Repo-2', true], + ['', false], + ['just-a-name', false], + ['/Users/me/code/repo', false], + ['org/with spaces', false], + ['org//double', false], + ])('returns %s for %j', (id, expected) => { + expect(isValidOwnerRepo(id as string)).toBe(expected); + }); + }); + + describe('parseOwnerRepoFromRemote', () => { + it.each([ + ['git@github.com:looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'], + ['https://github.com/looptech-ai/understand-quickly', 'looptech-ai/understand-quickly'], + ['https://github.com/looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'], + ['ssh://git@github.com/abhigyanpatwari/GitNexus.git', 'abhigyanpatwari/GitNexus'], + ['https://gitlab.example.com/group/sub/project.git', 'sub/project'], + ])('parses %s -> %s', (url, expected) => { + expect(parseOwnerRepoFromRemote(url)).toBe(expected); + }); + + it.each([null, undefined, '', ' ', 'not-a-url', 'https://github.com/'])( + 'returns null for %j', + (input) => { + expect(parseOwnerRepoFromRemote(input as string | null | undefined)).toBeNull(); + }, + ); + }); + + describe('buildUqDispatchPayload', () => { + it('wraps the id in the registry-expected event shape', () => { + expect(buildUqDispatchPayload('looptech-ai/understand-quickly')).toEqual({ + event_type: 'sync-entry', + client_payload: { id: 'looptech-ai/understand-quickly' }, + }); + }); + + it('throws on a malformed id rather than building an invalid payload', () => { + expect(() => buildUqDispatchPayload('just-a-name')).toThrow(/owner\/repo/); + expect(() => buildUqDispatchPayload('/Users/me/repo')).toThrow(/owner\/repo/); + }); + }); +}); + +describe('publishCommand (no-token no-op)', () => { + let tempDir: string; + let originalToken: string | undefined; + let exitCodeBefore: number | undefined; + + beforeEach(async () => { + vi.resetModules(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-')); + // Simulate an existing index so hasIndex() returns true. + await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, '.gitnexus', 'meta.json'), + JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }), + 'utf-8', + ); + originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV]; + delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV]; + exitCodeBefore = process.exitCode; + process.exitCode = 0; + }); + + afterEach(async () => { + if (originalToken !== undefined) { + process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken; + } else { + delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV]; + } + process.exitCode = exitCodeBefore; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('exits 0 without firing a network call when the token is unset', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + throw new Error('publishCommand should NOT call fetch when the token is missing'); + }); + + const { publishCommand } = await import('../../src/cli/publish.js'); + await publishCommand(tempDir, { id: 'looptech-ai/understand-quickly', skipGit: true }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(process.exitCode ?? 0).toBe(0); + fetchSpy.mockRestore(); + }); +});