Compare commits

...

51 commits

Author SHA1 Message Date
Claude
5e9a9c2790 feat: integrate lab dashboard as core lab serve
Port the standalone lab dashboard (lab.lthn.io) into the core CLI as
pkg/lab/ with collectors, handlers, and HTML templates. The dashboard
monitors machines, Docker containers, Forgejo, HuggingFace models,
training runs, and InfluxDB metrics with SSE live updates.

New command: core lab serve --bind :8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
1f3a1bcc47 feat: port 11 LEM data management commands into core ml
Ports all remaining LEM pipeline commands from pkg/lem into core ml,
eliminating the standalone LEM CLI dependency. Each command is split
into reusable business logic (pkg/ml/) and a thin cobra wrapper
(internal/cmd/ml/).

New commands: query, inventory, metrics, ingest, normalize, seed-influx,
consolidate, import-all, approve, publish, coverage.

Adds Path(), Exec(), QueryRowScan() convenience methods to DB type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
3269a773f4 feat: add Metal memory budget monitoring after each request
Tracks model size at load time and checks Metal active memory after
each generation. If usage exceeds 3× model size, forces double GC
and cache clear as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
6b603ee20b fix: remove Go-side array ref tracking, rely on MLX-C refcounting
The Go wrapper was tracking inter-array references via desc.inputs,
creating chains that kept all intermediate arrays alive across requests.
After 3-4 requests, Metal memory grew to 170GB+ and macOS killed the
process.

