merge: repository registry
This commit is contained in:
commit
5279f8fc0d
|
|
@ -0,0 +1 @@
|
|||
{"id":"orch-plan-20260704-193545-1","ticket_id":"00001KWPC13WQ","kind":"accepted_plan","accepted_plan":{"summary":"Ticket 00001KWPC13WQ は独立 queued Ticket で blocker なし。Workspace Repository projection を config-driven registry に移行する。human authorized routing 済みのため queued->inprogress acceptance 後に worktree + coder/reviewer loop へ進める。","branch":"work/00001KWPC13WQ-repository-registry","worktree":"/home/hare/Projects/yoi/.worktree/00001KWPC13WQ-repository-registry","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Repository registry config/API/backend projection と web compatibility を実装する。完了後、別 sibling Reviewer Pod で Ticket の IntentPacket / invariants / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-04T19:35:45Z"}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Config-driven Repository registry for Workspace Backend'
|
||||
state: 'queued'
|
||||
state: 'closed'
|
||||
created_at: '2026-07-04T10:50:45Z'
|
||||
updated_at: '2026-07-04T19:34:04Z'
|
||||
updated_at: '2026-07-04T20:19:15Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-07-04T19:34:04Z'
|
||||
|
|
|
|||
31
.yoi/tickets/00001KWPC13WQ/resolution.md
Normal file
31
.yoi/tickets/00001KWPC13WQ/resolution.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
完了。
|
||||
|
||||
実装内容:
|
||||
- Workspace Backend に explicit `[[repositories]]` registry を追加した。
|
||||
- `.yoi/workspace-backend.local.toml` / `resources/workspace-backend.default.toml` に Repository config schema を導入し、dogfood workspace config に explicit `main` Git repository entry (`uri = "."`) を追加した。
|
||||
- `uri = "."` は workspace config root 相対として解釈し、process cwd / workspace root を暗黙 Repository として扱う fallback を廃止した。
|
||||
- `/api/repositories` は configured repositories のみを返し、未設定時は empty list + `repository_config_empty` warning diagnostic を返す。
|
||||
- `/api/repositories/{id}/log` は configured Git repository id のみを受け付け、未知 id / unsupported provider は typed sanitized diagnostics で fail closed するようにした。
|
||||
- Browser-facing repository summary は configured `uri` / resolved local path / secret / internal path を露出せず、Git remote も credentials / local absolute path / `file://...` を redaction するようにした。
|
||||
- Web UI は configured repositories から navigation を導出し、empty registry で hardcoded `main` repository を invent しないようにした。
|
||||
- Focused backend/web tests を追加した。
|
||||
|
||||
主な commit / merge:
|
||||
- implementation: `2f0d1cee feat: add workspace repository registry`
|
||||
- review fix: `14e63ca5 fix: derive repository UI from registry`
|
||||
- merge into orchestration: `a786fd85 merge: repository registry implementation`
|
||||
|
||||
Review:
|
||||
- 初回 external review は request_changes。
|
||||
- follow-up external review は approve。merge-blocking issue なし。
|
||||
|
||||
Final validation in Orchestrator worktree:
|
||||
- `git diff --check`: pass
|
||||
- `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests)
|
||||
- `cargo check -p yoi`: pass
|
||||
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||
- `cd web/workspace && deno task test`: pass(15 tests)
|
||||
- `yoi ticket doctor`: ok
|
||||
|
||||
未実行:
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
|
||||
|
|
@ -30,4 +30,310 @@ Marked ready by `yoi ticket state`.
|
|||
Ticket を `workspace-panel` が queued にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: orchestrator at: 2026-07-04T19:35:39Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Routing decision: implementation_ready
|
||||
|
||||
Reason:
|
||||
- Ticket は Workspace Backend の Repository registry を config-driven にする範囲、非目標、受け入れ条件が具体化されている。
|
||||
- `WorkspaceBackendConfigFile` / `.yoi/workspace-backend.local.toml` に `[[repositories]]` を追加し、Browser/API が configured repositories のみを返すという binding decision が明確。
|
||||
- `RepositoryId` / `RepositorySelector` / `RepositoryPoint` の用語境界と、secret/path leak を避ける Browser-facing invariant が明記されている。
|
||||
- typed relation blocker は 0 件、OrchestrationPlan record は 0 件。
|
||||
- queued notification は human authorized routing であり、今回の routing acceptance 後にのみ implementation side effect へ進める。
|
||||
|
||||
Evidence checked:
|
||||
- Ticket body / thread / artifacts。
|
||||
- `TicketRelationQuery(00001KWPC13WQ)`: 0 件。
|
||||
- `TicketOrchestrationPlanQuery(00001KWPC13WQ)`: 0 件。
|
||||
- Orchestrator worktree git status: clean on `orchestration`。
|
||||
- queued Ticket 一覧: この Ticket 1件のみ。ready / inprogress は 0 件。
|
||||
- visible Pods: 2 visible pods(既存 visible state の確認のみ、spawn は未実施)。
|
||||
- `TicketDoctor`: 0 errors / 既存 diagnostics のみ。
|
||||
- Bounded code map: `crates/workspace-server/src/config.rs`, `crates/workspace-server/src/repositories.rs`, `crates/workspace-server/src/server.rs`, `crates/workspace-server/src/store.rs`, repository-related web workspace files。
|
||||
|
||||
IntentPacket:
|
||||
|
||||
Intent:
|
||||
- Workspace Backend の Repository view を workspace root/cwd の暗黙 fallback から、`.yoi/workspace-backend.local.toml` の explicit Repository registry に移行する。
|
||||
- `/api/repositories` と `/api/repositories/{id}/log` は configured repositories のみを扱い、未設定時は empty list + typed warning diagnostic を返す。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- `--workspace` は Repository root ではなく workspace config root / local descriptor root として扱う。
|
||||
- `uri = "."` は backend process cwd ではなく workspace config root 相対として解釈する。
|
||||
- cwd / workspace root を暗黙 Repository として Browser/API に返す fallback は廃止する。
|
||||
- dogfood workspace config には `id = "main"`, `provider = "git"`, `uri = "."` 相当の explicit Repository entry を追加する。
|
||||
- v0 provider は `git` 主対象。`local_fs` は必要なら placeholder/diagnostic 程度。
|
||||
- `RepositorySelector` は provider-specific な未解決 locator、`RepositoryPoint` は解決済み evidence として、Git branch/tag/hash 固定抽象にしない。
|
||||
- Browser-facing response は token/auth ref/secret、不要な absolute host path、internal config/store/runtime paths を漏らさない。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- `.yoi/workspace-backend.local.toml` に `[[repositories]]` を書ける。
|
||||
- `/api/repositories` は configured repositories のみを返す。
|
||||
- Repository 未設定 workspace は empty list + `repository_config_empty` 相当 warning diagnostic。
|
||||
- configured Git Repository では branch/head/status/log など既存 UI が必要とする summary を取得できる。
|
||||
- `/api/repositories/{id}/log` は configured Git Repository id のみ受け付け、未知 id / unsupported provider は typed sanitized error。
|
||||
- 既存 web UI は未設定時にも壊れず、明示 Repository がある場合だけ repository view を表示できる。
|
||||
|
||||
Implementation latitude:
|
||||
- 型名、module分割、API response shape の細部は既存 `workspace-server` / web workspace style に合わせてよい。
|
||||
- config TOML の comments/format preservation は既存 local config serializer の範囲で扱う。
|
||||
- Browser-facing path 表示は safe display name / sanitized summary に寄せ、必要なら absolute path を API 内部で使っても response に不要露出しない。
|
||||
- focused tests の構成は backend helper/route-level と web model/component-levelの現実的な範囲で選んでよい。
|
||||
|
||||
Escalate if:
|
||||
- credential/clone/fetch/write operation、Runtime materialization、Execution Workspace 作成、multi-workspace store 移行、Ticket target からの RepositoryPoint 解決が必要になる場合。
|
||||
- Browser-facing API に secret/auth ref/internal absolute paths を出さないと実装できない場合。
|
||||
- `RepositorySelector` / `RepositoryPoint` の概念境界を設計変更する必要が出た場合。
|
||||
|
||||
Validation:
|
||||
- `cargo test -p yoi-workspace-server`
|
||||
- `cargo check -p yoi`
|
||||
- `cd web/workspace && deno task check && deno task test`
|
||||
- `git diff --check`
|
||||
- `yoi ticket doctor`
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更がある場合だけ実行し、未実行なら理由を報告する。
|
||||
|
||||
Current code map:
|
||||
- Config: `crates/workspace-server/src/config.rs` (`WorkspaceBackendConfigFile`, local config load/write/resolve)。
|
||||
- Repository projection: `crates/workspace-server/src/repositories.rs` (`LocalRepositoryReader`, `RepositorySummary`, git inspect/log)。
|
||||
- API wiring: `crates/workspace-server/src/server.rs` (`/api/repositories`, `/api/repositories/{repository_id}`, log route)。
|
||||
- Store/schema context: `crates/workspace-server/src/store.rs` repositories table migration context。
|
||||
- Web: workspace repository panels/routes using `/api/repositories` and repository log summaries.
|
||||
|
||||
Critical risks / reviewer focus:
|
||||
- No implicit workspace-root/cwd repository fallback remains in Browser/API output.
|
||||
- configured repository ids, unknown ids, and unsupported providers fail closed with typed sanitized diagnostics.
|
||||
- path/secret/internal config/store/runtime details do not leak to Browser-facing responses.
|
||||
- `uri = "."` is resolved against workspace config root, not process cwd.
|
||||
- dogfood `.yoi/workspace-backend.local.toml` contains explicit `main` repository entry without introducing secret/local-only fields into tracked records.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-07-04T19:35:52Z from: queued to: inprogress reason: routing_acceptance_implementation_ready field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing.
|
||||
|
||||
Checked context:
|
||||
- Ticket body / thread / artifacts。
|
||||
- `TicketRelationQuery(00001KWPC13WQ)`: blocking relation 0 件。
|
||||
- `TicketOrchestrationPlanQuery(00001KWPC13WQ)`: prior record 0 件。今回 accepted_plan を記録済み。
|
||||
- Orchestrator worktree git status: clean on `orchestration`。
|
||||
- queued Ticket はこの Ticket 1件のみ。ready / inprogress は 0 件。
|
||||
- visible Pods / TicketDoctor / bounded code map を確認。
|
||||
|
||||
Acceptance basis:
|
||||
- concrete missing decision / information は残っていない。
|
||||
- Ticket の scope は explicit Repository registry / API projection / web compatibility に閉じており、非目標も明確。
|
||||
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-07-04T19:36:56Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implementation routing update:
|
||||
|
||||
- Worktree-workflow に従い implementation worktree を作成した。
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/00001KWPC13WQ-repository-registry`
|
||||
- Branch: `work/00001KWPC13WQ-repository-registry`
|
||||
- Worktree には tracked `.yoi` project records が見えている。
|
||||
- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。
|
||||
- Sibling Coder Pod `yoi-coder-00001KWPC13WQ-repository-registry` を起動した。
|
||||
- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。
|
||||
- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-07-04T19:58:53Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implementation progress report:
|
||||
|
||||
- Coder Pod completed implementation and committed `2f0d1cee feat: add workspace repository registry` on branch `work/00001KWPC13WQ-repository-registry`.
|
||||
- Worktree status was clean after commit.
|
||||
- Changed files include:
|
||||
- `.yoi/workspace-backend.local.toml`
|
||||
- `resources/workspace-backend.default.toml`
|
||||
- `crates/workspace-server/src/config.rs`
|
||||
- `crates/workspace-server/src/lib.rs`
|
||||
- `crates/workspace-server/src/repositories.rs`
|
||||
- `crates/workspace-server/src/server.rs`
|
||||
- web workspace repository/sidebar route files.
|
||||
- Implementation summary: explicit `[[repositories]]` registry, config-root-relative `uri`, no implicit cwd/workspace-root repository fallback, empty registry diagnostic, configured-id-only log route, typed sanitized unknown/unsupported diagnostics, dogfood `main` repository entry.
|
||||
- Orchestrator reran validation in the implementation worktree:
|
||||
- `git diff --check`: pass
|
||||
- `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests)
|
||||
- `cargo check -p yoi`: pass
|
||||
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||
- `cd web/workspace && deno task test`: pass(13 tests)
|
||||
- `yoi ticket doctor`: ok
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
|
||||
- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: reviewer at: 2026-07-04T20:02:57Z status: request_changes -->
|
||||
|
||||
## Review: request changes
|
||||
|
||||
External review result: request_changes
|
||||
|
||||
Blockers:
|
||||
|
||||
1. Web UI still exposes an implicit hardcoded `main` repository instead of deriving repository navigation from configured repositories.
|
||||
- `web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte:12-25` always renders count `1` and a `/repositories/main` link labeled “configured repository”。
|
||||
- `WorkspacePage.svelte:25-29` still defaults `repositoryId = 'main'`, and `WorkspacePage.svelte:181-187` loads repository data/tickets even outside the repository route。
|
||||
- This violates the acceptance criterion that the web UI behave safely with no configured repositories and display repository views only for explicit repositories. In an empty registry workspace, the sidebar still advertises `main`。
|
||||
|
||||
2. Browser-facing repository summaries can leak local absolute host paths through Git remotes.
|
||||
- `crates/workspace-server/src/repositories.rs:223-225` reads `git remote -v`。
|
||||
- `parse_remotes` exposes `fetch_url` in `GitRemoteSummary` (`repositories.rs:42-46`, `274-291`)。
|
||||
- `sanitize_remote_url` only redacts `scheme://credentials@host` userinfo (`repositories.rs:338-345`); local absolute remotes such as `/home/.../repo.git` or `file:///home/...` are returned unchanged。
|
||||
- This conflicts with the Ticket invariant that browser-facing responses must not leak unnecessary absolute host paths/internal path details。
|
||||
|
||||
Validation / evidence reviewed:
|
||||
- Ticket `item.md`, routing decision / IntentPacket, and orchestration plan。
|
||||
- Commit `2f0d1cee` diff/stat/name list and relevant backend/web files。
|
||||
- `git diff --check 2f0d1cee^ 2f0d1cee` had no whitespace errors。
|
||||
- Positive backend pieces exist: explicit dogfood `main` config, config-level `[[repositories]]`, `uri = "."` workspace-root resolution test, empty-registry `repository_config_empty` diagnostic, and unknown/unsupported repository log route tests。
|
||||
|
||||
Non-blocking follow-ups:
|
||||
- Add web-side tests or component-model coverage for empty repository registry / non-`main` configured repository navigation so the hardcoded fallback cannot regress。
|
||||
- Consider a stricter public `GitRemoteSummary` shape that reports only sanitized display metadata, not raw fetch URLs。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-07-04T20:11:32Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Review-fix implementation report:
|
||||
|
||||
- Coder Pod completed requested changes and committed follow-up `14e63ca5 fix: derive repository UI from registry` on branch `work/00001KWPC13WQ-repository-registry`.
|
||||
- Worktree status was clean after commit.
|
||||
- Fix summary:
|
||||
- Web UI no longer hardcodes implicit `main` repository navigation/default route behavior.
|
||||
- Repository navigation is derived from configured repositories; empty registry does not invent `main` and can surface diagnostics such as `repository_config_empty`.
|
||||
- Repository details/tickets load only for explicit repository routes.
|
||||
- Browser-facing Git remote display values now redact local absolute remotes such as `/home/...`, `/Users/...`, Windows-style absolute paths, `file:///...`, and `file://localhost/...`, while preserving credential redaction.
|
||||
- Added focused web tests for empty registry and non-`main` configured repository navigation.
|
||||
- Orchestrator reran validation in the implementation worktree:
|
||||
- `git diff --check`: pass
|
||||
- `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests)
|
||||
- `cargo check -p yoi`: pass
|
||||
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||
- `cd web/workspace && deno task test`: pass(15 tests)
|
||||
- `yoi ticket doctor`: ok
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
|
||||
- Requesting follow-up external review against `2f0d1cee..14e63ca5` and full combined implementation。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: reviewer at: 2026-07-04T20:17:56Z status: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
External follow-up review result: approve
|
||||
|
||||
Blockers: none.
|
||||
|
||||
Evidence reviewed in implementation worktree only:
|
||||
- Ticket `00001KWPC13WQ` item/thread and IntentPacket: explicit config-driven registry, no cwd/workspace-root fallback, `uri = "."` relative to workspace config root, browser-facing path/secret invariant.
|
||||
- `git show --stat --check 14e63ca5`, `git diff --stat 2f0d1cee..14e63ca5`, combined changed-file set, and relevant full files.
|
||||
|
||||
Findings:
|
||||
- Prior blocker 1 fixed: `/api/repositories` is built from `config.repositories` only, empty registry returns empty items plus `repository_config_empty`, sidebar derives links via `projectRepositoryNav`, no hardcoded `/repositories/main` remains, `WorkspacePage` no longer defaults to `main`, repository fetches happen only for explicit route ids, and non-`main` ids are covered by tests。
|
||||
- Prior blocker 2 fixed: Browser-facing `RepositorySummary` does not serialize configured `uri` or resolved local `path`; Git command failure diagnostics are generic; remote URLs redact credentials and local absolute/file remotes including `/home/...`, `/Users/...`, Windows absolute paths, `file:///...`, and `file://localhost/...`, with tests。
|
||||
- `uri = "."` resolves against workspace/config root, not process cwd, with unit test。
|
||||
- Unknown repository ids map to typed `repository_not_configured` diagnostics and 404。
|
||||
- Unsupported providers for git log map to typed `repository_provider_unsupported` diagnostics and 400。
|
||||
- Dogfood `.yoi/workspace-backend.local.toml` contains explicit `main` git repository entry with `uri = "."` and no secret/local-only fields。
|
||||
- No implicit workspace-root/cwd repository fallback found in Browser/API output paths。
|
||||
|
||||
Validation note:
|
||||
- Reviewer did not rerun the full test suite; reviewer relied on Orchestrator-reported passing validation and performed read-only diff/file inspection。
|
||||
- `git show --check` had no whitespace errors for the fix commit。
|
||||
|
||||
No merge-blocking issues found.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-07-04T20:19:02Z from: inprogress to: done reason: merged_validated_review_approved field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Merge/validation acceptance:
|
||||
|
||||
- Follow-up external review approved the implementation after blocker fixes。
|
||||
- Implementation branch `work/00001KWPC13WQ-repository-registry` was merged into the orchestration branch with merge commit `a786fd85 merge: repository registry implementation`。
|
||||
- Final validation in Orchestrator worktree passed:
|
||||
- `git diff --check`: pass
|
||||
- `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests)
|
||||
- `cargo check -p yoi`: pass
|
||||
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||
- `cd web/workspace && deno task test`: pass(15 tests)
|
||||
- `yoi ticket doctor`: ok
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
|
||||
|
||||
Implementation commits:
|
||||
- `2f0d1cee feat: add workspace repository registry`
|
||||
- `14e63ca5 fix: derive repository UI from registry`
|
||||
|
||||
No merge-blocking issues remain.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: hare at: 2026-07-04T20:19:15Z from: done to: closed reason: closed field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket を closed にしました。
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-07-04T20:19:15Z status: closed -->
|
||||
|
||||
## 完了
|
||||
|
||||
完了。
|
||||
|
||||
実装内容:
|
||||
- Workspace Backend に explicit `[[repositories]]` registry を追加した。
|
||||
- `.yoi/workspace-backend.local.toml` / `resources/workspace-backend.default.toml` に Repository config schema を導入し、dogfood workspace config に explicit `main` Git repository entry (`uri = "."`) を追加した。
|
||||
- `uri = "."` は workspace config root 相対として解釈し、process cwd / workspace root を暗黙 Repository として扱う fallback を廃止した。
|
||||
- `/api/repositories` は configured repositories のみを返し、未設定時は empty list + `repository_config_empty` warning diagnostic を返す。
|
||||
- `/api/repositories/{id}/log` は configured Git repository id のみを受け付け、未知 id / unsupported provider は typed sanitized diagnostics で fail closed するようにした。
|
||||
- Browser-facing repository summary は configured `uri` / resolved local path / secret / internal path を露出せず、Git remote も credentials / local absolute path / `file://...` を redaction するようにした。
|
||||
- Web UI は configured repositories から navigation を導出し、empty registry で hardcoded `main` repository を invent しないようにした。
|
||||
- Focused backend/web tests を追加した。
|
||||
|
||||
主な commit / merge:
|
||||
- implementation: `2f0d1cee feat: add workspace repository registry`
|
||||
- review fix: `14e63ca5 fix: derive repository UI from registry`
|
||||
- merge into orchestration: `a786fd85 merge: repository registry implementation`
|
||||
|
||||
Review:
|
||||
- 初回 external review は request_changes。
|
||||
- follow-up external review は approve。merge-blocking issue なし。
|
||||
|
||||
Final validation in Orchestrator worktree:
|
||||
- `git diff --check`: pass
|
||||
- `cargo test -p yoi-workspace-server`: pass(63 lib tests + 2 main tests)
|
||||
- `cargo check -p yoi`: pass
|
||||
- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings)
|
||||
- `cd web/workspace && deno task test`: pass(15 tests)
|
||||
- `yoi ticket doctor`: ok
|
||||
|
||||
未実行:
|
||||
- `nix build .#yoi --no-link` は Cargo.lock / dependency / resource packaging / Nix 変更ではないため未実行。
|
||||
|
||||
---
|
||||
|
|
|
|||
9
.yoi/workspace-backend.local.toml
Normal file
9
.yoi/workspace-backend.local.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Dogfood Workspace Backend configuration.
|
||||
# Repository URI values are resolved from this workspace config root.
|
||||
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "."
|
||||
display_name = "Yoi"
|
||||
default_selector = "HEAD"
|
||||
|
|
@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::hosts::RemoteRuntimeConfig;
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::repositories::ConfiguredRepository;
|
||||
use crate::server::{AuthConfig, ServerConfig};
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -26,6 +27,8 @@ pub struct WorkspaceBackendConfigFile {
|
|||
#[serde(default)]
|
||||
pub limits: WorkspaceBackendLimitsConfig,
|
||||
#[serde(default)]
|
||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
||||
#[serde(default)]
|
||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +61,18 @@ pub struct WorkspaceBackendLimitsConfig {
|
|||
pub max_records: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRepositoryConfigFile {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceBackendRuntimesConfig {
|
||||
|
|
@ -264,6 +279,11 @@ impl WorkspaceBackendConfigFile {
|
|||
.map(|path| resolve_workspace_path(workspace_root, path));
|
||||
server.embedded_runtime_store_root = embedded_runtime_store_root;
|
||||
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
|
||||
server.repositories = self
|
||||
.repositories
|
||||
.iter()
|
||||
.map(|repository| resolve_repository(workspace_root, repository))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
server.remote_runtime_sources = self
|
||||
.runtimes
|
||||
.remote
|
||||
|
|
@ -299,6 +319,70 @@ impl ResolvedWorkspaceBackendConfig {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_repository(
|
||||
workspace_root: &Path,
|
||||
config: &WorkspaceRepositoryConfigFile,
|
||||
) -> Result<ConfiguredRepository> {
|
||||
let id = normalize_required_string("repository id", &config.id)?;
|
||||
validate_repository_id(&id)?;
|
||||
let provider =
|
||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
||||
let path = resolve_repository_uri(workspace_root, &id, &uri)?;
|
||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
||||
|
||||
Ok(ConfiguredRepository {
|
||||
id,
|
||||
provider,
|
||||
uri,
|
||||
path,
|
||||
display_name,
|
||||
default_selector,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(Error::Config(format!("{field} must not be empty")));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_repository_id(id: &str) -> Result<()> {
|
||||
if id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Config(format!(
|
||||
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result<PathBuf> {
|
||||
if uri.contains("://") {
|
||||
return Err(Error::Config(format!(
|
||||
"repository `{id}` uses a remote URI, but remote repository materialization is not implemented"
|
||||
)));
|
||||
}
|
||||
Ok(resolve_workspace_path(workspace_root, Path::new(uri)))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_remote_runtime(
|
||||
config: &RemoteRuntimeConfigFile,
|
||||
) -> Result<RemoteRuntimeConfig> {
|
||||
|
|
@ -479,6 +563,57 @@ root = ".local-data"
|
|||
assert!(diff.text.contains("127.0.0.1:9999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_repository_uri_relative_to_workspace_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "."
|
||||
display_name = "Main"
|
||||
default_selector = "HEAD"
|
||||
"#,
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
||||
let repository = resolved.server.repositories.first().unwrap();
|
||||
|
||||
assert_eq!(repository.id, "main");
|
||||
assert_eq!(repository.provider, "git");
|
||||
assert_eq!(repository.path, dir.path());
|
||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_repository_uri_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = WorkspaceBackendConfigFile::parse_str(
|
||||
r#"
|
||||
[[repositories]]
|
||||
id = "main"
|
||||
provider = "git"
|
||||
uri = "https://example.com/org/repo.git"
|
||||
"#,
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
let error = match config.resolve(dir.path(), identity()) {
|
||||
Ok(_) => panic!("remote repository URI should fail closed"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("remote repository materialization is not implemented"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_value_field_is_not_in_schema() {
|
||||
let error = WorkspaceBackendConfigFile::parse_str(
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ pub use records::{
|
|||
LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary,
|
||||
};
|
||||
pub use repositories::{
|
||||
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader,
|
||||
RepositoryLogRead, RepositorySummary,
|
||||
ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary,
|
||||
RepositoryLogRead, RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve};
|
||||
pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
|
|
|||
|
|
@ -1,351 +1,379 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
pub type RepositoryId = String;
|
||||
pub type RepositorySelector = String;
|
||||
|
||||
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
|
||||
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
|
||||
const MAX_COMMAND_OUTPUT: usize = 4096;
|
||||
const DEFAULT_LOG_LIMIT: usize = 10;
|
||||
const MAX_LOG_LIMIT: usize = 50;
|
||||
const MAX_FIELD_LEN: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepositoryReader {
|
||||
workspace_root: PathBuf,
|
||||
workspace_id: String,
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredRepository {
|
||||
pub id: RepositoryId,
|
||||
pub provider: String,
|
||||
pub uri: String,
|
||||
pub path: PathBuf,
|
||||
pub display_name: Option<String>,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
}
|
||||
|
||||
impl LocalRepositoryReader {
|
||||
pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_root: workspace_root.into(),
|
||||
workspace_id: workspace_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(&self, workspace_display_name: &str) -> Vec<RepositorySummary> {
|
||||
vec![self.summary(workspace_display_name)]
|
||||
}
|
||||
|
||||
pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary {
|
||||
let git = inspect_git(&self.workspace_root);
|
||||
RepositorySummary {
|
||||
id: Self::repository_id_for_workspace(&self.workspace_id),
|
||||
display_name: workspace_display_name.to_string(),
|
||||
kind: "local".to_string(),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
record_authority: "local_workspace_root".to_string(),
|
||||
git,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recent_log(&self, requested_limit: Option<usize>) -> RepositoryLogRead {
|
||||
let limit = requested_limit
|
||||
.unwrap_or(DEFAULT_LOG_LIMIT)
|
||||
.clamp(1, MAX_LOG_LIMIT);
|
||||
git_log(&self.workspace_root, limit)
|
||||
}
|
||||
|
||||
pub fn repository_id_for_workspace(workspace_id: &str) -> String {
|
||||
format!(
|
||||
"{LOCAL_REPOSITORY_PREFIX}{}",
|
||||
sanitize_identifier_fragment(workspace_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool {
|
||||
id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_identifier_fragment(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut previous_dash = false;
|
||||
for ch in value.chars() {
|
||||
let mapped = if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
if mapped == '-' {
|
||||
if !previous_dash {
|
||||
output.push(mapped);
|
||||
}
|
||||
previous_dash = true;
|
||||
} else {
|
||||
output.push(mapped);
|
||||
previous_dash = false;
|
||||
}
|
||||
}
|
||||
let output = output.trim_matches('-').to_string();
|
||||
if output.is_empty() {
|
||||
"workspace".to_string()
|
||||
} else {
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositorySummary {
|
||||
pub id: String,
|
||||
pub id: RepositoryId,
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub provider: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub record_authority: String,
|
||||
pub git: GitRepositorySummary,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitRepositorySummary>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GitRepositorySummary {
|
||||
pub status: String,
|
||||
pub root: Option<PathBuf>,
|
||||
pub branch: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub dirty: Option<bool>,
|
||||
pub dirty_scope: String,
|
||||
pub remote: Option<GitRemoteSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
pub branch: Option<String>,
|
||||
pub dirty: bool,
|
||||
pub remotes: Vec<GitRemoteSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GitRemoteSummary {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub redacted: bool,
|
||||
pub fetch_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryDiagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepositoryListProjection {
|
||||
pub items: Vec<RepositorySummary>,
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub repository_id: RepositoryId,
|
||||
pub default_selector: Option<RepositorySelector>,
|
||||
pub limit: usize,
|
||||
pub commits: Vec<GitCommitSummary>,
|
||||
pub diagnostics: Vec<RepositoryDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GitCommitSummary {
|
||||
pub hash: String,
|
||||
pub subject: String,
|
||||
pub short_hash: String,
|
||||
pub summary: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub timestamp: String,
|
||||
pub author_date: String,
|
||||
pub parents: Vec<String>,
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositoryLogRead {
|
||||
pub limit: usize,
|
||||
pub items: Vec<GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RepositoryLookupError {
|
||||
UnknownRepository { id: RepositoryId },
|
||||
UnsupportedProvider { id: RepositoryId, provider: String },
|
||||
}
|
||||
|
||||
fn inspect_git(workspace_root: &Path) -> GitRepositorySummary {
|
||||
let mut diagnostics = Vec::new();
|
||||
let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
|
||||
Ok(root) => PathBuf::from(root.trim()),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_unavailable",
|
||||
"info",
|
||||
format!("Workspace root is not available as a Git repository: {message}"),
|
||||
));
|
||||
return GitRepositorySummary {
|
||||
status: "unavailable".to_string(),
|
||||
root: None,
|
||||
branch: None,
|
||||
head: None,
|
||||
dirty: None,
|
||||
dirty_scope: "tracked_changes_only".to_string(),
|
||||
remote: None,
|
||||
diagnostics,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepositoryRegistryReader {
|
||||
repositories: Vec<ConfiguredRepository>,
|
||||
}
|
||||
|
||||
impl RepositoryRegistryReader {
|
||||
pub fn new(repositories: Vec<ConfiguredRepository>) -> Self {
|
||||
Self { repositories }
|
||||
}
|
||||
|
||||
pub fn list(&self) -> RepositoryListProjection {
|
||||
if self.repositories.is_empty() {
|
||||
return RepositoryListProjection {
|
||||
items: Vec::new(),
|
||||
diagnostics: vec![RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_config_empty".to_string(),
|
||||
message: "No repositories are configured for this workspace backend."
|
||||
.to_string(),
|
||||
}],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let branch = git_stdout(workspace_root, &["branch", "--show-current"])
|
||||
.ok()
|
||||
.map(|value| truncate_field(value.trim(), MAX_FIELD_LEN))
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| Some("detached".to_string()));
|
||||
let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) {
|
||||
Ok(value) => Some(truncate_field(value.trim(), 40)),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_head_unavailable",
|
||||
"warn",
|
||||
format!("Git HEAD summary is unavailable: {message}"),
|
||||
));
|
||||
None
|
||||
RepositoryListProjection {
|
||||
items: self
|
||||
.repositories
|
||||
.iter()
|
||||
.map(|repository| self.summary_for_config(repository))
|
||||
.collect(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
};
|
||||
let dirty = match git_stdout(
|
||||
workspace_root,
|
||||
&["status", "--porcelain=v1", "--untracked-files=no"],
|
||||
) {
|
||||
Ok(value) => Some(!value.trim().is_empty()),
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_status_unavailable",
|
||||
"warn",
|
||||
format!("Git dirty status is unavailable: {message}"),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) {
|
||||
Ok(value) => {
|
||||
let (url, redacted) = sanitize_remote_url(value.trim());
|
||||
Some(GitRemoteSummary {
|
||||
name: "origin".to_string(),
|
||||
url,
|
||||
redacted,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_origin_remote_missing",
|
||||
"info",
|
||||
"No origin remote is configured or visible through the bounded Git summary."
|
||||
.to_string(),
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
GitRepositorySummary {
|
||||
status: "available".to_string(),
|
||||
root: Some(root),
|
||||
branch,
|
||||
head,
|
||||
dirty,
|
||||
dirty_scope: "tracked_changes_only".to_string(),
|
||||
remote,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead {
|
||||
let mut diagnostics = Vec::new();
|
||||
if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_unavailable",
|
||||
"info",
|
||||
format!("Recent Git log is unavailable for this local repository: {message}"),
|
||||
));
|
||||
return RepositoryLogRead {
|
||||
limit,
|
||||
items: Vec::new(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
match git_stdout(
|
||||
workspace_root,
|
||||
&[
|
||||
"log",
|
||||
"--no-show-signature",
|
||||
"--date=iso-strict",
|
||||
"--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e",
|
||||
"-n",
|
||||
&limit.to_string(),
|
||||
],
|
||||
) {
|
||||
Ok(output) => RepositoryLogRead {
|
||||
limit,
|
||||
items: parse_log(output.as_str()),
|
||||
diagnostics,
|
||||
},
|
||||
Err(message) => {
|
||||
diagnostics.push(diagnostic(
|
||||
"git_log_unavailable",
|
||||
"warn",
|
||||
format!("Recent Git log is unavailable: {message}"),
|
||||
));
|
||||
RepositoryLogRead {
|
||||
limit,
|
||||
items: Vec::new(),
|
||||
diagnostics,
|
||||
pub fn summary(&self, id: &str) -> Result<RepositorySummary, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
|
||||
Ok(self.summary_for_config(repository))
|
||||
}
|
||||
|
||||
pub fn recent_log(
|
||||
&self,
|
||||
id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<RepositoryLogRead, RepositoryLookupError> {
|
||||
let repository = self
|
||||
.find(id)
|
||||
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?;
|
||||
if repository.provider != "git" {
|
||||
return Err(RepositoryLookupError::UnsupportedProvider {
|
||||
id: repository.id.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let limit = limit.unwrap_or(40).clamp(1, 200);
|
||||
let mut diagnostics = Vec::new();
|
||||
let commits = match self.git_log(repository, limit) {
|
||||
Ok(commits) => commits,
|
||||
Err(message) => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_git_log_unavailable".to_string(),
|
||||
message,
|
||||
});
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(RepositoryLogRead {
|
||||
repository_id: repository.id.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
limit,
|
||||
commits,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
|
||||
self.repositories
|
||||
.iter()
|
||||
.find(|repository| repository.id == id)
|
||||
}
|
||||
|
||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||
let display_name = repository
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| repository.id.clone());
|
||||
let mut diagnostics = Vec::new();
|
||||
let git = match repository.provider.as_str() {
|
||||
"git" => match self.inspect_git(repository) {
|
||||
Ok(git) => Some(git),
|
||||
Err(message) => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_git_unavailable".to_string(),
|
||||
message,
|
||||
});
|
||||
None
|
||||
}
|
||||
},
|
||||
provider => {
|
||||
diagnostics.push(RepositoryDiagnostic {
|
||||
severity: "warning".to_string(),
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
message: format!(
|
||||
"Repository provider `{provider}` is configured but is not supported by the workspace backend API."
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
RepositorySummary {
|
||||
id: repository.id.clone(),
|
||||
display_name,
|
||||
kind: repository.provider.clone(),
|
||||
provider: repository.provider.clone(),
|
||||
default_selector: repository.default_selector.clone(),
|
||||
record_authority: "workspace-backend-config".to_string(),
|
||||
git,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_git(
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
) -> Result<GitRepositorySummary, String> {
|
||||
let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?;
|
||||
let branch = git_stdout(&repository.path, ["branch", "--show-current"])
|
||||
.ok()
|
||||
.and_then(|value| non_empty_string(value.trim()));
|
||||
let status = git_stdout(&repository.path, ["status", "--porcelain"])?;
|
||||
let remotes = git_stdout(&repository.path, ["remote", "-v"])
|
||||
.map(|raw| parse_remotes(&raw))
|
||||
.unwrap_or_default();
|
||||
Ok(GitRepositorySummary {
|
||||
status: "available".to_string(),
|
||||
head: non_empty_string(head.trim()),
|
||||
branch,
|
||||
dirty: !status.trim().is_empty(),
|
||||
remotes,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_log(
|
||||
&self,
|
||||
repository: &ConfiguredRepository,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GitCommitSummary>, String> {
|
||||
let limit_arg = format!("-{limit}");
|
||||
let output = git_stdout(
|
||||
&repository.path,
|
||||
[
|
||||
"log",
|
||||
"--date=iso-strict",
|
||||
"--decorate=short",
|
||||
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P%x1f%D%x1e",
|
||||
limit_arg.as_str(),
|
||||
],
|
||||
)?;
|
||||
Ok(parse_git_log(&output))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_log(output: &str) -> Vec<GitCommitSummary> {
|
||||
output
|
||||
.split('\u{1e}')
|
||||
fn git_stdout<'a, I>(repository_path: &PathBuf, args: I) -> Result<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repository_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|_| {
|
||||
"Git command could not be executed; backend-private path details were omitted."
|
||||
.to_string()
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err("Git command failed; backend-private path details were omitted.".to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn parse_remotes(raw: &str) -> Vec<GitRemoteSummary> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut remotes = Vec::new();
|
||||
for line in raw.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
let Some(name) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some(url) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert((name.to_string(), url.to_string())) {
|
||||
continue;
|
||||
}
|
||||
remotes.push(GitRemoteSummary {
|
||||
name: name.to_string(),
|
||||
fetch_url: sanitize_remote_url(url),
|
||||
});
|
||||
}
|
||||
remotes
|
||||
}
|
||||
|
||||
fn parse_git_log(raw: &str) -> Vec<GitCommitSummary> {
|
||||
raw.split('\u{1e}')
|
||||
.filter_map(|record| {
|
||||
let record = record.trim_matches('\n');
|
||||
if record.is_empty() {
|
||||
let trimmed = record.trim_matches('\n').trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut fields = record.split('\u{1f}');
|
||||
let mut fields = trimmed.split('\u{1f}');
|
||||
let hash = fields.next()?.to_string();
|
||||
let short_hash = fields.next().unwrap_or_default().to_string();
|
||||
let summary = fields.next().unwrap_or_default().to_string();
|
||||
let author_name = fields.next().unwrap_or_default().to_string();
|
||||
let author_email = fields.next().unwrap_or_default().to_string();
|
||||
let author_date = fields.next().unwrap_or_default().to_string();
|
||||
let parents = fields
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let refs = fields
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|reference| !reference.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
Some(GitCommitSummary {
|
||||
hash: truncate_field(fields.next()?, 40),
|
||||
author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN),
|
||||
hash,
|
||||
short_hash,
|
||||
summary,
|
||||
author_name,
|
||||
author_email,
|
||||
author_date,
|
||||
parents,
|
||||
refs,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace_root)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?;
|
||||
command_stdout(output)
|
||||
fn sanitize_remote_url(url: &str) -> String {
|
||||
let trimmed = url.trim();
|
||||
if is_local_path_like(trimmed) {
|
||||
return "<redacted-local-path>".to_string();
|
||||
}
|
||||
|
||||
let Some((scheme, rest)) = trimmed.split_once("://") else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if scheme.eq_ignore_ascii_case("file") {
|
||||
return "file://<redacted-local-path>".to_string();
|
||||
}
|
||||
let Some((_credentials, host_path)) = rest.split_once('@') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
format!("{scheme}://<redacted>@{host_path}")
|
||||
}
|
||||
|
||||
fn command_stdout(output: Output) -> Result<String, String> {
|
||||
if output.status.success() {
|
||||
return Ok(truncate_output(
|
||||
String::from_utf8_lossy(&output.stdout).as_ref(),
|
||||
));
|
||||
}
|
||||
let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref());
|
||||
if stderr.trim().is_empty() {
|
||||
Err(format!("git exited with status {}", output.status))
|
||||
fn is_local_path_like(value: &str) -> bool {
|
||||
Path::new(value).is_absolute() || is_windows_absolute_path_like(value)
|
||||
}
|
||||
|
||||
fn is_windows_absolute_path_like(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/')
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Err(stderr.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_remote_url(raw: &str) -> (String, bool) {
|
||||
let bounded = truncate_field(raw, MAX_FIELD_LEN);
|
||||
let Some(separator) = bounded.find("://") else {
|
||||
return (bounded, false);
|
||||
};
|
||||
let scheme_end = separator + 3;
|
||||
let after_scheme = &bounded[scheme_end..];
|
||||
let Some(at_index) = after_scheme.find('@') else {
|
||||
return (bounded, false);
|
||||
};
|
||||
let host_and_path = &after_scheme[(at_index + 1)..];
|
||||
(format!("{}{}", &bounded[..scheme_end], host_and_path), true)
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str) -> String {
|
||||
truncate_field(value, MAX_COMMAND_OUTPUT)
|
||||
}
|
||||
|
||||
fn truncate_field(value: &str, limit: usize) -> String {
|
||||
if value.len() <= limit {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut end = limit;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value[..end].to_string()
|
||||
}
|
||||
|
||||
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
|
||||
RuntimeDiagnostic {
|
||||
code: code.to_string(),
|
||||
severity: match severity {
|
||||
"error" => DiagnosticSeverity::Error,
|
||||
"warning" => DiagnosticSeverity::Warning,
|
||||
_ => DiagnosticSeverity::Info,
|
||||
},
|
||||
message,
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,24 +382,55 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_userinfo_from_url_remotes() {
|
||||
fn sanitizes_remote_credentials_and_local_paths() {
|
||||
assert_eq!(
|
||||
sanitize_remote_url("https://token@example.com/org/repo.git"),
|
||||
("https://example.com/org/repo.git".to_string(), true)
|
||||
sanitize_remote_url("https://user:token@example.com/org/repo.git"),
|
||||
"https://<redacted>@example.com/org/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("git@example.com:org/repo.git"),
|
||||
("git@example.com:org/repo.git".to_string(), false)
|
||||
"git@example.com:org/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("/home/alice/private/repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("/Users/alice/private/repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"),
|
||||
"<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("file:///home/alice/private/repo.git"),
|
||||
"file://<redacted-local-path>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_remote_url("file://localhost/home/alice/private/repo.git"),
|
||||
"file://<redacted-local-path>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bounded_git_log_records() {
|
||||
let parsed = parse_log(
|
||||
"0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n",
|
||||
fn empty_registry_reports_diagnostic_without_implicit_repository() {
|
||||
let projection = RepositoryRegistryReader::new(Vec::new()).list();
|
||||
|
||||
assert!(projection.items.is_empty());
|
||||
assert_eq!(projection.diagnostics.len(), 1);
|
||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_repository_is_not_resolved_from_fallback() {
|
||||
let reader = RepositoryRegistryReader::new(Vec::new());
|
||||
|
||||
assert_eq!(
|
||||
reader.summary("main").unwrap_err(),
|
||||
RepositoryLookupError::UnknownRepository {
|
||||
id: "main".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].hash, "0123456789abcdef");
|
||||
assert_eq!(parsed[0].subject, "Subject");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ use crate::observation::{
|
|||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
};
|
||||
use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary};
|
||||
use crate::repositories::{
|
||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||
RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRecord};
|
||||
use crate::{Error, Result};
|
||||
use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector};
|
||||
|
|
@ -68,6 +71,7 @@ pub struct ServerConfig {
|
|||
pub static_assets_dir: Option<PathBuf>,
|
||||
pub auth: AuthConfig,
|
||||
pub max_records: usize,
|
||||
pub repositories: Vec<ConfiguredRepository>,
|
||||
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
|
||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||
}
|
||||
|
|
@ -89,6 +93,7 @@ impl ServerConfig {
|
|||
token_configured: false,
|
||||
},
|
||||
max_records: 200,
|
||||
repositories: Vec::new(),
|
||||
runtime_event_sources: Vec::new(),
|
||||
remote_runtime_sources: Vec::new(),
|
||||
}
|
||||
|
|
@ -202,19 +207,8 @@ impl WorkspaceApi {
|
|||
self.config.workspace_id.as_str()
|
||||
}
|
||||
|
||||
fn local_repository_reader(&self) -> LocalRepositoryReader {
|
||||
LocalRepositoryReader::new(
|
||||
self.config.workspace_root.clone(),
|
||||
self.config.workspace_id.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_repository_id(&self) -> String {
|
||||
LocalRepositoryReader::repository_id_for_workspace(self.workspace_id())
|
||||
}
|
||||
|
||||
fn workspace_display_name(&self) -> &str {
|
||||
self.config.workspace_display_name.as_str()
|
||||
fn repository_reader(&self) -> RepositoryRegistryReader {
|
||||
RepositoryRegistryReader::new(self.config.repositories.clone())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +473,8 @@ pub struct RepositoryDetailResponse {
|
|||
pub struct RepositoryLogResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_selector: Option<String>,
|
||||
pub limit: usize,
|
||||
pub items: Vec<crate::repositories::GitCommitSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
|
|
@ -633,13 +629,12 @@ async fn get_objective(
|
|||
async fn list_repositories(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RepositoryListResponse>> {
|
||||
let reader = api.local_repository_reader();
|
||||
let items = reader.list(api.workspace_display_name());
|
||||
let RepositoryListProjection { items, diagnostics } = api.repository_reader().list();
|
||||
Ok(Json(RepositoryListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
items,
|
||||
source: "local_workspace_root".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
source: "workspace_backend_config".to_string(),
|
||||
diagnostics: repository_diagnostics(diagnostics),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -647,12 +642,11 @@ async fn repository_detail(
|
|||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(repository_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RepositoryDetailResponse>> {
|
||||
let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let reader = api.local_repository_reader();
|
||||
let item = repository_lookup(api.repository_reader().summary(&repository_id))?;
|
||||
Ok(Json(RepositoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
item: reader.summary(api.workspace_display_name()),
|
||||
source: "local_workspace_root".to_string(),
|
||||
item,
|
||||
source: "workspace_backend_config".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -661,18 +655,23 @@ async fn repository_log(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<LogQuery>,
|
||||
) -> ApiResult<Json<RepositoryLogResponse>> {
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
let RepositoryLogRead {
|
||||
repository_id,
|
||||
default_selector,
|
||||
limit,
|
||||
items,
|
||||
commits,
|
||||
diagnostics,
|
||||
} = api.local_repository_reader().recent_log(query.limit);
|
||||
} = repository_lookup(
|
||||
api.repository_reader()
|
||||
.recent_log(&repository_id, query.limit),
|
||||
)?;
|
||||
Ok(Json(RepositoryLogResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
repository_id: canonical_repository_id,
|
||||
repository_id,
|
||||
default_selector,
|
||||
limit,
|
||||
items,
|
||||
diagnostics,
|
||||
items: commits,
|
||||
diagnostics: repository_diagnostics(diagnostics),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +680,8 @@ async fn repository_tickets(
|
|||
AxumPath(repository_id): AxumPath<String>,
|
||||
Query(query): Query<TicketKanbanQuery>,
|
||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
||||
let canonical_repository_id = ensure_local_repository(&api, &repository_id)?;
|
||||
repository_lookup(api.repository_reader().summary(&repository_id))?;
|
||||
let canonical_repository_id = repository_id;
|
||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
||||
let ProjectRecordList {
|
||||
items,
|
||||
|
|
@ -2056,13 +2056,58 @@ fn sanitize_backend_error(_message: &str) -> String {
|
|||
"operation failed; backend-private details were omitted".to_string()
|
||||
}
|
||||
|
||||
fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result<String> {
|
||||
let canonical_repository_id = api.local_repository_id();
|
||||
if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) {
|
||||
Ok(canonical_repository_id)
|
||||
} else {
|
||||
Err(Error::UnknownRepository(repository_id.to_string()))
|
||||
}
|
||||
fn repository_diagnostics(
|
||||
diagnostics: Vec<crate::repositories::RepositoryDiagnostic>,
|
||||
) -> Vec<RuntimeDiagnostic> {
|
||||
diagnostics
|
||||
.into_iter()
|
||||
.map(|diagnostic| RuntimeDiagnostic {
|
||||
code: diagnostic.code,
|
||||
severity: match diagnostic.severity.as_str() {
|
||||
"error" => DiagnosticSeverity::Error,
|
||||
"warning" => DiagnosticSeverity::Warning,
|
||||
_ => DiagnosticSeverity::Info,
|
||||
},
|
||||
message: diagnostic.message,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
|
||||
result.map_err(|error| match error {
|
||||
RepositoryLookupError::UnknownRepository { id } => {
|
||||
let message = format!("repository `{id}` is not configured for this workspace");
|
||||
ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-repository-registry".to_string(),
|
||||
code: "repository_not_configured".to_string(),
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "repository_not_configured".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message,
|
||||
}],
|
||||
)
|
||||
}
|
||||
RepositoryLookupError::UnsupportedProvider { id, provider } => {
|
||||
let message = format!(
|
||||
"repository `{id}` uses unsupported provider `{provider}` for this operation"
|
||||
);
|
||||
ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-repository-registry".to_string(),
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
message: message.clone(),
|
||||
},
|
||||
vec![RuntimeDiagnostic {
|
||||
code: "repository_provider_unsupported".to_string(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message,
|
||||
}],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
|
||||
|
|
@ -2231,6 +2276,14 @@ impl IntoResponse for ApiError {
|
|||
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
|
||||
Error::Ticket(_) => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "repository_provider_unsupported" =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
|
|
@ -2290,7 +2343,7 @@ mod tests {
|
|||
use crate::store::SqliteWorkspaceStore;
|
||||
|
||||
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
||||
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
|
||||
const TEST_REPOSITORY_ID: &str = "main";
|
||||
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
||||
|
||||
#[test]
|
||||
|
|
@ -2402,8 +2455,17 @@ mod tests {
|
|||
fn test_server_config(workspace_root: impl Into<PathBuf>) -> ServerConfig {
|
||||
let workspace_root = workspace_root.into();
|
||||
let store_root = workspace_root.join(".test-embedded-runtime-store");
|
||||
ServerConfig::local_dev(workspace_root, test_identity())
|
||||
.with_embedded_runtime_store_root(store_root)
|
||||
let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity())
|
||||
.with_embedded_runtime_store_root(store_root);
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: TEST_REPOSITORY_ID.to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: workspace_root,
|
||||
display_name: Some("Test Repository".to_string()),
|
||||
default_selector: Some("HEAD".to_string()),
|
||||
}];
|
||||
config
|
||||
}
|
||||
|
||||
async fn test_app(workspace_root: impl Into<PathBuf>) -> Router {
|
||||
|
|
@ -2861,16 +2923,26 @@ mod tests {
|
|||
|
||||
let repositories = get_json(app.clone(), "/api/repositories").await;
|
||||
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repositories["items"][0]["kind"], "local");
|
||||
assert_eq!(repositories["items"][0]["kind"], "git");
|
||||
assert_eq!(
|
||||
repositories["items"][0]["record_authority"],
|
||||
"workspace-backend-config"
|
||||
);
|
||||
assert!(
|
||||
repositories
|
||||
.to_string()
|
||||
.contains("repository_git_unavailable")
|
||||
);
|
||||
|
||||
let repository_detail = get_json(app.clone(), "/api/repositories/local").await;
|
||||
let repository_detail = get_json(app.clone(), "/api/repositories/main").await;
|
||||
assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID);
|
||||
|
||||
let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await;
|
||||
let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await;
|
||||
assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID);
|
||||
assert_eq!(repository_log["default_selector"], "HEAD");
|
||||
assert_eq!(repository_log["limit"], 3);
|
||||
|
||||
let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await;
|
||||
let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await;
|
||||
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
|
||||
let ready_column = repository_tickets["columns"]
|
||||
.as_array()
|
||||
|
|
@ -3324,6 +3396,77 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_repository_config_returns_empty_list_with_warning() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories.clear();
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
config,
|
||||
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
let repositories = get_json(app, "/api/repositories").await;
|
||||
|
||||
assert!(repositories["items"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
repositories["diagnostics"][0]["code"],
|
||||
"repository_config_empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_log_rejects_unknown_or_unsupported_configured_repository() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(root.path());
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: "files".to_string(),
|
||||
provider: "local_fs".to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: root.path().to_path_buf(),
|
||||
display_name: None,
|
||||
default_selector: None,
|
||||
}];
|
||||
let api = WorkspaceApi::new_with_execution_backend(
|
||||
config,
|
||||
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
|
||||
Arc::new(DeterministicExecutionBackend::default()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_router(api);
|
||||
|
||||
let unknown = request_json(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/repositories/main/log",
|
||||
None,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
unknown["diagnostics"][0]["code"],
|
||||
"repository_not_configured"
|
||||
);
|
||||
|
||||
let unsupported = request_json(
|
||||
app,
|
||||
"GET",
|
||||
"/api/repositories/files/log",
|
||||
None,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
unsupported["diagnostics"][0]["code"],
|
||||
"repository_provider_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ frontend_url = "http://127.0.0.1:5173"
|
|||
[limits]
|
||||
max_records = 200
|
||||
|
||||
# Repository registry. Browser/API repository projection reads only configured
|
||||
# entries and never falls back to the backend process cwd. Relative URI values
|
||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
||||
#
|
||||
# [[repositories]]
|
||||
# id = "main"
|
||||
# provider = "git"
|
||||
# uri = "."
|
||||
# display_name = "Main repository"
|
||||
# default_selector = "HEAD"
|
||||
|
||||
# Remote Runtime sources. Token values must not be written here.
|
||||
# Use token_ref only after secret-ref resolution is implemented for this config.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts",
|
||||
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
ObjectiveDetail,
|
||||
ObjectiveListResponse,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositorySummary,
|
||||
RepositoryTicketsResponse,
|
||||
Worker,
|
||||
|
|
@ -17,19 +18,21 @@
|
|||
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
|
||||
|
||||
type RouteState =
|
||||
| { page: 'overview'; objectiveId?: undefined }
|
||||
| { page: 'repository'; objectiveId?: undefined }
|
||||
| { page: 'objectives'; objectiveId?: undefined }
|
||||
| { page: 'objective'; objectiveId: string };
|
||||
| { page: 'overview'; objectiveId?: undefined; repositoryId?: undefined }
|
||||
| { page: 'repository'; repositoryId: string; objectiveId?: undefined }
|
||||
| { page: 'objectives'; objectiveId?: undefined; repositoryId?: undefined }
|
||||
| { page: 'objective'; objectiveId: string; repositoryId?: undefined };
|
||||
|
||||
let {
|
||||
view = 'overview',
|
||||
objectiveId = null
|
||||
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props();
|
||||
objectiveId = null,
|
||||
repositoryId = null
|
||||
}: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
|
||||
|
||||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let repositories = $state<RepositoryListResponse | null>(null);
|
||||
let repository = $state<RepositorySummary | null>(null);
|
||||
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
||||
let objectives = $state<ObjectiveListResponse | null>(null);
|
||||
|
|
@ -38,13 +41,14 @@
|
|||
let workspaceError = $state<string | null>(null);
|
||||
let hostsError = $state<string | null>(null);
|
||||
let workersError = $state<string | null>(null);
|
||||
let repositoriesError = $state<string | null>(null);
|
||||
let repositoryError = $state<string | null>(null);
|
||||
let repositoryTicketsError = $state<string | null>(null);
|
||||
let objectivesError = $state<string | null>(null);
|
||||
let objectiveDetailError = $state<string | null>(null);
|
||||
let objectiveDetailLoading = $state(false);
|
||||
let objectiveDetailRequest = 0;
|
||||
let route = $derived(routeFromView(view, objectiveId));
|
||||
let route = $derived(routeFromView(view, objectiveId, repositoryId));
|
||||
let currentPath = $derived(pathFromRoute(route));
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
|
|
@ -85,10 +89,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepository() {
|
||||
async function loadRepositories() {
|
||||
repositoriesError = null;
|
||||
try {
|
||||
repositories = await getJson<RepositoryListResponse>('/api/repositories');
|
||||
} catch (error) {
|
||||
repositoriesError = error instanceof Error ? error.message : String(error);
|
||||
repositories = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRepository(id: string) {
|
||||
repositoryError = null;
|
||||
try {
|
||||
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
|
||||
const detail = await getJson<RepositoryDetailResponse>(
|
||||
`/api/repositories/${encodeURIComponent(id)}`
|
||||
);
|
||||
repository = detail.item;
|
||||
} catch (error) {
|
||||
repositoryError = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -96,10 +112,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRepositoryTickets() {
|
||||
async function loadRepositoryTickets(id: string) {
|
||||
repositoryTicketsError = null;
|
||||
try {
|
||||
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
|
||||
repositoryTickets = await getJson<RepositoryTicketsResponse>(
|
||||
`/api/repositories/${encodeURIComponent(id)}/tickets`
|
||||
);
|
||||
} catch (error) {
|
||||
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
||||
repositoryTickets = null;
|
||||
|
|
@ -137,9 +155,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState {
|
||||
if (view === 'repository') {
|
||||
return { page: 'repository' };
|
||||
function routeFromView(
|
||||
view: WorkspaceView,
|
||||
objectiveId: string | null,
|
||||
repositoryId: string | null
|
||||
): RouteState {
|
||||
if (view === 'repository' && repositoryId) {
|
||||
return { page: 'repository', repositoryId };
|
||||
}
|
||||
if (view === 'objective' && objectiveId) {
|
||||
return { page: 'objective', objectiveId };
|
||||
|
|
@ -152,7 +174,7 @@
|
|||
|
||||
function pathFromRoute(route: RouteState): string {
|
||||
if (route.page === 'repository') {
|
||||
return '/repositories/local';
|
||||
return `/repositories/${route.repositoryId}`;
|
||||
}
|
||||
if (route.page === 'objective') {
|
||||
return `/objectives/${route.objectiveId}`;
|
||||
|
|
@ -171,11 +193,22 @@
|
|||
void loadWorkspace();
|
||||
void loadHosts();
|
||||
void loadWorkers();
|
||||
void loadRepository();
|
||||
void loadRepositoryTickets();
|
||||
void loadRepositories();
|
||||
void loadObjectives();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (route.page === 'repository') {
|
||||
void loadRepository(route.repositoryId);
|
||||
void loadRepositoryTickets(route.repositoryId);
|
||||
} else {
|
||||
repository = null;
|
||||
repositoryTickets = null;
|
||||
repositoryError = null;
|
||||
repositoryTicketsError = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
|
||||
if (selectedObjectiveId) {
|
||||
|
|
@ -199,7 +232,7 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="workspace-layout">
|
||||
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
||||
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
|
||||
|
||||
<main class="shell">
|
||||
{#if route.page === 'repository'}
|
||||
|
|
@ -216,8 +249,12 @@
|
|||
<dd>{repository.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Workspace root</dt>
|
||||
<dd><code>{repository.workspace_root}</code></dd>
|
||||
<dt>Provider</dt>
|
||||
<dd>{repository.provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default selector</dt>
|
||||
<dd>{repository.default_selector ?? 'none configured'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
|
|
@ -225,13 +262,25 @@
|
|||
</div>
|
||||
<div>
|
||||
<dt>Git</dt>
|
||||
<dd>{repository.git.status}</dd>
|
||||
<dd>{repository.git?.status ?? 'not available'}</dd>
|
||||
</div>
|
||||
{#if repository.diagnostics && repository.diagnostics.length > 0}
|
||||
<div>
|
||||
<dt>Diagnostics</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{#each repository.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else if repositoryError}
|
||||
<p class="error">{repositoryError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local</code>…</p>
|
||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
@ -245,7 +294,7 @@
|
|||
{:else if repositoryTicketsError}
|
||||
<p class="error">{repositoryTicketsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/repositories/local/tickets</code>…</p>
|
||||
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { WorkspaceResponse } from './types';
|
||||
import { projectRepositoryNav } from './repository-nav';
|
||||
import type { RepositoryListResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
repositories: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let { workspace, currentPath = '/' }: Props = $props();
|
||||
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
|
||||
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="repositories-heading">repositories</h2>
|
||||
<span class="section-count">1</span>
|
||||
<span class="section-count">{navigation.count}</span>
|
||||
</div>
|
||||
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
<li>
|
||||
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/local">
|
||||
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
||||
<span class="item-meta">local repository · read-only</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{#if repositoriesError}
|
||||
<p class="nav-empty error">Repository registry unavailable.</p>
|
||||
{:else if !repositories}
|
||||
<p class="nav-empty">Loading repositories…</p>
|
||||
{:else if navigation.items.length === 0}
|
||||
<p class="nav-empty">No repositories configured.</p>
|
||||
{#if navigation.diagnostics.length > 0}
|
||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||
{#each navigation.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
{#each navigation.items as item (item.id)}
|
||||
<li>
|
||||
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
|
||||
<span class="item-title">{item.title}</span>
|
||||
<span class="item-meta">{item.meta}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,23 @@
|
|||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
repositories?: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||
let {
|
||||
workspace,
|
||||
workspaceError = null,
|
||||
repositories = null,
|
||||
repositoriesError = null,
|
||||
currentPath = '/'
|
||||
}: Props = $props();
|
||||
let settingsActive = $derived(currentPath.startsWith("/settings"));
|
||||
</script>
|
||||
|
||||
|
|
@ -43,7 +51,7 @@
|
|||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {workspace} {currentPath} />
|
||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
|
||||
<ObjectivesNavSection {currentPath} />
|
||||
<WorkersNavSection {currentPath} />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { projectRepositoryNav } from "./repository-nav.ts";
|
||||
import type { RepositoryListResponse } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
function repositories(
|
||||
items: RepositoryListResponse["items"],
|
||||
): RepositoryListResponse {
|
||||
return {
|
||||
workspace_id: "workspace-1",
|
||||
items,
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("repository nav does not invent main for an empty registry", () => {
|
||||
const projection = projectRepositoryNav({
|
||||
workspace_id: "workspace-1",
|
||||
items: [],
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "repository_config_empty",
|
||||
severity: "warning",
|
||||
message: "No repositories configured",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assertEquals(projection.count, 0);
|
||||
assertEquals(projection.items, []);
|
||||
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
|
||||
});
|
||||
|
||||
Deno.test("repository nav links configured non-main repository ids", () => {
|
||||
const projection = projectRepositoryNav(
|
||||
repositories([
|
||||
{
|
||||
id: "infra",
|
||||
display_name: "Infrastructure",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
record_authority: "workspace-backend-config",
|
||||
git: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
]),
|
||||
"/repositories/infra",
|
||||
);
|
||||
|
||||
assertEquals(projection.count, 1);
|
||||
assertEquals(projection.items[0], {
|
||||
id: "infra",
|
||||
title: "Infrastructure",
|
||||
href: "/repositories/infra",
|
||||
meta: "git repository · read-only",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
36
web/workspace/src/lib/workspace-sidebar/repository-nav.ts
Normal file
36
web/workspace/src/lib/workspace-sidebar/repository-nav.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Diagnostic, RepositoryListResponse } from "./types";
|
||||
|
||||
export type RepositoryNavItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
meta: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type RepositoryNavProjection = {
|
||||
count: number;
|
||||
items: RepositoryNavItem[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export function projectRepositoryNav(
|
||||
repositories: RepositoryListResponse | null,
|
||||
currentPath = "/",
|
||||
): RepositoryNavProjection {
|
||||
const summaries = repositories?.items ?? [];
|
||||
return {
|
||||
count: summaries.length,
|
||||
diagnostics: repositories?.diagnostics ?? [],
|
||||
items: summaries.map((repository) => {
|
||||
const href = `/repositories/${encodeURIComponent(repository.id)}`;
|
||||
return {
|
||||
id: repository.id,
|
||||
title: repository.display_name || repository.id,
|
||||
href,
|
||||
meta: `${repository.provider} repository · read-only`,
|
||||
active: currentPath === href || currentPath.startsWith(`${href}/`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -181,34 +181,42 @@ export type RepositorySummary = {
|
|||
id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
workspace_root: string;
|
||||
provider: string;
|
||||
default_selector?: string | null;
|
||||
record_authority: string;
|
||||
git: GitRepositorySummary;
|
||||
git?: GitRepositorySummary | null;
|
||||
diagnostics?: Diagnostic[];
|
||||
};
|
||||
|
||||
export type GitRepositorySummary = {
|
||||
status: string;
|
||||
root?: string | null;
|
||||
branch?: string | null;
|
||||
head?: string | null;
|
||||
dirty?: boolean | null;
|
||||
dirty_scope: string;
|
||||
remote?: GitRemoteSummary | null;
|
||||
diagnostics: Diagnostic[];
|
||||
dirty: boolean;
|
||||
remotes: GitRemoteSummary[];
|
||||
};
|
||||
|
||||
export type GitRemoteSummary = {
|
||||
name: string;
|
||||
url: string;
|
||||
redacted: boolean;
|
||||
fetch_url: string;
|
||||
};
|
||||
|
||||
export type GitCommitSummary = {
|
||||
hash: string;
|
||||
subject: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
timestamp: string;
|
||||
author_date: string;
|
||||
parents: string[];
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type RepositoryListResponse = {
|
||||
workspace_id: string;
|
||||
items: RepositorySummary[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RepositoryDetailResponse = {
|
||||
|
|
@ -220,6 +228,7 @@ export type RepositoryDetailResponse = {
|
|||
export type RepositoryLogResponse = {
|
||||
workspace_id: string;
|
||||
repository_id: string;
|
||||
default_selector?: string | null;
|
||||
limit: number;
|
||||
items: GitCommitSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||
</script>
|
||||
|
||||
<WorkspacePage view="repository" />
|
||||
<WorkspacePage view="repository" repositoryId={page.params.repositoryId} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user