diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..27cfafa1 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,152 @@ +"docs": + - changed-files: + - any-glob-to-any-file: + - "*.md" + - "docs/**" + - "examples/**" + - "spec/**" + - "templates/README.md" + +"ci": + - changed-files: + - any-glob-to-any-file: + - ".github/**" + +"cli": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/cli.py" + - "tests/test_cli.py" + +"auto-pr": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/auto_pr.py" + - "skills/auto-pr/**" + - "tests/test_auto_pr.py" + +"dual-solve": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/dual_solve.py" + - "src/vouch/sandbox.py" + - "src/vouch/web/dual_solve_api.py" + - "src/vouch/web/static/dual_solve.*" + - "src/vouch/web/templates/dual_solve.html" + - "tests/test_dual_solve.py" + - "tests/test_sandbox.py" + - "tests/test_web_dual_solve.py" + +"review-ui": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/web/**" + - "docs/review-ui.md" + - "tests/test_web*.py" + +"website": + - changed-files: + - any-glob-to-any-file: + - "web/**" + +"adapters": + - changed-files: + - any-glob-to-any-file: + - "adapters/**" + - "src/vouch/install_adapter.py" + - "tests/test_install_adapter.py" + +"openclaw": + - changed-files: + - any-glob-to-any-file: + - "adapters/openclaw/**" + - "openclaw.plugin.json" + - "src/vouch/openclaw/**" + - "tests/test_openclaw*.py" + +"mcp": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/capabilities.py" + - "src/vouch/http_server.py" + - "src/vouch/jsonl_server.py" + - "src/vouch/server.py" + - "tests/test_capabilities.py" + - "tests/test_http_server*.py" + - "tests/test_jsonl_server.py" + +"storage": + - changed-files: + - any-glob-to-any-file: + - "migrations/**" + - "src/vouch/audit.py" + - "src/vouch/bundle.py" + - "src/vouch/lifecycle.py" + - "src/vouch/migrations/**" + - "src/vouch/models.py" + - "src/vouch/page_kinds.py" + - "src/vouch/proposals.py" + - "src/vouch/storage.py" + - "templates/**" + - "tests/fixtures/migrations/**" + - "tests/test_audit*.py" + - "tests/test_bundle.py" + - "tests/test_migrations.py" + - "tests/test_page_kinds.py" + - "tests/test_schema_migrations.py" + - "tests/test_storage.py" + +"retrieval": + - changed-files: + - any-glob-to-any-file: + - "eval/**" + - "src/vouch/context.py" + - "src/vouch/eval/**" + - "src/vouch/graph.py" + - "src/vouch/hot_memory.py" + - "src/vouch/index_db.py" + - "src/vouch/salience.py" + - "src/vouch/synthesize.py" + - "src/vouch/volunteer_context.py" + - "tests/test_context.py" + - "tests/test_eval_recall.py" + - "tests/test_graph.py" + - "tests/test_index.py" + - "tests/test_retrieval_backend.py" + - "tests/test_salience.py" + - "tests/test_synthesize.py" + - "tests/test_volunteer_context.py" + +"embeddings": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/embeddings/**" + - "tests/embeddings/**" + +"sync": + - changed-files: + - any-glob-to-any-file: + - "src/vouch/diff.py" + - "src/vouch/sync.py" + - "src/vouch/vault_sync.py" + - "tests/test_diff.py" + - "tests/test_sync.py" + - "tests/test_vault_sync.py" + +"schemas": + - changed-files: + - any-glob-to-any-file: + - "schemas/**" + - "scripts/gen_schemas.py" + +"packaging": + - changed-files: + - any-glob-to-any-file: + - "MANIFEST.in" + - "Makefile" + - "pyproject.toml" + +"tests": + - changed-files: + - any-glob-to-any-file: + - "tests/**" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..c442645b --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,392 @@ +name: Labeler + +"on": + pull_request_target: + # Maintainer-owned triage workflow: it reads base-branch config and PR + # metadata only. It never checks out or executes pull request code. + types: [opened, synchronize, reopened, edited, ready_for_review] + workflow_dispatch: + inputs: + max_prs: + description: "Maximum number of open PRs to process (0 = all)" + required: false + default: "200" + per_page: + description: "PRs per page (1-100)" + required: false + default: "50" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} + +permissions: {} + +jobs: + ensure-label-taxonomy: + name: ensure label taxonomy + runs-on: ubuntu-24.04 + permissions: + issues: write + steps: + - name: Ensure label taxonomy exists + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const labels = { + "docs": ["0A3069", "documentation, specs, examples, and repo guidance"], + "ci": ["E5E7EB", "github actions and automation"], + "cli": ["0A3069", "command line interface"], + "auto-pr": ["6E7781", "auto-pr orchestration"], + "dual-solve": ["6E7781", "dual-solve orchestration"], + "review-ui": ["0969DA", "browser review ui"], + "website": ["0969DA", "static website"], + "adapters": ["6E7781", "agent host adapters and install manifests"], + "openclaw": ["6E7781", "openclaw integration"], + "mcp": ["7057FF", "mcp, jsonl, and http surfaces"], + "storage": ["D6E3DA", "kb storage, migrations, schemas, and proposals"], + "retrieval": ["2DA44E", "context, search, synthesis, and evaluation"], + "embeddings": ["2DA44E", "embedding-backed retrieval"], + "sync": ["57606A", "sync, vault mirror, and diff flows"], + "schemas": ["F9D65C", "json schemas and generated schema assets"], + "packaging": ["E5E7EB", "packaging, build metadata, and make targets"], + "tests": ["F9D65C", "tests and fixtures"], + "size: XS": ["8C959F", "less than 50 changed non-doc lines"], + "size: S": ["8C959F", "50-199 changed non-doc lines"], + "size: M": ["8C959F", "200-499 changed non-doc lines"], + "size: L": ["8C959F", "500-999 changed non-doc lines"], + "size: XL": ["8C959F", "1000 or more changed non-doc lines"], + }; + + for (const [name, [color, description]] of Object.entries(labels)) { + try { + const current = await github.rest.issues.getLabel({ + ...context.repo, + name, + }); + if ( + current.data.color?.toLowerCase() !== color.toLowerCase() || + current.data.description !== description + ) { + await github.rest.issues.updateLabel({ + ...context.repo, + name, + color, + description, + }); + } + } catch (error) { + if (error?.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + ...context.repo, + name, + color, + description, + }); + } + } + + label: + name: label pull request + if: github.event_name == 'pull_request_target' + needs: ensure-label-taxonomy + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Apply path labels + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 + with: + configuration-path: .github/labeler.yml + repo-token: ${{ secrets.GITHUB_TOKEN }} + sync-labels: true + + - name: Apply PR size label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + return; + } + + const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: pullRequest.number, + per_page: 100, + }); + + const excludedLockfiles = new Set([ + "bun.lockb", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "poetry.lock", + "uv.lock", + "yarn.lock", + ]); + const excludedDocFiles = new Set([ + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "GOVERNANCE.md", + "README.md", + "ROADMAP.md", + "SECURITY.md", + "llms.txt", + ]); + + function ignoredForSize(path) { + return ( + path.startsWith("docs/") || + path.startsWith("examples/") || + path.startsWith("spec/") || + excludedDocFiles.has(path) || + excludedLockfiles.has(path) || + path.endsWith("/package-lock.json") || + path.endsWith("/npm-shrinkwrap.json") + ); + } + + const totalChangedLines = files.reduce((total, file) => { + const path = file.filename ?? ""; + if (ignoredForSize(path)) { + return total; + } + return total + (file.additions ?? 0) + (file.deletions ?? 0); + }, 0); + + let target = "size: XL"; + if (totalChangedLines < 50) { + target = "size: XS"; + } else if (totalChangedLines < 200) { + target = "size: S"; + } else if (totalChangedLines < 500) { + target = "size: M"; + } else if (totalChangedLines < 1000) { + target = "size: L"; + } + + const currentLabels = await github.paginate( + github.rest.issues.listLabelsOnIssue, + { + ...context.repo, + issue_number: pullRequest.number, + per_page: 100, + }, + ); + const currentNames = new Set( + currentLabels.map((label) => label.name).filter((name) => typeof name === "string"), + ); + + for (const label of currentLabels) { + const name = label.name ?? ""; + if (!sizeLabels.includes(name) || name === target) { + continue; + } + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: pullRequest.number, + name, + }); + currentNames.delete(name); + } + + if (!currentNames.has(target)) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: pullRequest.number, + labels: [target], + }); + } + + backfill-pr-labels: + name: backfill open PR labels + if: github.event_name == 'workflow_dispatch' + needs: ensure-label-taxonomy + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Collect open PR numbers + id: open-prs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + result-encoding: string + script: | + const inputs = context.payload.inputs ?? {}; + const maxPrsInput = inputs.max_prs ?? "200"; + const perPageInput = inputs.per_page ?? "50"; + const parsedMaxPrs = Number.parseInt(maxPrsInput, 10); + const parsedPerPage = Number.parseInt(perPageInput, 10); + const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200; + const perPage = Number.isFinite(parsedPerPage) + ? Math.min(100, Math.max(1, parsedPerPage)) + : 50; + const processAll = maxPrs <= 0; + const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs); + const numbers = []; + + let page = 1; + while (numbers.length < maxCount) { + const remaining = maxCount - numbers.length; + const pageSize = processAll ? perPage : Math.min(perPage, remaining); + const { data: pullRequests } = await github.rest.pulls.list({ + ...context.repo, + state: "open", + per_page: pageSize, + page, + }); + + if (pullRequests.length === 0) { + break; + } + + for (const pullRequest of pullRequests) { + if (!processAll && numbers.length >= maxCount) { + break; + } + numbers.push(String(pullRequest.number)); + } + + if (pullRequests.length < pageSize) { + break; + } + page += 1; + } + + core.info(`Collected ${numbers.length} open pull requests.`); + return numbers.join("\n"); + + - name: Backfill path labels + if: steps.open-prs.outputs.result != '' + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 + with: + configuration-path: .github/labeler.yml + repo-token: ${{ secrets.GITHUB_TOKEN }} + sync-labels: true + pr-number: ${{ steps.open-prs.outputs.result }} + + - name: Backfill PR size labels + if: steps.open-prs.outputs.result != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PR_NUMBERS: ${{ steps.open-prs.outputs.result }} + with: + script: | + const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; + const numbers = (process.env.PR_NUMBERS ?? "") + .split(/\s+/) + .map((raw) => Number.parseInt(raw, 10)) + .filter((number) => Number.isFinite(number)); + + const excludedLockfiles = new Set([ + "bun.lockb", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "poetry.lock", + "uv.lock", + "yarn.lock", + ]); + const excludedDocFiles = new Set([ + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "GOVERNANCE.md", + "README.md", + "ROADMAP.md", + "SECURITY.md", + "llms.txt", + ]); + + function ignoredForSize(path) { + return ( + path.startsWith("docs/") || + path.startsWith("examples/") || + path.startsWith("spec/") || + excludedDocFiles.has(path) || + excludedLockfiles.has(path) || + path.endsWith("/package-lock.json") || + path.endsWith("/npm-shrinkwrap.json") + ); + } + + async function applySizeLabel(pullNumber) { + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: pullNumber, + per_page: 100, + }); + + const totalChangedLines = files.reduce((total, file) => { + const path = file.filename ?? ""; + if (ignoredForSize(path)) { + return total; + } + return total + (file.additions ?? 0) + (file.deletions ?? 0); + }, 0); + + let target = "size: XL"; + if (totalChangedLines < 50) { + target = "size: XS"; + } else if (totalChangedLines < 200) { + target = "size: S"; + } else if (totalChangedLines < 500) { + target = "size: M"; + } else if (totalChangedLines < 1000) { + target = "size: L"; + } + + const currentLabels = await github.paginate( + github.rest.issues.listLabelsOnIssue, + { + ...context.repo, + issue_number: pullNumber, + per_page: 100, + }, + ); + const currentNames = new Set( + currentLabels.map((label) => label.name).filter((name) => typeof name === "string"), + ); + + for (const label of currentLabels) { + const name = label.name ?? ""; + if (!sizeLabels.includes(name) || name === target) { + continue; + } + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: pullNumber, + name, + }); + currentNames.delete(name); + } + + if (!currentNames.has(target)) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: pullNumber, + labels: [target], + }); + } + } + + for (const number of numbers) { + await applySizeLabel(number); + } + core.info(`Backfilled size labels for ${numbers.length} open pull requests.`); diff --git a/CHANGELOG.md b/CHANGELOG.md index 8023df2d..8f4d85de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ All notable changes to vouch are documented here. Format follows committed SVGs stay reproducible (#286). ### Added +- GitHub PR auto-labeling: a pull-request metadata-only labeler workflow now + applies vouch surface labels from `.github/labeler.yml`, keeps those labels + in sync as files change, and adds OpenClaw-style `size: XS` through + `size: XL` labels based on non-doc changed lines. Maintainers can also run + it manually to backfill labels on already-open PRs. - `vouch dual-solve ` — run claude + codex on one github issue in isolated git worktrees, compare the two diffs, keep the branch you pick, and propose the chosen solution's rationale into the KB. A sibling tool to diff --git a/PR_BODY.md b/PR_BODY.md deleted file mode 100644 index 783ad6f6..00000000 --- a/PR_BODY.md +++ /dev/null @@ -1,79 +0,0 @@ -# feat(synthesize): `kb.synthesize` answer-mode retrieval over the review-gated KB - -## What changed - -Adds `kb.synthesize` — an answer-mode counterpart to `kb.context`. Where -`kb.context` returns a *ranked list* of relevant items, `kb.synthesize` -answers a query in prose, but strictly from **approved (durable) claims**, -with an inline `[claim_id]` citation behind every sentence. - -New surface, wired across all three transports that the capabilities test -keeps in sync: - -- `src/vouch/synthesize.py` — `synthesize(store, *, query, depth=3, - max_chars=4000, llm=False)`. Walks `build_context_pack(... limit=depth)`, - keeps only `claim` items that resolve to a durable claim via - `store.get_claim`, and composes a deterministic answer: one short, - single-clause sentence per claim, each carrying at least one `[claim_id]` - citation. No sentence is emitted that isn't traceable to a claim id. - `max_chars` truncates by dropping trailing claims (never by cutting a - citation). Returns - `{"query", "answer", "claims", "gaps", "_meta": {"synthesis_confidence"}}`. - `gaps` lists the query's salient terms for which no approved claim was - found (and is the whole answer when nothing matched). `synthesis_confidence` - is `high` when every cited claim is `stable`, `medium` when any is - `working`/`actionable`, `low` when any is `contested`. `llm=True` raises - (reserved for an opt-in generative backend; deterministic synthesis is the - v1 default). -- `src/vouch/capabilities.py` — `kb.synthesize` appended to `METHODS`. -- `src/vouch/jsonl_server.py` — `_h_synthesize` handler + `HANDLERS` entry. -- `src/vouch/server.py` — `@mcp.tool() kb_synthesize(query, depth=3, - max_chars=4000)`. -- `src/vouch/cli.py` — `vouch synthesize "" [--depth N] [--max-chars N]`. -- `CHANGELOG.md` — `### Added` bullet under `## [Unreleased]`. - -## Why / root cause - -`kb.context` is a retrieval primitive: it ranks and budgets items but leaves -answer composition (and the discipline of *only* using approved knowledge) to -the caller. There was no first-class way to ask the KB a question and get a -prose answer whose every clause is provably backed by a reviewed claim, with -the uncovered parts of the question surfaced rather than silently dropped. -`kb.synthesize` fills that gap deterministically — citation-gated by -construction, so it cannot fabricate an unbacked sentence — and grades its own -confidence from the lifecycle status of the claims it actually cited. - -## Test plan - -`tests/test_synthesize.py` covers: - -- 3 approved `auth` claims → non-empty answer citing all 3 ids by `[id]`, - confidence `high`. -- A query the KB doesn't cover → `answer == ""`, `claims == []`, `gaps` - populated with the query's salient terms. -- Fuzz/traceability: every sentence in a non-empty answer carries at least one - `[id]` citation whose id is in `claims` and resolves via `store.get_claim`. -- `max_chars` drops trailing claims without cutting a citation - (citation count == cited-claim count). -- Confidence reflects claim status (`working` → medium, `contested` → low). -- `llm=True` raises the reserved-backend `ValueError`. -- `kb.synthesize` is in `capabilities().methods` and in the JSONL `HANDLERS`, - and is callable via `handle_request` end-to-end. - -Verification gate (fresh venv, editable install of this worktree): - -``` -$ ./.venv/bin/ruff check src tests -All checks passed! - -$ ./.venv/bin/mypy src -Success: no issues found in 30 source files - -$ ./.venv/bin/python -m pytest -q -94 passed, 6 skipped in 0.81s -``` - -(The 6 skips are pre-existing numpy/embedding-optional tests, unrelated to this -change.) - -Closes #222 diff --git a/desktop/docs/screenshots/dual-solve-file-changes.png b/desktop/docs/screenshots/dual-solve-file-changes.png new file mode 100644 index 00000000..ca3be7c7 Binary files /dev/null and b/desktop/docs/screenshots/dual-solve-file-changes.png differ diff --git a/desktop/src/renderer/src/app.css b/desktop/src/renderer/src/app.css index 3b276402..dfd6c8a6 100644 --- a/desktop/src/renderer/src/app.css +++ b/desktop/src/renderer/src/app.css @@ -255,6 +255,17 @@ details summary { cursor: pointer; color: var(--ink-2); } .dl.hunk { background: var(--panel); color: var(--sepia); } .dl.ctx { color: var(--ink-2); } +/* file-changes view (dual-solve candidate pane): tree rail + diff pane */ +.fc { display: flex; gap: 8px; align-items: stretch; max-height: 460px; } +.fc-rail { flex: 0 0 140px; max-width: 140px; overflow: auto; font-family: var(--mono); font-size: 11.5px; padding-right: 4px; border-right: 1px solid var(--line); } +.fc-pane { flex: 1 1 auto; min-width: 0; overflow: auto; } +.fc-dir { color: var(--ink-2); padding: 1px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fc-file { display: block; width: 100%; text-align: left; background: none; border: 0; font: inherit; color: var(--ink); padding: 1px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; border-radius: 3px; } +.fc-file:hover { background: var(--panel-2); } +.fc-file:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--sepia); } +.fc-file.sel { background: var(--verm-dim); color: var(--ink); } +.fc-empty { font-size: 12.5px; } + /* spinner / boxes */ .spinner-row { display: flex; align-items: center; gap: 8px; color: var(--ink-2); font-size: 12.5px; } .spinner { width: 13px; height: 13px; border: 2px solid var(--line-2); border-top-color: var(--verm); border-radius: 50%; animation: spin .7s linear infinite; } diff --git a/desktop/src/renderer/src/components/Diff.tsx b/desktop/src/renderer/src/components/Diff.tsx index adeced59..f39ef371 100644 --- a/desktop/src/renderer/src/components/Diff.tsx +++ b/desktop/src/renderer/src/components/Diff.tsx @@ -1,55 +1,15 @@ // Diff.tsx — minimal unified-diff renderer. -// Ported from src/renderer/views/dualsolve.js renderDiff (lines 162-179). -// Splits a unified diff string into per-file sections; colors +/-/context lines. +// Splits a unified diff into per-file sections (via the shared diffParse helper) +// and colors +/-/context lines. The dual-solve file-changes view reuses the +// same parser; this component stays for the all-files stacked rendering. import type { ReactNode } from 'react' +import { parseDiff } from './diffParse' interface DiffProps { diff?: string | null } -interface DiffLine { - cls: 'hunk' | 'add' | 'del' | 'ctx' - text: string -} - -interface DiffFile { - head: string - lines: DiffLine[] -} - -function parseDiff(diff: string): DiffFile[] { - const files: DiffFile[] = [] - let cur: DiffFile | null = null - - for (const line of diff.split('\n')) { - if (line.startsWith('diff --git')) { - const m = line.match(/ b\/(.+)$/) - cur = { head: m ? m[1] : line, lines: [] } - files.push(cur) - } else if (!cur) { - continue - } else if ( - line.startsWith('+++') || - line.startsWith('---') || - line.startsWith('index ') - ) { - continue - } else { - const cls: DiffLine['cls'] = line.startsWith('@@') - ? 'hunk' - : line.startsWith('+') - ? 'add' - : line.startsWith('-') - ? 'del' - : 'ctx' - cur.lines.push({ cls, text: line || ' ' }) - } - } - - return files -} - export function Diff({ diff }: DiffProps): ReactNode { const files = parseDiff(diff ?? '') diff --git a/desktop/src/renderer/src/components/FileChanges.tsx b/desktop/src/renderer/src/components/FileChanges.tsx new file mode 100644 index 00000000..251c7e2e --- /dev/null +++ b/desktop/src/renderer/src/components/FileChanges.tsx @@ -0,0 +1,104 @@ +// FileChanges.tsx — file-changes view for a dual-solve candidate. +// modeled on gittensor-ui's RepositoryCodeBrowser tree→content pattern, trimmed +// to changed files only: a compact nested file tree (folders-first) as a narrow +// left rail drives a content pane showing the selected file's diff hunks. +// +// selection is local state — two instances (claude / codex) are independent by +// design; one candidate's selection never affects the other. + +import { useMemo, useState, type ReactNode } from 'react' +import { parseDiff, type DiffFile } from './diffParse' +import { buildFileTree, type FileNode } from './fileTree' + +interface FileChangesProps { + diff?: string | null +} + +// recursive rail row: a directory label (non-interactive) or a clickable file. +function TreeRow({ + node, + depth, + selected, + onSelect, +}: { + node: FileNode + depth: number + selected: string | null + onSelect: (path: string) => void +}): ReactNode { + const pad = { paddingLeft: `${depth * 10}px` } + if (node.type === 'tree') { + return ( +
+
+ {node.name}/ +
+ {node.children?.map((c) => ( + + ))} +
+ ) + } + const cls = node.path === selected ? 'fc-file sel' : 'fc-file' + return ( + + ) +} + +export function FileChanges({ diff }: FileChangesProps): ReactNode { + const files: DiffFile[] = useMemo(() => parseDiff(diff ?? ''), [diff]) + const tree = useMemo(() => buildFileTree(files.map((f) => f.head)), [files]) + + // default selection = first changed file; falls back if the diff changes. + const firstPath = files[0]?.head ?? null + const [selected, setSelected] = useState(firstPath) + const activePath = + selected && files.some((f) => f.head === selected) ? selected : firstPath + const active = files.find((f) => f.head === activePath) ?? null + + if (!files.length) { + return

(no file changes)

+ } + + return ( +
+
+ {tree.map((n) => ( + + ))} +
+
+ {active && ( +
+
{active.head}
+ {active.lines.map((l, j) => ( +
+ {l.text} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/desktop/src/renderer/src/components/diffParse.ts b/desktop/src/renderer/src/components/diffParse.ts new file mode 100644 index 00000000..0f489585 --- /dev/null +++ b/desktop/src/renderer/src/components/diffParse.ts @@ -0,0 +1,48 @@ +// diffParse.ts — split a unified-diff string into per-file sections. +// extracted from Diff.tsx so the diff renderer and the file-changes view share +// one parser. behavior is unchanged from the original Diff.tsx parseDiff. + +export interface DiffLine { + cls: 'hunk' | 'add' | 'del' | 'ctx' + text: string +} + +export interface DiffFile { + head: string + lines: DiffLine[] +} + +export function parseDiff(diff: string): DiffFile[] { + const files: DiffFile[] = [] + let cur: DiffFile | null = null + + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git')) { + const m = line.match(/ b\/(.+)$/) + cur = { head: m ? m[1] : line, lines: [] } + files.push(cur) + } else if (!cur) { + continue + } else if ( + // only the file-header markers, which always carry a trailing space and + // path ("+++ b/x", "--- a/x"). a content line like "++counter" or + // "--flag" must NOT be skipped. + line.startsWith('+++ ') || + line.startsWith('--- ') || + line.startsWith('index ') + ) { + continue + } else { + const cls: DiffLine['cls'] = line.startsWith('@@') + ? 'hunk' + : line.startsWith('+') + ? 'add' + : line.startsWith('-') + ? 'del' + : 'ctx' + cur.lines.push({ cls, text: line || ' ' }) + } + } + + return files +} diff --git a/desktop/src/renderer/src/components/fileTree.ts b/desktop/src/renderer/src/components/fileTree.ts new file mode 100644 index 00000000..da76d80b --- /dev/null +++ b/desktop/src/renderer/src/components/fileTree.ts @@ -0,0 +1,54 @@ +// fileTree.ts — build a nested file tree from a flat list of paths. +// modeled on gittensor-ui's src/components/repositories/fileTree.ts, trimmed to +// what the dual-solve file-changes view needs: no urls, no github tree types. +// +// nodes are folders-first then alphabetical at every level. intermediate +// directories are synthesized from path segments so a lone "a/b/c.py" still +// produces the a → b → c.py chain. + +export interface FileNode { + name: string // last path segment + path: string // full path from root + type: 'tree' | 'blob' // directory | file + children?: FileNode[] // present iff type === 'tree' +} + +function sortNodes(nodes: FileNode[]): void { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'tree' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const n of nodes) if (n.children) sortNodes(n.children) +} + +export function buildFileTree(paths: string[]): FileNode[] { + const roots: FileNode[] = [] + // path -> node, so intermediate dirs are created once and reused. + const byPath = new Map() + + for (const raw of paths) { + const full = raw.replace(/^\/+|\/+$/g, '') + if (!full) continue + const segs = full.split('/') + let prefix = '' + let siblings = roots + + segs.forEach((seg, i) => { + prefix = prefix ? `${prefix}/${seg}` : seg + const isLeaf = i === segs.length - 1 + let node = byPath.get(prefix) + if (!node) { + node = isLeaf + ? { name: seg, path: prefix, type: 'blob' } + : { name: seg, path: prefix, type: 'tree', children: [] } + byPath.set(prefix, node) + siblings.push(node) + } + // descend; only directory nodes carry children. + if (node.children) siblings = node.children + }) + } + + sortNodes(roots) + return roots +} diff --git a/desktop/src/renderer/src/views/DualSolve.tsx b/desktop/src/renderer/src/views/DualSolve.tsx index 3621b93c..5b2305f0 100644 --- a/desktop/src/renderer/src/views/DualSolve.tsx +++ b/desktop/src/renderer/src/views/DualSolve.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import * as api from '../lib/client' -import { Diff } from '../components/Diff' +import { FileChanges } from '../components/FileChanges' import type { ProgressFrame } from '../../../shared/ipc' // --------------------------------------------------------------------------- @@ -347,11 +347,6 @@ export default function DualSolve() { ? ok : failed} - {c.changed_files && c.changed_files.length > 0 && ( -
    - {c.changed_files.map((f, fi) =>
  • {f}
  • )} -
- )} {c.log && (
{c.engine} log @@ -359,7 +354,7 @@ export default function DualSolve() {
)} {c.ok - ? + ? :

{c.error || 'engine produced no diff'}

} ))} diff --git a/desktop/test/diff-parse.test.ts b/desktop/test/diff-parse.test.ts new file mode 100644 index 00000000..9cff2b26 --- /dev/null +++ b/desktop/test/diff-parse.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { parseDiff } from '../src/renderer/src/components/diffParse' + +describe('parseDiff', () => { + it('splits into per-file sections keyed off the b/ path', () => { + const d = [ + 'diff --git a/src/x.py b/src/x.py', + 'index 111..222 100644', + '--- a/src/x.py', + '+++ b/src/x.py', + '@@ -1,2 +1,2 @@', + '-old', + '+new', + ].join('\n') + const files = parseDiff(d) + expect(files).toHaveLength(1) + expect(files[0].head).toBe('src/x.py') + // header markers (---, +++, index) are dropped; only hunk + changes remain. + expect(files[0].lines.map((l) => `${l.cls}:${l.text}`)).toEqual([ + 'hunk:@@ -1,2 +1,2 @@', + 'del:-old', + 'add:+new', + ]) + }) + + it('keeps added/removed content lines that start with ++ or --', () => { + // these begin with +/- twice but are NOT file headers (no trailing space). + const d = [ + 'diff --git a/c.py b/c.py', + '--- a/c.py', + '+++ b/c.py', + '@@ -1,1 +1,2 @@', + '++counter', + '---flag', + ].join('\n') + const lines = parseDiff(d)[0].lines + expect(lines).toEqual([ + { cls: 'hunk', text: '@@ -1,1 +1,2 @@' }, + { cls: 'add', text: '++counter' }, + { cls: 'del', text: '---flag' }, + ]) + }) + + it('returns [] for an empty diff', () => { + expect(parseDiff('')).toEqual([]) + }) + + it('handles a multi-file diff', () => { + const d = [ + 'diff --git a/a.py b/a.py', + '@@ -1 +1 @@', + '+a', + 'diff --git a/b.py b/b.py', + '@@ -1 +1 @@', + '+b', + ].join('\n') + expect(parseDiff(d).map((f) => f.head)).toEqual(['a.py', 'b.py']) + }) +}) diff --git a/desktop/test/file-tree.test.ts b/desktop/test/file-tree.test.ts new file mode 100644 index 00000000..7baf7376 --- /dev/null +++ b/desktop/test/file-tree.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { buildFileTree, type FileNode } from '../src/renderer/src/components/fileTree' + +// flatten the tree to ":" in render order, for compact assertions. +function flat(nodes: FileNode[], out: string[] = []): string[] { + for (const n of nodes) { + out.push(`${n.type}:${n.path}`) + if (n.children) flat(n.children, out) + } + return out +} + +describe('buildFileTree', () => { + it('returns [] for empty input', () => { + expect(buildFileTree([])).toEqual([]) + }) + + it('keeps a single root-level file flat', () => { + expect(buildFileTree(['README.md'])).toEqual([ + { name: 'README.md', path: 'README.md', type: 'blob' }, + ]) + }) + + it('synthesizes intermediate directories from path segments', () => { + const t = buildFileTree(['src/parser.py']) + expect(flat(t)).toEqual(['tree:src', 'blob:src/parser.py']) + expect(t[0].type).toBe('tree') + expect(t[0].children?.[0]).toMatchObject({ name: 'parser.py', type: 'blob' }) + }) + + it('sorts folders first, then files, alphabetically at every level', () => { + const t = buildFileTree([ + 'zeta.txt', + 'src/parser.py', + 'alpha.txt', + 'src/aaa.py', + 'docs/guide.md', + ]) + // dirs (docs, src) before files (alpha, zeta); each alpha-sorted. + expect(flat(t)).toEqual([ + 'tree:docs', + 'blob:docs/guide.md', + 'tree:src', + 'blob:src/aaa.py', + 'blob:src/parser.py', + 'blob:alpha.txt', + 'blob:zeta.txt', + ]) + }) + + it('merges files that share a directory under one node', () => { + const t = buildFileTree(['src/a.py', 'src/b.py']) + expect(t).toHaveLength(1) + expect(t[0]).toMatchObject({ path: 'src', type: 'tree' }) + expect(t[0].children).toHaveLength(2) + }) + + it('de-dupes a repeated path', () => { + expect(buildFileTree(['x.py', 'x.py'])).toHaveLength(1) + }) + + it('handles deep nesting', () => { + const t = buildFileTree(['a/b/c/d.py']) + expect(flat(t)).toEqual(['tree:a', 'tree:a/b', 'tree:a/b/c', 'blob:a/b/c/d.py']) + }) +}) diff --git a/tests/test_pr_labeler_workflow.py b/tests/test_pr_labeler_workflow.py new file mode 100644 index 00000000..9495571d --- /dev/null +++ b/tests/test_pr_labeler_workflow.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +LABELER = ROOT / ".github" / "labeler.yml" +WORKFLOW = ROOT / ".github" / "workflows" / "labeler.yml" + + +def _load_yaml(path: Path) -> dict: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return loaded + + +def test_pr_labeler_taxonomy_covers_core_surfaces() -> None: + labels = _load_yaml(LABELER) + assert { + "docs", + "ci", + "cli", + "auto-pr", + "dual-solve", + "review-ui", + "adapters", + "openclaw", + "mcp", + "storage", + "retrieval", + "tests", + }.issubset(labels) + + for label, rules in labels.items(): + assert isinstance(label, str) and label + assert isinstance(rules, list) and rules, label + assert any("changed-files" in rule for rule in rules), label + + +def test_pr_labeler_workflow_is_pull_request_metadata_only() -> None: + workflow = _load_yaml(WORKFLOW) + triggers = workflow["on"] + assert "pull_request_target" in triggers + assert "workflow_dispatch" in triggers + assert "pull_request" not in triggers + + jobs = workflow["jobs"] + assert jobs["label"]["if"] == "github.event_name == 'pull_request_target'" + assert jobs["backfill-pr-labels"]["if"] == "github.event_name == 'workflow_dispatch'" + + steps = [ + step + for job in jobs.values() + for step in job.get("steps", []) + ] + used_actions = [step.get("uses", "") for step in steps] + assert any( + action.startswith("actions/labeler@") + and not action.endswith("@v6") + for action in used_actions + ) + assert "actions/checkout@v4" not in used_actions + + labeler_step = next( + step for step in jobs["label"]["steps"] + if step.get("uses", "").startswith("actions/labeler@") + ) + assert labeler_step["with"]["sync-labels"] is True + + backfill_step = next( + step for step in jobs["backfill-pr-labels"]["steps"] + if step.get("uses", "").startswith("actions/labeler@") + ) + assert backfill_step["with"]["pr-number"] == "${{ steps.open-prs.outputs.result }}" + + +def test_pr_labeler_workflow_creates_every_configured_label() -> None: + labels = _load_yaml(LABELER) + workflow_text = WORKFLOW.read_text(encoding="utf-8") + for label in labels: + assert f'"{label}"' in workflow_text + + +def test_pr_labeler_size_labels_follow_openclaw_thresholds() -> None: + workflow_text = WORKFLOW.read_text(encoding="utf-8") + for label in ["size: XS", "size: S", "size: M", "size: L", "size: XL"]: + assert f'"{label}"' in workflow_text + for threshold in ["< 50", "< 200", "< 500", "< 1000"]: + assert threshold in workflow_text + for ignored_path in ["docs/", "examples/", "spec/", "package-lock.json"]: + assert ignored_path in workflow_text