Fix: remove desc.inputs/numRefs entirely. MLX-C has its own internal
reference counting — when Go GC finalizes an Array wrapper, it calls
mlx_array_free which decrements the C-side refcount. If the C-side
count reaches 0, Metal memory is freed. Go GC + MLX-C refcounting
together handle all lifecycle management correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
8cdafc8d66 fix: add GC-based memory management for MLX array handles
Go GC cannot see Metal/C memory pressure, so intermediate arrays from
each forward pass accumulated without bound, causing OOM kills after
3-4 requests. Fix: runtime.SetFinalizer on every Array releases C
handles when GC collects them, and runtime.GC() is forced every 4
tokens during generation. Also adds SetMemoryLimit(24GB) as a hard
Metal ceiling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
9688e086ca fix: add Metal cache management to prevent memory growth
- Add ClearCache() wrapping mlx_clear_cache
- Clear Metal allocator cache every 8 tokens during generation
- Set 16GB cache limit on backend init
- Prevents GPU memory from growing unbounded during inference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
098f496364 fix: correct SDPA mask mode and slice logits to last position 2026-02-16 05:53:52 +00:00
Claude
09da05d799 fix: use affine quantization mode and infer head_dim from weights 2026-02-16 05:53:52 +00:00
Claude
d3c31aa5a6 debug: add shape logging and stderr error handler for inference debugging 2026-02-16 05:53:52 +00:00
Claude
56c6e2fa8d feat: support quantized inference (4-bit) for Gemma 3
- Add QuantizedLinear with QuantizedMatmul for packed uint32 weights
- Add quantized Embedding with Dequantize before lookup
- Parse quantization config (group_size, bits) from config.json
- Detect .scales/.biases weight tensors and auto-select quantized path
- Add Dequantize op wrapping mlx_dequantize
- Add safety guard to KVCache.Update for malformed shapes
- Handle tied embeddings with quantization (AsLinear helper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
a4fde16998 fix: handle both string and array merge formats in tokenizer
Gemma 3 tokenizer.json uses [["a","b"],...] format for merges
instead of the ["a b",...] format. Support both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
af523913cb feat: use native MLX backend when --model-path is set on Apple Silicon
Build-tagged backend selection: MLX on darwin/arm64/mlx, HTTP elsewhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
2a67653bf7 feat: handle nested text_config and language_model weight prefix
Supports both multimodal (Gemma3ForConditionalGeneration) and
text-only configs. Resolves weights with language_model. prefix
fallback. Computes head_dim from hidden_size when missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
9ae86017f4 chore: target macOS 26.0, fix duplicate -lstdc++ linker warning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
e9d9a3c3a0 fix: remove unused vars in TopP sampler placeholder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
a0f77960a1 fix: resolve CGo type conflict in error handler
Use pure C callback instead of //export to avoid const char* vs
GoString type mismatch in cgo-generated headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
5e2d941b4d fix: correct 20 mlx-c API mismatches for v0.4.1
- Use _axis/_axes variants for softmax, argmax, topk, sum, mean, squeeze,
  concatenate, argpartition
- Fix size_t vs int for count parameters throughout
- Fix int64_t strides in as_strided
- Add mlx_optional_int + mode param to quantized_matmul
- Use mlx_array_new() for null arrays (freqs, key, mask, sinks)
- Fix expand_dims to single-axis signature
- Fix compile callback signature (size_t index)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
c6597691bb fix: correct mlx_closure_new_func_payload signature for mlx-c v0.4.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
bc28aad526 feat: add native MLX backend for Apple Silicon inference (pkg/mlx)
CGo wrapper for mlx-c providing zero-Python Metal GPU inference.
Includes Gemma 3 model architecture, BPE tokenizer, KV cache,
composable sampling, and OpenAI-compatible serve command.

Build-tagged (darwin && arm64 && mlx) with stubs for cross-platform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
548256312d feat: add ML inference, scoring, and training pipeline (pkg/ml)
Port LEM scoring/training pipeline into CoreGo as pkg/ml with:
- Inference abstraction with HTTP, llama-server, and Ollama backends
- 3-tier scoring engine (heuristic, exact, LLM judge)
- Capability and content probes for model evaluation
- GGUF/safetensors format converters, MLX to PEFT adapter conversion
- DuckDB integration for training data pipeline
- InfluxDB metrics for lab dashboard
- Training data export (JSONL + Parquet)
- Expansion generation pipeline with distributed workers
- 10 CLI commands under 'core ml' (score, probe, export, expand, status, gguf, convert, agent, worker)
- 5 MCP tools (ml_generate, ml_score, ml_probe, ml_status, ml_backends)

All 37 ML tests passing. Binary builds at 138MB with all commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
52d358daa2 refactor: rename module from github.com/host-uk/core to forge.lthn.ai/core/cli
Move module identity to our own Forgejo instance. All import paths
updated across 434 Go files, sub-module go.mod files, and go.work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
ca46d4679a fix: restore CLI entry point and register all commands
The main.go was removed when Wails3 apps were added to cmd/, breaking
`go build .` for the core CLI. Restore it and update variants/full.go
to include daemon, forge, mcpcmd, prod, and session commands. Drop gitea
(superseded by forge) and unifi (unused).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Snider
df90c984b1 feat(bugseti): wire HubService into main.go with auto-registration
Add HubService to the Wails service list and attempt hub registration
at startup when hubUrl is configured. Drains any pending operations
queued from previous sessions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
b75fa9dd3f feat(bugseti): implement pending operations queue with disk persistence
Replace no-op stubs with real implementations for queueOp, drainPendingOps,
savePendingOps, and loadPendingOps. Operations are persisted to hub_pending.json
and replayed on next hub connection — 5xx/transport errors are retried, 4xx
responses are dropped as stale. Adds PendingCount() for queue inspection.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
23f40f0856 feat(bugseti): add hub read operations
Add IsIssueClaimed, ListClaims, GetLeaderboard, and GetGlobalStats
methods. IsIssueClaimed returns (nil, nil) on 404 for unclaimed
issues. GetLeaderboard returns entries and total participant count.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
a3296dd464 feat(bugseti): add hub write operations
Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim,
and SyncStats methods for hub coordination. ClaimIssue returns
ConflictError on 409 and calls drainPendingOps before mutating.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
4134c58488 feat(bugseti): add AutoRegister via Forge token exchange
Exchange a Forge API token for a hub API key by POSTing to
/api/bugseti/auth/forge. Skips if hub token already cached.
Adds drainPendingOps() stub for future Task 7 use.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
50829dc3ba feat(bugseti): add HubService HTTP request helpers
Add doRequest() and doJSON() methods for hub API communication. doRequest
builds full URLs, sets bearer auth and JSON headers, tracks connected
state. doJSON handles status codes: 401 unauthorised, 409 ConflictError,
404 NotFoundError, and generic errors for other 4xx/5xx responses.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Snider
336766d13d feat(bugseti): add HubService types and constructor
Introduce HubService struct with types for hub coordination: PendingOp,
HubClaim, LeaderboardEntry, GlobalStats, ConflictError, NotFoundError.
Constructor generates a crypto/rand client ID when none exists. Includes
no-op loadPendingOps/savePendingOps stubs for future persistence.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Snider
3029ac6711 feat(bugseti): add hub coordination config fields and accessors
Add HubURL, HubToken, ClientID, and ClientName fields to Config struct
for agentic portal integration. Include getter/setter methods following
the existing pattern (SetForgeURL, SetForgeToken also added).

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Snider
cb017b014f docs: add BugSETI HubService implementation plan
10 tasks covering Go client + Laravel auth endpoint.
TDD approach with httptest mocks.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
9c25d39570 docs: add BugSETI HubService design doc
Thin HTTP client for portal coordination API — issue claiming,
stats sync, leaderboard, auto-register via forge token.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
2373a7d439 feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK
Replace all exec.Command("gh", ...) calls with the existing pkg/forge
wrapper around the Forgejo Go SDK. BugSETI no longer requires the gh
CLI to be installed.

Changes:
- fetcher: use forge.ListIssues/GetIssue instead of gh issue list/view
- submit: use forge.ForkRepo/CreatePullRequest instead of gh pr create
- seeder: use git clone with forge URL + token auth instead of gh clone
- ghcheck: CheckForge() returns *forge.Client via forge.NewFromConfig()
- config: add ForgeURL/ForgeToken fields (GitHubToken kept for migration)
- pkg/forge: add Token(), GetCurrentUser(), ForkRepo(), CreatePullRequest(),
  ListIssueComments(), and label filtering to ListIssuesOpts

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Snider
df9a975125 chore: migrate forge.lthn.ai → forge.lthn.io
Update Forgejo domain references in CI pipeline, vanity import
tool, and core-app codex prompt.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00
Claude
0096a27c5b fix(bugseti): add background TTL sweeper and configurable workspace limits
The workspace map previously only cleaned up during Capture() calls,
meaning stale entries would accumulate indefinitely if no new captures
occurred. This adds:

- Background sweeper goroutine (Start/Stop lifecycle) that runs every 5
  minutes to evict expired workspaces
- Configurable MaxWorkspaces and WorkspaceTTLMinutes in Config (defaults:
  100 entries, 24h TTL) replacing hardcoded constants
- cleanup() now returns eviction count for observability logging
- Nil-config fallback to safe defaults

Fixes #54

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
5de7ee4fb8 fix(security): sanitize path components in journal logging (#46)
Prevent path traversal in Journal.Append() by validating RepoOwner and
RepoName before using them in file paths. Malicious values like
"../../etc/cron.d" could previously write outside the journal baseDir.

Defence layers:
- Reject inputs containing path separators (/ or \)
- Reject ".." and "." traversal components
- Validate against safe character regex ^[a-zA-Z0-9][a-zA-Z0-9._-]*$
- Verify resolved absolute path stays within baseDir

Closes #46
CVSS 6.3 — OWASP A01:2021-Broken Access Control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
7900b8c4da fix(bugseti): hold mutex during entire QueueService initialization
Move shared state initialization (issues, seen) and the load() call
inside the mutex scope in NewQueueService() to eliminate the race
window where concurrent callers could observe partially initialized
state. Remove the redundant heap.Init before the lock since load()
already calls heap.Init when restoring from disk.

Add documentation to save() and load() noting they must be called
with q.mu held.

Fixes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude
0edbc35ffc fix(security): move Gemini API key from URL query params to header (#47)
Pass the API key via x-goog-api-key HTTP header instead of the URL
query parameter to prevent credential leakage in proxy logs, web
server access logs, and monitoring systems.

Resolves: #47 (CVSS 5.3, OWASP A09:2021)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
7a474d0690 feat(agentic): add agent allowance system for model quotas and budgets
Implements quota enforcement for agents including daily token limits,
daily job limits, concurrent job caps, model allowlists, and global
per-model budgets. Quota recovery returns 50% for failed jobs and
100% for cancelled jobs.

Go: AllowanceService with MemoryStore, AllowanceStore interface, and
25 tests covering all enforcement paths.

Laravel: migration for 5 tables (agent_allowances, quota_usage,
model_quotas, usage_reports, repo_limits), Eloquent models,
AllowanceService, QuotaMiddleware, and REST API routes.

Closes #99

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
32267a5dab feat(agentic): add Forgejo integration bridge for PHP platform
Add ForgejoClient and ForgejoService to the Laravel app, providing a
clean service layer for all Forgejo REST API operations the orchestrator
needs. Supports multiple instances (forge, dev, qa) with config-driven
auto-routing, token auth, retry with circuit breaker, and pagination.

Covers issues, PRs, repos, branches, user/token management, and orgs.

Closes #98

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
46273a0f5c feat(agentic): add agent trust model with tiered access control
Implements the security wall between non-aligned agents (issue #97).

Adds pkg/trust with:
- Three trust tiers: Full (Tier 3), Verified (Tier 2), Untrusted (Tier 1)
- Agent registry with mutex-protected concurrent access
- Policy engine with capability-based access control
- Repo-scoped permissions for Tier 2 agents
- Default policies matching the spec (rate limits, approval gates, denials)
- 49 tests covering all tiers, capabilities, edge cases, and helpers

Closes #97

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
87d5f3eb76 feat(agentic): add real-time dashboard with Livewire components (#96)
Add a live agent activity dashboard to the Core App Laravel frontend.
Provides real-time visibility into agent fleet status, job queue,
activity feed, metrics, and human-in-the-loop actions — replacing
SSH + tail -f as the operator interface.

Dashboard panels:
- Agent Fleet: grid of agent cards with heartbeat, status, model info
- Job Queue: filterable table with cancel/retry actions
- Live Activity Feed: real-time stream with agent/type filters
- Metrics: stat cards, budget gauge, cost breakdown, throughput chart
- Human Actions: inline question answering, review gate approval

Tech: Laravel Blade + Livewire 4 + Tailwind CSS + Alpine.js + ApexCharts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
f348b1b1d6 fix(bugseti): add test coverage for SubmitService PR workflow (#64)
Extract buildForkURL helper for testable fork URL construction and add
19 tests covering Submit validation, HTTPS/SSH fork URLs, PR body
generation, and ensureFork error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
f28259bb13 fix(bugseti): sanitize shell metacharacters in seeder env vars
SanitizeEnv() only removed control characters but not shell
metacharacters. A malicious repo name could execute arbitrary commands
via environment variable injection (e.g. backticks, $(), semicolons).

Add stripShellMeta() to strip backticks, dollar signs, semicolons,
pipes, ampersands, and other shell-significant characters from values
passed to the bash seed script environment.

Fixes #59

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude (M3 Studio)
f033d45680 fix(bugseti): update config file permissions to 0600
This commit updates the file permissions for the BugSETI configuration file from 0644 to 0600, ensuring owner-only access. This addresses the security concern where the GitHub token stored in the config file was world-readable.

Fixes #53
2026-02-16 05:53:52 +00:00
Athena
a54ceb54dd fix(bugseti): add mutex protection to seeder concurrent access
Add sync.Mutex to SeederService to protect shared state during
concurrent SeedIssue, GetWorkspaceDir, and CleanupWorkspace calls.
Extract getWorkspaceDir as lock-free helper to avoid double-locking.

Closes #63

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
b698faf8d5 fix(bugseti): handle silent git fetch failure in submit.go
Capture and log the error from `git fetch origin` in createBranch()
instead of silently ignoring it. Warns the user they may be proceeding
with stale data if the fetch fails.

Fixes #62

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
bee56c3fe1 fix(bugseti): add gh CLI availability check with helpful error
Adds a startup check that verifies gh is in PATH and authenticated
before initializing services. Provides clear install/auth instructions
on failure instead of cryptic exec errors at runtime.

Closes #61

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Athena
d13565df4c fix(bugseti): add comprehensive tests for FetcherService (#60)
Add fetcher_test.go covering: service creation, start/pause lifecycle,
calculatePriority scoring for all label types, label query construction
with custom and default labels, gh CLI JSON parsing for both list and
single-issue endpoints, channel backpressure when issuesCh is full,
fetchAll with no repos configured, and missing binary error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude (M3 Studio)
1fe8376cb4 fix(bugseti): add TTL cleanup and max size cap to workspace map (#55)
The workspaces map in WorkspaceService grew unboundedly. Add cleanup()
that evicts entries older than 24h and enforces a 100-entry cap by
removing oldest entries first. Called on each Capture().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
Claude (M3 Studio)
6bf271e4b1 fix(bugseti): acquire mutex in NewQueueService before load()
q.load() accesses shared state (issues, seen, current) without holding
the mutex, creating a race condition. Wrap the call with q.mu.Lock().

Fixes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00
610 changed files with 27317 additions and 1329 deletions

View file

@ -41,7 +41,7 @@ steps:
settings:
api_key:
from_secret: forgejo_token
base_url: https://forge.lthn.ai
base_url: https://forge.lthn.io
files:
- bin/bugseti-linux-amd64.tar.gz
- bin/bugseti-linux-amd64.tar.gz.sha256

View file

@ -1,40 +1,50 @@
module github.com/host-uk/core/cmd/bugseti
module forge.lthn.ai/core/cli/cmd/bugseti
go 1.25.5
require (
forge.lthn.ai/core/cli v0.0.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
github.com/Snider/Borg v0.2.0
github.com/host-uk/core v0.0.0
github.com/host-uk/core/internal/bugseti v0.0.0
github.com/host-uk/core/internal/bugseti/updater v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..
replace github.com/host-uk/core/internal/bugseti => ../../internal/bugseti
replace forge.lthn.ai/core/cli/internal/bugseti => ../../internal/bugseti
replace github.com/host-uk/core/internal/bugseti/updater => ../../internal/bugseti/updater
replace forge.lthn.ai/core/cli/internal/bugseti/updater => ../../internal/bugseti/updater
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/Snider/Enchantrix v0.0.2 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@ -42,20 +52,34 @@ require (
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mark3labs/mcp-go v0.43.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,5 +1,7 @@
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@ -15,8 +17,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@ -27,14 +31,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
@ -47,6 +55,7 @@ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRko
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@ -55,6 +64,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
@ -76,6 +87,8 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -85,6 +98,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@ -99,6 +113,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
@ -106,17 +121,26 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=

View file

@ -2,7 +2,7 @@
// BugSETI - "Distributed Bug Fixing like SETI@home but for code"
//
// The application runs as a system tray app that:
// - Pulls OSS issues from GitHub
// - Pulls OSS issues from Forgejo
// - Uses AI to prepare context for each issue
// - Presents issues to users for fixing
// - Automates PR submission
@ -16,9 +16,9 @@ import (
"runtime"
"strings"
"github.com/host-uk/core/cmd/bugseti/icons"
"github.com/host-uk/core/internal/bugseti"
"github.com/host-uk/core/internal/bugseti/updater"
"forge.lthn.ai/core/cli/cmd/bugseti/icons"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti/updater"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
@ -39,13 +39,20 @@ func main() {
log.Printf("Warning: Could not load config: %v", err)
}
// Check Forgejo API availability
forgeClient, err := bugseti.CheckForge()
if err != nil {
log.Fatalf("Forgejo check failed: %v\n\nConfigure with: core forge config --url URL --token TOKEN", err)
}
// Initialize core services
notifyService := bugseti.NewNotifyService(configService)
statsService := bugseti.NewStatsService(configService)
fetcherService := bugseti.NewFetcherService(configService, notifyService)
fetcherService := bugseti.NewFetcherService(configService, notifyService, forgeClient)
queueService := bugseti.NewQueueService(configService)
seederService := bugseti.NewSeederService(configService)
submitService := bugseti.NewSubmitService(configService, notifyService, statsService)
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
hubService := bugseti.NewHubService(configService)
versionService := bugseti.NewVersionService()
workspaceService := NewWorkspaceService(configService)
@ -69,6 +76,7 @@ func main() {
application.NewService(submitService),
application.NewService(versionService),
application.NewService(workspaceService),
application.NewService(hubService),
application.NewService(trayService),
}
@ -107,6 +115,19 @@ func main() {
log.Println(" - Waiting for issues...")
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
// Attempt hub registration (non-blocking)
if hubURL := configService.GetHubURL(); hubURL != "" {
if err := hubService.AutoRegister(); err != nil {
log.Printf(" - Hub: auto-register skipped: %v", err)
} else if err := hubService.Register(); err != nil {
log.Printf(" - Hub: registration failed: %v", err)
} else {
log.Println(" - Hub: registered with portal")
}
} else {
log.Println(" - Hub: not configured (set hubUrl in config)")
}
if err := app.Run(); err != nil {
log.Fatal(err)
}

View file

@ -5,7 +5,7 @@ import (
"context"
"log"
"github.com/host-uk/core/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -7,12 +7,22 @@ import (
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/Snider/Borg/pkg/tim"
"github.com/host-uk/core/internal/bugseti"
"github.com/host-uk/core/pkg/io/datanode"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/pkg/io/datanode"
)
const (
// defaultMaxWorkspaces is the fallback upper bound when config is unavailable.
defaultMaxWorkspaces = 100
// defaultWorkspaceTTL is the fallback TTL when config is unavailable.
defaultWorkspaceTTL = 24 * time.Hour
// sweepInterval is how often the background sweeper runs.
sweepInterval = 5 * time.Minute
)
// WorkspaceService manages DataNode-backed workspaces for issues.
@ -20,8 +30,10 @@ import (
// snapshotted, packaged as a TIM container, or shipped as a crash report.
type WorkspaceService struct {
config *bugseti.ConfigService
workspaces map[string]*Workspace // issue ID workspace
workspaces map[string]*Workspace // issue ID -> workspace
mu sync.RWMutex
done chan struct{} // signals the background sweeper to stop
stopped chan struct{} // closed when the sweeper goroutine exits
}
// Workspace tracks a DataNode-backed workspace for an issue.
@ -47,10 +59,13 @@ type CrashReport struct {
}
// NewWorkspaceService creates a new WorkspaceService.
// Call Start() to begin the background TTL sweeper.
func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService {
return &WorkspaceService{
config: config,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
}
@ -59,6 +74,56 @@ func (w *WorkspaceService) ServiceName() string {
return "WorkspaceService"
}
// Start launches the background sweeper goroutine that periodically
// evicts expired workspaces. This prevents unbounded map growth even
// when no new Capture calls arrive.
func (w *WorkspaceService) Start() {
go func() {
defer close(w.stopped)
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.mu.Lock()
evicted := w.cleanup()
w.mu.Unlock()
if evicted > 0 {
log.Printf("Workspace sweeper: evicted %d stale entries, %d remaining", evicted, w.ActiveWorkspaces())
}
case <-w.done:
return
}
}
}()
log.Printf("Workspace sweeper started (interval=%s, ttl=%s, max=%d)",
sweepInterval, w.ttl(), w.maxCap())
}
// Stop signals the background sweeper to exit and waits for it to finish.
func (w *WorkspaceService) Stop() {
close(w.done)
<-w.stopped
log.Printf("Workspace sweeper stopped")
}
// ttl returns the configured workspace TTL, falling back to the default.
func (w *WorkspaceService) ttl() time.Duration {
if w.config != nil {
return w.config.GetWorkspaceTTL()
}
return defaultWorkspaceTTL
}
// maxCap returns the configured max workspace count, falling back to the default.
func (w *WorkspaceService) maxCap() int {
if w.config != nil {
return w.config.GetMaxWorkspaces()
}
return defaultMaxWorkspaces
}
// Capture loads a filesystem workspace into a DataNode Medium.
// Call this after git clone to create the in-memory snapshot.
func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error {
@ -109,6 +174,7 @@ func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error
}
w.mu.Lock()
w.cleanup()
w.workspaces[issue.ID] = &Workspace{
Issue: issue,
Medium: m,
@ -240,6 +306,46 @@ func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error)
return path, nil
}
// cleanup evicts expired workspaces and enforces the max size cap.
// Must be called with w.mu held for writing.
// Returns the number of evicted entries.
func (w *WorkspaceService) cleanup() int {
now := time.Now()
ttl := w.ttl()
cap := w.maxCap()
evicted := 0
// First pass: evict entries older than TTL.
for id, ws := range w.workspaces {
if now.Sub(ws.CreatedAt) > ttl {
delete(w.workspaces, id)
evicted++
}
}
// Second pass: if still over cap, evict oldest entries.
if len(w.workspaces) > cap {
type entry struct {
id string
createdAt time.Time
}
entries := make([]entry, 0, len(w.workspaces))
for id, ws := range w.workspaces {
entries = append(entries, entry{id, ws.CreatedAt})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].createdAt.Before(entries[j].createdAt)
})
toEvict := len(w.workspaces) - cap
for i := 0; i < toEvict; i++ {
delete(w.workspaces, entries[i].id)
evicted++
}
}
return evicted
}
// Release removes a workspace from memory.
func (w *WorkspaceService) Release(issueID string) {
w.mu.Lock()

View file

@ -0,0 +1,151 @@
package main
import (
"fmt"
"testing"
"time"
"forge.lthn.ai/core/cli/internal/bugseti"
)
func TestCleanup_TTL(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Seed with entries that are older than TTL.
svc.mu.Lock()
for i := 0; i < 5; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
// Add one fresh entry.
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != 1 {
t.Errorf("expected 1 workspace after TTL cleanup, got %d", got)
}
}
func TestCleanup_MaxSize(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Fill beyond the cap with fresh entries.
svc.mu.Lock()
for i := 0; i < maxCap+20; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != maxCap {
t.Errorf("expected %d workspaces after cap cleanup, got %d", maxCap, got)
}
}
func TestCleanup_EvictsOldestWhenOverCap(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Create maxCap+1 entries; the newest should survive.
svc.mu.Lock()
for i := 0; i <= maxCap; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(maxCap-i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
// The newest entry (ws-<maxCap>) should still exist.
newest := fmt.Sprintf("ws-%d", maxCap)
svc.mu.RLock()
_, exists := svc.workspaces[newest]
svc.mu.RUnlock()
if !exists {
t.Error("expected newest workspace to survive eviction")
}
// The oldest entry (ws-0) should have been evicted.
svc.mu.RLock()
_, exists = svc.workspaces["ws-0"]
svc.mu.RUnlock()
if exists {
t.Error("expected oldest workspace to be evicted")
}
}
func TestCleanup_ReturnsEvictedCount(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.mu.Lock()
for i := 0; i < 3; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
evicted := svc.cleanup()
svc.mu.Unlock()
if evicted != 3 {
t.Errorf("expected 3 evicted entries, got %d", evicted)
}
}
func TestStartStop(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.Start()
// Add a stale entry while the sweeper is running.
svc.mu.Lock()
svc.workspaces["stale"] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
svc.mu.Unlock()
// Stop should return without hanging.
svc.Stop()
}
func TestConfigurableTTL(t *testing.T) {
cfg := bugseti.NewConfigService()
svc := NewWorkspaceService(cfg)
// Default TTL should be 24h (1440 minutes).
if got := svc.ttl(); got != 24*time.Hour {
t.Errorf("expected default TTL of 24h, got %s", got)
}
// Default max cap should be 100.
if got := svc.maxCap(); got != 100 {
t.Errorf("expected default max cap of 100, got %d", got)
}
}
func TestNilConfigFallback(t *testing.T) {
svc := &WorkspaceService{
config: nil,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
if got := svc.ttl(); got != defaultWorkspaceTTL {
t.Errorf("expected fallback TTL %s, got %s", defaultWorkspaceTTL, got)
}
if got := svc.maxCap(); got != defaultMaxWorkspaces {
t.Errorf("expected fallback max cap %d, got %d", defaultMaxWorkspaces, got)
}
}

View file

@ -94,7 +94,7 @@ go build -tags nowatcher -o ../../bin/core-app .
## CRITICAL WARNINGS
- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged.
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.ai / git.lthn.ai) is the source of truth.
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.io / git.lthn.ai) is the source of truth.
- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped.
- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c.
- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP.

View file

@ -1,4 +1,4 @@
module github.com/host-uk/core/cmd/core-app
module forge.lthn.ai/core/cli/cmd/core-app
go 1.25.5
@ -64,4 +64,4 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\AllowanceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class QuotaMiddleware
{
public function __construct(
private readonly AllowanceService $allowanceService,
) {}
public function handle(Request $request, Closure $next): Response
{
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
$model = $request->input('model', '');
if ($agentId === '') {
return response()->json([
'error' => 'agent_id is required',
], 400);
}
$result = $this->allowanceService->check($agentId, $model);
if (! $result['allowed']) {
return response()->json([
'error' => 'quota_exceeded',
'status' => $result['status'],
'reason' => $result['reason'],
'remaining_tokens' => $result['remaining_tokens'],
'remaining_jobs' => $result['remaining_jobs'],
], 429);
}
// Attach quota info to request for downstream use
$request->merge(['_quota' => $result]);
return $next($request);
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class ActivityFeed extends Component
{
public array $entries = [];
public string $agentFilter = 'all';
public string $typeFilter = 'all';
public bool $showOnlyQuestions = false;
public function mount(): void
{
$this->loadEntries();
}
public function loadEntries(): void
{
// Placeholder data — will be replaced with real-time WebSocket feed
$this->entries = [
[
'id' => 'act-001',
'agent' => 'Athena',
'type' => 'code_write',
'message' => 'Created AgentFleet Livewire component',
'job' => '#96',
'timestamp' => now()->subMinutes(2)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-002',
'agent' => 'Athena',
'type' => 'tool_call',
'message' => 'Read file: cmd/core-app/laravel/composer.json',
'job' => '#96',
'timestamp' => now()->subMinutes(5)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-003',
'agent' => 'Clotho',
'type' => 'question',
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'job' => '#84',
'timestamp' => now()->subMinutes(8)->toIso8601String(),
'is_question' => true,
],
[
'id' => 'act-004',
'agent' => 'Virgil',
'type' => 'pr_created',
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
'job' => '#89',
'timestamp' => now()->subMinutes(15)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-005',
'agent' => 'Virgil',
'type' => 'test_run',
'message' => 'All 47 tests passed (0.8s)',
'job' => '#89',
'timestamp' => now()->subMinutes(18)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-006',
'agent' => 'Athena',
'type' => 'git_push',
'message' => 'Pushed branch feat/agentic-dashboard',
'job' => '#96',
'timestamp' => now()->subMinutes(22)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-007',
'agent' => 'Clotho',
'type' => 'code_write',
'message' => 'Added input validation for MCP file_write paths',
'job' => '#84',
'timestamp' => now()->subMinutes(30)->toIso8601String(),
'is_question' => false,
],
];
}
public function getFilteredEntriesProperty(): array
{
return array_filter($this->entries, function ($entry) {
if ($this->showOnlyQuestions && !$entry['is_question']) {
return false;
}
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
return false;
}
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.activity-feed');
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class AgentFleet extends Component
{
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
public array $agents = [];
public ?string $selectedAgent = null;
public function mount(): void
{
$this->loadAgents();
}
public function loadAgents(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->agents = [
[
'id' => 'athena',
'name' => 'Athena',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'working',
'job' => '#96 agentic dashboard',
'heartbeat' => 'green',
'uptime' => '4h 23m',
'tokens_today' => 142_580,
'jobs_completed' => 3,
],
[
'id' => 'virgil',
'name' => 'Virgil',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'idle',
'job' => '',
'heartbeat' => 'green',
'uptime' => '12h 07m',
'tokens_today' => 89_230,
'jobs_completed' => 5,
],
[
'id' => 'clotho',
'name' => 'Clotho',
'host' => 'darwin-au',
'model' => 'claude-sonnet-4-5',
'status' => 'working',
'job' => '#84 security audit',
'heartbeat' => 'yellow',
'uptime' => '1h 45m',
'tokens_today' => 34_100,
'jobs_completed' => 1,
],
[
'id' => 'charon',
'name' => 'Charon',
'host' => 'linux.snider.dev',
'model' => 'claude-haiku-4-5',
'status' => 'unhealthy',
'job' => '',
'heartbeat' => 'red',
'uptime' => '0m',
'tokens_today' => 0,
'jobs_completed' => 0,
],
];
}
public function selectAgent(string $agentId): void
{
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
}
public function render()
{
return view('livewire.dashboard.agent-fleet');
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class HumanActions extends Component
{
public array $pendingQuestions = [];
public array $reviewGates = [];
public string $answerText = '';
public ?string $answeringId = null;
public function mount(): void
{
$this->loadPending();
}
public function loadPending(): void
{
// Placeholder data — will be replaced with real data from Go backend
$this->pendingQuestions = [
[
'id' => 'q-001',
'agent' => 'Clotho',
'job' => '#84',
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'asked_at' => now()->subMinutes(8)->toIso8601String(),
'context' => 'Working on security audit — found unvalidated input in transport layer.',
],
];
$this->reviewGates = [
[
'id' => 'rg-001',
'agent' => 'Virgil',
'job' => '#89',
'type' => 'pr_review',
'title' => 'PR #89: fix WebSocket reconnection logic',
'description' => 'Adds exponential backoff and connection state tracking.',
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
],
];
}
public function startAnswer(string $questionId): void
{
$this->answeringId = $questionId;
$this->answerText = '';
}
public function submitAnswer(): void
{
if (! $this->answeringId || trim($this->answerText) === '') {
return;
}
// Remove answered question from list
$this->pendingQuestions = array_values(
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
);
$this->answeringId = null;
$this->answerText = '';
}
public function cancelAnswer(): void
{
$this->answeringId = null;
$this->answerText = '';
}
public function approveGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function rejectGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function render()
{
return view('livewire.dashboard.human-actions');
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class JobQueue extends Component
{
public array $jobs = [];
public string $statusFilter = 'all';
public string $agentFilter = 'all';
public function mount(): void
{
$this->loadJobs();
}
public function loadJobs(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->jobs = [
[
'id' => 'job-001',
'issue' => '#96',
'repo' => 'host-uk/core',
'title' => 'feat(agentic): real-time dashboard',
'agent' => 'Athena',
'status' => 'in_progress',
'priority' => 1,
'queued_at' => now()->subMinutes(45)->toIso8601String(),
'started_at' => now()->subMinutes(30)->toIso8601String(),
],
[
'id' => 'job-002',
'issue' => '#84',
'repo' => 'host-uk/core',
'title' => 'fix: security audit findings',
'agent' => 'Clotho',
'status' => 'in_progress',
'priority' => 2,
'queued_at' => now()->subHours(2)->toIso8601String(),
'started_at' => now()->subHours(1)->toIso8601String(),
],
[
'id' => 'job-003',
'issue' => '#102',
'repo' => 'host-uk/core',
'title' => 'feat: add rate limiting to MCP',
'agent' => null,
'status' => 'queued',
'priority' => 3,
'queued_at' => now()->subMinutes(10)->toIso8601String(),
'started_at' => null,
],
[
'id' => 'job-004',
'issue' => '#89',
'repo' => 'host-uk/core',
'title' => 'fix: WebSocket reconnection',
'agent' => 'Virgil',
'status' => 'review',
'priority' => 2,
'queued_at' => now()->subHours(4)->toIso8601String(),
'started_at' => now()->subHours(3)->toIso8601String(),
],
[
'id' => 'job-005',
'issue' => '#78',
'repo' => 'host-uk/core',
'title' => 'docs: update CLAUDE.md',
'agent' => 'Virgil',
'status' => 'completed',
'priority' => 4,
'queued_at' => now()->subHours(6)->toIso8601String(),
'started_at' => now()->subHours(5)->toIso8601String(),
],
];
}
public function updatedStatusFilter(): void
{
// Livewire auto-updates the view
}
public function cancelJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
$job['status'] = 'cancelled';
}
return $job;
}, $this->jobs);
}
public function retryJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
$job['status'] = 'queued';
$job['agent'] = null;
}
return $job;
}, $this->jobs);
}
public function getFilteredJobsProperty(): array
{
return array_filter($this->jobs, function ($job) {
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
return false;
}
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.job-queue');
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class Metrics extends Component
{
public array $stats = [];
public array $throughputData = [];
public array $costBreakdown = [];
public float $budgetUsed = 0;
public float $budgetLimit = 0;
public function mount(): void
{
$this->loadMetrics();
}
public function loadMetrics(): void
{
// Placeholder data — will be replaced with real metrics from Go backend
$this->stats = [
'jobs_completed' => 12,
'prs_merged' => 8,
'tokens_used' => 1_245_800,
'cost_today' => 18.42,
'active_agents' => 3,
'queue_depth' => 4,
];
$this->budgetUsed = 18.42;
$this->budgetLimit = 50.00;
// Hourly throughput for chart
$this->throughputData = [
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
];
$this->costBreakdown = [
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
];
}
public function render()
{
return view('livewire.dashboard.metrics');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AgentAllowance extends Model
{
protected $fillable = [
'agent_id',
'daily_token_limit',
'daily_job_limit',
'concurrent_jobs',
'max_job_duration_minutes',
'model_allowlist',
];
protected function casts(): array
{
return [
'daily_token_limit' => 'integer',
'daily_job_limit' => 'integer',
'concurrent_jobs' => 'integer',
'max_job_duration_minutes' => 'integer',
'model_allowlist' => 'array',
];
}
public function usageRecords(): HasMany
{
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
}
public function todayUsage(): ?QuotaUsage
{
return $this->usageRecords()
->where('period_date', now()->toDateString())
->first();
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelQuota extends Model
{
protected $fillable = [
'model',
'daily_token_budget',
'hourly_rate_limit',
'cost_ceiling',
];
protected function casts(): array
{
return [
'daily_token_budget' => 'integer',
'hourly_rate_limit' => 'integer',
'cost_ceiling' => 'integer',
];
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QuotaUsage extends Model
{
protected $table = 'quota_usage';
protected $fillable = [
'agent_id',
'tokens_used',
'jobs_started',
'active_jobs',
'period_date',
];
protected function casts(): array
{
return [
'tokens_used' => 'integer',
'jobs_started' => 'integer',
'active_jobs' => 'integer',
'period_date' => 'date',
];
}
public function allowance(): BelongsTo
{
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UsageReport extends Model
{
protected $fillable = [
'agent_id',
'job_id',
'model',
'tokens_in',
'tokens_out',
'event',
'reported_at',
];
protected function casts(): array
{
return [
'tokens_in' => 'integer',
'tokens_out' => 'integer',
'reported_at' => 'datetime',
];
}
}

View file

@ -4,12 +4,29 @@ declare(strict_types=1);
namespace App\Providers;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
/** @var array<string, mixed> $config */
$config = $app['config']->get('forgejo', []);
return new ForgejoService(
instances: $config['instances'] ?? [],
defaultInstance: $config['default'] ?? 'forge',
timeout: $config['timeout'] ?? 30,
retryTimes: $config['retry_times'] ?? 3,
retrySleep: $config['retry_sleep'] ?? 500,
);
});
}
public function boot(): void
{
// Auto-migrate on first boot. Single-user desktop app with

View file

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\QuotaUsage;
use App\Models\UsageReport;
class AllowanceService
{
/**
* Pre-dispatch check: verify agent has remaining allowance.
*
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
*/
public function check(string $agentId, string $model = ''): array
{
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return [
'allowed' => false,
'status' => 'exceeded',
'remaining_tokens' => 0,
'remaining_jobs' => 0,
'reason' => 'no allowance configured for agent',
];
}
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
$result = [
'allowed' => true,
'status' => 'ok',
'remaining_tokens' => -1,
'remaining_jobs' => -1,
'reason' => null,
];
// Check model allowlist
if ($model !== '' && ! empty($allowance->model_allowlist)) {
if (! in_array($model, $allowance->model_allowlist, true)) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "model not in allowlist: {$model}",
]);
}
}
// Check daily token limit
if ($allowance->daily_token_limit > 0) {
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
$result['remaining_tokens'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily token limit exceeded',
]);
}
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
if ($ratio >= 0.8) {
$result['status'] = 'warning';
}
}
// Check daily job limit
if ($allowance->daily_job_limit > 0) {
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
$result['remaining_jobs'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily job limit exceeded',
]);
}
}
// Check concurrent jobs
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'concurrent job limit reached',
]);
}
// Check global model quota
if ($model !== '') {
$modelQuota = ModelQuota::where('model', $model)->first();
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
$modelUsage = UsageReport::where('model', $model)
->whereDate('reported_at', now()->toDateString())
->sum(\DB::raw('tokens_in + tokens_out'));
if ($modelUsage >= $modelQuota->daily_token_budget) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "global model token budget exceeded for: {$model}",
]);
}
}
}
return $result;
}
/**
* Record usage from an agent runner report.
*/
public function recordUsage(array $report): void
{
$agentId = $report['agent_id'];
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
// Persist the raw report
UsageReport::create([
'agent_id' => $report['agent_id'],
'job_id' => $report['job_id'],
'model' => $report['model'] ?? null,
'tokens_in' => $report['tokens_in'] ?? 0,
'tokens_out' => $report['tokens_out'] ?? 0,
'event' => $report['event'],
'reported_at' => $report['timestamp'] ?? now(),
]);
match ($report['event']) {
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
'job_completed' => $this->handleCompleted($usage, $totalTokens),
'job_failed' => $this->handleFailed($usage, $totalTokens),
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
default => null,
};
}
/**
* Reset daily usage counters for an agent.
*/
public function resetAgent(string $agentId): void
{
QuotaUsage::updateOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
}
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
{
$usage->increment('tokens_used', $totalTokens);
$usage->decrement('active_jobs');
}
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
{
$returnAmount = intdiv($totalTokens, 2);
$usage->increment('tokens_used', $totalTokens - $returnAmount);
$usage->decrement('active_jobs');
}
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
{
$usage->decrement('active_jobs');
// 100% returned — no token charge
}
}

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Low-level HTTP client for a single Forgejo instance.
*
* Wraps the Laravel HTTP client with token auth, retry, and
* base-URL scoping so callers never deal with raw HTTP details.
*/
class ForgejoClient
{
private PendingRequest $http;
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
int $timeout = 30,
int $retryTimes = 3,
int $retrySleep = 500,
) {
if ($this->token === '') {
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
}
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
->withHeaders([
'Authorization' => "token {$this->token}",
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])
->timeout($timeout)
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
$e instanceof \Illuminate\Http\Client\ConnectionException
);
}
public function baseUrl(): string
{
return $this->baseUrl;
}
// ----- Generic verbs -----
/** @return array<string, mixed> */
public function get(string $path, array $query = []): array
{
return $this->decodeOrFail($this->http->get($path, $query));
}
/** @return array<string, mixed> */
public function post(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->post($path, $data));
}
/** @return array<string, mixed> */
public function patch(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->patch($path, $data));
}
/** @return array<string, mixed> */
public function put(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->put($path, $data));
}
public function delete(string $path): void
{
$response = $this->http->delete($path);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
);
}
}
/**
* GET a path and return the raw response body as a string.
* Useful for endpoints that return non-JSON content (e.g. diffs).
*/
public function getRaw(string $path, array $query = []): string
{
$response = $this->http->get($path, $query);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
);
}
return $response->body();
}
/**
* Paginate through all pages of a list endpoint.
*
* @return list<array<string, mixed>>
*/
public function paginate(string $path, array $query = [], int $limit = 50): array
{
$all = [];
$page = 1;
do {
$response = $this->http->get($path, array_merge($query, [
'page' => $page,
'limit' => $limit,
]));
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
);
}
$items = $response->json();
if (!is_array($items) || $items === []) {
break;
}
array_push($all, ...$items);
// Forgejo returns total count in x-total-count header.
$total = (int) $response->header('x-total-count');
$page++;
} while (count($all) < $total);
return $all;
}
// ----- Internals -----
/** @return array<string, mixed> */
private function decodeOrFail(Response $response): array
{
if ($response->failed()) {
throw new RuntimeException(
"Forgejo API error [{$response->status()}]: {$response->body()}"
);
}
return $response->json() ?? [];
}
}

View file

@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use RuntimeException;
/**
* Business-logic layer for Forgejo operations.
*
* Manages multiple Forgejo instances (forge, dev, qa) and provides
* a unified API for issues, pull requests, repositories, and user
* management. Mirrors the Go pkg/forge API surface.
*/
class ForgejoService
{
/** @var array<string, ForgejoClient> */
private array $clients = [];
private string $defaultInstance;
/**
* @param array<string, array{url: string, token: string}> $instances
*/
public function __construct(
array $instances,
string $defaultInstance = 'forge',
private readonly int $timeout = 30,
private readonly int $retryTimes = 3,
private readonly int $retrySleep = 500,
) {
$this->defaultInstance = $defaultInstance;
foreach ($instances as $name => $cfg) {
if (($cfg['token'] ?? '') === '') {
continue; // skip unconfigured instances
}
$this->clients[$name] = new ForgejoClient(
baseUrl: $cfg['url'],
token: $cfg['token'],
timeout: $this->timeout,
retryTimes: $this->retryTimes,
retrySleep: $this->retrySleep,
);
}
}
// ----------------------------------------------------------------
// Instance resolution
// ----------------------------------------------------------------
public function client(?string $instance = null): ForgejoClient
{
$name = $instance ?? $this->defaultInstance;
return $this->clients[$name]
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
}
/** @return list<string> */
public function instances(): array
{
return array_keys($this->clients);
}
// ----------------------------------------------------------------
// Issue Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createIssue(
string $owner,
string $repo,
string $title,
string $body = '',
array $labels = [],
string $assignee = '',
?string $instance = null,
): array {
$data = ['title' => $title, 'body' => $body];
if ($labels !== []) {
$data['labels'] = $labels;
}
if ($assignee !== '') {
$data['assignees'] = [$assignee];
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
}
/** @return array<string, mixed> */
public function updateIssue(
string $owner,
string $repo,
int $number,
array $fields,
?string $instance = null,
): array {
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
}
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
{
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
}
/** @return array<string, mixed> */
public function addComment(
string $owner,
string $repo,
int $number,
string $body,
?string $instance = null,
): array {
return $this->client($instance)->post(
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
['body' => $body],
);
}
/**
* @return list<array<string, mixed>>
*/
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
int $page = 1,
int $limit = 50,
?string $instance = null,
): array {
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
'state' => $state,
'type' => 'issues',
'page' => $page,
'limit' => $limit,
]);
}
// ----------------------------------------------------------------
// Pull Request Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createPR(
string $owner,
string $repo,
string $head,
string $base,
string $title,
string $body = '',
?string $instance = null,
): array {
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
'head' => $head,
'base' => $base,
'title' => $title,
'body' => $body,
]);
}
public function mergePR(
string $owner,
string $repo,
int $number,
string $strategy = 'merge',
?string $instance = null,
): void {
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
'Do' => $strategy,
'delete_branch_after_merge' => true,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function listPRs(
string $owner,
string $repo,
string $state = 'open',
?string $instance = null,
): array {
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
'state' => $state,
]);
}
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
{
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
}
// ----------------------------------------------------------------
// Repository Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listRepos(string $org, ?string $instance = null): array
{
return $this->client($instance)->paginate("/orgs/{$org}/repos");
}
/** @return array<string, mixed> */
public function getRepo(string $owner, string $name, ?string $instance = null): array
{
return $this->client($instance)->get("/repos/{$owner}/{$name}");
}
/** @return array<string, mixed> */
public function createBranch(
string $owner,
string $repo,
string $name,
string $from = '',
?string $instance = null,
): array {
$data = ['new_branch_name' => $name];
if ($from !== '') {
$data['old_branch_name'] = $from;
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
}
public function deleteBranch(
string $owner,
string $repo,
string $name,
?string $instance = null,
): void {
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
}
// ----------------------------------------------------------------
// User / Token Management
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createUser(
string $username,
string $email,
string $password,
?string $instance = null,
): array {
return $this->client($instance)->post('/admin/users', [
'username' => $username,
'email' => $email,
'password' => $password,
'must_change_password' => false,
]);
}
/** @return array<string, mixed> */
public function createToken(
string $username,
string $name,
array $scopes = [],
?string $instance = null,
): array {
$data = ['name' => $name];
if ($scopes !== []) {
$data['scopes'] = $scopes;
}
return $this->client($instance)->post("/users/{$username}/tokens", $data);
}
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
{
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
}
/** @return array<string, mixed> */
public function addToOrg(
string $username,
string $org,
int $teamId,
?string $instance = null,
): array {
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
}
// ----------------------------------------------------------------
// Org Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listOrgs(?string $instance = null): array
{
return $this->client($instance)->paginate('/user/orgs');
}
}

View file

@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Forgejo Instance
|--------------------------------------------------------------------------
|
| The instance name to use when no explicit instance is specified.
|
*/
'default' => env('FORGEJO_DEFAULT', 'forge'),
/*
|--------------------------------------------------------------------------
| Forgejo Instances
|--------------------------------------------------------------------------
|
| Each entry defines a Forgejo instance the platform can talk to.
| The service auto-routes by matching the configured URL.
|
| url Base URL of the Forgejo instance (no trailing slash)
| token Admin API token for the instance
|
*/
'instances' => [
'forge' => [
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
'token' => env('FORGEJO_FORGE_TOKEN', ''),
],
'dev' => [
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
'token' => env('FORGEJO_DEV_TOKEN', ''),
],
'qa' => [
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
'token' => env('FORGEJO_QA_TOKEN', ''),
],
],
/*
|--------------------------------------------------------------------------
| HTTP Client Settings
|--------------------------------------------------------------------------
*/
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
];

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('agent_allowances', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->unique();
$table->bigInteger('daily_token_limit')->default(0);
$table->integer('daily_job_limit')->default(0);
$table->integer('concurrent_jobs')->default(1);
$table->integer('max_job_duration_minutes')->default(0);
$table->json('model_allowlist')->nullable();
$table->timestamps();
});
Schema::create('quota_usage', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->bigInteger('tokens_used')->default(0);
$table->integer('jobs_started')->default(0);
$table->integer('active_jobs')->default(0);
$table->date('period_date')->index();
$table->timestamps();
$table->unique(['agent_id', 'period_date']);
});
Schema::create('model_quotas', function (Blueprint $table) {
$table->id();
$table->string('model')->unique();
$table->bigInteger('daily_token_budget')->default(0);
$table->integer('hourly_rate_limit')->default(0);
$table->bigInteger('cost_ceiling')->default(0);
$table->timestamps();
});
Schema::create('usage_reports', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->string('job_id')->index();
$table->string('model')->nullable();
$table->bigInteger('tokens_in')->default(0);
$table->bigInteger('tokens_out')->default(0);
$table->string('event');
$table->timestamp('reported_at');
$table->timestamps();
});
Schema::create('repo_limits', function (Blueprint $table) {
$table->id();
$table->string('repo')->unique();
$table->integer('max_daily_prs')->default(0);
$table->integer('max_daily_issues')->default(0);
$table->integer('cooldown_after_failure_minutes')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('repo_limits');
Schema::dropIfExists('usage_reports');
Schema::dropIfExists('model_quotas');
Schema::dropIfExists('quota_usage');
Schema::dropIfExists('agent_allowances');
}
};

View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'Agentic Dashboard' }} Core</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
border: { DEFAULT: '#30363d', subtle: '#21262d' },
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
success: '#238636',
warning: '#d29922',
danger: '#da3633',
muted: '#8b949e',
},
},
},
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<style>
[x-cloak] { display: none !important; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
</style>
@livewireStyles
</head>
<body class="h-full bg-surface text-gray-200 antialiased">
<div class="flex h-full" x-data="{ sidebarOpen: true }">
{{-- Sidebar --}}
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
:class="sidebarOpen ? 'w-56' : 'w-16'">
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
</div>
<nav class="flex-1 py-2 space-y-0.5 px-2">
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<span x-show="sidebarOpen">Dashboard</span>
</a>
<a href="{{ route('dashboard.agents') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span x-show="sidebarOpen">Agent Fleet</span>
</a>
<a href="{{ route('dashboard.jobs') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
<span x-show="sidebarOpen">Job Queue</span>
</a>
<a href="{{ route('dashboard.activity') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
<span x-show="sidebarOpen">Activity</span>
</a>
</nav>
<div class="border-t border-border p-2">
<button @click="sidebarOpen = !sidebarOpen"
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
</button>
</div>
</aside>
{{-- Main content --}}
<main class="flex-1 overflow-auto">
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-xs text-muted"
x-data="{ connected: true }"
x-init="
setInterval(() => {
connected = navigator.onLine;
}, 3000)
">
<span class="w-2 h-2 rounded-full heartbeat"
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
</div>
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
</div>
</header>
<div class="p-6">
{{ $slot }}
</div>
</main>
</div>
@livewireScripts
</body>
</html>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Live Activity">
<livewire:dashboard.activity-feed />
</x-dashboard-layout>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Agent Fleet">
<livewire:dashboard.agent-fleet />
</x-dashboard-layout>

View file

@ -0,0 +1,34 @@
<x-dashboard-layout title="Dashboard">
{{-- Metrics overview at top --}}
<section class="mb-8">
<livewire:dashboard.metrics />
</section>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
{{-- Left column: Agent fleet + Human actions --}}
<div class="xl:col-span-2 space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
<livewire:dashboard.agent-fleet />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
<livewire:dashboard.job-queue />
</section>
</div>
{{-- Right column: Actions + Activity --}}
<div class="space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
<livewire:dashboard.human-actions />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
<livewire:dashboard.activity-feed />
</section>
</div>
</div>
</x-dashboard-layout>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Job Queue">
<livewire:dashboard.job-queue />
</x-dashboard-layout>

View file

@ -0,0 +1,72 @@
<div wire:poll.3s="loadEntries">
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-3 mb-4">
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
<select wire:model.live="typeFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All types</option>
<option value="code_write">Code write</option>
<option value="tool_call">Tool call</option>
<option value="test_run">Test run</option>
<option value="pr_created">PR created</option>
<option value="git_push">Git push</option>
<option value="question">Question</option>
</select>
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input type="checkbox" wire:model.live="showOnlyQuestions"
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
Waiting for answer only
</label>
</div>
{{-- Feed --}}
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
@forelse ($this->filteredEntries as $entry)
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
<div class="flex items-start gap-3">
{{-- Type icon --}}
@php
$typeIcons = [
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
];
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
@endphp
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
@if ($entry['is_question'])
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
@endif
</div>
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
</div>
{{-- Timestamp --}}
<span class="text-[11px] text-muted shrink-0">
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
</span>
</div>
</div>
@empty
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
@endforelse
</div>
</div>

View file

@ -0,0 +1,58 @@
<div wire:poll.5s="loadAgents">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@foreach ($agents as $agent)
<div wire:click="selectAgent('{{ $agent['id'] }}')"
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
{{-- Header --}}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full heartbeat
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
{{ $agent['status'] }}
</span>
</div>
{{-- Info --}}
<div class="space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Host</span>
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
</div>
<div class="flex justify-between">
<span>Model</span>
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
</div>
<div class="flex justify-between">
<span>Uptime</span>
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
</div>
@if ($agent['job'])
<div class="flex justify-between">
<span>Job</span>
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
</div>
@endif
</div>
{{-- Expanded detail --}}
@if ($selectedAgent === $agent['id'])
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Tokens today</span>
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
</div>
<div class="flex justify-between">
<span>Jobs completed</span>
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>

View file

@ -0,0 +1,92 @@
<div wire:poll.3s="loadPending">
{{-- Pending questions --}}
@if (count($pendingQuestions) > 0)
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
Agent Questions ({{ count($pendingQuestions) }})
</h3>
<div class="space-y-3">
@foreach ($pendingQuestions as $q)
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
</div>
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
@if (!empty($q['context']))
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
@endif
@if ($answeringId === $q['id'])
<div class="mt-3">
<textarea wire:model="answerText"
rows="3"
placeholder="Type your answer..."
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
<div class="flex gap-2 mt-2">
<button wire:click="submitAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
Send Answer
</button>
<button wire:click="cancelAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
Cancel
</button>
</div>
</div>
@else
<button wire:click="startAnswer('{{ $q['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
Answer
</button>
@endif
</div>
@endforeach
</div>
</div>
@endif
{{-- Review gates --}}
@if (count($reviewGates) > 0)
<div>
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
Review Gates ({{ count($reviewGates) }})
</h3>
<div class="space-y-3">
@foreach ($reviewGates as $gate)
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
</div>
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
<div class="flex gap-2">
<button wire:click="approveGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
Approve
</button>
<button wire:click="rejectGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
Reject
</button>
</div>
</div>
@endforeach
</div>
</div>
@endif
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
<div class="text-center py-12 text-muted">
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm">No pending actions. All agents are autonomous.</p>
</div>
@endif
</div>

View file

@ -0,0 +1,98 @@
<div wire:poll.5s="loadJobs">
{{-- Filters --}}
<div class="flex flex-wrap gap-3 mb-4">
<select wire:model.live="statusFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All statuses</option>
<option value="queued">Queued</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
</div>
{{-- Table --}}
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
<th class="text-left px-4 py-3 font-medium">Job</th>
<th class="text-left px-4 py-3 font-medium">Issue</th>
<th class="text-left px-4 py-3 font-medium">Agent</th>
<th class="text-left px-4 py-3 font-medium">Status</th>
<th class="text-left px-4 py-3 font-medium">Priority</th>
<th class="text-left px-4 py-3 font-medium">Queued</th>
<th class="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@forelse ($this->filteredJobs as $job)
<tr class="hover:bg-surface-overlay/50 transition">
<td class="px-4 py-3">
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
</td>
<td class="px-4 py-3">
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
</td>
<td class="px-4 py-3 text-xs">
{{ $job['agent'] ?? '—' }}
</td>
<td class="px-4 py-3">
@php
$statusColors = [
'queued' => 'bg-yellow-500/20 text-yellow-400',
'in_progress' => 'bg-blue-500/20 text-blue-400',
'review' => 'bg-purple-500/20 text-purple-400',
'completed' => 'bg-green-500/20 text-green-400',
'failed' => 'bg-red-500/20 text-red-400',
'cancelled' => 'bg-gray-500/20 text-gray-400',
];
@endphp
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
{{ str_replace('_', ' ', $job['status']) }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
</td>
<td class="px-4 py-3 text-xs text-muted">
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
@if (in_array($job['status'], ['queued', 'in_progress']))
<button wire:click="cancelJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
Cancel
</button>
@endif
@if (in_array($job['status'], ['failed', 'cancelled']))
<button wire:click="retryJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
Retry
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,113 @@
<div wire:poll.10s="loadMetrics">
{{-- Stat cards --}}
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
@php
$statCards = [
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
];
@endphp
@foreach ($statCards as $card)
<div class="bg-surface-raised border border-border rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
</svg>
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
</div>
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
</div>
@endforeach
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Budget gauge --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Budget</h3>
<div class="flex items-end gap-3 mb-3">
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
</div>
@php
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
@endphp
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
</div>
{{-- Cost breakdown by model --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
<div class="space-y-3">
@foreach ($costBreakdown as $model)
@php
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
$modelColors = [
'claude-opus-4-6' => 'bg-purple-500',
'claude-sonnet-4-5' => 'bg-blue-500',
'claude-haiku-4-5' => 'bg-green-500',
];
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
@endphp
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
</div>
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Throughput chart --}}
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
x-data="{
chart: null,
init() {
this.chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 240,
background: 'transparent',
toolbar: { show: false },
zoom: { enabled: false },
},
theme: { mode: 'dark' },
colors: ['#39d0d8', '#8b5cf6'],
series: [
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
],
xaxis: {
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
},
yaxis: [
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
],
grid: { borderColor: '#21262d', strokeDashArray: 3 },
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
dataLabels: { enabled: false },
legend: { labels: { colors: '#8b949e' } },
tooltip: { theme: 'dark' },
});
this.chart.render();
}
}">
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
<div x-ref="chart"></div>
</div>
</div>

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\RepoLimit;
use App\Services\AllowanceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Allowance API Routes
|--------------------------------------------------------------------------
|
| Endpoints for managing agent quotas, checking allowances, and recording
| usage. Protected endpoints use QuotaMiddleware for enforcement.
|
*/
// Health check for quota service
Route::get('/allowances/health', fn () => response()->json(['status' => 'ok']));
// Agent allowance CRUD
Route::prefix('allowances/agents')->group(function () {
Route::get('/', function () {
return AgentAllowance::all();
});
Route::get('/{agentId}', function (string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
return $allowance;
});
Route::post('/', function (Request $request) {
$validated = $request->validate([
'agent_id' => 'required|string|unique:agent_allowances,agent_id',
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
return AgentAllowance::create($validated);
});
Route::put('/{agentId}', function (Request $request, string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
$allowance->update($validated);
return $allowance;
});
Route::delete('/{agentId}', function (string $agentId) {
AgentAllowance::where('agent_id', $agentId)->delete();
return response()->json(['status' => 'deleted']);
});
});
// Quota check endpoint
Route::get('/allowances/check/{agentId}', function (Request $request, string $agentId, AllowanceService $svc) {
$model = $request->query('model', '');
return response()->json($svc->check($agentId, $model));
});
// Usage reporting endpoint
Route::post('/allowances/usage', function (Request $request, AllowanceService $svc) {
$validated = $request->validate([
'agent_id' => 'required|string',
'job_id' => 'required|string',
'model' => 'nullable|string',
'tokens_in' => 'integer|min:0',
'tokens_out' => 'integer|min:0',
'event' => 'required|in:job_started,job_completed,job_failed,job_cancelled',
'timestamp' => 'nullable|date',
]);
$svc->recordUsage($validated);
return response()->json(['status' => 'recorded']);
});
// Daily reset endpoint
Route::post('/allowances/reset/{agentId}', function (string $agentId, AllowanceService $svc) {
$svc->resetAgent($agentId);
return response()->json(['status' => 'reset']);
});
// Model quota management
Route::prefix('allowances/models')->group(function () {
Route::get('/', fn () => ModelQuota::all());
Route::post('/', function (Request $request) {
$validated = $request->validate([
'model' => 'required|string|unique:model_quotas,model',
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
return ModelQuota::create($validated);
});
Route::put('/{model}', function (Request $request, string $model) {
$quota = ModelQuota::where('model', $model)->first();
if (! $quota) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
$quota->update($validated);
return $quota;
});
});

View file

@ -7,3 +7,9 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// Agentic Dashboard
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoClient;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoClientTest extends TestCase
{
private const BASE_URL = 'https://forge.test';
private const TOKEN = 'test-token-abc123';
// ---- Construction ----
public function test_constructor_good(): void
{
Http::fake();
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
$this->assertSame(self::BASE_URL, $client->baseUrl());
}
public function test_constructor_bad_empty_token(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API token is required');
new ForgejoClient(self::BASE_URL, '');
}
// ---- GET ----
public function test_get_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->get('/repos/owner/repo');
$this->assertSame(1, $result['id']);
$this->assertSame('repo', $result['name']);
}
public function test_get_bad_server_error(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Forgejo API error [500]');
$client->get('/repos/owner/repo');
}
// ---- POST ----
public function test_post_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
$this->assertSame(42, $result['number']);
}
// ---- PATCH ----
public function test_patch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
$this->assertSame('closed', $result['state']);
}
// ---- PUT ----
public function test_put_good(): void
{
Http::fake([
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->put('/teams/5/members/alice');
$this->assertIsArray($result);
}
// ---- DELETE ----
public function test_delete_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
// Should not throw
$client->delete('/repos/owner/repo/branches/old');
$this->assertTrue(true);
}
public function test_delete_bad_not_found(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('failed [404]');
$client->delete('/repos/owner/repo/branches/gone');
}
// ---- getRaw ----
public function test_getRaw_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
"diff --git a/file.txt b/file.txt\n",
200,
['Content-Type' => 'text/plain'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Pagination ----
public function test_paginate_good(): void
{
Http::fake([
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
[['id' => 1], ['id' => 2]],
200,
['x-total-count' => '3'],
),
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
[['id' => 3]],
200,
['x-total-count' => '3'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
$this->assertCount(3, $repos);
$this->assertSame(1, $repos[0]['id']);
$this->assertSame(3, $repos[2]['id']);
}
public function test_paginate_good_empty(): void
{
Http::fake([
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/empty/repos');
$this->assertSame([], $repos);
}
// ---- Auth header ----
public function test_auth_header_sent(): void
{
Http::fake([
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$client->get('/user');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
});
}
}

View file

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoServiceTest extends TestCase
{
private const INSTANCES = [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
];
private function service(): ForgejoService
{
return new ForgejoService(
instances: self::INSTANCES,
defaultInstance: 'forge',
timeout: 5,
retryTimes: 0,
retrySleep: 0,
);
}
// ---- Instance management ----
public function test_instances_good(): void
{
$svc = $this->service();
$this->assertSame(['forge', 'dev'], $svc->instances());
}
public function test_instances_skips_empty_token(): void
{
$svc = new ForgejoService(
instances: [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
'qa' => ['url' => 'https://qa.test', 'token' => ''],
],
);
$this->assertSame(['forge'], $svc->instances());
}
public function test_client_bad_unknown_instance(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("instance 'nope' is not configured");
$this->service()->client('nope');
}
// ---- Issues ----
public function test_createIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
'number' => 99,
'title' => 'New bug',
], 201),
]);
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
$this->assertSame(99, $result['number']);
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
}
public function test_createIssue_good_with_labels_and_assignee(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
]);
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
}
public function test_closeIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
]);
$result = $this->service()->closeIssue('org', 'repo', 5);
$this->assertSame('closed', $result['state']);
}
public function test_addComment_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
]);
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
$this->assertSame(100, $result['id']);
}
public function test_listIssues_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
['number' => 1],
['number' => 2],
], 200),
]);
$issues = $this->service()->listIssues('org', 'repo');
$this->assertCount(2, $issues);
}
// ---- Pull Requests ----
public function test_createPR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
'number' => 10,
'title' => 'Feature X',
], 201),
]);
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
$this->assertSame(10, $result['number']);
}
public function test_mergePR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
]);
// Should not throw
$this->service()->mergePR('org', 'repo', 10, 'squash');
$this->assertTrue(true);
}
public function test_getPRDiff_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
"diff --git a/f.go b/f.go\n+new line\n",
200,
),
]);
$diff = $this->service()->getPRDiff('org', 'repo', 10);
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Repositories ----
public function test_getRepo_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
]);
$result = $this->service()->getRepo('org', 'core');
$this->assertSame('org/core', $result['full_name']);
}
public function test_createBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
]);
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
$this->assertSame('feat/y', $result['name']);
Http::assertSent(fn ($r) =>
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
);
}
public function test_deleteBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
]);
$this->service()->deleteBranch('org', 'repo', 'old');
$this->assertTrue(true);
}
// ---- User / Token Management ----
public function test_createUser_good(): void
{
Http::fake([
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
]);
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
$this->assertSame('bot', $result['login']);
Http::assertSent(fn ($r) =>
$r['username'] === 'bot'
&& $r['must_change_password'] === false
);
}
public function test_createToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
]);
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
$this->assertSame('abc123', $result['sha1']);
}
public function test_revokeToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
]);
$this->service()->revokeToken('bot', 42);
$this->assertTrue(true);
}
// ---- Multi-instance routing ----
public function test_explicit_instance_routing(): void
{
Http::fake([
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
]);
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
$this->assertSame('org/repo', $result['full_name']);
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
}
}

View file

@ -11,7 +11,7 @@ import (
"log"
"runtime"
"github.com/host-uk/core/cmd/core-app/icons"
"forge.lthn.ai/core/cli/cmd/core-app/icons"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -5,7 +5,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -5,7 +5,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -1,10 +1,10 @@
module github.com/host-uk/core/cmd/core-ide
module forge.lthn.ai/core/cli/cmd/core-ide
go 1.25.5
require (
github.com/gorilla/websocket v1.5.3
github.com/host-uk/core v0.0.0
forge.lthn.ai/core/cli v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
@ -54,4 +54,4 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..

View file

@ -4,8 +4,8 @@ import (
"context"
"log"
"github.com/host-uk/core/pkg/mcp/ide"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -13,9 +13,9 @@ import (
"runtime"
"strings"
"github.com/host-uk/core/cmd/core-ide/icons"
"github.com/host-uk/core/pkg/mcp/ide"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/cmd/core-ide/icons"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -8,7 +8,7 @@ import (
"net/http"
"sync"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -2,7 +2,7 @@
//
// When a Go tool requests ?go-get=1, this server responds with HTML
// containing <meta name="go-import"> tags that map dappco.re module
// paths to their Git repositories on forge.lthn.ai.
// paths to their Git repositories on forge.lthn.io.
//
// For browser requests (no ?go-get=1), it redirects to the Forgejo
// repository web UI.
@ -22,7 +22,7 @@ var modules = map[string]string{
}
const (
forgeBase = "https://forge.lthn.ai"
forgeBase = "https://forge.lthn.io"
vanityHost = "dappco.re"
defaultAddr = ":8080"
)

View file

@ -0,0 +1,150 @@
# BugSETI HubService Design
## Overview
A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances.
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Target | Direct to portal API | Endpoints built for this purpose |
| Auth | Auto-register via forge token | No manual key management for users |
| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync |
| Offline mode | Offline-first | Queue failed writes, retry on reconnect |
| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps |
## Architecture
**File:** `internal/bugseti/hub.go` + `hub_test.go`
```
HubService
├── HTTP client (net/http, 10s timeout)
├── Auth: auto-register via forge token → cached ak_ token
├── Config: HubURL, HubToken, ClientID in ConfigService
├── Offline-first: queue failed writes, drain on next success
└── Lazy sync: user-triggered, no background goroutines
```
**Dependencies:** ConfigService only.
**Integration:**
- QueueService calls `hub.ClaimIssue()` when user picks an issue
- SubmitService calls `hub.UpdateStatus("completed")` after PR
- TrayService calls `hub.GetLeaderboard()` from UI
- main.go calls `hub.Register()` on startup
## Data Types
```go
type HubClient struct {
ClientID string // UUID, generated once, persisted in config
Name string // e.g. "Snider's MacBook"
Version string // bugseti.GetVersion()
OS string // runtime.GOOS
Arch string // runtime.GOARCH
}
type HubClaim struct {
IssueID string // "owner/repo#123"
Repo string
IssueNumber int
Title string
URL string
Status string // claimed|in_progress|completed|skipped
ClaimedAt time.Time
PRUrl string
PRNumber int
}
type LeaderboardEntry struct {
Rank int
ClientName string
IssuesCompleted int
PRsSubmitted int
PRsMerged int
CurrentStreak int
}
type GlobalStats struct {
TotalParticipants int
ActiveParticipants int
TotalIssuesCompleted int
TotalPRsMerged int
ActiveClaims int
}
```
## API Mapping
| Method | HTTP | Endpoint | Trigger |
|--------|------|----------|---------|
| `Register()` | POST /register | App startup |
| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled |
| `ClaimIssue(issue)` | POST /issues/claim | User picks issue |
| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip |
| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons |
| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue |
| `ListClaims(filters)` | GET /issues/claimed | UI active claims view |
| `SyncStats(stats)` | POST /stats/sync | Manual from UI |
| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view |
| `GetGlobalStats()` | GET /stats | UI stats dashboard |
## Auto-Register Flow
New endpoint on portal:
```
POST /api/bugseti/auth/forge
Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." }
```
Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`.
HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers.
## Error Handling
| Error | Behaviour |
|-------|-----------|
| Network unreachable | Log, queue write ops, return cached reads |
| 401 Unauthorised | Clear token, re-register via forge |
| 409 Conflict (claim) | Return "already claimed" — not an error |
| 404 (claim not found) | Return nil |
| 429 Rate limited | Back off, queue the op |
| 5xx Server error | Log, queue write ops |
**Pending operations queue:**
- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json`
- Drained on next successful user-triggered call (no background goroutine)
- Each op has: method, path, body, created_at
## Config Changes
New fields in `Config` struct:
```go
HubURL string `json:"hubUrl,omitempty"` // portal API base URL
HubToken string `json:"hubToken,omitempty"` // cached ak_ token
ClientID string `json:"clientId,omitempty"` // UUID, generated once
ClientName string `json:"clientName,omitempty"` // display name
```
## Files Changed
| File | Action |
|------|--------|
| `internal/bugseti/hub.go` | New — HubService |
| `internal/bugseti/hub_test.go` | New — httptest-based tests |
| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields |
| `cmd/bugseti/main.go` | Edit — create + register HubService |
| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items |
| Laravel: auth controller | New — `/api/bugseti/auth/forge` |
## Testing
- `httptest.NewServer` mocks for all endpoints
- Test success, network error, 409 conflict, 401 re-auth flows
- Test pending ops queue: add when offline, drain on reconnect
- `_Good`, `_Bad`, `_Ugly` naming convention

File diff suppressed because it is too large Load diff

19
go.mod
View file

@ -1,4 +1,4 @@
module github.com/host-uk/core
module forge.lthn.ai/core/cli
go 1.25.5
@ -6,7 +6,6 @@ require (
code.gitea.io/sdk/gitea v0.23.2
github.com/Snider/Borg v0.2.0
github.com/getkin/kin-openapi v0.133.0
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1
github.com/leaanthony/debme v1.2.1
github.com/leaanthony/gosod v1.0.4
@ -39,6 +38,8 @@ require (
github.com/Snider/Enchantrix v0.0.2 // indirect
github.com/TwiN/go-color v1.4.1 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
@ -72,9 +73,11 @@ require (
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
@ -86,11 +89,13 @@ require (
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
@ -98,8 +103,12 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/parquet-go/bitpack v1.0.0 // indirect
github.com/parquet-go/jsonlite v1.0.0 // indirect
github.com/parquet-go/parquet-go v0.27.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@ -120,9 +129,9 @@ require (
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twpayne/go-geom v1.6.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/unpoller/unifi/v5 v5.17.0 // indirect
github.com/wI2L/jsondiff v0.7.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
@ -131,10 +140,14 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

218
go.sum
View file

@ -1,336 +1,193 @@
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 h1:v5LWtsFypIhFzZpTx+mY64D5TyCI+CqJY8hmqmEx23E=
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555/go.mod h1:YWAcL4vml/IMkYVKqf5J4ukTINVH1zGw0G8vg/qlops=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasdiff/oasdiff v1.11.9 h1:M/pIY4K1MWnML0DkAdUQU/CnJdNDr2z2hpD0lpKSccM=
github.com/oasdiff/oasdiff v1.11.9/go.mod h1:4qorAPsG2EE/lXEs+FGzAJcYHXS3G7XghfqkCFPKzNQ=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M=
github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.27.0 h1:vHWK2xaHbj+v1DYps03yDRpEsdtOeKbhiXUaixoPb3g=
github.com/parquet-go/parquet-go v0.27.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unpoller/unifi/v5 v5.17.0 h1:e2yES/35+/Ddd6BsXOjXRhsO663uqI99PKleS9plF/w=
github.com/unpoller/unifi/v5 v5.17.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
@ -348,44 +205,35 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=

View file

@ -19,7 +19,17 @@ type ConfigService struct {
// Config holds all BugSETI configuration.
type Config struct {
// Authentication
// Authentication — Forgejo API (resolved via pkg/forge config if empty)
ForgeURL string `json:"forgeUrl,omitempty"`
ForgeToken string `json:"forgeToken,omitempty"`
// Hub coordination (agentic portal)
HubURL string `json:"hubUrl,omitempty"`
HubToken string `json:"hubToken,omitempty"`
ClientID string `json:"clientId,omitempty"`
ClientName string `json:"clientName,omitempty"`
// Deprecated: use ForgeToken. Kept for migration.
GitHubToken string `json:"githubToken,omitempty"`
// Repositories
@ -52,6 +62,10 @@ type Config struct {
MaxConcurrentIssues int `json:"maxConcurrentIssues"`
AutoSeedContext bool `json:"autoSeedContext"`
// Workspace cache
MaxWorkspaces int `json:"maxWorkspaces"` // Upper bound on cached workspace entries (0 = default 100)
WorkspaceTTLMinutes int `json:"workspaceTtlMinutes"` // TTL for workspace entries in minutes (0 = default 1440 = 24h)
// Updates
UpdateChannel string `json:"updateChannel"` // stable, beta, nightly
AutoUpdate bool `json:"autoUpdate"` // Automatically install updates
@ -99,6 +113,8 @@ func NewConfigService() *ConfigService {
AutoSeedContext: true,
DataDir: bugsetiDir,
MarketplaceMCPRoot: "",
MaxWorkspaces: 100,
WorkspaceTTLMinutes: 1440, // 24 hours
UpdateChannel: "stable",
AutoUpdate: false,
UpdateCheckInterval: 6, // Check every 6 hours
@ -149,7 +165,7 @@ func (c *ConfigService) saveUnsafe() error {
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0644)
return os.WriteFile(c.path, data, 0600)
}
// mergeDefaults fills in default values for any unset fields.
@ -169,6 +185,12 @@ func (c *ConfigService) mergeDefaults(config *Config) {
if config.DataDir == "" {
config.DataDir = c.config.DataDir
}
if config.MaxWorkspaces == 0 {
config.MaxWorkspaces = 100
}
if config.WorkspaceTTLMinutes == 0 {
config.WorkspaceTTLMinutes = 1440
}
if config.UpdateChannel == "" {
config.UpdateChannel = "stable"
}
@ -406,6 +428,26 @@ func (c *ConfigService) SetAutoSeedEnabled(enabled bool) error {
return c.saveUnsafe()
}
// GetMaxWorkspaces returns the maximum number of cached workspaces.
func (c *ConfigService) GetMaxWorkspaces() int {
c.mu.RLock()
defer c.mu.RUnlock()
if c.config.MaxWorkspaces <= 0 {
return 100
}
return c.config.MaxWorkspaces
}
// GetWorkspaceTTL returns the workspace TTL as a time.Duration.
func (c *ConfigService) GetWorkspaceTTL() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
if c.config.WorkspaceTTLMinutes <= 0 {
return 24 * time.Hour
}
return time.Duration(c.config.WorkspaceTTLMinutes) * time.Minute
}
// UpdateSettings holds update-related configuration.
type UpdateSettings struct {
Channel string `json:"channel"`
@ -496,6 +538,96 @@ func (c *ConfigService) SetLastUpdateCheck(t time.Time) error {
return c.saveUnsafe()
}
// GetForgeURL returns the configured Forge URL (may be empty to use pkg/forge defaults).
func (c *ConfigService) GetForgeURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ForgeURL
}
// GetForgeToken returns the configured Forge token (may be empty to use pkg/forge defaults).
func (c *ConfigService) GetForgeToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ForgeToken
}
// SetForgeURL sets the Forge URL.
func (c *ConfigService) SetForgeURL(url string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.ForgeURL = url
return c.saveUnsafe()
}
// SetForgeToken sets the Forge token.
func (c *ConfigService) SetForgeToken(token string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.ForgeToken = token
return c.saveUnsafe()
}
// GetHubURL returns the configured Hub URL.
func (c *ConfigService) GetHubURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.HubURL
}
// SetHubURL sets the Hub URL.
func (c *ConfigService) SetHubURL(url string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.HubURL = url
return c.saveUnsafe()
}
// GetHubToken returns the configured Hub token.
func (c *ConfigService) GetHubToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.HubToken
}
// SetHubToken sets the Hub token.
func (c *ConfigService) SetHubToken(token string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.HubToken = token
return c.saveUnsafe()
}
// GetClientID returns the configured client ID.
func (c *ConfigService) GetClientID() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ClientID
}
// SetClientID sets the client ID.
func (c *ConfigService) SetClientID(id string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.ClientID = id
return c.saveUnsafe()
}
// GetClientName returns the configured client name.
func (c *ConfigService) GetClientName() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.config.ClientName
}
// SetClientName sets the client name.
func (c *ConfigService) SetClientName(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.config.ClientName = name
return c.saveUnsafe()
}
// ShouldCheckForUpdates returns true if it's time to check for updates.
func (c *ConfigService) ShouldCheckForUpdates() bool {
c.mu.RLock()

View file

@ -0,0 +1,37 @@
package bugseti
import (
"os"
"testing"
)
func TestConfigPermissions(t *testing.T) {
// Get a temporary file path
f, err := os.CreateTemp("", "bugseti-config-*.json")
if err != nil {
t.Fatal(err)
}
name := f.Name()
f.Close()
os.Remove(name) // Ensure it doesn't exist
defer os.Remove(name)
c := &ConfigService{
path: name,
config: &Config{},
}
if err := c.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
}
info, err := os.Stat(name)
if err != nil {
t.Fatal(err)
}
mode := info.Mode().Perm()
if mode != 0600 {
t.Errorf("expected file permissions 0600, got %04o", mode)
}
}

View file

@ -106,7 +106,23 @@ func loadEthicsGuard(ctx context.Context, rootHint string) *EthicsGuard {
}
func (g *EthicsGuard) SanitizeEnv(value string) string {
return sanitizeInline(value, maxEnvRunes)
return stripShellMeta(sanitizeInline(value, maxEnvRunes))
}
// stripShellMeta removes shell metacharacters that could allow command
// injection when a value is interpolated inside a shell environment variable.
func stripShellMeta(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '`', '$', ';', '|', '&', '(', ')', '{', '}', '<', '>', '!', '\\', '\'', '"', '\n', '\r':
continue
default:
b.WriteRune(r)
}
}
return strings.TrimSpace(b.String())
}
func (g *EthicsGuard) SanitizeTitle(value string) string {

View file

@ -1,6 +1,8 @@
package bugseti
import "testing"
import (
"testing"
)
func TestSanitizeInline_Good(t *testing.T) {
input := "Hello world"
@ -26,3 +28,47 @@ func TestSanitizeMultiline_Ugly(t *testing.T) {
t.Fatalf("expected %q, got %q", "ab\ncd", output)
}
}
func TestSanitizeEnv_Good(t *testing.T) {
g := &EthicsGuard{}
input := "owner/repo-name"
output := g.SanitizeEnv(input)
if output != input {
t.Fatalf("expected %q, got %q", input, output)
}
}
func TestSanitizeEnv_Bad(t *testing.T) {
g := &EthicsGuard{}
tests := []struct {
name string
input string
expected string
}{
{"backtick", "owner/repo`whoami`", "owner/repowhoami"},
{"dollar", "owner/repo$(id)", "owner/repoid"},
{"semicolon", "owner/repo;rm -rf /", "owner/reporm -rf /"},
{"pipe", "owner/repo|cat /etc/passwd", "owner/repocat /etc/passwd"},
{"ampersand", "owner/repo&&echo pwned", "owner/repoecho pwned"},
{"mixed", "`$;|&(){}<>!\\'\"\n\r", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output := g.SanitizeEnv(tc.input)
if output != tc.expected {
t.Fatalf("expected %q, got %q", tc.expected, output)
}
})
}
}
func TestStripShellMeta_Ugly(t *testing.T) {
// All metacharacters should be stripped, leaving empty string
input := "`$;|&(){}<>!\\'\""
output := stripShellMeta(input)
if output != "" {
t.Fatalf("expected empty string, got %q", output)
}
}

View file

@ -2,20 +2,20 @@
package bugseti
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"strings"
"sync"
"time"
"forge.lthn.ai/core/cli/pkg/forge"
)
// FetcherService fetches issues from configured OSS repositories.
type FetcherService struct {
config *ConfigService
notify *NotifyService
forge *forge.Client
running bool
mu sync.RWMutex
stopCh chan struct{}
@ -23,10 +23,11 @@ type FetcherService struct {
}
// NewFetcherService creates a new FetcherService.
func NewFetcherService(config *ConfigService, notify *NotifyService) *FetcherService {
func NewFetcherService(config *ConfigService, notify *NotifyService, forgeClient *forge.Client) *FetcherService {
return &FetcherService{
config: config,
notify: notify,
forge: forgeClient,
issuesCh: make(chan []*Issue, 10),
}
}
@ -133,68 +134,50 @@ func (f *FetcherService) fetchAll() {
}
}
// fetchFromRepo fetches issues from a single repository using GitHub CLI.
// fetchFromRepo fetches issues from a single repository using the Forgejo API.
func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
owner, repoName, err := splitRepo(repo)
if err != nil {
return nil, err
}
// Build query for good first issues
labels := f.config.GetLabels()
if len(labels) == 0 {
labels = []string{"good first issue", "help wanted", "beginner-friendly"}
}
labelQuery := strings.Join(labels, ",")
// Use gh CLI to fetch issues
cmd := exec.CommandContext(ctx, "gh", "issue", "list",
"--repo", repo,
"--label", labelQuery,
"--state", "open",
"--limit", "20",
"--json", "number,title,body,url,labels,createdAt,author")
output, err := cmd.Output()
forgeIssues, err := f.forge.ListIssues(owner, repoName, forge.ListIssuesOpts{
State: "open",
Labels: labels,
Limit: 20,
})
if err != nil {
return nil, fmt.Errorf("gh issue list failed: %w", err)
return nil, fmt.Errorf("forge list issues failed: %w", err)
}
var ghIssues []struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
issues := make([]*Issue, 0, len(forgeIssues))
for _, fi := range forgeIssues {
labelNames := make([]string, len(fi.Labels))
for i, l := range fi.Labels {
labelNames[i] = l.Name
}
if err := json.Unmarshal(output, &ghIssues); err != nil {
return nil, fmt.Errorf("failed to parse gh output: %w", err)
}
issues := make([]*Issue, 0, len(ghIssues))
for _, gi := range ghIssues {
labels := make([]string, len(gi.Labels))
for i, l := range gi.Labels {
labels[i] = l.Name
author := ""
if fi.Poster != nil {
author = fi.Poster.UserName
}
issues = append(issues, &Issue{
ID: fmt.Sprintf("%s#%d", repo, gi.Number),
Number: gi.Number,
ID: fmt.Sprintf("%s#%d", repo, fi.Index),
Number: int(fi.Index),
Repo: repo,
Title: gi.Title,
Body: gi.Body,
URL: gi.URL,
Labels: labels,
Author: gi.Author.Login,
CreatedAt: gi.CreatedAt,
Priority: calculatePriority(labels),
Title: fi.Title,
Body: fi.Body,
URL: fi.HTMLURL,
Labels: labelNames,
Author: author,
CreatedAt: fi.Created,
Priority: calculatePriority(labelNames),
})
}
@ -203,71 +186,68 @@ func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) {
// FetchIssue fetches a single issue by repo and number.
func (f *FetcherService) FetchIssue(repo string, number int) (*Issue, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "issue", "view",
"--repo", repo,
fmt.Sprintf("%d", number),
"--json", "number,title,body,url,labels,createdAt,author,comments")
output, err := cmd.Output()
owner, repoName, err := splitRepo(repo)
if err != nil {
return nil, fmt.Errorf("gh issue view failed: %w", err)
return nil, err
}
var ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Comments []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"comments"`
fi, err := f.forge.GetIssue(owner, repoName, int64(number))
if err != nil {
return nil, fmt.Errorf("forge get issue failed: %w", err)
}
if err := json.Unmarshal(output, &ghIssue); err != nil {
return nil, fmt.Errorf("failed to parse gh output: %w", err)
labelNames := make([]string, len(fi.Labels))
for i, l := range fi.Labels {
labelNames[i] = l.Name
}
labels := make([]string, len(ghIssue.Labels))
for i, l := range ghIssue.Labels {
labels[i] = l.Name
author := ""
if fi.Poster != nil {
author = fi.Poster.UserName
}
comments := make([]Comment, len(ghIssue.Comments))
for i, c := range ghIssue.Comments {
comments[i] = Comment{
Author: c.Author.Login,
Body: c.Body,
// Fetch comments
forgeComments, err := f.forge.ListIssueComments(owner, repoName, int64(number))
if err != nil {
log.Printf("Warning: could not fetch comments for %s#%d: %v", repo, number, err)
}
comments := make([]Comment, 0, len(forgeComments))
for _, c := range forgeComments {
commentAuthor := ""
if c.Poster != nil {
commentAuthor = c.Poster.UserName
}
comments = append(comments, Comment{
Author: commentAuthor,
Body: c.Body,
})
}
return &Issue{
ID: fmt.Sprintf("%s#%d", repo, ghIssue.Number),
Number: ghIssue.Number,
ID: fmt.Sprintf("%s#%d", repo, fi.Index),
Number: int(fi.Index),
Repo: repo,
Title: ghIssue.Title,
Body: ghIssue.Body,
URL: ghIssue.URL,
Labels: labels,
Author: ghIssue.Author.Login,
CreatedAt: ghIssue.CreatedAt,
Priority: calculatePriority(labels),
Title: fi.Title,
Body: fi.Body,
URL: fi.HTMLURL,
Labels: labelNames,
Author: author,
CreatedAt: fi.Created,
Priority: calculatePriority(labelNames),
Comments: comments,
}, nil
}
// splitRepo splits "owner/repo" into owner and repo parts.
func splitRepo(repo string) (string, string, error) {
parts := strings.SplitN(repo, "/", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid repo format %q, expected owner/repo", repo)
}
return parts[0], parts[1], nil
}
// calculatePriority assigns a priority score based on labels.
func calculatePriority(labels []string) int {
priority := 50 // Default priority

View file

@ -0,0 +1,407 @@
package bugseti
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testConfigService creates a ConfigService with in-memory config for testing.
func testConfigService(t *testing.T, repos []string, labels []string) *ConfigService {
t.Helper()
dir := t.TempDir()
cs := &ConfigService{
path: dir + "/config.json",
config: &Config{
WatchedRepos: repos,
Labels: labels,
FetchInterval: 15,
DataDir: dir,
},
}
return cs
}
// TestHelperProcess is invoked by the test binary when GO_TEST_HELPER_PROCESS
// is set. It prints the value of GO_TEST_HELPER_OUTPUT and optionally exits
// with a non-zero code. Kept for future exec.Command mocking.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}
fmt.Fprint(os.Stdout, os.Getenv("GO_TEST_HELPER_OUTPUT"))
if os.Getenv("GO_TEST_HELPER_EXIT_ERROR") == "1" {
os.Exit(1)
}
os.Exit(0)
}
// ---- NewFetcherService ----
func TestNewFetcherService_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify, nil)
require.NotNil(t, f)
assert.Equal(t, "FetcherService", f.ServiceName())
assert.False(t, f.IsRunning())
assert.NotNil(t, f.Issues())
}
// ---- Start / Pause / IsRunning lifecycle ----
func TestStartPause_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify, nil)
require.NoError(t, f.Start())
assert.True(t, f.IsRunning())
// Starting again is a no-op.
require.NoError(t, f.Start())
assert.True(t, f.IsRunning())
f.Pause()
assert.False(t, f.IsRunning())
// Pausing again is a no-op.
f.Pause()
assert.False(t, f.IsRunning())
}
// ---- calculatePriority ----
func TestCalculatePriority_Good(t *testing.T) {
tests := []struct {
name string
labels []string
expected int
}{
{"no labels", nil, 50},
{"good first issue", []string{"good first issue"}, 80},
{"help wanted", []string{"Help Wanted"}, 70},
{"beginner", []string{"beginner-friendly"}, 75},
{"easy", []string{"Easy"}, 70},
{"bug", []string{"bug"}, 60},
{"documentation", []string{"Documentation"}, 55},
{"priority", []string{"high-priority"}, 65},
{"combined", []string{"good first issue", "bug"}, 90},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, calculatePriority(tt.labels))
})
}
}
func TestCalculatePriority_Bad(t *testing.T) {
// Unknown labels should not change priority from default.
assert.Equal(t, 50, calculatePriority([]string{"unknown-label", "something-else"}))
}
// ---- Label query construction ----
func TestLabelQuery_Good(t *testing.T) {
// When config has custom labels, fetchFromRepo should use them.
cfg := testConfigService(t, []string{"owner/repo"}, []string{"custom-label", "another"})
labels := cfg.GetLabels()
labelQuery := strings.Join(labels, ",")
assert.Equal(t, "custom-label,another", labelQuery)
}
func TestLabelQuery_Bad(t *testing.T) {
// When config has empty labels, fetchFromRepo falls back to defaults.
cfg := testConfigService(t, []string{"owner/repo"}, nil)
labels := cfg.GetLabels()
if len(labels) == 0 {
labels = []string{"good first issue", "help wanted", "beginner-friendly"}
}
labelQuery := strings.Join(labels, ",")
assert.Equal(t, "good first issue,help wanted,beginner-friendly", labelQuery)
}
// ---- fetchFromRepo with mocked gh CLI output ----
func TestFetchFromRepo_Good(t *testing.T) {
ghIssues := []struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}{
{
Number: 42,
Title: "Fix login bug",
Body: "The login page crashes",
URL: "https://github.com/test/repo/issues/42",
CreatedAt: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
},
}
ghIssues[0].Author.Login = "octocat"
ghIssues[0].Labels = []struct {
Name string `json:"name"`
}{
{Name: "good first issue"},
{Name: "bug"},
}
output, err := json.Marshal(ghIssues)
require.NoError(t, err)
// We can't easily intercept exec.CommandContext in the production code
// without refactoring, so we test the JSON parsing path by directly
// calling json.Unmarshal the same way fetchFromRepo does.
var parsed []struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
require.NoError(t, json.Unmarshal(output, &parsed))
require.Len(t, parsed, 1)
gi := parsed[0]
labels := make([]string, len(gi.Labels))
for i, l := range gi.Labels {
labels[i] = l.Name
}
issue := &Issue{
ID: fmt.Sprintf("%s#%d", "test/repo", gi.Number),
Number: gi.Number,
Repo: "test/repo",
Title: gi.Title,
Body: gi.Body,
URL: gi.URL,
Labels: labels,
Author: gi.Author.Login,
CreatedAt: gi.CreatedAt,
Priority: calculatePriority(labels),
}
assert.Equal(t, "test/repo#42", issue.ID)
assert.Equal(t, 42, issue.Number)
assert.Equal(t, "Fix login bug", issue.Title)
assert.Equal(t, "octocat", issue.Author)
assert.Equal(t, []string{"good first issue", "bug"}, issue.Labels)
assert.Equal(t, 90, issue.Priority) // 50 + 30 (good first issue) + 10 (bug)
}
func TestFetchFromRepo_Bad_InvalidJSON(t *testing.T) {
// Simulate gh returning invalid JSON.
var ghIssues []struct {
Number int `json:"number"`
}
err := json.Unmarshal([]byte(`not json at all`), &ghIssues)
assert.Error(t, err, "invalid JSON should produce an error")
}
func TestFetchFromRepo_Bad_GhNotInstalled(t *testing.T) {
// Verify that a missing executable produces an exec error.
cmd := exec.Command("gh-nonexistent-binary-12345")
_, err := cmd.Output()
assert.Error(t, err, "missing binary should produce an error")
}
// ---- fetchAll: no repos configured ----
func TestFetchAll_Bad_NoRepos(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify, nil)
// fetchAll with no repos should not panic and should not send to channel.
f.fetchAll()
// Channel should be empty.
select {
case <-f.issuesCh:
t.Fatal("expected no issues on channel when no repos configured")
default:
// expected
}
}
// ---- Channel backpressure ----
func TestChannelBackpressure_Ugly(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify, nil)
// Fill the channel to capacity (buffer size is 10).
for i := 0; i < 10; i++ {
f.issuesCh <- []*Issue{{ID: fmt.Sprintf("test#%d", i)}}
}
// Now try to send via the select path (same logic as fetchAll).
// This should be a non-blocking drop, not a deadlock.
done := make(chan struct{})
go func() {
defer close(done)
issues := []*Issue{{ID: "overflow#1"}}
select {
case f.issuesCh <- issues:
// Shouldn't happen — channel is full.
t.Error("expected channel send to be skipped due to backpressure")
default:
// This is the expected path — channel full, message dropped.
}
}()
select {
case <-done:
// success — did not deadlock
case <-time.After(time.Second):
t.Fatal("backpressure test timed out — possible deadlock")
}
}
// ---- FetchIssue single-issue parsing ----
func TestFetchIssue_Good_Parse(t *testing.T) {
// Test the JSON parsing and Issue construction for FetchIssue.
ghIssue := struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Comments []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"comments"`
}{
Number: 99,
Title: "Add dark mode",
Body: "Please add dark mode support",
URL: "https://github.com/test/repo/issues/99",
CreatedAt: time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC),
}
ghIssue.Author.Login = "contributor"
ghIssue.Labels = []struct {
Name string `json:"name"`
}{
{Name: "help wanted"},
}
ghIssue.Comments = []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
}{
{Body: "I can work on this"},
}
ghIssue.Comments[0].Author.Login = "volunteer"
data, err := json.Marshal(ghIssue)
require.NoError(t, err)
// Re-parse as the function would.
var parsed struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
Comments []struct {
Body string `json:"body"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"comments"`
}
require.NoError(t, json.Unmarshal(data, &parsed))
labels := make([]string, len(parsed.Labels))
for i, l := range parsed.Labels {
labels[i] = l.Name
}
comments := make([]Comment, len(parsed.Comments))
for i, c := range parsed.Comments {
comments[i] = Comment{Author: c.Author.Login, Body: c.Body}
}
issue := &Issue{
ID: fmt.Sprintf("%s#%d", "test/repo", parsed.Number),
Number: parsed.Number,
Repo: "test/repo",
Title: parsed.Title,
Body: parsed.Body,
URL: parsed.URL,
Labels: labels,
Author: parsed.Author.Login,
CreatedAt: parsed.CreatedAt,
Priority: calculatePriority(labels),
Comments: comments,
}
assert.Equal(t, "test/repo#99", issue.ID)
assert.Equal(t, "contributor", issue.Author)
assert.Equal(t, 70, issue.Priority) // 50 + 20 (help wanted)
require.Len(t, issue.Comments, 1)
assert.Equal(t, "volunteer", issue.Comments[0].Author)
assert.Equal(t, "I can work on this", issue.Comments[0].Body)
}
// ---- Issues() channel accessor ----
func TestIssuesChannel_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
notify := NewNotifyService(cfg)
f := NewFetcherService(cfg, notify, nil)
ch := f.Issues()
require.NotNil(t, ch)
// Send and receive through the channel.
go func() {
f.issuesCh <- []*Issue{{ID: "test#1", Title: "Test issue"}}
}()
select {
case issues := <-ch:
require.Len(t, issues, 1)
assert.Equal(t, "test#1", issues[0].ID)
case <-time.After(time.Second):
t.Fatal("timed out waiting for issues on channel")
}
}

View file

@ -0,0 +1,22 @@
package bugseti
import (
"forge.lthn.ai/core/cli/pkg/forge"
)
// CheckForge verifies that the Forgejo API is configured and reachable.
// Returns nil if a token is configured and the API responds, or an error
// with actionable instructions for the user.
func CheckForge() (*forge.Client, error) {
client, err := forge.NewFromConfig("", "")
if err != nil {
return nil, err
}
// Verify the token works by fetching the current user
if _, err := client.GetCurrentUser(); err != nil {
return nil, err
}
return client, nil
}

View file

@ -0,0 +1,23 @@
package bugseti
import (
"os"
"testing"
)
func TestCheckForge_Bad_MissingConfig(t *testing.T) {
// Clear any env-based forge config to ensure CheckForge fails
t.Setenv("FORGE_TOKEN", "")
t.Setenv("FORGE_URL", "")
// Point HOME to a temp dir so no config file is found
t.Setenv("HOME", t.TempDir())
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
_, err := CheckForge()
if err == nil {
t.Fatal("expected error when forge is not configured")
}
}

View file

@ -1,17 +1,32 @@
module github.com/host-uk/core/internal/bugseti
module forge.lthn.ai/core/cli/internal/bugseti
go 1.25.5
require github.com/mark3labs/mcp-go v0.43.2
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
github.com/mark3labs/mcp-go v0.43.2
github.com/stretchr/testify v1.11.1
)
require (
github.com/42wim/httpsig v1.2.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,39 +1,39 @@
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

576
internal/bugseti/hub.go Normal file
View file

@ -0,0 +1,576 @@
// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"forge.lthn.ai/core/cli/pkg/forge"
)
// HubService coordinates with the agentic portal for issue assignment and leaderboard.
type HubService struct {
config *ConfigService
client *http.Client
connected bool
pending []PendingOp
mu sync.RWMutex
}
// PendingOp represents an operation queued for retry when the hub is unreachable.
type PendingOp struct {
Method string `json:"method"`
Path string `json:"path"`
Body json.RawMessage `json:"body,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// HubClaim represents a claimed issue from the hub.
type HubClaim struct {
ID string `json:"id"`
IssueURL string `json:"issueUrl"`
ClientID string `json:"clientId"`
ClaimedAt time.Time `json:"claimedAt"`
ExpiresAt time.Time `json:"expiresAt"`
Status string `json:"status"`
}
// LeaderboardEntry represents a single entry on the leaderboard.
type LeaderboardEntry struct {
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
Score int `json:"score"`
PRsMerged int `json:"prsMerged"`
Rank int `json:"rank"`
}
// GlobalStats holds aggregate statistics from the hub.
type GlobalStats struct {
TotalClients int `json:"totalClients"`
TotalClaims int `json:"totalClaims"`
TotalPRsMerged int `json:"totalPrsMerged"`
ActiveClaims int `json:"activeClaims"`
IssuesAvailable int `json:"issuesAvailable"`
}
// ConflictError indicates a 409 response from the hub (e.g. issue already claimed).
type ConflictError struct {
StatusCode int
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("conflict: status %d", e.StatusCode)
}
// NotFoundError indicates a 404 response from the hub.
type NotFoundError struct {
StatusCode int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("not found: status %d", e.StatusCode)
}
// NewHubService creates a new HubService with the given config.
// If the config has no ClientID, one is generated and persisted.
func NewHubService(config *ConfigService) *HubService {
h := &HubService{
config: config,
client: &http.Client{
Timeout: 10 * time.Second,
},
pending: make([]PendingOp, 0),
}
// Generate client ID if not set.
if config.GetClientID() == "" {
id := generateClientID()
_ = config.SetClientID(id)
}
h.loadPendingOps()
return h
}
// ServiceName returns the service name for Wails.
func (h *HubService) ServiceName() string {
return "HubService"
}
// GetClientID returns the client ID from config.
func (h *HubService) GetClientID() string {
return h.config.GetClientID()
}
// IsConnected returns whether the hub was reachable on the last request.
func (h *HubService) IsConnected() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return h.connected
}
// generateClientID creates a random hex string (16 bytes = 32 hex chars).
func generateClientID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback: this should never happen with crypto/rand.
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// doRequest builds and executes an HTTP request against the hub API.
// It returns the raw *http.Response and any transport-level error.
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
hubURL := h.config.GetHubURL()
if hubURL == "" {
return nil, fmt.Errorf("hub URL not configured")
}
fullURL := hubURL + "/api/bugseti" + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
token := h.config.GetHubToken()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.client.Do(req)
if err != nil {
h.mu.Lock()
h.connected = false
h.mu.Unlock()
return nil, err
}
h.mu.Lock()
h.connected = true
h.mu.Unlock()
return resp, nil
}
// doJSON executes an HTTP request and decodes the JSON response into dest.
// It handles common error status codes with typed errors.
func (h *HubService) doJSON(method, path string, body, dest interface{}) error {
resp, err := h.doRequest(method, path, body)
if err != nil {
return err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("unauthorised")
case resp.StatusCode == http.StatusConflict:
return &ConflictError{StatusCode: resp.StatusCode}
case resp.StatusCode == http.StatusNotFound:
return &NotFoundError{StatusCode: resp.StatusCode}
case resp.StatusCode >= 400:
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody))
}
if dest != nil {
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// queueOp marshals body to JSON and appends a PendingOp to the queue.
func (h *HubService) queueOp(method, path string, body interface{}) {
var raw json.RawMessage
if body != nil {
data, err := json.Marshal(body)
if err != nil {
log.Printf("BugSETI: queueOp marshal error: %v", err)
return
}
raw = data
}
h.mu.Lock()
h.pending = append(h.pending, PendingOp{
Method: method,
Path: path,
Body: raw,
CreatedAt: time.Now(),
})
h.mu.Unlock()
h.savePendingOps()
}
// drainPendingOps replays queued operations against the hub.
// 5xx/transport errors are kept for retry; 4xx responses are dropped (stale).
func (h *HubService) drainPendingOps() {
h.mu.Lock()
ops := h.pending
h.pending = make([]PendingOp, 0)
h.mu.Unlock()
if len(ops) == 0 {
return
}
var failed []PendingOp
for _, op := range ops {
var body interface{}
if len(op.Body) > 0 {
body = json.RawMessage(op.Body)
}
resp, err := h.doRequest(op.Method, op.Path, body)
if err != nil {
// Transport error — keep for retry.
failed = append(failed, op)
continue
}
resp.Body.Close()
if resp.StatusCode >= 500 {
// Server error — keep for retry.
failed = append(failed, op)
} // 4xx are dropped (stale).
}
if len(failed) > 0 {
h.mu.Lock()
h.pending = append(failed, h.pending...)
h.mu.Unlock()
}
h.savePendingOps()
}
// savePendingOps persists the pending operations queue to disk.
func (h *HubService) savePendingOps() {
dataDir := h.config.GetDataDir()
if dataDir == "" {
return
}
h.mu.RLock()
data, err := json.Marshal(h.pending)
h.mu.RUnlock()
if err != nil {
log.Printf("BugSETI: savePendingOps marshal error: %v", err)
return
}
path := filepath.Join(dataDir, "hub_pending.json")
if err := os.WriteFile(path, data, 0600); err != nil {
log.Printf("BugSETI: savePendingOps write error: %v", err)
}
}
// loadPendingOps loads the pending operations queue from disk.
// Errors are silently ignored (the file may not exist yet).
func (h *HubService) loadPendingOps() {
dataDir := h.config.GetDataDir()
if dataDir == "" {
return
}
path := filepath.Join(dataDir, "hub_pending.json")
data, err := os.ReadFile(path)
if err != nil {
return
}
var ops []PendingOp
if err := json.Unmarshal(data, &ops); err != nil {
return
}
h.pending = ops
}
// PendingCount returns the number of queued pending operations.
func (h *HubService) PendingCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.pending)
}
// ---- Task 4: Auto-Register via Forge Token ----
// AutoRegister exchanges a Forge API token for a hub API key.
// If a hub token is already configured, this is a no-op.
func (h *HubService) AutoRegister() error {
// Skip if already registered.
if h.config.GetHubToken() != "" {
return nil
}
hubURL := h.config.GetHubURL()
if hubURL == "" {
return fmt.Errorf("hub URL not configured")
}
// Resolve forge credentials from config/env.
forgeURL := h.config.GetForgeURL()
forgeToken := h.config.GetForgeToken()
if forgeToken == "" {
resolvedURL, resolvedToken, err := forge.ResolveConfig(forgeURL, "")
if err != nil {
return fmt.Errorf("resolve forge config: %w", err)
}
forgeURL = resolvedURL
forgeToken = resolvedToken
}
if forgeToken == "" {
return fmt.Errorf("no forge token available (set FORGE_TOKEN or run: core forge config --token TOKEN)")
}
// Build request body.
payload := map[string]string{
"forge_url": forgeURL,
"forge_token": forgeToken,
"client_id": h.config.GetClientID(),
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal auto-register body: %w", err)
}
// POST directly (no bearer token yet).
resp, err := h.client.Post(hubURL+"/api/bugseti/auth/forge", "application/json", bytes.NewReader(data))
if err != nil {
h.mu.Lock()
h.connected = false
h.mu.Unlock()
return fmt.Errorf("auto-register request: %w", err)
}
defer resp.Body.Close()
h.mu.Lock()
h.connected = true
h.mu.Unlock()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auto-register failed %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
APIKey string `json:"api_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode auto-register response: %w", err)
}
if err := h.config.SetHubToken(result.APIKey); err != nil {
return fmt.Errorf("cache hub token: %w", err)
}
log.Printf("BugSETI: auto-registered with hub, token cached")
return nil
}
// ---- Task 5: Write Operations ----
// Register registers this client with the hub.
func (h *HubService) Register() error {
h.drainPendingOps()
name := h.config.GetClientName()
clientID := h.config.GetClientID()
if name == "" {
if len(clientID) >= 8 {
name = "BugSETI-" + clientID[:8]
} else {
name = "BugSETI-" + clientID
}
}
body := map[string]string{
"client_id": clientID,
"name": name,
"version": GetVersion(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
return h.doJSON("POST", "/register", body, nil)
}
// Heartbeat sends a heartbeat to the hub.
func (h *HubService) Heartbeat() error {
body := map[string]string{
"client_id": h.config.GetClientID(),
}
return h.doJSON("POST", "/heartbeat", body, nil)
}
// ClaimIssue claims an issue on the hub, returning the claim details.
// Returns a ConflictError if the issue is already claimed by another client.
func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) {
h.drainPendingOps()
body := map[string]interface{}{
"client_id": h.config.GetClientID(),
"issue_id": issue.ID,
"repo": issue.Repo,
"issue_number": issue.Number,
"title": issue.Title,
"url": issue.URL,
}
var claim HubClaim
if err := h.doJSON("POST", "/issues/claim", body, &claim); err != nil {
return nil, err
}
return &claim, nil
}
// UpdateStatus updates the status of a claimed issue on the hub.
func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error {
body := map[string]interface{}{
"client_id": h.config.GetClientID(),
"status": status,
}
if prURL != "" {
body["pr_url"] = prURL
}
if prNumber > 0 {
body["pr_number"] = prNumber
}
path := "/issues/" + url.PathEscape(issueID) + "/status"
return h.doJSON("PATCH", path, body, nil)
}
// ReleaseClaim releases a previously claimed issue back to the pool.
func (h *HubService) ReleaseClaim(issueID string) error {
body := map[string]string{
"client_id": h.config.GetClientID(),
}
path := "/issues/" + url.PathEscape(issueID) + "/claim"
return h.doJSON("DELETE", path, body, nil)
}
// SyncStats uploads local statistics to the hub.
func (h *HubService) SyncStats(stats *Stats) error {
// Build repos_contributed as a flat string slice from the map keys.
repos := make([]string, 0, len(stats.ReposContributed))
for k := range stats.ReposContributed {
repos = append(repos, k)
}
body := map[string]interface{}{
"client_id": h.config.GetClientID(),
"stats": map[string]interface{}{
"issues_attempted": stats.IssuesAttempted,
"issues_completed": stats.IssuesCompleted,
"issues_skipped": stats.IssuesSkipped,
"prs_submitted": stats.PRsSubmitted,
"prs_merged": stats.PRsMerged,
"prs_rejected": stats.PRsRejected,
"current_streak": stats.CurrentStreak,
"longest_streak": stats.LongestStreak,
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
"repos_contributed": repos,
},
}
return h.doJSON("POST", "/stats/sync", body, nil)
}
// ---- Task 6: Read Operations ----
// IsIssueClaimed checks whether an issue is currently claimed on the hub.
// Returns the claim if it exists, or (nil, nil) if the issue is not claimed (404).
func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) {
path := "/issues/" + url.PathEscape(issueID)
var claim HubClaim
if err := h.doJSON("GET", path, nil, &claim); err != nil {
if _, ok := err.(*NotFoundError); ok {
return nil, nil
}
return nil, err
}
return &claim, nil
}
// ListClaims returns claimed issues, optionally filtered by status and/or repo.
func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) {
params := url.Values{}
if status != "" {
params.Set("status", status)
}
if repo != "" {
params.Set("repo", repo)
}
path := "/issues/claimed"
if encoded := params.Encode(); encoded != "" {
path += "?" + encoded
}
var claims []*HubClaim
if err := h.doJSON("GET", path, nil, &claims); err != nil {
return nil, err
}
return claims, nil
}
// leaderboardResponse wraps the hub leaderboard JSON envelope.
type leaderboardResponse struct {
Entries []LeaderboardEntry `json:"entries"`
TotalParticipants int `json:"totalParticipants"`
}
// GetLeaderboard fetches the top N leaderboard entries from the hub.
func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) {
path := fmt.Sprintf("/leaderboard?limit=%d", limit)
var resp leaderboardResponse
if err := h.doJSON("GET", path, nil, &resp); err != nil {
return nil, 0, err
}
return resp.Entries, resp.TotalParticipants, nil
}
// GetGlobalStats fetches aggregate statistics from the hub.
func (h *HubService) GetGlobalStats() (*GlobalStats, error) {
var stats GlobalStats
if err := h.doJSON("GET", "/stats", nil, &stats); err != nil {
return nil, err
}
return &stats, nil
}

View file

@ -0,0 +1,558 @@
package bugseti
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testHubService(t *testing.T, serverURL string) *HubService {
t.Helper()
cfg := testConfigService(t, nil, nil)
if serverURL != "" {
cfg.config.HubURL = serverURL
}
return NewHubService(cfg)
}
// ---- NewHubService ----
func TestNewHubService_Good(t *testing.T) {
h := testHubService(t, "")
require.NotNil(t, h)
assert.NotNil(t, h.config)
assert.NotNil(t, h.client)
assert.False(t, h.IsConnected())
}
func TestHubServiceName_Good(t *testing.T) {
h := testHubService(t, "")
assert.Equal(t, "HubService", h.ServiceName())
}
func TestNewHubService_Good_GeneratesClientID(t *testing.T) {
h := testHubService(t, "")
id := h.GetClientID()
assert.NotEmpty(t, id)
// 16 bytes = 32 hex characters
assert.Len(t, id, 32)
}
func TestNewHubService_Good_ReusesClientID(t *testing.T) {
cfg := testConfigService(t, nil, nil)
cfg.config.ClientID = "existing-client-id"
h := NewHubService(cfg)
assert.Equal(t, "existing-client-id", h.GetClientID())
}
// ---- doRequest ----
func TestDoRequest_Good(t *testing.T) {
var gotAuth string
var gotContentType string
var gotAccept string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
gotAccept = r.Header.Get("Accept")
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "test-token-123"
h := NewHubService(cfg)
body := map[string]string{"key": "value"}
resp, err := h.doRequest("POST", "/test", body)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "Bearer test-token-123", gotAuth)
assert.Equal(t, "application/json", gotContentType)
assert.Equal(t, "application/json", gotAccept)
assert.Equal(t, "value", gotBody["key"])
assert.True(t, h.IsConnected())
}
func TestDoRequest_Bad_NoHubURL(t *testing.T) {
h := testHubService(t, "")
resp, err := h.doRequest("GET", "/test", nil)
assert.Nil(t, resp)
assert.Error(t, err)
assert.Contains(t, err.Error(), "hub URL not configured")
}
func TestDoRequest_Bad_NetworkError(t *testing.T) {
// Point to a port where nothing is listening.
h := testHubService(t, "http://127.0.0.1:1")
resp, err := h.doRequest("GET", "/test", nil)
assert.Nil(t, resp)
assert.Error(t, err)
assert.False(t, h.IsConnected())
}
// ---- Task 4: AutoRegister ----
func TestAutoRegister_Good(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/auth/forge", r.URL.Path)
assert.Equal(t, "POST", r.Method)
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"api_key":"ak_test_12345"}`))
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.ForgeURL = "https://forge.example.com"
cfg.config.ForgeToken = "forge-tok-abc"
h := NewHubService(cfg)
err := h.AutoRegister()
require.NoError(t, err)
// Verify token was cached.
assert.Equal(t, "ak_test_12345", h.config.GetHubToken())
// Verify request body.
assert.Equal(t, "https://forge.example.com", gotBody["forge_url"])
assert.Equal(t, "forge-tok-abc", gotBody["forge_token"])
assert.NotEmpty(t, gotBody["client_id"])
}
func TestAutoRegister_Bad_NoForgeToken(t *testing.T) {
// Isolate from user's real ~/.core/config.yaml and env vars.
origHome := os.Getenv("HOME")
t.Setenv("HOME", t.TempDir())
t.Setenv("FORGE_TOKEN", "")
t.Setenv("FORGE_URL", "")
defer os.Setenv("HOME", origHome)
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = "https://hub.example.com"
// No forge token set, and env/config are empty in test.
h := NewHubService(cfg)
err := h.AutoRegister()
require.Error(t, err)
assert.Contains(t, err.Error(), "no forge token available")
}
func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = "https://hub.example.com"
cfg.config.HubToken = "existing-token"
h := NewHubService(cfg)
err := h.AutoRegister()
require.NoError(t, err)
// Token should remain unchanged.
assert.Equal(t, "existing-token", h.config.GetHubToken())
}
// ---- Task 5: Write Operations ----
func TestRegister_Good(t *testing.T) {
var gotPath string
var gotMethod string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
cfg.config.ClientName = "MyBugSETI"
h := NewHubService(cfg)
err := h.Register()
require.NoError(t, err)
assert.Equal(t, "/api/bugseti/register", gotPath)
assert.Equal(t, "POST", gotMethod)
assert.Equal(t, "MyBugSETI", gotBody["name"])
assert.NotEmpty(t, gotBody["client_id"])
assert.NotEmpty(t, gotBody["version"])
assert.NotEmpty(t, gotBody["os"])
assert.NotEmpty(t, gotBody["arch"])
}
func TestHeartbeat_Good(t *testing.T) {
var gotPath string
var gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
err := h.Heartbeat()
require.NoError(t, err)
assert.Equal(t, "/api/bugseti/heartbeat", gotPath)
assert.Equal(t, "POST", gotMethod)
}
func TestClaimIssue_Good(t *testing.T) {
now := time.Now().Truncate(time.Second)
expires := now.Add(30 * time.Minute)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "issue-42", body["issue_id"])
assert.Equal(t, "org/repo", body["repo"])
assert.Equal(t, float64(42), body["issue_number"])
assert.Equal(t, "Fix the bug", body["title"])
w.WriteHeader(http.StatusOK)
resp := HubClaim{
ID: "claim-1",
IssueURL: "https://github.com/org/repo/issues/42",
ClientID: "test",
ClaimedAt: now,
ExpiresAt: expires,
Status: "claimed",
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
issue := &Issue{
ID: "issue-42",
Number: 42,
Repo: "org/repo",
Title: "Fix the bug",
URL: "https://github.com/org/repo/issues/42",
}
claim, err := h.ClaimIssue(issue)
require.NoError(t, err)
require.NotNil(t, claim)
assert.Equal(t, "claim-1", claim.ID)
assert.Equal(t, "claimed", claim.Status)
}
func TestClaimIssue_Bad_Conflict(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"}
claim, err := h.ClaimIssue(issue)
assert.Nil(t, claim)
require.Error(t, err)
var conflictErr *ConflictError
assert.ErrorAs(t, err, &conflictErr)
}
func TestUpdateStatus_Good(t *testing.T) {
var gotPath string
var gotMethod string
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10)
require.NoError(t, err)
assert.Equal(t, "PATCH", gotMethod)
assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath)
assert.Equal(t, "completed", gotBody["status"])
assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"])
assert.Equal(t, float64(10), gotBody["pr_number"])
}
func TestSyncStats_Good(t *testing.T) {
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path)
assert.Equal(t, "POST", r.Method)
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
stats := &Stats{
IssuesAttempted: 10,
IssuesCompleted: 7,
IssuesSkipped: 3,
PRsSubmitted: 6,
PRsMerged: 5,
PRsRejected: 1,
CurrentStreak: 3,
LongestStreak: 5,
TotalTimeSpent: 90 * time.Minute,
ReposContributed: map[string]*RepoStats{
"org/repo-a": {Name: "org/repo-a"},
"org/repo-b": {Name: "org/repo-b"},
},
}
err := h.SyncStats(stats)
require.NoError(t, err)
assert.NotEmpty(t, gotBody["client_id"])
statsMap, ok := gotBody["stats"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, float64(10), statsMap["issues_attempted"])
assert.Equal(t, float64(7), statsMap["issues_completed"])
assert.Equal(t, float64(3), statsMap["issues_skipped"])
assert.Equal(t, float64(6), statsMap["prs_submitted"])
assert.Equal(t, float64(5), statsMap["prs_merged"])
assert.Equal(t, float64(1), statsMap["prs_rejected"])
assert.Equal(t, float64(3), statsMap["current_streak"])
assert.Equal(t, float64(5), statsMap["longest_streak"])
assert.Equal(t, float64(90), statsMap["total_time_minutes"])
reposRaw, ok := statsMap["repos_contributed"].([]interface{})
require.True(t, ok)
assert.Len(t, reposRaw, 2)
}
// ---- Task 6: Read Operations ----
func TestIsIssueClaimed_Good_Claimed(t *testing.T) {
now := time.Now().Truncate(time.Second)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusOK)
claim := HubClaim{
ID: "claim-1",
IssueURL: "https://github.com/org/repo/issues/42",
ClientID: "client-abc",
ClaimedAt: now,
Status: "claimed",
}
_ = json.NewEncoder(w).Encode(claim)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
claim, err := h.IsIssueClaimed("issue-42")
require.NoError(t, err)
require.NotNil(t, claim)
assert.Equal(t, "claim-1", claim.ID)
assert.Equal(t, "claimed", claim.Status)
}
func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
claim, err := h.IsIssueClaimed("issue-999")
assert.NoError(t, err)
assert.Nil(t, claim)
}
func TestGetLeaderboard_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path)
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "10", r.URL.Query().Get("limit"))
resp := leaderboardResponse{
Entries: []LeaderboardEntry{
{ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1},
{ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2},
},
TotalParticipants: 42,
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
entries, total, err := h.GetLeaderboard(10)
require.NoError(t, err)
assert.Equal(t, 42, total)
require.Len(t, entries, 2)
assert.Equal(t, "Alice", entries[0].ClientName)
assert.Equal(t, 1, entries[0].Rank)
assert.Equal(t, "Bob", entries[1].ClientName)
}
func TestGetGlobalStats_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/bugseti/stats", r.URL.Path)
assert.Equal(t, "GET", r.Method)
stats := GlobalStats{
TotalClients: 100,
TotalClaims: 500,
TotalPRsMerged: 300,
ActiveClaims: 25,
IssuesAvailable: 150,
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(stats)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
stats, err := h.GetGlobalStats()
require.NoError(t, err)
require.NotNil(t, stats)
assert.Equal(t, 100, stats.TotalClients)
assert.Equal(t, 500, stats.TotalClaims)
assert.Equal(t, 300, stats.TotalPRsMerged)
assert.Equal(t, 25, stats.ActiveClaims)
assert.Equal(t, 150, stats.IssuesAvailable)
}
// ---- Task 7: Pending Operations Queue ----
func TestPendingOps_Good_QueueAndDrain(t *testing.T) {
var callCount int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = srv.URL
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
// Manually queue a pending op (simulates a previous failed request).
h.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
assert.Equal(t, 1, h.PendingCount())
// Register() calls drainPendingOps() first, then sends its own request.
err := h.Register()
require.NoError(t, err)
// At least 2 calls: 1 from drain (the queued heartbeat) + 1 from Register itself.
assert.GreaterOrEqual(t, callCount, int32(2))
assert.Equal(t, 0, h.PendingCount())
}
func TestPendingOps_Good_PersistAndLoad(t *testing.T) {
cfg1 := testConfigService(t, nil, nil)
cfg1.config.HubURL = "https://hub.example.com"
cfg1.config.HubToken = "tok"
h1 := NewHubService(cfg1)
// Queue an op — this also calls savePendingOps.
h1.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
assert.Equal(t, 1, h1.PendingCount())
// Create a second HubService with the same data dir.
// NewHubService calls loadPendingOps() in its constructor.
cfg2 := testConfigService(t, nil, nil)
cfg2.config.DataDir = cfg1.config.DataDir // Share the same data dir.
cfg2.config.HubURL = "https://hub.example.com"
cfg2.config.HubToken = "tok"
h2 := NewHubService(cfg2)
assert.Equal(t, 1, h2.PendingCount())
}
func TestPendingCount_Good(t *testing.T) {
cfg := testConfigService(t, nil, nil)
cfg.config.HubURL = "https://hub.example.com"
cfg.config.HubToken = "tok"
h := NewHubService(cfg)
assert.Equal(t, 0, h.PendingCount())
h.queueOp("POST", "/test1", nil)
assert.Equal(t, 1, h.PendingCount())
h.queueOp("POST", "/test2", map[string]string{"key": "val"})
assert.Equal(t, 2, h.PendingCount())
}

View file

@ -99,11 +99,17 @@ func (h *issueHeap) Pop() any {
func NewQueueService(config *ConfigService) *QueueService {
q := &QueueService{
config: config,
issues: make(issueHeap, 0),
seen: make(map[string]bool),
}
heap.Init(&q.issues)
q.load() // Load persisted queue
// Hold the lock for the entire initialization sequence so that all
// shared state (issues, seen, current) is fully populated before
// any concurrent caller can observe the service.
q.mu.Lock()
defer q.mu.Unlock()
q.issues = make(issueHeap, 0)
q.seen = make(map[string]bool)
q.load() // Load persisted queue (overwrites issues/seen if file exists)
return q
}
@ -245,7 +251,7 @@ type queueState struct {
Seen []string `json:"seen"`
}
// save persists the queue to disk.
// save persists the queue to disk. Must be called with q.mu held.
func (q *QueueService) save() {
dataDir := q.config.GetDataDir()
if dataDir == "" {
@ -276,7 +282,7 @@ func (q *QueueService) save() {
}
}
// load restores the queue from disk.
// load restores the queue from disk. Must be called with q.mu held.
func (q *QueueService) load() {
dataDir := q.config.GetDataDir()
if dataDir == "" {

View file

@ -11,18 +11,24 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// SeederService prepares context for issues using the seed-agent-developer skill.
type SeederService struct {
config *ConfigService
mu sync.Mutex
config *ConfigService
forgeURL string
forgeToken string
}
// NewSeederService creates a new SeederService.
func NewSeederService(config *ConfigService) *SeederService {
func NewSeederService(config *ConfigService, forgeURL, forgeToken string) *SeederService {
return &SeederService{
config: config,
config: config,
forgeURL: forgeURL,
forgeToken: forgeToken,
}
}
@ -33,6 +39,9 @@ func (s *SeederService) ServiceName() string {
// SeedIssue prepares context for an issue by calling the seed-agent-developer skill.
func (s *SeederService) SeedIssue(issue *Issue) (*IssueContext, error) {
s.mu.Lock()
defer s.mu.Unlock()
if issue == nil {
return nil, fmt.Errorf("issue is nil")
}
@ -76,7 +85,18 @@ func (s *SeederService) prepareWorkspace(issue *Issue) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", issue.Repo, workDir, "--", "--depth=1")
cloneURL := fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo)
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir)
cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_ASKPASS=echo"),
fmt.Sprintf("GIT_TERMINAL_PROMPT=0"),
)
if s.forgeToken != "" {
// Use token auth via URL for HTTPS clones
cloneURL = fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo)
cloneURL = strings.Replace(cloneURL, "://", fmt.Sprintf("://bugseti:%s@", s.forgeToken), 1)
cmd = exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
@ -338,6 +358,14 @@ func sanitizeIssueContext(ctx *IssueContext, guard *EthicsGuard) *IssueContext {
// GetWorkspaceDir returns the workspace directory for an issue.
func (s *SeederService) GetWorkspaceDir(issue *Issue) string {
s.mu.Lock()
defer s.mu.Unlock()
return s.getWorkspaceDir(issue)
}
// getWorkspaceDir is the lock-free implementation; caller must hold s.mu.
func (s *SeederService) getWorkspaceDir(issue *Issue) string {
baseDir := s.config.GetWorkspaceDir()
if baseDir == "" {
baseDir = filepath.Join(os.TempDir(), "bugseti")
@ -347,6 +375,9 @@ func (s *SeederService) GetWorkspaceDir(issue *Issue) string {
// CleanupWorkspace removes the workspace for an issue.
func (s *SeederService) CleanupWorkspace(issue *Issue) error {
workDir := s.GetWorkspaceDir(issue)
s.mu.Lock()
defer s.mu.Unlock()
workDir := s.getWorkspaceDir(issue)
return os.RemoveAll(workDir)
}

View file

@ -4,13 +4,15 @@ package bugseti
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"time"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"forge.lthn.ai/core/cli/pkg/forge"
)
// SubmitService handles the PR submission flow.
@ -18,14 +20,16 @@ type SubmitService struct {
config *ConfigService
notify *NotifyService
stats *StatsService
forge *forge.Client
}
// NewSubmitService creates a new SubmitService.
func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService) *SubmitService {
func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService, forgeClient *forge.Client) *SubmitService {
return &SubmitService{
config: config,
notify: notify,
stats: stats,
forge: forgeClient,
}
}
@ -55,7 +59,7 @@ type PRResult struct {
}
// Submit creates a pull request for the given issue.
// Flow: Fork -> Branch -> Commit -> PR
// Flow: Fork -> Branch -> Commit -> Push -> PR
func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
if submission == nil || submission.Issue == nil {
return nil, fmt.Errorf("invalid submission")
@ -70,8 +74,13 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot())
issueTitle := guard.SanitizeTitle(issue.Title)
owner, repoName, err := splitRepo(issue.Repo)
if err != nil {
return &PRResult{Success: false, Error: err.Error()}, err
}
// Step 1: Ensure we have a fork
forkOwner, err := s.ensureFork(issue.Repo)
forkOwner, err := s.ensureFork(owner, repoName)
if err != nil {
return &PRResult{Success: false, Error: fmt.Sprintf("fork failed: %v", err)}, err
}
@ -97,7 +106,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
}
// Step 4: Push to fork
if err := s.pushToFork(workDir, forkOwner, branch); err != nil {
if err := s.pushToFork(workDir, forkOwner, repoName, branch); err != nil {
return &PRResult{Success: false, Error: fmt.Sprintf("push failed: %v", err)}, err
}
@ -114,7 +123,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
}
prBody = guard.SanitizeBody(prBody)
prURL, prNumber, err := s.createPR(issue.Repo, forkOwner, branch, prTitle, prBody)
prURL, prNumber, err := s.createPR(owner, repoName, forkOwner, branch, prTitle, prBody)
if err != nil {
return &PRResult{Success: false, Error: fmt.Sprintf("PR creation failed: %v", err)}, err
}
@ -133,39 +142,31 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
}, nil
}
// ensureFork ensures a fork exists for the repo.
func (s *SubmitService) ensureFork(repo string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Check if fork exists
parts := strings.Split(repo, "/")
if len(parts) != 2 {
return "", fmt.Errorf("invalid repo format: %s", repo)
}
// ensureFork ensures a fork exists for the repo, returns the fork owner's username.
func (s *SubmitService) ensureFork(owner, repo string) (string, error) {
// Get current user
cmd := exec.CommandContext(ctx, "gh", "api", "user", "--jq", ".login")
output, err := cmd.Output()
user, err := s.forge.GetCurrentUser()
if err != nil {
return "", fmt.Errorf("failed to get user: %w", err)
return "", fmt.Errorf("failed to get current user: %w", err)
}
username := strings.TrimSpace(string(output))
username := user.UserName
// Check if fork exists
forkRepo := fmt.Sprintf("%s/%s", username, parts[1])
cmd = exec.CommandContext(ctx, "gh", "repo", "view", forkRepo, "--json", "name")
if err := cmd.Run(); err != nil {
// Fork doesn't exist, create it
log.Printf("Creating fork of %s...", repo)
cmd = exec.CommandContext(ctx, "gh", "repo", "fork", repo, "--clone=false")
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to create fork: %w", err)
}
// Wait a bit for GitHub to process
time.Sleep(2 * time.Second)
// Check if fork already exists
_, err = s.forge.GetRepo(username, repo)
if err == nil {
return username, nil
}
// Fork doesn't exist, create it
log.Printf("Creating fork of %s/%s...", owner, repo)
_, err = s.forge.ForkRepo(owner, repo, "")
if err != nil {
return "", fmt.Errorf("failed to create fork: %w", err)
}
// Wait for Forgejo to process the fork
time.Sleep(2 * time.Second)
return username, nil
}
@ -177,7 +178,9 @@ func (s *SubmitService) createBranch(workDir, branch string) error {
// Fetch latest from upstream
cmd := exec.CommandContext(ctx, "git", "fetch", "origin")
cmd.Dir = workDir
cmd.Run() // Ignore errors
if err := cmd.Run(); err != nil {
log.Printf("WARNING: git fetch origin failed in %s: %v (proceeding with potentially stale data)", workDir, err)
}
// Create and checkout new branch
cmd = exec.CommandContext(ctx, "git", "checkout", "-b", branch)
@ -239,7 +242,7 @@ func (s *SubmitService) commitChanges(workDir string, files []string, message st
}
// pushToFork pushes the branch to the user's fork.
func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
func (s *SubmitService) pushToFork(workDir, forkOwner, repoName, branch string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@ -248,29 +251,12 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", forkRemote)
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
// Get the origin URL and construct fork URL
cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
cmd.Dir = workDir
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get origin URL: %w", err)
}
// Construct fork URL using the forge instance URL
forkURL := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(s.forge.URL(), "/"), forkOwner, repoName)
originURL := strings.TrimSpace(string(output))
// Replace original owner with fork owner
var forkURL string
if strings.HasPrefix(originURL, "https://") {
// https://github.com/owner/repo.git
parts := strings.Split(originURL, "/")
if len(parts) >= 4 {
parts[len(parts)-2] = forkOwner
forkURL = strings.Join(parts, "/")
}
} else {
// git@github.com:owner/repo.git
forkURL = strings.Replace(originURL, ":", fmt.Sprintf(":%s/", forkOwner), 1)
forkURL = strings.Replace(forkURL, strings.Split(forkURL, "/")[0]+"/", "", 1)
forkURL = fmt.Sprintf("git@github.com:%s/%s", forkOwner, filepath.Base(originURL))
// Embed token for HTTPS push auth
if s.forge.Token() != "" {
forkURL = strings.Replace(forkURL, "://", fmt.Sprintf("://bugseti:%s@", s.forge.Token()), 1)
}
cmd = exec.CommandContext(ctx, "git", "remote", "add", forkRemote, forkURL)
@ -292,36 +278,19 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
return nil
}
// createPR creates a pull request using GitHub CLI.
func (s *SubmitService) createPR(repo, forkOwner, branch, title, body string) (string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create PR
cmd := exec.CommandContext(ctx, "gh", "pr", "create",
"--repo", repo,
"--head", fmt.Sprintf("%s:%s", forkOwner, branch),
"--title", title,
"--body", body,
"--json", "url,number")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", 0, fmt.Errorf("failed to create PR: %s: %w", stderr.String(), err)
// createPR creates a pull request using the Forgejo API.
func (s *SubmitService) createPR(owner, repo, forkOwner, branch, title, body string) (string, int, error) {
pr, err := s.forge.CreatePullRequest(owner, repo, forgejo.CreatePullRequestOption{
Head: fmt.Sprintf("%s:%s", forkOwner, branch),
Base: "main",
Title: title,
Body: body,
})
if err != nil {
return "", 0, fmt.Errorf("failed to create PR: %w", err)
}
var result struct {
URL string `json:"url"`
Number int `json:"number"`
}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
return "", 0, fmt.Errorf("failed to parse PR response: %w", err)
}
return result.URL, result.Number, nil
return pr.HTMLURL, int(pr.Index), nil
}
// generatePRBody creates a default PR body for an issue.
@ -344,60 +313,44 @@ func (s *SubmitService) generatePRBody(issue *Issue) string {
body.WriteString("<!-- Describe how you tested your changes -->\n\n")
body.WriteString("---\n\n")
body.WriteString("*Submitted via [BugSETI](https://github.com/host-uk/core) - Distributed Bug Fixing*\n")
body.WriteString("*Submitted via [BugSETI](https://bugseti.app) - Distributed Bug Fixing*\n")
return body.String()
}
// GetPRStatus checks the status of a submitted PR.
func (s *SubmitService) GetPRStatus(repo string, prNumber int) (*PRStatus, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
owner, repoName, err := splitRepo(repo)
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, "gh", "pr", "view",
"--repo", repo,
fmt.Sprintf("%d", prNumber),
"--json", "state,mergeable,reviews,statusCheckRollup")
output, err := cmd.Output()
pr, err := s.forge.GetPullRequest(owner, repoName, int64(prNumber))
if err != nil {
return nil, fmt.Errorf("failed to get PR status: %w", err)
}
var result struct {
State string `json:"state"`
Mergeable string `json:"mergeable"`
StatusCheckRollup []struct {
State string `json:"state"`
} `json:"statusCheckRollup"`
Reviews []struct {
State string `json:"state"`
} `json:"reviews"`
}
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse PR status: %w", err)
}
status := &PRStatus{
State: result.State,
Mergeable: result.Mergeable == "MERGEABLE",
State: string(pr.State),
Mergeable: pr.Mergeable,
}
// Check CI status
status.CIPassing = true
for _, check := range result.StatusCheckRollup {
if check.State != "SUCCESS" && check.State != "NEUTRAL" {
status.CIPassing = false
break
// Check CI status via combined commit status
if pr.Head != nil {
combined, err := s.forge.GetCombinedStatus(owner, repoName, pr.Head.Sha)
if err == nil && combined != nil {
status.CIPassing = combined.State == forgejo.StatusSuccess
}
}
// Check review status
for _, review := range result.Reviews {
if review.State == "APPROVED" {
status.Approved = true
break
reviews, err := s.forge.ListPRReviews(owner, repoName, int64(prNumber))
if err == nil {
for _, review := range reviews {
if review.State == forgejo.ReviewStateApproved {
status.Approved = true
break
}
}
}

View file

@ -0,0 +1,234 @@
package bugseti
import (
"strings"
"testing"
)
func testSubmitService(t *testing.T) *SubmitService {
t.Helper()
cfg := testConfigService(t, nil, nil)
notify := &NotifyService{enabled: false, config: cfg}
stats := &StatsService{
config: cfg,
stats: &Stats{
ReposContributed: make(map[string]*RepoStats),
DailyActivity: make(map[string]*DayStats),
},
}
return NewSubmitService(cfg, notify, stats, nil)
}
// --- NewSubmitService / ServiceName ---
func TestNewSubmitService_Good(t *testing.T) {
s := testSubmitService(t)
if s == nil {
t.Fatal("expected non-nil SubmitService")
}
if s.config == nil || s.notify == nil || s.stats == nil {
t.Fatal("expected all dependencies set")
}
}
func TestServiceName_Good(t *testing.T) {
s := testSubmitService(t)
if got := s.ServiceName(); got != "SubmitService" {
t.Fatalf("expected %q, got %q", "SubmitService", got)
}
}
// --- Submit validation ---
func TestSubmit_Bad_NilSubmission(t *testing.T) {
s := testSubmitService(t)
_, err := s.Submit(nil)
if err == nil {
t.Fatal("expected error for nil submission")
}
if !strings.Contains(err.Error(), "invalid submission") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSubmit_Bad_NilIssue(t *testing.T) {
s := testSubmitService(t)
_, err := s.Submit(&PRSubmission{Issue: nil})
if err == nil {
t.Fatal("expected error for nil issue")
}
if !strings.Contains(err.Error(), "invalid submission") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSubmit_Bad_EmptyWorkDir(t *testing.T) {
s := testSubmitService(t)
_, err := s.Submit(&PRSubmission{
Issue: &Issue{Number: 1, Repo: "owner/repo", Title: "test"},
WorkDir: "",
})
if err == nil {
t.Fatal("expected error for empty work directory")
}
if !strings.Contains(err.Error(), "work directory not specified") {
t.Fatalf("unexpected error: %v", err)
}
}
// --- generatePRBody ---
func TestGeneratePRBody_Good_Basic(t *testing.T) {
s := testSubmitService(t)
issue := &Issue{Number: 42, Repo: "owner/repo", Title: "A bug"}
body := s.generatePRBody(issue)
if !strings.Contains(body, "#42") {
t.Fatal("PR body should reference issue number")
}
if !strings.Contains(body, "## Summary") {
t.Fatal("PR body should have Summary section")
}
if !strings.Contains(body, "## Changes") {
t.Fatal("PR body should have Changes section")
}
if !strings.Contains(body, "## Testing") {
t.Fatal("PR body should have Testing section")
}
if !strings.Contains(body, "BugSETI") {
t.Fatal("PR body should have BugSETI attribution")
}
}
func TestGeneratePRBody_Good_WithContext(t *testing.T) {
s := testSubmitService(t)
issue := &Issue{
Number: 7,
Repo: "owner/repo",
Title: "Fix login",
Context: &IssueContext{
Summary: "The login endpoint returns 500 on empty password.",
},
}
body := s.generatePRBody(issue)
if !strings.Contains(body, "## Context") {
t.Fatal("PR body should have Context section when context exists")
}
if !strings.Contains(body, "login endpoint returns 500") {
t.Fatal("PR body should include context summary")
}
}
func TestGeneratePRBody_Good_WithoutContext(t *testing.T) {
s := testSubmitService(t)
issue := &Issue{Number: 7, Repo: "owner/repo", Title: "Fix login"}
body := s.generatePRBody(issue)
if strings.Contains(body, "## Context") {
t.Fatal("PR body should omit Context section when no context")
}
}
func TestGeneratePRBody_Good_EmptyContextSummary(t *testing.T) {
s := testSubmitService(t)
issue := &Issue{
Number: 7,
Repo: "owner/repo",
Title: "Fix login",
Context: &IssueContext{Summary: ""},
}
body := s.generatePRBody(issue)
if strings.Contains(body, "## Context") {
t.Fatal("PR body should omit Context section when summary is empty")
}
}
// --- PRSubmission / PRResult struct tests ---
func TestPRSubmission_Good_Defaults(t *testing.T) {
sub := &PRSubmission{
Issue: &Issue{Number: 10, Repo: "o/r"},
WorkDir: "/tmp/work",
}
if sub.Branch != "" {
t.Fatal("expected empty branch to be default")
}
if sub.Title != "" {
t.Fatal("expected empty title to be default")
}
if sub.CommitMsg != "" {
t.Fatal("expected empty commit msg to be default")
}
}
func TestPRResult_Good_Success(t *testing.T) {
r := &PRResult{
Success: true,
PRURL: "https://forge.lthn.ai/o/r/pulls/1",
PRNumber: 1,
ForkOwner: "me",
}
if !r.Success {
t.Fatal("expected success")
}
if r.Error != "" {
t.Fatal("expected no error on success")
}
}
func TestPRResult_Good_Failure(t *testing.T) {
r := &PRResult{
Success: false,
Error: "fork failed: something",
}
if r.Success {
t.Fatal("expected failure")
}
if r.Error == "" {
t.Fatal("expected error message")
}
}
// --- PRStatus struct ---
func TestPRStatus_Good(t *testing.T) {
s := &PRStatus{
State: "open",
Mergeable: true,
CIPassing: true,
Approved: false,
}
if s.State != "open" {
t.Fatalf("expected open, got %s", s.State)
}
if !s.Mergeable {
t.Fatal("expected mergeable")
}
if s.Approved {
t.Fatal("expected not approved")
}
}
// --- splitRepo ---
func TestSplitRepo_Good(t *testing.T) {
owner, repo, err := splitRepo("myorg/myrepo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "myorg" || repo != "myrepo" {
t.Fatalf("expected myorg/myrepo, got %s/%s", owner, repo)
}
}
func TestSplitRepo_Bad(t *testing.T) {
_, _, err := splitRepo("invalidrepo")
if err == nil {
t.Fatal("expected error for invalid repo format")
}
if !strings.Contains(err.Error(), "invalid repo format") {
t.Fatalf("unexpected error: %v", err)
}
}

View file

@ -1,10 +1,30 @@
module github.com/host-uk/core/internal/bugseti/updater
module forge.lthn.ai/core/cli/internal/bugseti/updater
go 1.25.5
require (
github.com/host-uk/core/internal/bugseti v0.0.0
golang.org/x/mod v0.25.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
golang.org/x/mod v0.32.0
)
replace github.com/host-uk/core/internal/bugseti => ../
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mark3labs/mcp-go v0.43.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace forge.lthn.ai/core/cli/internal/bugseti => ../

View file

@ -1,2 +1,28 @@
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -7,7 +7,7 @@ import (
"sync"
"time"
"github.com/host-uk/core/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti"
)
// Service provides update functionality and Wails bindings.

View file

@ -7,7 +7,7 @@ import (
)
// Version information - these are set at build time via ldflags
// Example: go build -ldflags "-X github.com/host-uk/core/internal/bugseti.Version=1.0.0"
// Example: go build -ldflags "-X forge.lthn.ai/core/cli/internal/bugseti.Version=1.0.0"
var (
// Version is the semantic version (e.g., "1.0.0", "1.0.0-beta.1", "nightly-20260205")
Version = "dev"

View file

@ -7,9 +7,9 @@ import (
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/agentci"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/config"
"forge.lthn.ai/core/cli/pkg/agentci"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/config"
)
// AddAgentCommands registers the 'agent' subcommand group under 'ai'.

View file

@ -3,7 +3,7 @@
package ai
import (
"github.com/host-uk/core/pkg/cli"
"forge.lthn.ai/core/cli/pkg/cli"
)
// Style aliases from shared package

View file

@ -13,9 +13,9 @@
package ai
import (
ragcmd "github.com/host-uk/core/internal/cmd/rag"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
ragcmd "forge.lthn.ai/core/cli/internal/cmd/rag"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
func init() {

View file

@ -16,8 +16,8 @@ import (
"syscall"
"time"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/log"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/log"
)
// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'.

View file

@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/host-uk/core/pkg/agentic"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/agentic"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// task:commit command flags

View file

@ -7,9 +7,9 @@ import (
"fmt"
"time"
"github.com/host-uk/core/pkg/ai"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/ai"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
var (

View file

@ -7,9 +7,9 @@ import (
"text/tabwriter"
"time"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/config"
"github.com/host-uk/core/pkg/ratelimit"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/config"
"forge.lthn.ai/core/cli/pkg/ratelimit"
)
// AddRateLimitCommands registers the 'ratelimits' subcommand group under 'ai'.

View file

@ -9,10 +9,10 @@ import (
"strings"
"time"
"github.com/host-uk/core/pkg/agentic"
"github.com/host-uk/core/pkg/ai"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/agentic"
"forge.lthn.ai/core/cli/pkg/ai"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// tasks command flags

View file

@ -6,10 +6,10 @@ import (
"context"
"time"
"github.com/host-uk/core/pkg/agentic"
"github.com/host-uk/core/pkg/ai"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/agentic"
"forge.lthn.ai/core/cli/pkg/ai"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// task:update command flags

View file

@ -3,8 +3,8 @@ package ai
import (
"context"
"github.com/host-uk/core/pkg/log"
"github.com/host-uk/core/pkg/ratelimit"
"forge.lthn.ai/core/cli/pkg/log"
"forge.lthn.ai/core/cli/pkg/ratelimit"
)
// executeWithRateLimit wraps an agent execution with rate limiting logic.

View file

@ -5,9 +5,9 @@ import (
"os/exec"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/release"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/release"
)
func runChangelog(fromRef, toRef string) error {

View file

@ -2,8 +2,8 @@
package ci
import (
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// Style aliases from shared

View file

@ -10,7 +10,7 @@
package ci
import (
"github.com/host-uk/core/pkg/cli"
"forge.lthn.ai/core/cli/pkg/cli"
)
func init() {

View file

@ -3,9 +3,9 @@ package ci
import (
"os"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/release"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/release"
)
func runCIReleaseInit() error {

View file

@ -5,9 +5,9 @@ import (
"errors"
"os"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/release"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/release"
)
// runCIPublish publishes pre-built artifacts from dist/.

View file

@ -3,9 +3,9 @@ package ci
import (
"os"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/release"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/release"
)
// runCIReleaseVersion shows the determined version.

View file

@ -3,10 +3,10 @@ package collect
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/io"
)
func init() {

View file

@ -4,9 +4,9 @@ import (
"context"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// BitcoinTalk command flags

View file

@ -4,9 +4,9 @@ import (
"fmt"
"time"
"github.com/host-uk/core/pkg/cli"
collectpkg "github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
collectpkg "forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.

View file

@ -4,9 +4,9 @@ import (
"context"
"fmt"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// Excavate command flags

View file

@ -4,9 +4,9 @@ import (
"context"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// GitHub command flags

View file

@ -3,9 +3,9 @@ package collect
import (
"context"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// Market command flags

View file

@ -3,9 +3,9 @@ package collect
import (
"context"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// Papers command flags

View file

@ -3,9 +3,9 @@ package collect
import (
"context"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/collect"
"github.com/host-uk/core/pkg/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/collect"
"forge.lthn.ai/core/cli/pkg/i18n"
)
// addProcessCommand adds the 'process' subcommand to the collect parent.

View file

@ -1,6 +1,6 @@
package config
import "github.com/host-uk/core/pkg/cli"
import "forge.lthn.ai/core/cli/pkg/cli"
func init() {
cli.RegisterCommands(AddConfigCommands)

View file

@ -3,8 +3,8 @@ package config
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/config"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/cli/pkg/config"
)
func addGetCommand(parent *cli.Command) {

View file

@ -3,7 +3,7 @@ package config
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"forge.lthn.ai/core/cli/pkg/cli"
"gopkg.in/yaml.v3"
)

View file

@ -3,7 +3,7 @@ package config
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addPathCommand(parent *cli.Command) {

Some files were not shown because too many files have changed in this diff Show more