diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e26d2e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +env: + GOFLAGS: -buildvcs=false + GOWORK: "off" + GOPROXY: "direct" + GOSUMDB: "off" + +jobs: + test: + name: Test + Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test with coverage + working-directory: go + run: go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: go/coverage.out + flags: unittests + fail_ci_if_error: false + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: go + args: --timeout=5m --tests=false + + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test for coverage + working-directory: go + run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=dappcore + -Dsonar.projectKey=dappcore_go-scm + -Dsonar.sources=go + -Dsonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/*_test.go + -Dsonar.tests=go + -Dsonar.test.inclusions=**/*_test.go + -Dsonar.go.coverage.reportPaths=go/coverage.out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ffdea1c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,20 @@ +[submodule "external/go"] + path = external/go + url = https://github.com/dappcore/go.git + branch = dev +[submodule "external/config"] + path = external/config + url = https://github.com/dappcore/config.git + branch = dev +[submodule "external/io"] + path = external/io + url = https://github.com/dappcore/go-io.git + branch = dev +[submodule "external/ws"] + path = external/ws + url = https://github.com/dappcore/go-ws.git + branch = dev +[submodule "external/log"] + path = external/log + url = https://github.com/dappcore/go-log.git + branch = dev diff --git a/.woodpecker.yml b/.woodpecker.yml index 107f0e6..60358ee 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -14,7 +14,7 @@ steps: GOFLAGS: -buildvcs=false GOWORK: "off" commands: - - golangci-lint run --timeout=5m ./... + - cd go && golangci-lint run --timeout=5m ./... - name: go-test image: golang:1.26-alpine @@ -25,7 +25,7 @@ steps: CGO_ENABLED: "1" commands: - apk add --no-cache git build-base - - go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - cd go && go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... - name: sonar image: sonarsource/sonar-scanner-cli:latest depends_on: [go-test] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9606763 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance for agents working in this repository. + +## Project Overview + +This repository contains the Go module for `dappco.re/go/scm`. + +## Repo Layout + +Go module files and all Go packages now live under `go/`. + +```text +go-scm/ +├── go/ +│ ├── go.mod +│ ├── go.sum +│ ├── scm.go +│ ├── scm_test.go +│ ├── agentci/ +│ ├── cmd/ +│ ├── collect/ +│ ├── core/ +│ ├── forge/ +│ ├── git/ +│ ├── gitea/ +│ ├── internal/ +│ ├── jobrunner/ +│ ├── manifest/ +│ ├── marketplace/ +│ ├── pkg/ +│ ├── plugin/ +│ ├── repos/ +│ ├── tests/ +│ ├── third_party/ +│ ├── README.md (symlink when present at repo root) +│ ├── CLAUDE.md (symlink when present at repo root) +│ └── AGENTS.md (symlink when present at repo root) +├── .woodpecker.yml +└── sonar-project.properties +``` + +All Go-related checks, tooling, and package commands should be run from +`go/` (for example `cd go && go test ./...`, `cd go && go mod tidy`). + +For repository docs layout, keep cross-language files at the repo root and +gate them into `go/` via symlinks when they exist. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7026ee1 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ + + + + +> SCM façade — forge/gitea/forgejo, marketplace, manifest, plugin runner + +[![CI](https://github.com/dappcore/go-scm/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/dappcore/go-scm/actions/workflows/ci.yml) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=alert_status)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Coverage](https://codecov.io/gh/dappcore/go-scm/branch/dev/graph/badge.svg)](https://codecov.io/gh/dappcore/go-scm) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=security_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=code_smells)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-scm&metric=ncloc)](https://sonarcloud.io/dashboard?id=dappcore_go-scm) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/go-scm.svg)](https://pkg.go.dev/dappco.re/go/go-scm) +[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://eupl.eu/1.2/en/) + + diff --git a/collect/excavate.go b/collect/excavate.go deleted file mode 100644 index ecac9a3..0000000 --- a/collect/excavate.go +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: EUPL-1.2 - -package collect - -import ( - // Note: context.Context is retained as the excavator API cancellation contract. - "context" - // Note: time.Now is retained behind nowUTC for collection state timestamps. - "time" - - core "dappco.re/go" -) - -// Excavator runs multiple collectors as a coordinated operation. -// It provides sequential execution with rate limit respect, state tracking -// for resume support, and aggregated results. -type Excavator struct { - // Collectors is the list of collectors to run. - Collectors []Collector - - // ScanOnly reports what would be collected without performing collection. - ScanOnly bool - - // Resume enables incremental collection using saved state. - Resume bool -} - -// Name returns the orchestrator name. -func (e *Excavator) Name() string { - return "excavator" -} - -// Run executes all collectors sequentially, respecting rate limits and -// using state for resume support. Results are aggregated from all collectors. -func (e *Excavator) Run(ctx context.Context, cfg *Config) (*Result, error) { - result := &Result{Source: e.Name()} - if cfg == nil { - return nil, core.E("collect.Excavator.Run", "config is required", nil) - } - if ctx == nil { - ctx = context.Background() - } - - if len(e.Collectors) == 0 { - return result, nil - } - - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitStart(e.Name(), core.Sprintf("Starting excavation with %d collectors", len(e.Collectors))) - } - - if e.Resume && cfg.State != nil { - if err := cfg.State.Load(); err != nil { - return result, err - } - } - - if e.ScanOnly { - for _, c := range e.Collectors { - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(e.Name(), core.Sprintf("[scan] Would run collector: %s", c.Name()), nil) - } - } - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitComplete(e.Name(), "Excavation scan complete", result) - } - return result, nil - } - - for i, c := range e.Collectors { - if c == nil { - continue - } - if err := ctx.Err(); err != nil { - return result, err - } - - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(e.Name(), core.Sprintf("Running collector %d/%d: %s", i+1, len(e.Collectors), c.Name()), nil) - } - - if e.Resume && cfg.State != nil { - if entry, ok := cfg.State.Get(c.Name()); ok && entry != nil && entry.Items > 0 && !entry.LastRun.IsZero() { - result.Skipped++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress( - e.Name(), - core.Sprintf("Skipping %s (already collected %d items on %s)", c.Name(), entry.Items, entry.LastRun.Format("2006-01-02T15:04:05Z07:00")), - nil, - ) - } - continue - } - } - - if cfg.Limiter != nil { - if err := cfg.Limiter.Wait(ctx, c.Name()); err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Rate limit wait failed for %s: %v", c.Name(), err), nil) - } - continue - } - } - - collectorResult, err := c.Collect(ctx, cfg) - if err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Collector %s failed: %v", c.Name(), err), nil) - } - continue - } - if collectorResult == nil { - continue - } - - result.Items += collectorResult.Items - result.Errors += collectorResult.Errors - result.Skipped += collectorResult.Skipped - result.Files = append(result.Files, collectorResult.Files...) - - if cfg.State != nil { - cfg.State.Set(c.Name(), &StateEntry{ - Source: c.Name(), - LastRun: nowUTC(), - Items: collectorResult.Items, - }) - } - } - - if cfg.State != nil { - if err := cfg.State.Save(); err != nil && cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Failed to save state: %v", err), nil) - } - } - - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitComplete(e.Name(), core.Sprintf("Excavation complete: %d items, %d errors, %d skipped", result.Items, result.Errors, result.Skipped), result) - } - - return result, nil -} - -func nowUTC() time.Time { - return time.Now().UTC() -} diff --git a/external/config b/external/config new file mode 160000 index 0000000..aba665e --- /dev/null +++ b/external/config @@ -0,0 +1 @@ +Subproject commit aba665ec8915fea5f5a5452939c516599f3cb9a4 diff --git a/external/go b/external/go new file mode 160000 index 0000000..d661b70 --- /dev/null +++ b/external/go @@ -0,0 +1 @@ +Subproject commit d661b703e16183b3cbab101de189f688888a1174 diff --git a/external/io b/external/io new file mode 160000 index 0000000..789653d --- /dev/null +++ b/external/io @@ -0,0 +1 @@ +Subproject commit 789653dfc376383a3873993cdb875c8c717e4b05 diff --git a/external/log b/external/log new file mode 160000 index 0000000..df05298 --- /dev/null +++ b/external/log @@ -0,0 +1 @@ +Subproject commit df0529839b2ab786a6a3da374fa664867d5f9f09 diff --git a/external/ws b/external/ws new file mode 160000 index 0000000..c83f7a1 --- /dev/null +++ b/external/ws @@ -0,0 +1 @@ +Subproject commit c83f7a1d91c314543ac0d61d14a13b24877b8cd7 diff --git a/go.work b/go.work new file mode 100644 index 0000000..d742411 --- /dev/null +++ b/go.work @@ -0,0 +1,13 @@ +go 1.26.2 + +// Workspace mode for development: pulls fresh code from external/ submodules. +// CI uses GOWORK=off to fall back to go/go.mod tags (reproducible). + +use ( + ./go + ./external/go + ./external/config + ./external/io + ./external/ws + ./external/log +) diff --git a/go/CLAUDE.md b/go/CLAUDE.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/go/CLAUDE.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/agentci/agentci_test.go b/go/agentci/agentci_test.go similarity index 86% rename from agentci/agentci_test.go rename to go/agentci/agentci_test.go index c079dd7..98a290c 100644 --- a/agentci/agentci_test.go +++ b/go/agentci/agentci_test.go @@ -12,6 +12,14 @@ import ( "gopkg.in/yaml.v3" ) +const ( + sonarAgentciTestAgentLocal = "agent.local" + sonarAgentciTestAgentYaml = "agent.yaml" + sonarAgentciTestClothoVerified = "clotho-verified" + sonarAgentciTestCodexBot = "codex-bot" + sonarAgentciTestEchoReady = "echo ready" +) + func ax7AgentConfig(t *core.T) *config.Config { r := config.New(config.WithPath(filepath.Join(t.TempDir(), "config.yaml"))) core.RequireNoError(t, configResultError(r)) @@ -89,10 +97,10 @@ func TestAgentci_LoadActiveAgents_Ugly(t *core.T) { func TestAgentci_LoadClothoConfig_Good(t *core.T) { cfg := ax7AgentConfig(t) - core.RequireNoError(t, configResultError(cfg.Set("clotho", map[string]any{"strategy": "clotho-verified", "validation_threshold": 0.75}))) + core.RequireNoError(t, configResultError(cfg.Set("clotho", map[string]any{"strategy": sonarAgentciTestClothoVerified, "validation_threshold": 0.75}))) got, err := LoadClothoConfig(cfg) core.AssertNoError(t, err) - core.AssertEqual(t, "clotho-verified", got.Strategy) + core.AssertEqual(t, sonarAgentciTestClothoVerified, got.Strategy) core.AssertEqual(t, 0.75, got.ValidationThreshold) } @@ -112,11 +120,11 @@ func TestAgentci_LoadClothoConfig_Ugly(t *core.T) { func TestAgentci_SaveAgent_Good(t *core.T) { cfg := ax7AgentConfig(t) - err := SaveAgent(cfg, "codex", AgentConfig{Host: "agent.local", Active: true}) + err := SaveAgent(cfg, "codex", AgentConfig{Host: sonarAgentciTestAgentLocal, Active: true}) core.AssertNoError(t, err) agents, loadErr := LoadAgents(cfg) core.RequireNoError(t, loadErr) - core.AssertEqual(t, "agent.local", agents["codex"].Host) + core.AssertEqual(t, sonarAgentciTestAgentLocal, agents["codex"].Host) } func TestAgentci_SaveAgent_Bad(t *core.T) { @@ -159,9 +167,9 @@ func TestAgentci_RemoveAgent_Ugly(t *core.T) { } func TestAgentci_AgentConfig_MarshalYAML_Good(t *core.T) { - raw, err := yaml.Marshal(AgentConfig{Host: "agent.local", Roles: []string{"coder"}}) + raw, err := yaml.Marshal(AgentConfig{Host: sonarAgentciTestAgentLocal, Roles: []string{"coder"}}) core.AssertNoError(t, err) - core.AssertContains(t, string(raw), "agent.local") + core.AssertContains(t, string(raw), sonarAgentciTestAgentLocal) } func TestAgentci_AgentConfig_MarshalYAML_Bad(t *core.T) { @@ -180,7 +188,7 @@ func TestAgentci_AgentConfig_UnmarshalYAML_Good(t *core.T) { var agent AgentConfig err := yaml.Unmarshal([]byte("host: agent.local\nactive: true\n"), &agent) core.AssertNoError(t, err) - core.AssertEqual(t, "agent.local", agent.Host) + core.AssertEqual(t, sonarAgentciTestAgentLocal, agent.Host) core.AssertTrue(t, agent.Active) } @@ -217,7 +225,7 @@ func TestAgentci_NewSpinner_Ugly(t *core.T) { } func TestAgentci_Spinner_DeterminePlan_Good(t *core.T) { - spinner := NewSpinner(ClothoConfig{Strategy: "clotho-verified"}, map[string]AgentConfig{}) + spinner := NewSpinner(ClothoConfig{Strategy: sonarAgentciTestClothoVerified}, map[string]AgentConfig{}) got := spinner.DeterminePlan(&jobrunner.PipelineSignal{NeedsCoding: true}, "codex") core.AssertEqual(t, RunModeClothoVerified, got) } @@ -235,11 +243,11 @@ func TestAgentci_Spinner_DeterminePlan_Ugly(t *core.T) { } func TestAgentci_Spinner_FindByForgejoUser_Good(t *core.T) { - spinner := NewSpinner(ClothoConfig{}, map[string]AgentConfig{"codex": {ForgejoUser: "codex-bot"}}) - name, agent, ok := spinner.FindByForgejoUser("codex-bot") + spinner := NewSpinner(ClothoConfig{}, map[string]AgentConfig{"codex": {ForgejoUser: sonarAgentciTestCodexBot}}) + name, agent, ok := spinner.FindByForgejoUser(sonarAgentciTestCodexBot) core.AssertTrue(t, ok) core.AssertEqual(t, "codex", name) - core.AssertEqual(t, "codex-bot", agent.ForgejoUser) + core.AssertEqual(t, sonarAgentciTestCodexBot, agent.ForgejoUser) } func TestAgentci_Spinner_FindByForgejoUser_Bad(t *core.T) { @@ -334,14 +342,14 @@ func TestAgentci_ValidatePathElement_Ugly(t *core.T) { func TestAgentci_ResolvePathWithinRoot_Good(t *core.T) { root := t.TempDir() - name, path, err := ResolvePathWithinRoot(root, "agent.yaml") + name, path, err := ResolvePathWithinRoot(root, sonarAgentciTestAgentYaml) core.AssertNoError(t, err) - core.AssertEqual(t, "agent.yaml", name) - core.AssertEqual(t, filepath.Join(root, "agent.yaml"), path) + core.AssertEqual(t, sonarAgentciTestAgentYaml, name) + core.AssertEqual(t, filepath.Join(root, sonarAgentciTestAgentYaml), path) } func TestAgentci_ResolvePathWithinRoot_Bad(t *core.T) { - _, _, err := ResolvePathWithinRoot("", "agent.yaml") + _, _, err := ResolvePathWithinRoot("", sonarAgentciTestAgentYaml) core.AssertError( t, err, ) @@ -414,9 +422,9 @@ func TestAgentci_EscapeShellArg_Ugly(t *core.T) { } func TestAgentci_SecureSSHCommand_Good(t *core.T) { - cmd := SecureSSHCommand("agent.local", "echo ready") + cmd := SecureSSHCommand(sonarAgentciTestAgentLocal, sonarAgentciTestEchoReady) core.AssertEqual(t, "ssh", filepath.Base(cmd.Path)) - core.AssertContains(t, cmd.Args, "agent.local") + core.AssertContains(t, cmd.Args, sonarAgentciTestAgentLocal) } func TestAgentci_SecureSSHCommand_Bad(t *core.T) { @@ -426,28 +434,29 @@ func TestAgentci_SecureSSHCommand_Bad(t *core.T) { } func TestAgentci_SecureSSHCommand_Ugly(t *core.T) { - cmd := SecureSSHCommand("agent.local", "printf 'x'") + cmd := SecureSSHCommand(sonarAgentciTestAgentLocal, "printf 'x'") core.AssertContains( t, cmd.Args, "StrictHostKeyChecking=yes", ) } func TestAgentci_SecureSSHCommandContext_Good(t *core.T) { - cmd := SecureSSHCommandContext(context.Background(), "agent.local", "echo ready") + cmd := SecureSSHCommandContext(context.Background(), sonarAgentciTestAgentLocal, sonarAgentciTestEchoReady) core.AssertEqual(t, "ssh", filepath.Base(cmd.Path)) - core.AssertContains(t, cmd.Args, "echo ready") + core.AssertContains(t, cmd.Args, sonarAgentciTestEchoReady) } func TestAgentci_SecureSSHCommandContext_Bad(t *core.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - cmd := SecureSSHCommandContext(ctx, "agent.local", "echo ready") + cmd := SecureSSHCommandContext(ctx, sonarAgentciTestAgentLocal, sonarAgentciTestEchoReady) core.AssertNotNil(t, cmd) core.AssertNotNil(t, cmd.Cancel) } func TestAgentci_SecureSSHCommandContext_Ugly(t *core.T) { - cmd := SecureSSHCommandContext(nil, "agent.local", "echo ready") + var ctx context.Context + cmd := SecureSSHCommandContext(ctx, sonarAgentciTestAgentLocal, sonarAgentciTestEchoReady) core.AssertNotNil(t, cmd) core.AssertContains(t, cmd.Args, "BatchMode=yes") } diff --git a/agentci/clotho.go b/go/agentci/clotho.go similarity index 100% rename from agentci/clotho.go rename to go/agentci/clotho.go diff --git a/agentci/clotho_test.go b/go/agentci/clotho_test.go similarity index 85% rename from agentci/clotho_test.go rename to go/agentci/clotho_test.go index a042469..32c4fe3 100644 --- a/agentci/clotho_test.go +++ b/go/agentci/clotho_test.go @@ -11,12 +11,17 @@ import ( "dappco.re/go/scm/jobrunner" ) +const ( + sonarClothoTestGpt53CodexSpark = "gpt-5.3-codex-spark" + sonarClothoTestGpt54 = "gpt-5.4" +) + func TestSpinnerDeterministicBehaviour(t *testing.T) { s := NewSpinner(ClothoConfig{Strategy: "clotho-verified"}, map[string]AgentConfig{ "charon": { ForgejoUser: "forge", - Model: "gpt-5.4", - VerifyModel: "gpt-5.3-codex-spark", + Model: sonarClothoTestGpt54, + VerifyModel: sonarClothoTestGpt53CodexSpark, SecurityLevel: "high", }, }) @@ -38,7 +43,7 @@ func TestSpinnerDeterministicBehaviour(t *testing.T) { if name, _, ok := s.FindByForgejoUser("forge"); !ok || name != "charon" { t.Fatalf("expected forgejo lookup to resolve charon") } - if got := s.GetVerifierModel("charon"); got != "gpt-5.3-codex-spark" { + if got := s.GetVerifierModel("charon"); got != sonarClothoTestGpt53CodexSpark { t.Fatalf("unexpected verifier model: %q", got) } ok, err := s.Weave(context.Background(), []byte("same"), []byte("same\n")) @@ -51,12 +56,12 @@ func TestSpinnerResolveByForgejoUser(t *testing.T) { s := NewSpinner(ClothoConfig{}, map[string]AgentConfig{ "charon": { ForgejoUser: "forge", - Model: "gpt-5.4", - VerifyModel: "gpt-5.3-codex-spark", + Model: sonarClothoTestGpt54, + VerifyModel: sonarClothoTestGpt53CodexSpark, }, }) - if got := s.GetVerifierModel("forge"); got != "gpt-5.3-codex-spark" { + if got := s.GetVerifierModel("forge"); got != sonarClothoTestGpt53CodexSpark { t.Fatalf("expected verifier model by forgejo user, got %q", got) } } @@ -65,7 +70,7 @@ func TestSpinnerGetVerifierModelReturnsOnlySecondaryModel(t *testing.T) { s := NewSpinner(ClothoConfig{}, map[string]AgentConfig{ "charon": { ForgejoUser: "forge", - Model: "gpt-5.4", + Model: sonarClothoTestGpt54, }, }) @@ -106,8 +111,8 @@ func TestSpinnerDeterminePlanHonorsAgentOverridesUnderDirectStrategy(t *testing. s := NewSpinner(ClothoConfig{Strategy: "direct"}, map[string]AgentConfig{ "charon": { ForgejoUser: "forge", - Model: "gpt-5.4", - VerifyModel: "gpt-5.3-codex-spark", + Model: sonarClothoTestGpt54, + VerifyModel: sonarClothoTestGpt53CodexSpark, SecurityLevel: "high", }, }) diff --git a/agentci/config.go b/go/agentci/config.go similarity index 100% rename from agentci/config.go rename to go/agentci/config.go diff --git a/agentci/config_test.go b/go/agentci/config_test.go similarity index 88% rename from agentci/config_test.go rename to go/agentci/config_test.go index 0728431..32ea69d 100644 --- a/agentci/config_test.go +++ b/go/agentci/config_test.go @@ -12,6 +12,12 @@ import ( "dappco.re/go/config" ) +const ( + sonarConfigTestNewConfigV = "new config: %v" + sonarConfigTestNotAMap = "not-a-map" + sonarConfigTestSetClothoV = "set clotho: %v" +) + func configResultError(r core.Result) error { if r.OK { return nil @@ -23,7 +29,7 @@ func testConfig(t *testing.T, opts ...config.Option) *config.Config { t.Helper() r := config.New(opts...) if err := configResultError(r); err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } return core.MustCast[*config.Config](r) } @@ -108,7 +114,7 @@ func TestLoadClothoConfigDefaults(t *testing.T) { func TestLoadAgentsReturnsErrorForInvalidData(t *testing.T) { cfg := testConfig(t) - if err := configResultError(cfg.Set("agents", "not-a-map")); err != nil { + if err := configResultError(cfg.Set("agents", sonarConfigTestNotAMap)); err != nil { t.Fatalf("set agents: %v", err) } @@ -120,8 +126,8 @@ func TestLoadAgentsReturnsErrorForInvalidData(t *testing.T) { func TestLoadClothoConfigReturnsErrorForInvalidData(t *testing.T) { cfg := testConfig(t) - if err := configResultError(cfg.Set("clotho", "not-a-map")); err != nil { - t.Fatalf("set clotho: %v", err) + if err := configResultError(cfg.Set("clotho", sonarConfigTestNotAMap)); err != nil { + t.Fatalf(sonarConfigTestSetClothoV, err) } if _, err := LoadClothoConfig(cfg); err == nil { @@ -133,7 +139,7 @@ func TestLoadClothoConfigHandlesNullConfig(t *testing.T) { cfg := testConfig(t) if err := configResultError(cfg.Set("clotho", nil)); err != nil { - t.Fatalf("set clotho: %v", err) + t.Fatalf(sonarConfigTestSetClothoV, err) } clotho, err := LoadClothoConfig(cfg) @@ -154,7 +160,7 @@ func TestLoadClothoConfigRejectsOutOfRangeThreshold(t *testing.T) { if err := configResultError(cfg.Set("clotho", map[string]any{ "validation_threshold": 1.5, })); err != nil { - t.Fatalf("set clotho: %v", err) + t.Fatalf(sonarConfigTestSetClothoV, err) } if _, err := LoadClothoConfig(cfg); err == nil { @@ -168,7 +174,7 @@ func TestLoadClothoConfigRejectsUnknownStrategy(t *testing.T) { if err := configResultError(cfg.Set("clotho", map[string]any{ "strategy": "experimental", })); err != nil { - t.Fatalf("set clotho: %v", err) + t.Fatalf(sonarConfigTestSetClothoV, err) } if _, err := LoadClothoConfig(cfg); err == nil { @@ -179,7 +185,7 @@ func TestLoadClothoConfigRejectsUnknownStrategy(t *testing.T) { func TestSaveAndRemoveAgentPropagateLoadErrors(t *testing.T) { cfg := testConfig(t) - if err := configResultError(cfg.Set("agents", "not-a-map")); err != nil { + if err := configResultError(cfg.Set("agents", sonarConfigTestNotAMap)); err != nil { t.Fatalf("set agents: %v", err) } diff --git a/agentci/security.go b/go/agentci/security.go similarity index 74% rename from agentci/security.go rename to go/agentci/security.go index bfb5424..572d5b4 100644 --- a/agentci/security.go +++ b/go/agentci/security.go @@ -12,6 +12,12 @@ import ( core "dappco.re/go" ) +const ( + sonarSecurityAgentciResolvepathwithinroot = "agentci.ResolvePathWithinRoot" + sonarSecurityAgentciSanitizepath = "agentci.SanitizePath" + sonarSecurityAgentciValidateremotedir = "agentci.ValidateRemoteDir" +) + var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) // SanitizePath ensures a filename or directory name is safe and prevents path traversal. @@ -19,16 +25,16 @@ var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) // Usage: SanitizePath(...) func SanitizePath(input string) (string, error) { if input == "" { - return "", core.E("agentci.SanitizePath", "path element is required", nil) + return "", core.E(sonarSecurityAgentciSanitizepath, "path element is required", nil) } if input == "." || input == ".." { - return "", core.E("agentci.SanitizePath", core.Sprintf("invalid path element: %s", input), nil) + return "", core.E(sonarSecurityAgentciSanitizepath, core.Sprintf("invalid path element: %s", input), nil) } if core.Contains(input, "/") || core.Contains(input, `\`) { - return "", core.E("agentci.SanitizePath", core.Sprintf("path separators are not allowed: %s", input), nil) + return "", core.E(sonarSecurityAgentciSanitizepath, core.Sprintf("path separators are not allowed: %s", input), nil) } if !safeNameRegex.MatchString(input) { - return "", core.E("agentci.SanitizePath", core.Sprintf("invalid characters in path element: %s", input), nil) + return "", core.E(sonarSecurityAgentciSanitizepath, core.Sprintf("invalid characters in path element: %s", input), nil) } return input, nil } @@ -50,12 +56,12 @@ func ValidatePathElement(input string) (string, error) { // Usage: ResolvePathWithinRoot(...) func ResolvePathWithinRoot(root string, input string) (string, string, error) { if core.Trim(root) == "" { - return "", "", core.E("agentci.ResolvePathWithinRoot", "root is required", nil) + return "", "", core.E(sonarSecurityAgentciResolvepathwithinroot, "root is required", nil) } safeName, err := ValidatePathElement(input) if err != nil { - return "", "", core.E("agentci.ResolvePathWithinRoot", "invalid path element", err) + return "", "", core.E(sonarSecurityAgentciResolvepathwithinroot, "invalid path element", err) } absRoot := absoluteLocalPath(root) @@ -63,14 +69,14 @@ func ResolvePathWithinRoot(root string, input string) (string, string, error) { cleanRoot := cleanLocalPath(absRoot) if cleanRoot == localPathSeparator() { if !core.HasPrefix(resolved, cleanRoot) { - return "", "", core.E("agentci.ResolvePathWithinRoot", "resolved path escaped root", nil) + return "", "", core.E(sonarSecurityAgentciResolvepathwithinroot, "resolved path escaped root", nil) } return safeName, resolved, nil } rootPrefix := core.Concat(cleanRoot, localPathSeparator()) if resolved != cleanRoot && !core.HasPrefix(resolved, rootPrefix) { - return "", "", core.E("agentci.ResolvePathWithinRoot", "resolved path escaped root", nil) + return "", "", core.E(sonarSecurityAgentciResolvepathwithinroot, "resolved path escaped root", nil) } return safeName, resolved, nil @@ -80,10 +86,10 @@ func ResolvePathWithinRoot(root string, input string) (string, string, error) { // Usage: ValidateRemoteDir(...) func ValidateRemoteDir(dir string) (string, error) { if core.Trim(dir) == "" { - return "", core.E("agentci.ValidateRemoteDir", "directory is required", nil) + return "", core.E(sonarSecurityAgentciValidateremotedir, "directory is required", nil) } if core.Contains(dir, `\`) { - return "", core.E("agentci.ValidateRemoteDir", "backslashes are not allowed", nil) + return "", core.E(sonarSecurityAgentciValidateremotedir, "backslashes are not allowed", nil) } switch dir { @@ -91,38 +97,48 @@ func ValidateRemoteDir(dir string) (string, error) { return dir, nil } - prefix := "" - rest := dir + prefix, rest := splitRemoteDirPrefix(dir) + if err := validateRemoteDirSegments(rest); err != nil { + return "", err + } + + if rest == "" || rest == "." { + return remoteDirRoot(prefix), nil + } + + return cleanRemotePath(dir), nil +} +func splitRemoteDirPrefix(dir string) (prefix, rest string) { if core.HasPrefix(dir, "~/") { - prefix = "~/" - rest = core.TrimPrefix(dir, "~/") + return "~/", core.TrimPrefix(dir, "~/") } if core.HasPrefix(dir, "/") { - prefix = "/" - rest = core.TrimPrefix(dir, "/") + return "/", core.TrimPrefix(dir, "/") } + return "", dir +} +func validateRemoteDirSegments(rest string) error { for _, part := range core.Split(rest, "/") { if part == "" { continue } if part == "." || part == ".." { - return "", core.E("agentci.ValidateRemoteDir", "directory escaped root", nil) + return core.E(sonarSecurityAgentciValidateremotedir, "directory escaped root", nil) } if _, err := ValidatePathElement(part); err != nil { - return "", core.E("agentci.ValidateRemoteDir", "invalid directory segment", err) + return core.E(sonarSecurityAgentciValidateremotedir, "invalid directory segment", err) } } + return nil +} - if rest == "" || rest == "." { - if prefix == "~/" { - return "~", nil - } - return prefix, nil +func remoteDirRoot(prefix string) string { + if prefix == "~/" { + return "~" } - - return cleanRemotePath(dir), nil + return prefix } // JoinRemotePath joins validated remote path elements using forward slashes. diff --git a/agentci/security_test.go b/go/agentci/security_test.go similarity index 87% rename from agentci/security_test.go rename to go/agentci/security_test.go index 95c6ae0..773b33b 100644 --- a/agentci/security_test.go +++ b/go/agentci/security_test.go @@ -11,6 +11,12 @@ import ( "testing" ) +const ( + sonarSecurityTestAiWorkQueue = "~/ai-work/queue" + sonarSecurityTestHostExampleCom = "host.example.com" + sonarSecurityTestLsLa = "ls -la" +) + func checkNoError(t *testing.T, err error) { t.Helper() if err != nil { @@ -120,7 +126,7 @@ func TestValidateRemoteDir_Good(t *testing.T) { {"queue", "queue"}, {"queue/subdir", "queue/subdir"}, {"/var/tmp/queue", "/var/tmp/queue"}, - {"~/ai-work/queue", "~/ai-work/queue"}, + {sonarSecurityTestAiWorkQueue, sonarSecurityTestAiWorkQueue}, {"queue//nested", "queue/nested"}, } @@ -155,9 +161,9 @@ func TestValidateRemoteDir_Bad(t *testing.T) { } func TestJoinRemotePath_Good_BaseOnly_Good(t *testing.T) { - got, err := JoinRemotePath("~/ai-work/queue") + got, err := JoinRemotePath(sonarSecurityTestAiWorkQueue) checkNoError(t, err) - checkEqual(t, "~/ai-work/queue", got) + checkEqual(t, sonarSecurityTestAiWorkQueue, got) } func TestResolvePathWithinRoot_Good_RootDirectory_Good(t *testing.T) { @@ -189,7 +195,7 @@ func TestResolvePathWithinRoot_Bad_EmptyRoot(t *testing.T) { } func TestSecureSSHCommand_Good(t *testing.T) { - cmd := SecureSSHCommand("host.example.com", "ls -la") + cmd := SecureSSHCommand(sonarSecurityTestHostExampleCom, sonarSecurityTestLsLa) args := cmd.Args checkEqual(t, "ssh", args[0]) @@ -197,12 +203,12 @@ func TestSecureSSHCommand_Good(t *testing.T) { checkContains(t, args, "StrictHostKeyChecking=yes") checkContains(t, args, "BatchMode=yes") checkContains(t, args, "ConnectTimeout=10") - checkEqual(t, "host.example.com", args[len(args)-2]) - checkEqual(t, "ls -la", args[len(args)-1]) + checkEqual(t, sonarSecurityTestHostExampleCom, args[len(args)-2]) + checkEqual(t, sonarSecurityTestLsLa, args[len(args)-1]) } func TestSecureSSHCommandContext_Good(t *testing.T) { - cmd := SecureSSHCommandContext(context.Background(), "host.example.com", "ls -la") + cmd := SecureSSHCommandContext(context.Background(), sonarSecurityTestHostExampleCom, sonarSecurityTestLsLa) args := cmd.Args checkEqual(t, "ssh", args[0]) @@ -210,8 +216,8 @@ func TestSecureSSHCommandContext_Good(t *testing.T) { checkContains(t, args, "StrictHostKeyChecking=yes") checkContains(t, args, "BatchMode=yes") checkContains(t, args, "ConnectTimeout=10") - checkEqual(t, "host.example.com", args[len(args)-2]) - checkEqual(t, "ls -la", args[len(args)-1]) + checkEqual(t, sonarSecurityTestHostExampleCom, args[len(args)-2]) + checkEqual(t, sonarSecurityTestLsLa, args[len(args)-1]) } func TestMaskToken_Good(t *testing.T) { diff --git a/cmd/collect/main.go b/go/cmd/collect/main.go similarity index 92% rename from cmd/collect/main.go rename to go/cmd/collect/main.go index 808a758..83a4959 100644 --- a/cmd/collect/main.go +++ b/go/cmd/collect/main.go @@ -17,9 +17,9 @@ func newApp() *core.Core { app := core.New(core.WithOption("name", "collect")) app.App().Version = "dev" - app.Command("github", core.Command{Action: github}) - app.Command("market", core.Command{Action: market}) - app.Command("papers", core.Command{Action: papers}) + _ = app.Command("github", core.Command{Action: github}) + _ = app.Command("market", core.Command{Action: market}) + _ = app.Command("papers", core.Command{Action: papers}) return app } diff --git a/cmd/compile/cmd_compile.go b/go/cmd/compile/cmd_compile.go similarity index 100% rename from cmd/compile/cmd_compile.go rename to go/cmd/compile/cmd_compile.go diff --git a/cmd/compile/cmd_compile_test.go b/go/cmd/compile/cmd_compile_test.go similarity index 100% rename from cmd/compile/cmd_compile_test.go rename to go/cmd/compile/cmd_compile_test.go diff --git a/cmd/forge/main.go b/go/cmd/forge/main.go similarity index 93% rename from cmd/forge/main.go rename to go/cmd/forge/main.go index 7e5925c..1cbf853 100644 --- a/cmd/forge/main.go +++ b/go/cmd/forge/main.go @@ -15,8 +15,8 @@ func newApp() *core.Core { app := core.New(core.WithOption("name", "forge")) app.App().Version = "dev" - app.Command("auth", core.Command{Action: auth}) - app.Command("repos", core.Command{Action: repos}) + _ = app.Command("auth", core.Command{Action: auth}) + _ = app.Command("repos", core.Command{Action: repos}) return app } diff --git a/cmd/gitea/main.go b/go/cmd/gitea/main.go similarity index 95% rename from cmd/gitea/main.go rename to go/cmd/gitea/main.go index f3c9b72..735b676 100644 --- a/cmd/gitea/main.go +++ b/go/cmd/gitea/main.go @@ -18,8 +18,8 @@ func newApp() *core.Core { app := core.New(core.WithOption("name", "gitea")) app.App().Version = "dev" - app.Command("repos", core.Command{Action: repos}) - app.Command("issues", core.Command{Action: issues}) + _ = app.Command("repos", core.Command{Action: repos}) + _ = app.Command("issues", core.Command{Action: issues}) return app } diff --git a/cmd/pkg/cmd_pkg.go b/go/cmd/pkg/cmd_pkg.go similarity index 100% rename from cmd/pkg/cmd_pkg.go rename to go/cmd/pkg/cmd_pkg.go diff --git a/cmd/pkg/cmd_pkg_test.go b/go/cmd/pkg/cmd_pkg_test.go similarity index 100% rename from cmd/pkg/cmd_pkg_test.go rename to go/cmd/pkg/cmd_pkg_test.go diff --git a/cmd/scm/main.go b/go/cmd/scm/main.go similarity index 75% rename from cmd/scm/main.go rename to go/cmd/scm/main.go index 4d5d0d6..d70e708 100644 --- a/cmd/scm/main.go +++ b/go/cmd/scm/main.go @@ -22,12 +22,12 @@ func newApp() *core.Core { ) app.App().Version = "dev" - app.Command("health", core.Command{Action: health(app)}) - app.Command("dev/health", core.Command{Action: health(app)}) - compilecmd.Register(app) - signcmd.Register(app) - verifycmd.Register(app) - pkgcmd.Register(app) + _ = app.Command("health", core.Command{Action: health(app)}) + _ = app.Command("dev/health", core.Command{Action: health(app)}) + _ = compilecmd.Register(app) + _ = signcmd.Register(app) + _ = verifycmd.Register(app) + _ = pkgcmd.Register(app) return app } diff --git a/cmd/sign/cmd_sign.go b/go/cmd/sign/cmd_sign.go similarity index 100% rename from cmd/sign/cmd_sign.go rename to go/cmd/sign/cmd_sign.go diff --git a/cmd/sign/cmd_sign_test.go b/go/cmd/sign/cmd_sign_test.go similarity index 100% rename from cmd/sign/cmd_sign_test.go rename to go/cmd/sign/cmd_sign_test.go diff --git a/cmd/verify/cmd_verify.go b/go/cmd/verify/cmd_verify.go similarity index 100% rename from cmd/verify/cmd_verify.go rename to go/cmd/verify/cmd_verify.go diff --git a/cmd/verify/cmd_verify_test.go b/go/cmd/verify/cmd_verify_test.go similarity index 100% rename from cmd/verify/cmd_verify_test.go rename to go/cmd/verify/cmd_verify_test.go diff --git a/collect/bitcointalk.go b/go/collect/bitcointalk.go similarity index 86% rename from collect/bitcointalk.go rename to go/collect/bitcointalk.go index 8edde78..a6696d4 100644 --- a/collect/bitcointalk.go +++ b/go/collect/bitcointalk.go @@ -148,46 +148,61 @@ func (b *BitcoinTalkCollector) collectTopic(ctx context.Context, cfg *Config, to if pages > 0 && page > pages { break } - if cfg.Limiter != nil { - if err := cfg.Limiter.Wait(ctx, b.Name()); err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(b.Name(), core.Sprintf("Rate limit wait failed for page %d: %v", page, err), nil) - } - break - } - } - url := b.pageURL(topicID, page) - posts, err := fetcher(ctx, url) - if err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(b.Name(), core.Sprintf("Failed to fetch page %d: %v", page, err), nil) - } + if !b.collectTopicPage(ctx, cfg, topicID, fetcher, page, result) { break } - if len(posts) == 0 { - break - } - for _, post := range posts { - result.Items++ - md := FormatPostMarkdown(post.Number, post.Author, post.Date, post.Content) - name := core.Sprintf("%s-page-%d-post-%d.md", topicID, page, post.Number) - outPath, err := writeResultFile(cfg, b.Name(), name, md) - if err != nil { - result.Errors++ - continue - } - result.Files = append(result.Files, outPath) - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitItem(b.Name(), core.Sprintf("Post %d by %s", post.Number, post.Author), nil) - } - } page++ } return result } +func (b *BitcoinTalkCollector) collectTopicPage( + ctx context.Context, + cfg *Config, + topicID string, + fetcher func(context.Context, string) ([]btPost, error), + page int, + result *Result, +) bool { + if err := waitCollectLimiter(ctx, cfg, b.Name()); err != nil { + result.Errors++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(b.Name(), core.Sprintf("Rate limit wait failed for page %d: %v", page, err), nil) + } + return false + } + posts, err := fetcher(ctx, b.pageURL(topicID, page)) + if err != nil { + result.Errors++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(b.Name(), core.Sprintf("Failed to fetch page %d: %v", page, err), nil) + } + return false + } + if len(posts) == 0 { + return false + } + for _, post := range posts { + b.writeTopicPost(cfg, topicID, page, post, result) + } + return true +} + +func (b *BitcoinTalkCollector) writeTopicPost(cfg *Config, topicID string, page int, post btPost, result *Result) { + result.Items++ + md := FormatPostMarkdown(post.Number, post.Author, post.Date, post.Content) + name := core.Sprintf("%s-page-%d-post-%d.md", topicID, page, post.Number) + outPath, err := writeResultFile(cfg, b.Name(), name, md) + if err != nil { + result.Errors++ + return + } + result.Files = append(result.Files, outPath) + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitItem(b.Name(), core.Sprintf("Post %d by %s", post.Number, post.Author), nil) + } +} + func (b *BitcoinTalkCollector) pageURL(topicID string, page int) string { base := b.URL if base == "" { @@ -218,7 +233,9 @@ func fetchBitcoinTalkPage(ctx context.Context, url string) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return "", core.E("collect.BitcoinTalkCollector", core.Sprintf("http %s", resp.Status), nil) } diff --git a/collect/bitcointalk_test.go b/go/collect/bitcointalk_test.go similarity index 100% rename from collect/bitcointalk_test.go rename to go/collect/bitcointalk_test.go diff --git a/collect/collect.go b/go/collect/collect.go similarity index 78% rename from collect/collect.go rename to go/collect/collect.go index 9e970de..8524ca3 100644 --- a/collect/collect.go +++ b/go/collect/collect.go @@ -75,6 +75,31 @@ func MergeResults(source string, results ...*Result) *Result { return merged } +func activeCollectContext(ctx context.Context) (context.Context, error) { + if ctx == nil { + ctx = context.Background() + } + return ctx, ctx.Err() +} + +func emitDryRun(cfg *Config, source, progress, complete string, result *Result) bool { + if cfg == nil || !cfg.DryRun { + return false + } + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitProgress(source, progress, nil) + cfg.Dispatcher.EmitComplete(source, complete, result) + } + return true +} + +func waitCollectLimiter(ctx context.Context, cfg *Config, source string) error { + if cfg == nil || cfg.Limiter == nil { + return nil + } + return cfg.Limiter.Wait(ctx, source) +} + func writeResultFile(cfg *Config, source, name, content string) (string, error) { if cfg == nil || cfg.Output == nil { return "", core.E("collect.writeResultFile", "output medium is required", nil) diff --git a/collect/collect_test.go b/go/collect/collect_test.go similarity index 96% rename from collect/collect_test.go rename to go/collect/collect_test.go index d199936..da8e316 100644 --- a/collect/collect_test.go +++ b/go/collect/collect_test.go @@ -15,6 +15,10 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarCollectTestStateJson = "state.json" +) + type ax7Collector struct { name string result *Result @@ -120,13 +124,21 @@ func TestCollect_NewDispatcher_Bad(t *core.T) { func TestCollect_NewDispatcher_Ugly(t *core.T) { dispatcher := NewDispatcher() - dispatcher.On(EventStart, func(Event) {}) + ax7RegisterStartHandler(dispatcher) core.AssertLen(t, dispatcher.handlers[EventStart], 1) } +func ax7RegisterStartHandler(dispatcher *Dispatcher) { + dispatcher.On(EventStart, func(Event) { + // Empty handler verifies registration without side effects. + }) +} + func TestCollect_Dispatcher_On_Good(t *core.T) { dispatcher := NewDispatcher() - dispatcher.On(EventStart, func(Event) {}) + dispatcher.On(EventStart, func(Event) { + // Empty handler verifies registration without side effects. + }) core.AssertLen(t, dispatcher.handlers[EventStart], 1) } @@ -139,7 +151,9 @@ func TestCollect_Dispatcher_On_Bad(t *core.T) { func TestCollect_Dispatcher_On_Ugly(t *core.T) { var dispatcher *Dispatcher core.AssertNotPanics(t, func() { - dispatcher.On(EventStart, func(Event) {}) + dispatcher.On(EventStart, func(Event) { + // Empty handler verifies nil dispatcher handling. + }) }) } @@ -276,8 +290,8 @@ func TestCollect_Dispatcher_EmitComplete_Ugly(t *core.T) { } func TestCollect_NewState_Good(t *core.T) { - state := NewState(coreio.NewMockMedium(), "state.json") - core.AssertEqual(t, "state.json", state.path) + state := NewState(coreio.NewMockMedium(), sonarCollectTestStateJson) + core.AssertEqual(t, sonarCollectTestStateJson, state.path) core.AssertNotNil(t, state.entries) } @@ -290,7 +304,7 @@ func TestCollect_NewState_Bad(t *core.T) { func TestCollect_NewState_Ugly(t *core.T) { state := NewState(coreio.NewMockMedium(), "dir/../state.json") core.AssertEqual( - t, "state.json", state.path, + t, sonarCollectTestStateJson, state.path, ) } @@ -338,8 +352,8 @@ func TestCollect_State_Set_Ugly(t *core.T) { func TestCollect_State_Load_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("state.json", `{"github":{"source":"github","items":2}}`)) - state := NewState(medium, "state.json") + core.RequireNoError(t, medium.Write(sonarCollectTestStateJson, `{"github":{"source":"github","items":2}}`)) + state := NewState(medium, sonarCollectTestStateJson) err := state.Load() core.AssertNoError(t, err) entry, ok := state.Get("github") @@ -362,11 +376,11 @@ func TestCollect_State_Load_Ugly(t *core.T) { func TestCollect_State_Save_Good(t *core.T) { medium := coreio.NewMockMedium() - state := NewState(medium, "state.json") + state := NewState(medium, sonarCollectTestStateJson) state.Set("github", &StateEntry{Items: 2}) err := state.Save() core.AssertNoError(t, err) - raw, readErr := medium.Read("state.json") + raw, readErr := medium.Read(sonarCollectTestStateJson) core.RequireNoError(t, readErr) core.AssertContains(t, raw, "github") } @@ -507,7 +521,8 @@ func TestCollect_RateLimiter_Wait_Bad(t *core.T) { func TestCollect_RateLimiter_Wait_Ugly(t *core.T) { var limiter *RateLimiter - err := limiter.Wait(nil, "unit") + var ctx context.Context + err := limiter.Wait(ctx, "unit") core.AssertNoError(t, err) } @@ -593,7 +608,8 @@ func TestCollect_RateLimiter_CheckGitHubRateLimitCtx_Bad(t *core.T) { func TestCollect_RateLimiter_CheckGitHubRateLimitCtx_Ugly(t *core.T) { var limiter *RateLimiter - used, limit, err := limiter.CheckGitHubRateLimitCtx(nil) + var ctx context.Context + used, limit, err := limiter.CheckGitHubRateLimitCtx(ctx) core.AssertNoError(t, err) core.AssertEqual(t, 0, used) core.AssertEqual(t, 0, limit) diff --git a/collect/events.go b/go/collect/events.go similarity index 100% rename from collect/events.go rename to go/collect/events.go diff --git a/go/collect/excavate.go b/go/collect/excavate.go new file mode 100644 index 0000000..abb0c0c --- /dev/null +++ b/go/collect/excavate.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package collect + +import ( + // Note: context.Context is retained as the excavator API cancellation contract. + "context" + // Note: time.Now is retained behind nowUTC for collection state timestamps. + "time" + + core "dappco.re/go" +) + +// Excavator runs multiple collectors as a coordinated operation. +// It provides sequential execution with rate limit respect, state tracking +// for resume support, and aggregated results. +type Excavator struct { + // Collectors is the list of collectors to run. + Collectors []Collector + + // ScanOnly reports what would be collected without performing collection. + ScanOnly bool + + // Resume enables incremental collection using saved state. + Resume bool +} + +// Name returns the orchestrator name. +func (e *Excavator) Name() string { + return "excavator" +} + +// Run executes all collectors sequentially, respecting rate limits and +// using state for resume support. Results are aggregated from all collectors. +func (e *Excavator) Run(ctx context.Context, cfg *Config) (*Result, error) { + result := &Result{Source: e.Name()} + if cfg == nil { + return nil, core.E("collect.Excavator.Run", "config is required", nil) + } + ctx, err := activeCollectContext(ctx) + if err != nil { + return result, err + } + + if len(e.Collectors) == 0 { + return result, nil + } + + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitStart(e.Name(), core.Sprintf("Starting excavation with %d collectors", len(e.Collectors))) + } + + if err := e.loadResumeState(cfg); err != nil { + return result, err + } + + if e.ScanOnly { + e.emitScan(cfg, result) + return result, nil + } + + for i, c := range e.Collectors { + if err := ctx.Err(); err != nil { + return result, err + } + e.runCollector(ctx, cfg, i, c, result) + } + + e.saveState(cfg) + e.emitComplete(cfg, result) + + return result, nil +} + +func (e *Excavator) loadResumeState(cfg *Config) error { + if !e.Resume || cfg.State == nil { + return nil + } + return cfg.State.Load() +} + +func (e *Excavator) saveState(cfg *Config) { + if cfg.State == nil { + return + } + if err := cfg.State.Save(); err != nil && cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Failed to save state: %v", err), nil) + } +} + +func (e *Excavator) emitComplete(cfg *Config, result *Result) { + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitComplete(e.Name(), core.Sprintf("Excavation complete: %d items, %d errors, %d skipped", result.Items, result.Errors, result.Skipped), result) + } +} + +func (e *Excavator) emitScan(cfg *Config, result *Result) { + if cfg.Dispatcher == nil { + return + } + for _, c := range e.Collectors { + cfg.Dispatcher.EmitProgress(e.Name(), core.Sprintf("[scan] Would run collector: %s", c.Name()), nil) + } + cfg.Dispatcher.EmitComplete(e.Name(), "Excavation scan complete", result) +} + +func (e *Excavator) runCollector(ctx context.Context, cfg *Config, index int, c Collector, result *Result) { + if c == nil { + return + } + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitProgress(e.Name(), core.Sprintf("Running collector %d/%d: %s", index+1, len(e.Collectors), c.Name()), nil) + } + if e.skipCollected(cfg, c, result) { + return + } + if err := waitCollectLimiter(ctx, cfg, c.Name()); err != nil { + result.Errors++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Rate limit wait failed for %s: %v", c.Name(), err), nil) + } + return + } + collectorResult, err := c.Collect(ctx, cfg) + if err != nil { + result.Errors++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(e.Name(), core.Sprintf("Collector %s failed: %v", c.Name(), err), nil) + } + return + } + e.mergeCollectorResult(cfg, c, collectorResult, result) +} + +func (e *Excavator) skipCollected(cfg *Config, c Collector, result *Result) bool { + if !e.Resume || cfg.State == nil { + return false + } + entry, ok := cfg.State.Get(c.Name()) + if !ok || entry == nil || entry.Items <= 0 || entry.LastRun.IsZero() { + return false + } + result.Skipped++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitProgress( + e.Name(), + core.Sprintf("Skipping %s (already collected %d items on %s)", c.Name(), entry.Items, entry.LastRun.Format("2006-01-02T15:04:05Z07:00")), + nil, + ) + } + return true +} + +func (e *Excavator) mergeCollectorResult(cfg *Config, c Collector, collectorResult *Result, result *Result) { + if collectorResult == nil { + return + } + result.Items += collectorResult.Items + result.Errors += collectorResult.Errors + result.Skipped += collectorResult.Skipped + result.Files = append(result.Files, collectorResult.Files...) + if cfg.State != nil { + cfg.State.Set(c.Name(), &StateEntry{ + Source: c.Name(), + LastRun: nowUTC(), + Items: collectorResult.Items, + }) + } +} + +func nowUTC() time.Time { + return time.Now().UTC() +} diff --git a/collect/github.go b/go/collect/github.go similarity index 77% rename from collect/github.go rename to go/collect/github.go index 97ccbb0..ee1353d 100644 --- a/collect/github.go +++ b/go/collect/github.go @@ -24,29 +24,19 @@ func (g *GitHubCollector) Collect(ctx context.Context, cfg *Config) (*Result, er if cfg == nil { return nil, core.E("collect.GitHubCollector.Collect", "config is required", nil) } - if ctx == nil { - ctx = context.Background() - } - if ctx != nil { - if err := ctx.Err(); err != nil { - return nil, err - } + ctx, err := activeCollectContext(ctx) + if err != nil { + return nil, err } result := &Result{Source: g.Name()} if cfg.Dispatcher != nil { cfg.Dispatcher.EmitStart(g.Name(), "Starting GitHub collection") } - if cfg.DryRun { - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(g.Name(), "[dry-run] Would collect GitHub data", nil) - cfg.Dispatcher.EmitComplete(g.Name(), "GitHub dry-run complete", result) - } + if emitDryRun(cfg, g.Name(), "[dry-run] Would collect GitHub data", "GitHub dry-run complete", result) { return result, nil } - if cfg.Limiter != nil { - if err := cfg.Limiter.Wait(ctx, "github"); err != nil { - return result, err - } + if err := waitCollectLimiter(ctx, cfg, "github"); err != nil { + return result, err } content := core.Sprintf("# GitHub Collection\n\n- Org: %s\n- Repo: %s\n- IssuesOnly: %t\n- PRsOnly: %t\n", g.Org, g.Repo, g.IssuesOnly, g.PRsOnly) path := "github.md" diff --git a/collect/market.go b/go/collect/market.go similarity index 63% rename from collect/market.go rename to go/collect/market.go index cf44180..0545b81 100644 --- a/collect/market.go +++ b/go/collect/market.go @@ -38,33 +38,22 @@ func (m *MarketCollector) Collect(ctx context.Context, cfg *Config) (*Result, er if cfg == nil { return nil, core.E("collect.MarketCollector.Collect", "config is required", nil) } - if ctx == nil { - ctx = context.Background() - } - if ctx != nil { - if err := ctx.Err(); err != nil { - return nil, err - } + ctx, err := activeCollectContext(ctx) + if err != nil { + return nil, err } if cfg.Dispatcher != nil { cfg.Dispatcher.EmitStart(m.Name(), "Starting market data collection") } - if cfg.DryRun { - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(m.Name(), "[dry-run] Would collect market data", nil) - cfg.Dispatcher.EmitComplete(m.Name(), "Market dry-run complete", &Result{Source: m.Name()}) - } - return &Result{Source: m.Name()}, nil + result := &Result{Source: m.Name()} + if emitDryRun(cfg, m.Name(), "[dry-run] Would collect market data", "Market dry-run complete", result) { + return result, nil } - if cfg.Limiter != nil { - if err := cfg.Limiter.Wait(ctx, "coingecko"); err != nil { - return &Result{Source: m.Name()}, err - } + if err := waitCollectLimiter(ctx, cfg, "coingecko"); err != nil { + return result, err } - if m.Historical && core.Trim(m.FromDate) != "" { - if _, err := time.Parse("2006-01-02", core.Trim(m.FromDate)); err != nil { - return &Result{Source: m.Name()}, core.E("collect.MarketCollector.Collect", core.Sprintf("invalid from_date %q", m.FromDate), err) - } + if err := m.validateHistoricalDate(); err != nil { + return result, err } data := &coinData{ Name: titleText(m.CoinID), @@ -74,27 +63,13 @@ func (m *MarketCollector) Collect(ctx context.Context, cfg *Config) (*Result, er Volume: 50_000, Change24H: 0, } - content := FormatMarketSummary(data) - if m.Historical || core.Trim(m.FromDate) != "" { - details := core.NewBuilder() - details.WriteString("\n") - details.WriteString("- Historical: ") - details.WriteString(strconv.FormatBool(m.Historical)) - details.WriteString("\n") - if core.Trim(m.FromDate) != "" { - details.WriteString(core.Sprintf("- From date: %s\n", core.Trim(m.FromDate))) - } - content += details.String() - } - path := "market.md" - if m.CoinID != "" { - path = m.CoinID + ".md" - } - outPath, err := writeResultFile(cfg, m.Name(), path, content) + content := FormatMarketSummary(data) + m.historicalDetails() + outPath, err := writeResultFile(cfg, m.Name(), m.outputPath(), content) if err != nil { return &Result{Source: m.Name(), Errors: 1}, err } - result := &Result{Source: m.Name(), Items: 1, Files: []string{outPath}} + result.Items = 1 + result.Files = []string{outPath} if cfg.Dispatcher != nil { cfg.Dispatcher.EmitItem(m.Name(), core.Sprintf("Collected market data for %s", m.CoinID), nil) cfg.Dispatcher.EmitComplete(m.Name(), "Market collection complete", result) @@ -102,6 +77,39 @@ func (m *MarketCollector) Collect(ctx context.Context, cfg *Config) (*Result, er return result, nil } +func (m *MarketCollector) validateHistoricalDate() error { + fromDate := core.Trim(m.FromDate) + if !m.Historical || fromDate == "" { + return nil + } + if _, err := time.Parse("2006-01-02", fromDate); err != nil { + return core.E("collect.MarketCollector.Collect", core.Sprintf("invalid from_date %q", m.FromDate), err) + } + return nil +} + +func (m *MarketCollector) historicalDetails() string { + if !m.Historical && core.Trim(m.FromDate) == "" { + return "" + } + details := core.NewBuilder() + details.WriteString("\n") + details.WriteString("- Historical: ") + details.WriteString(strconv.FormatBool(m.Historical)) + details.WriteString("\n") + if fromDate := core.Trim(m.FromDate); fromDate != "" { + details.WriteString(core.Sprintf("- From date: %s\n", fromDate)) + } + return details.String() +} + +func (m *MarketCollector) outputPath() string { + if m.CoinID == "" { + return "market.md" + } + return m.CoinID + ".md" +} + // FormatMarketSummary is exported for testing. func FormatMarketSummary(data *coinData) string { if data == nil { diff --git a/collect/market_test.go b/go/collect/market_test.go similarity index 100% rename from collect/market_test.go rename to go/collect/market_test.go diff --git a/collect/papers.go b/go/collect/papers.go similarity index 71% rename from collect/papers.go rename to go/collect/papers.go index cb4112d..726c68a 100644 --- a/collect/papers.go +++ b/go/collect/papers.go @@ -29,36 +29,19 @@ func (p *PapersCollector) Collect(ctx context.Context, cfg *Config) (*Result, er if cfg == nil { return nil, core.E("collect.PapersCollector.Collect", "config is required", nil) } - if ctx == nil { - ctx = context.Background() - } - if ctx != nil { - if err := ctx.Err(); err != nil { - return nil, err - } + ctx, err := activeCollectContext(ctx) + if err != nil { + return nil, err } if cfg.Dispatcher != nil { cfg.Dispatcher.EmitStart(p.Name(), "Starting papers collection") } - if cfg.DryRun { - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(p.Name(), "[dry-run] Would collect papers", nil) - cfg.Dispatcher.EmitComplete(p.Name(), "Papers dry-run complete", &Result{Source: p.Name()}) - } - return &Result{Source: p.Name()}, nil - } - if cfg.Limiter != nil { - source := ensureText(p.Source, PaperSourceAll) - if source == PaperSourceAll { - if err := cfg.Limiter.Wait(ctx, PaperSourceIACR); err != nil { - return &Result{Source: p.Name()}, err - } - if err := cfg.Limiter.Wait(ctx, PaperSourceArXiv); err != nil { - return &Result{Source: p.Name()}, err - } - } else if err := cfg.Limiter.Wait(ctx, source); err != nil { - return &Result{Source: p.Name()}, err - } + result := &Result{Source: p.Name()} + if emitDryRun(cfg, p.Name(), "[dry-run] Would collect papers", "Papers dry-run complete", result) { + return result, nil + } + if err := p.waitForLimiter(ctx, cfg); err != nil { + return result, err } content := FormatPaperMarkdown( ensureText(p.Query, "Untitled paper"), @@ -76,7 +59,8 @@ func (p *PapersCollector) Collect(ctx context.Context, cfg *Config) (*Result, er if err != nil { return &Result{Source: p.Name(), Errors: 1}, err } - result := &Result{Source: p.Name(), Items: 1, Files: []string{outPath}} + result.Items = 1 + result.Files = []string{outPath} if cfg.Dispatcher != nil { cfg.Dispatcher.EmitItem(p.Name(), core.Sprintf("Collected paper data for %q", p.Query), nil) cfg.Dispatcher.EmitComplete(p.Name(), "Papers collection complete", result) @@ -84,6 +68,20 @@ func (p *PapersCollector) Collect(ctx context.Context, cfg *Config) (*Result, er return result, nil } +func (p *PapersCollector) waitForLimiter(ctx context.Context, cfg *Config) error { + if cfg == nil || cfg.Limiter == nil { + return nil + } + source := ensureText(p.Source, PaperSourceAll) + if source != PaperSourceAll { + return cfg.Limiter.Wait(ctx, source) + } + if err := cfg.Limiter.Wait(ctx, PaperSourceIACR); err != nil { + return err + } + return cfg.Limiter.Wait(ctx, PaperSourceArXiv) +} + // FormatPaperMarkdown formats paper metadata as markdown. func FormatPaperMarkdown(title string, authors []string, date, paperURL, source, abstract string) string { b := core.NewBuilder() diff --git a/collect/process.go b/go/collect/process.go similarity index 59% rename from collect/process.go rename to go/collect/process.go index 08f212c..87dc7f9 100644 --- a/collect/process.go +++ b/go/collect/process.go @@ -33,13 +33,9 @@ func (p *Processor) Process(ctx context.Context, cfg *Config) (*Result, error) { if cfg == nil { return nil, core.E("collect.Processor.Process", "config is required", nil) } - if ctx == nil { - ctx = context.Background() - } - if ctx != nil { - if err := ctx.Err(); err != nil { - return nil, err - } + ctx, err := activeCollectContext(ctx) + if err != nil { + return nil, err } if cfg.Dispatcher != nil { cfg.Dispatcher.EmitStart(p.Name(), "Starting processing") @@ -52,12 +48,9 @@ func (p *Processor) Process(ctx context.Context, cfg *Config) (*Result, error) { if dir == "" { return &Result{Source: p.Name()}, nil } - if cfg.DryRun { - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(p.Name(), "[dry-run] Would process files", nil) - cfg.Dispatcher.EmitComplete(p.Name(), "Process dry-run complete", &Result{Source: p.Name()}) - } - return &Result{Source: p.Name()}, nil + result := &Result{Source: p.Name()} + if emitDryRun(cfg, p.Name(), "[dry-run] Would process files", "Process dry-run complete", result) { + return result, nil } if cfg.Output == nil { return nil, core.E("collect.Processor.Process", "output medium is required", nil) @@ -67,58 +60,14 @@ func (p *Processor) Process(ctx context.Context, cfg *Config) (*Result, error) { if err != nil { return nil, err } - result := &Result{Source: p.Name()} for _, entry := range entries { if entry == nil || entry.IsDir() { continue } - if ctx != nil { - if err := ctx.Err(); err != nil { - return result, err - } - } - name := entry.Name() - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitProgress(p.Name(), core.Sprintf("Processing %s", name), nil) - } - raw, err := cfg.Output.Read(core.JoinPath(dir, name)) - if err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(p.Name(), core.Sprintf("Failed to read %s: %v", name, err), nil) - } - continue - } - var md string - switch core.Lower(core.PathExt(name)) { - case ".html", ".htm": - md, err = HTMLToMarkdown(raw) - case ".json", ".jsonl": - md, err = JSONToMarkdown(raw) - default: - md = raw - } - if err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(p.Name(), core.Sprintf("Failed to convert %s: %v", name, err), nil) - } - continue - } - outName := core.TrimSuffix(name, core.PathExt(name)) + ".md" - outPath, err := writeResultFile(cfg, p.Name(), outName, md) - if err != nil { - result.Errors++ - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitError(p.Name(), core.Sprintf("Failed to write %s: %v", outName, err), nil) - } - continue - } - result.Items++ - result.Files = append(result.Files, outPath) - if cfg.Dispatcher != nil { - cfg.Dispatcher.EmitItem(p.Name(), core.Sprintf("Processed %s", name), nil) + if err := ctx.Err(); err != nil { + return result, err } + p.processEntry(cfg, dir, entry.Name(), result) } if cfg.Dispatcher != nil { cfg.Dispatcher.EmitComplete(p.Name(), core.Sprintf("Processed %d files", result.Items), result) @@ -126,6 +75,51 @@ func (p *Processor) Process(ctx context.Context, cfg *Config) (*Result, error) { return result, nil } +func (p *Processor) processEntry(cfg *Config, dir, name string, result *Result) { + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitProgress(p.Name(), core.Sprintf("Processing %s", name), nil) + } + raw, err := cfg.Output.Read(core.JoinPath(dir, name)) + if err != nil { + p.recordProcessError(cfg, result, core.Sprintf("Failed to read %s: %v", name, err)) + return + } + md, err := markdownForFile(name, raw) + if err != nil { + p.recordProcessError(cfg, result, core.Sprintf("Failed to convert %s: %v", name, err)) + return + } + outName := core.TrimSuffix(name, core.PathExt(name)) + ".md" + outPath, err := writeResultFile(cfg, p.Name(), outName, md) + if err != nil { + p.recordProcessError(cfg, result, core.Sprintf("Failed to write %s: %v", outName, err)) + return + } + result.Items++ + result.Files = append(result.Files, outPath) + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitItem(p.Name(), core.Sprintf("Processed %s", name), nil) + } +} + +func (p *Processor) recordProcessError(cfg *Config, result *Result, message string) { + result.Errors++ + if cfg.Dispatcher != nil { + cfg.Dispatcher.EmitError(p.Name(), message, nil) + } +} + +func markdownForFile(name, raw string) (string, error) { + switch core.Lower(core.PathExt(name)) { + case ".html", ".htm": + return HTMLToMarkdown(raw) + case ".json", ".jsonl": + return JSONToMarkdown(raw) + default: + return raw, nil + } +} + // HTMLToMarkdown is exported for testing. func HTMLToMarkdown(content string) (string, error) { if core.Trim(content) == "" { @@ -181,37 +175,48 @@ func JSONToMarkdown(content string) (string, error) { buf.WriteString("```json\n") var value any if err := json.Unmarshal([]byte(content), &value); err == nil { - enc := json.NewEncoder(buf) - enc.SetIndent("", " ") - if err := enc.Encode(value); err != nil { + if err := encodeJSONValue(buf, value); err != nil { return "", err } } else { - lines := core.Split(content, "\n") - enc := json.NewEncoder(buf) - enc.SetIndent("", " ") - encoded := false - for _, line := range lines { - line = core.Trim(line) - if line == "" { - continue - } - var lineValue any - if err := json.Unmarshal([]byte(line), &lineValue); err != nil { - return "", err - } - if encoded { - buf.WriteString("\n") - } - if err := enc.Encode(lineValue); err != nil { - return "", err - } - encoded = true - } - if !encoded { + if ok, err := encodeJSONLines(buf, content); err != nil { + return "", err + } else if !ok { return "", nil } } buf.WriteString("```\n") return core.Trim(buf.String()), nil } + +func encodeJSONValue(buf *bytes.Buffer, value any) error { + enc := json.NewEncoder(buf) + enc.SetIndent("", " ") + return enc.Encode(value) +} + +func encodeJSONLines(buf *bytes.Buffer, content string) (bool, error) { + encoded := false + for _, line := range core.Split(content, "\n") { + line = core.Trim(line) + if line == "" { + continue + } + if encoded { + buf.WriteString("\n") + } + if err := encodeJSONLine(buf, line); err != nil { + return false, err + } + encoded = true + } + return encoded, nil +} + +func encodeJSONLine(buf *bytes.Buffer, line string) error { + var lineValue any + if err := json.Unmarshal([]byte(line), &lineValue); err != nil { + return err + } + return encodeJSONValue(buf, lineValue) +} diff --git a/collect/process_test.go b/go/collect/process_test.go similarity index 100% rename from collect/process_test.go rename to go/collect/process_test.go diff --git a/collect/ratelimit.go b/go/collect/ratelimit.go similarity index 87% rename from collect/ratelimit.go rename to go/collect/ratelimit.go index 169befe..ef8d2ae 100644 --- a/collect/ratelimit.go +++ b/go/collect/ratelimit.go @@ -17,6 +17,10 @@ import ( core "dappco.re/go" ) +const ( + sonarRatelimitCollectRatelimiterCheckgithubratelimitctx = "collect.RateLimiter.CheckGitHubRateLimitCtx" +) + // RateLimiter tracks per-source rate limiting to avoid overwhelming APIs. type RateLimiter struct { mu sync.Mutex @@ -133,22 +137,22 @@ func (r *RateLimiter) CheckGitHubRateLimitCtx(ctx context.Context) (used, limit cmd := exec.CommandContext(ctx, "gh", "api", "rate_limit", "--jq", ".rate | \"\\(.used) \\(.limit)\"") out, err := cmd.Output() if err != nil { - return 0, 0, core.E("collect.RateLimiter.CheckGitHubRateLimitCtx", "gh api rate_limit", err) + return 0, 0, core.E(sonarRatelimitCollectRatelimiterCheckgithubratelimitctx, "gh api rate_limit", err) } trimmed := core.Trim(string(out)) parts := textFields(trimmed) if len(parts) != 2 { - return 0, 0, core.E("collect.RateLimiter.CheckGitHubRateLimitCtx", core.Sprintf("unexpected output %q", trimmed), nil) + return 0, 0, core.E(sonarRatelimitCollectRatelimiterCheckgithubratelimitctx, core.Sprintf("unexpected output %q", trimmed), nil) } used, err = strconv.Atoi(parts[0]) if err != nil { - return 0, 0, core.E("collect.RateLimiter.CheckGitHubRateLimitCtx", "parse used", err) + return 0, 0, core.E(sonarRatelimitCollectRatelimiterCheckgithubratelimitctx, "parse used", err) } limit, err = strconv.Atoi(parts[1]) if err != nil { - return 0, 0, core.E("collect.RateLimiter.CheckGitHubRateLimitCtx", "parse limit", err) + return 0, 0, core.E(sonarRatelimitCollectRatelimiterCheckgithubratelimitctx, "parse limit", err) } if limit > 0 && float64(used)/float64(limit) >= 0.75 { diff --git a/collect/state.go b/go/collect/state.go similarity index 83% rename from collect/state.go rename to go/collect/state.go index 1e87127..7d12fdc 100644 --- a/collect/state.go +++ b/go/collect/state.go @@ -16,6 +16,11 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarStateCollectStateLoad = "collect.State.Load" + sonarStateCollectStateSave = "collect.State.Save" +) + // State tracks collection progress for incremental runs. type State struct { mu sync.Mutex @@ -78,7 +83,7 @@ func (s *State) Set(source string, entry *StateEntry) { // Load reads state from disk. func (s *State) Load() error { if s == nil { - return core.E("collect.State.Load", "state is required", nil) + return core.E(sonarStateCollectStateLoad, "state is required", nil) } if s.medium == nil || s.path == "" { return nil @@ -91,11 +96,11 @@ func (s *State) Load() error { s.mu.Unlock() return nil } - return core.E("collect.State.Load", "read", err) + return core.E(sonarStateCollectStateLoad, "read", err) } var data map[string]*StateEntry if err := json.Unmarshal([]byte(raw), &data); err != nil { - return core.E("collect.State.Load", "unmarshal", err) + return core.E(sonarStateCollectStateLoad, "unmarshal", err) } s.mu.Lock() defer s.mu.Unlock() @@ -109,7 +114,7 @@ func (s *State) Load() error { // Save writes state to disk. func (s *State) Save() error { if s == nil { - return core.E("collect.State.Save", "state is required", nil) + return core.E(sonarStateCollectStateSave, "state is required", nil) } if s.medium == nil || s.path == "" { return nil @@ -118,16 +123,16 @@ func (s *State) Save() error { defer s.mu.Unlock() raw, err := json.MarshalIndent(s.entries, "", " ") if err != nil { - return core.E("collect.State.Save", "marshal", err) + return core.E(sonarStateCollectStateSave, "marshal", err) } dir := core.PathDir(s.path) if dir != "." { if err := s.medium.EnsureDir(dir); err != nil { - return core.E("collect.State.Save", "ensure dir", err) + return core.E(sonarStateCollectStateSave, "ensure dir", err) } } if err := s.medium.Write(s.path, string(raw)); err != nil { - return core.E("collect.State.Save", "write", err) + return core.E(sonarStateCollectStateSave, "write", err) } return nil } diff --git a/collect/state_test.go b/go/collect/state_test.go similarity index 100% rename from collect/state_test.go rename to go/collect/state_test.go diff --git a/collect/text_helpers.go b/go/collect/text_helpers.go similarity index 100% rename from collect/text_helpers.go rename to go/collect/text_helpers.go diff --git a/core/config/config.go b/go/core/config/config.go similarity index 100% rename from core/config/config.go rename to go/core/config/config.go diff --git a/core/config/config_test.go b/go/core/config/config_test.go similarity index 77% rename from core/config/config_test.go rename to go/core/config/config_test.go index 9d44a52..261b39e 100644 --- a/core/config/config_test.go +++ b/go/core/config/config_test.go @@ -8,8 +8,14 @@ import ( "testing" ) +const ( + sonarConfigTestAgentName = "agent.name" + sonarConfigTestConfigYaml = "config.yaml" + sonarConfigTestNewConfigV = "new config: %v" +) + func TestConfig_SetGetCommit(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") + path := filepath.Join(t.TempDir(), sonarConfigTestConfigYaml) cfg := NewWithPath(path) if err := cfg.Set("agents.alpha.active", true); err != nil { @@ -43,11 +49,11 @@ func TestConfig_SetGetCommit(t *testing.T) { } func TestConfig_WithPath_Good(t *testing.T) { - cfg, err := New(WithPath("config.yaml")) + cfg, err := New(WithPath(sonarConfigTestConfigYaml)) if err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } - if cfg.Path != "config.yaml" { + if cfg.Path != sonarConfigTestConfigYaml { t.Fatalf("expected configured path, got %q", cfg.Path) } } @@ -55,7 +61,7 @@ func TestConfig_WithPath_Good(t *testing.T) { func TestConfig_WithPath_Bad(t *testing.T) { cfg, err := New(WithPath("")) if err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } if cfg.Path != "" { t.Fatalf("expected empty path, got %q", cfg.Path) @@ -63,19 +69,19 @@ func TestConfig_WithPath_Bad(t *testing.T) { } func TestConfig_WithPath_Ugly(t *testing.T) { - cfg, err := New(WithPath(filepath.Join("..", "config.yaml"))) + cfg, err := New(WithPath(filepath.Join("..", sonarConfigTestConfigYaml))) if err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } - if cfg.Path != filepath.Join("..", "config.yaml") { + if cfg.Path != filepath.Join("..", sonarConfigTestConfigYaml) { t.Fatalf("expected relative path preserved, got %q", cfg.Path) } } func TestConfig_New_Good(t *testing.T) { - cfg, err := New(WithPath("config.yaml")) + cfg, err := New(WithPath(sonarConfigTestConfigYaml)) if err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } if cfg == nil || cfg.data == nil { t.Fatalf("expected initialized config") @@ -98,7 +104,7 @@ func TestConfig_New_Bad(t *testing.T) { func TestConfig_New_Ugly(t *testing.T) { cfg, err := New(WithPath("first.yaml"), WithPath("second.yaml")) if err != nil { - t.Fatalf("new config: %v", err) + t.Fatalf(sonarConfigTestNewConfigV, err) } if cfg.Path != "second.yaml" { t.Fatalf("expected later option to win, got %q", cfg.Path) @@ -106,8 +112,8 @@ func TestConfig_New_Ugly(t *testing.T) { } func TestConfig_NewWithPath_Good(t *testing.T) { - cfg := NewWithPath("config.yaml") - if cfg.Path != "config.yaml" { + cfg := NewWithPath(sonarConfigTestConfigYaml) + if cfg.Path != sonarConfigTestConfigYaml { t.Fatalf("expected configured path, got %q", cfg.Path) } if cfg.data == nil { @@ -126,7 +132,7 @@ func TestConfig_NewWithPath_Bad(t *testing.T) { } func TestConfig_NewWithPath_Ugly(t *testing.T) { - path := filepath.Join(t.TempDir(), "nested", "config.yaml") + path := filepath.Join(t.TempDir(), "nested", sonarConfigTestConfigYaml) cfg := NewWithPath(path) if cfg.Path != path { t.Fatalf("expected temp path, got %q", cfg.Path) @@ -135,11 +141,11 @@ func TestConfig_NewWithPath_Ugly(t *testing.T) { func TestConfig_Config_Set_Good(t *testing.T) { cfg := NewWithPath("") - if err := cfg.Set("agent.name", "codex"); err != nil { + if err := cfg.Set(sonarConfigTestAgentName, "codex"); err != nil { t.Fatalf("set dotted key: %v", err) } var got string - if err := cfg.Get("agent.name", &got); err != nil { + if err := cfg.Get(sonarConfigTestAgentName, &got); err != nil { t.Fatalf("get dotted key: %v", err) } if got != "codex" { @@ -162,7 +168,7 @@ func TestConfig_Config_Set_Ugly(t *testing.T) { t.Fatalf("set root map: %v", err) } var got string - if err := cfg.Get("agent.name", &got); err != nil { + if err := cfg.Get(sonarConfigTestAgentName, &got); err != nil { t.Fatalf("get cloned root value: %v", err) } if got != "codex" { @@ -202,9 +208,9 @@ func TestConfig_Config_Get_Ugly(t *testing.T) { } func TestConfig_Config_Commit_Good(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") + path := filepath.Join(t.TempDir(), sonarConfigTestConfigYaml) cfg := NewWithPath(path) - if err := cfg.Set("agent.name", "codex"); err != nil { + if err := cfg.Set(sonarConfigTestAgentName, "codex"); err != nil { t.Fatalf("set agent: %v", err) } if err := cfg.Commit(); err != nil { @@ -224,7 +230,7 @@ func TestConfig_Config_Commit_Bad(t *testing.T) { } func TestConfig_Config_Commit_Ugly(t *testing.T) { - cfg := NewWithPath(filepath.Join(t.TempDir(), "nested", "config.yaml")) + cfg := NewWithPath(filepath.Join(t.TempDir(), "nested", sonarConfigTestConfigYaml)) if err := cfg.Commit(); err != nil { t.Fatalf("commit empty config: %v", err) } diff --git a/core/config/go.mod b/go/core/config/go.mod similarity index 100% rename from core/config/go.mod rename to go/core/config/go.mod diff --git a/core/config/go.sum b/go/core/config/go.sum similarity index 100% rename from core/config/go.sum rename to go/core/config/go.sum diff --git a/forge/client.go b/go/forge/client.go similarity index 100% rename from forge/client.go rename to go/forge/client.go diff --git a/forge/config.go b/go/forge/config.go similarity index 76% rename from forge/config.go rename to go/forge/config.go index 428d3d3..857c4e9 100644 --- a/forge/config.go +++ b/go/forge/config.go @@ -14,23 +14,7 @@ import ( ) func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - home, homeErr := os.UserHomeDir() - if homeErr == nil { - path := filepath.Join(home, ".core", "config.yaml") - if raw, readErr := os.ReadFile(path); readErr == nil { - var data map[string]any - if yamlErr := yaml.Unmarshal(raw, &data); yamlErr == nil { - if forge, ok := data["forge"].(map[string]any); ok { - if v, ok := forge["url"].(string); ok { - url = v - } - if v, ok := forge["token"].(string); ok { - token = v - } - } - } - } - } + url, token = loadForgeConfigValues() if v := os.Getenv("FORGE_URL"); v != "" { url = v } @@ -49,6 +33,25 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { return url, token, nil } +func loadForgeConfigValues() (string, string) { + home, err := os.UserHomeDir() + if err != nil { + return "", "" + } + raw, err := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + if err != nil { + return "", "" + } + var data map[string]any + if err := yaml.Unmarshal(raw, &data); err != nil { + return "", "" + } + forge, _ := data["forge"].(map[string]any) + url, _ := forge["url"].(string) + token, _ := forge["token"].(string) + return url, token +} + func NewFromConfig(flagURL, flagToken string) (*Client, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { diff --git a/forge/forge_test.go b/go/forge/forge_test.go similarity index 96% rename from forge/forge_test.go rename to go/forge/forge_test.go index 18a28ab..e490aaa 100644 --- a/forge/forge_test.go +++ b/go/forge/forge_test.go @@ -12,11 +12,18 @@ import ( core "dappco.re/go" ) +const ( + sonarForgeTestApplicationJson = "application/json" + sonarForgeTestConfigYaml = "config.yaml" + sonarForgeTestContentType = "Content-Type" + sonarForgeTestUnexpectedHttpStatus = "unexpected HTTP status" +) + func ax7ForgeClient(t *core.T) *Client { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/version" { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarForgeTestContentType, sonarForgeTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) return } @@ -31,7 +38,7 @@ func ax7ForgeClient(t *core.T) *Client { func TestForge_New_Good(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarForgeTestContentType, sonarForgeTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -48,7 +55,7 @@ func TestForge_New_Bad(t *core.T) { func TestForge_New_Ugly(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarForgeTestContentType, sonarForgeTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -59,7 +66,7 @@ func TestForge_New_Ugly(t *core.T) { func TestForge_NewFromConfig_Good(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarForgeTestContentType, sonarForgeTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -76,7 +83,7 @@ func TestForge_NewFromConfig_Bad(t *core.T) { func TestForge_NewFromConfig_Ugly(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarForgeTestContentType, sonarForgeTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -109,7 +116,7 @@ func TestForge_ResolveConfig_Ugly(t *core.T) { t.Setenv("FORGE_URL", "") t.Setenv("FORGE_TOKEN", "") core.RequireNoError(t, os.MkdirAll(filepath.Join(home, ".core"), 0o755)) - core.RequireNoError(t, os.WriteFile(filepath.Join(home, ".core", "config.yaml"), []byte("forge:\n url: http://file.test\n token: file-token\n"), 0o600)) + core.RequireNoError(t, os.WriteFile(filepath.Join(home, ".core", sonarForgeTestConfigYaml), []byte("forge:\n url: http://file.test\n token: file-token\n"), 0o600)) url, token, err := ResolveConfig("", "") core.AssertNoError(t, err) core.AssertEqual(t, "http://file.test", url) @@ -121,7 +128,7 @@ func TestForge_SaveConfig_Good(t *core.T) { t.Setenv("HOME", home) err := SaveConfig("http://save.test", "saved-token") core.AssertNoError(t, err) - raw, readErr := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + raw, readErr := os.ReadFile(filepath.Join(home, ".core", sonarForgeTestConfigYaml)) core.RequireNoError(t, readErr) core.AssertContains(t, string(raw), "saved-token") } @@ -137,7 +144,7 @@ func TestForge_SaveConfig_Ugly(t *core.T) { t.Setenv("HOME", home) err := SaveConfig("", "token-only") core.AssertNoError(t, err) - raw, readErr := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + raw, readErr := os.ReadFile(filepath.Join(home, ".core", sonarForgeTestConfigYaml)) core.RequireNoError(t, readErr) core.AssertContains(t, string(raw), "token-only") } @@ -198,20 +205,20 @@ func TestForge_Client_Token_Ugly(t *core.T) { func TestForge_Error_Error_Good(t *core.T) { err := (&httpError{status: http.StatusTeapot}).Error() - core.AssertEqual(t, "unexpected HTTP status", err) + core.AssertEqual(t, sonarForgeTestUnexpectedHttpStatus, err) core.AssertEqual(t, http.StatusTeapot, (&httpError{status: http.StatusTeapot}).status) } func TestForge_Error_Error_Bad(t *core.T) { err := (&httpError{}).Error() - core.AssertEqual(t, "unexpected HTTP status", err) + core.AssertEqual(t, sonarForgeTestUnexpectedHttpStatus, err) core.AssertEqual(t, 0, (&httpError{}).status) } func TestForge_Error_Error_Ugly(t *core.T) { var err *httpError got := err.Error() - core.AssertEqual(t, "unexpected HTTP status", got) + core.AssertEqual(t, sonarForgeTestUnexpectedHttpStatus, got) } func TestForge_Client_GetCurrentUser_Good(t *core.T) { @@ -408,6 +415,7 @@ func TestForge_Client_ListIssueCommentsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListIssueCommentsIter("owner", "repo", 7) { + break } }) core.AssertNil(t, client.API()) @@ -417,6 +425,7 @@ func TestForge_Client_ListIssueCommentsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListIssueCommentsIter("owner", "repo", 7) { + break } }) core.AssertEqual(t, "", client.URL()) @@ -490,6 +499,7 @@ func TestForge_Client_ListPullRequestsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListPullRequestsIter("owner", "repo", "closed") { + break } }) core.AssertNil(t, client.API()) @@ -499,6 +509,7 @@ func TestForge_Client_ListPullRequestsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListPullRequestsIter("owner", "repo", "") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -608,6 +619,7 @@ func TestForge_Client_ListOrgReposIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListOrgReposIter("org") { + break } }) core.AssertNil(t, client.API()) @@ -617,6 +629,7 @@ func TestForge_Client_ListOrgReposIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListOrgReposIter("org") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -654,6 +667,7 @@ func TestForge_Client_ListUserReposIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListUserReposIter() { + break } }) core.AssertNil(t, client.API()) @@ -663,6 +677,7 @@ func TestForge_Client_ListUserReposIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListUserReposIter() { + break } }) core.AssertEqual(t, "", client.URL()) @@ -736,6 +751,7 @@ func TestForge_Client_ListRepoLabelsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListRepoLabelsIter("owner", "repo") { + break } }) core.AssertNil(t, client.API()) @@ -745,6 +761,7 @@ func TestForge_Client_ListRepoLabelsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListRepoLabelsIter("owner", "repo") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -782,6 +799,7 @@ func TestForge_Client_ListOrgLabelsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListOrgLabelsIter("org") { + break } }) core.AssertNil(t, client.API()) @@ -791,6 +809,7 @@ func TestForge_Client_ListOrgLabelsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListOrgLabelsIter("org") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -936,6 +955,7 @@ func TestForge_Client_ListPRReviewsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListPRReviewsIter("owner", "repo", 7) { + break } }) core.AssertNil(t, client.API()) @@ -945,6 +965,7 @@ func TestForge_Client_ListPRReviewsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListPRReviewsIter("owner", "repo", 7) { + break } }) core.AssertEqual(t, "", client.URL()) @@ -1126,6 +1147,7 @@ func TestForge_Client_ListMyOrgsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListMyOrgsIter() { + break } }) core.AssertNil(t, client.API()) @@ -1135,6 +1157,7 @@ func TestForge_Client_ListMyOrgsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListMyOrgsIter() { + break } }) core.AssertEqual(t, "", client.URL()) @@ -1190,6 +1213,7 @@ func TestForge_Client_ListRepoWebhooksIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListRepoWebhooksIter("owner", "repo") { + break } }) core.AssertNil(t, client.API()) @@ -1199,6 +1223,7 @@ func TestForge_Client_ListRepoWebhooksIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListRepoWebhooksIter("owner", "repo") { + break } }) core.AssertEqual(t, "", client.URL()) diff --git a/forge/issues.go b/go/forge/issues.go similarity index 61% rename from forge/issues.go rename to go/forge/issues.go index afd573b..ba4aa3b 100644 --- a/forge/issues.go +++ b/go/forge/issues.go @@ -16,6 +16,29 @@ type ListIssuesOpts struct { Limit int } +func forgeStateFromString(state string) forgejo.StateType { + switch state { + case "closed": + return forgejo.StateClosed + case "all": + return forgejo.StateAll + default: + return forgejo.StateOpen + } +} + +func normalizeForgeListIssuesOpts(opts ListIssuesOpts) (forgejo.StateType, int, int) { + limit := opts.Limit + if limit == 0 { + limit = 50 + } + page := opts.Page + if page == 0 { + page = 1 + } + return forgeStateFromString(opts.State), page, limit +} + func (c *Client) GetIssue(owner, repo string, number int64) (*forgejo.Issue, error) { issue, _, err := c.api.GetIssue(owner, repo, number) return issue, err @@ -48,45 +71,20 @@ func (c *Client) AssignIssue(owner, repo string, number int64, assignees []strin } func (c *Client) ListIssueComments(owner, repo string, number int64) ([]*forgejo.Comment, error) { - var all []*forgejo.Comment - page := 1 - for { - comments, resp, err := c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ + return collectForgePages(func(page int) ([]*forgejo.Comment, *forgeResponse, error) { + return c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, comments...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListIssueCommentsIter(owner, repo string, number int64) iter.Seq2[*forgejo.Comment, error] { return func(yield func(*forgejo.Comment, error) bool) { - page := 1 - for { - comments, resp, err := c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Comment, *forgeResponse, error) { + return c.api.ListIssueComments(owner, repo, number, forgejo.ListIssueCommentOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, comment := range comments { - if !yield(comment, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } @@ -96,104 +94,36 @@ func (c *Client) GetIssueLabels(owner, repo string, number int64) ([]*forgejo.La } func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*forgejo.Issue, error) { - state := forgejo.StateOpen - switch opts.State { - case "closed": - state = forgejo.StateClosed - case "all": - state = forgejo.StateAll - } - - limit := opts.Limit - if limit == 0 { - limit = 50 - } - page := opts.Page - if page == 0 { - page = 1 - } - - var all []*forgejo.Issue - for { - issues, resp, err := c.api.ListRepoIssues(owner, repo, forgejo.ListIssueOption{ + state, page, limit := normalizeForgeListIssuesOpts(opts) + return collectForgeLimitedPages(page, limit, func(page int) ([]*forgejo.Issue, *forgeResponse, error) { + return c.api.ListRepoIssues(owner, repo, forgejo.ListIssueOption{ ListOptions: forgejo.ListOptions{Page: page, PageSize: limit}, State: state, Type: forgejo.IssueTypeIssue, Labels: opts.Labels, }) - if err != nil { - return nil, err - } - all = append(all, issues...) - if len(issues) < limit || len(issues) == 0 { - break - } - if resp != nil && resp.LastPage > 0 && page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListPullRequests(owner, repo string, state string) ([]*forgejo.PullRequest, error) { - st := forgejo.StateOpen - switch state { - case "closed": - st = forgejo.StateClosed - case "all": - st = forgejo.StateAll - } - - var all []*forgejo.PullRequest - page := 1 - for { - prs, resp, err := c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{ + st := forgeStateFromString(state) + return collectForgePages(func(page int) ([]*forgejo.PullRequest, *forgeResponse, error) { + return c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, State: st, }) - if err != nil { - return nil, err - } - all = append(all, prs...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListPullRequestsIter(owner, repo string, state string) iter.Seq2[*forgejo.PullRequest, error] { - st := forgejo.StateOpen - switch state { - case "closed": - st = forgejo.StateClosed - case "all": - st = forgejo.StateAll - } - + st := forgeStateFromString(state) return func(yield func(*forgejo.PullRequest, error) bool) { - page := 1 - for { - prs, resp, err := c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.PullRequest, *forgeResponse, error) { + return c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, State: st, }) - if err != nil { - yield(nil, err) - return - } - for _, pr := range prs { - if !yield(pr, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/forge/labels.go b/go/forge/labels.go similarity index 64% rename from forge/labels.go rename to go/forge/labels.go index b536b77..73dd2a2 100644 --- a/forge/labels.go +++ b/go/forge/labels.go @@ -17,45 +17,20 @@ func (c *Client) CreateRepoLabel(owner, repo string, opts forgejo.CreateLabelOpt } func (c *Client) ListRepoLabels(owner, repo string) ([]*forgejo.Label, error) { - var all []*forgejo.Label - page := 1 - for { - labels, resp, err := c.api.ListRepoLabels(owner, repo, forgejo.ListLabelsOptions{ + return collectForgePages(func(page int) ([]*forgejo.Label, *forgeResponse, error) { + return c.api.ListRepoLabels(owner, repo, forgejo.ListLabelsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, labels...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListRepoLabelsIter(owner, repo string) iter.Seq2[*forgejo.Label, error] { return func(yield func(*forgejo.Label, error) bool) { - page := 1 - for { - labels, resp, err := c.api.ListRepoLabels(owner, repo, forgejo.ListLabelsOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Label, *forgeResponse, error) { + return c.api.ListRepoLabels(owner, repo, forgejo.ListLabelsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, label := range labels { - if !yield(label, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } @@ -75,53 +50,55 @@ func (c *Client) ListOrgLabels(org string) ([]*forgejo.Label, error) { if err != nil { return nil, err } - for _, label := range labels { - key := strings.ToLower(label.Name) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - all = append(all, label) - } + all = appendUniqueLabels(all, seen, labels) } return all, nil } +func appendUniqueLabels(all []*forgejo.Label, seen map[string]struct{}, labels []*forgejo.Label) []*forgejo.Label { + for _, label := range labels { + key := strings.ToLower(label.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + all = append(all, label) + } + return all +} + func (c *Client) ListOrgLabelsIter(org string) iter.Seq2[*forgejo.Label, error] { return func(yield func(*forgejo.Label, error) bool) { seen := make(map[string]struct{}) - page := 1 - for { - repos, resp, err := c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ - ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, - }) + yieldForgePages(func(repo *forgejo.Repository, err error) bool { if err != nil { - yield(nil, err) - return + return yield(nil, err) } - for _, repo := range repos { - labels, err := c.ListRepoLabels(repo.Owner.UserName, repo.Name) - if err != nil { - yield(nil, err) - return - } - for _, label := range labels { - key := strings.ToLower(label.Name) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if !yield(label, nil) { - return - } - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ + return c.yieldRepoLabels(yield, seen, repo) + }, func(page int) ([]*forgejo.Repository, *forgeResponse, error) { + return c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + }) + } +} + +func (c *Client) yieldRepoLabels(yield func(*forgejo.Label, error) bool, seen map[string]struct{}, repo *forgejo.Repository) bool { + labels, err := c.ListRepoLabels(repo.Owner.UserName, repo.Name) + if err != nil { + return yield(nil, err) + } + for _, label := range labels { + key := strings.ToLower(label.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if !yield(label, nil) { + return false } } + return true } func (c *Client) GetLabelByName(owner, repo, name string) (*forgejo.Label, error) { diff --git a/forge/meta.go b/go/forge/meta.go similarity index 65% rename from forge/meta.go rename to go/forge/meta.go index 1870faa..cf39ff8 100644 --- a/forge/meta.go +++ b/go/forge/meta.go @@ -6,6 +6,7 @@ import ( // Note: time.Time mirrors Forgejo metadata timestamps in public structs. "time" + "code.gitea.io/sdk/gitea" "codeberg.org/forgejo/go-sdk/forgejo" ) @@ -34,6 +35,67 @@ type PRMeta struct { const commentPageSize = 50 +type forgeResponse = gitea.Response + +func collectForgePages[T any](fetch func(page int) ([]T, *forgeResponse, error)) ([]T, error) { + var all []T + for page := 1; ; page++ { + items, resp, err := fetch(page) + if err != nil { + return nil, err + } + all = append(all, items...) + if !hasNextForgePage(resp, page) { + return all, nil + } + } +} + +func collectForgeLimitedPages[T any](page, limit int, fetch func(page int) ([]T, *forgeResponse, error)) ([]T, error) { + var all []T + for { + items, resp, err := fetch(page) + if err != nil { + return nil, err + } + all = append(all, items...) + if !hasMoreForgeItems(items, resp, page, limit) { + return all, nil + } + page++ + } +} + +func yieldForgePages[T any](yield func(T, error) bool, fetch func(page int) ([]T, *forgeResponse, error)) { + for page := 1; ; page++ { + items, resp, err := fetch(page) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + if !hasNextForgePage(resp, page) { + return + } + } +} + +func hasNextForgePage(resp *forgeResponse, page int) bool { + return resp != nil && page < resp.LastPage +} + +func hasMoreForgeItems[T any](items []T, resp *forgeResponse, page, limit int) bool { + if len(items) == 0 || len(items) < limit { + return false + } + return resp == nil || resp.LastPage <= 0 || page < resp.LastPage +} + func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) { iss, _, err := c.api.GetIssue(owner, repo, issue) if err != nil { diff --git a/forge/orgs.go b/go/forge/orgs.go similarity index 59% rename from forge/orgs.go rename to go/forge/orgs.go index ac6db8e..153fd61 100644 --- a/forge/orgs.go +++ b/go/forge/orgs.go @@ -20,44 +20,19 @@ func (c *Client) GetOrg(name string) (*forgejo.Organization, error) { } func (c *Client) ListMyOrgs() ([]*forgejo.Organization, error) { - var all []*forgejo.Organization - page := 1 - for { - orgs, resp, err := c.api.ListMyOrgs(forgejo.ListOrgsOptions{ + return collectForgePages(func(page int) ([]*forgejo.Organization, *forgeResponse, error) { + return c.api.ListMyOrgs(forgejo.ListOrgsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, orgs...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListMyOrgsIter() iter.Seq2[*forgejo.Organization, error] { return func(yield func(*forgejo.Organization, error) bool) { - page := 1 - for { - orgs, resp, err := c.api.ListMyOrgs(forgejo.ListOrgsOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Organization, *forgeResponse, error) { + return c.api.ListMyOrgs(forgejo.ListOrgsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, org := range orgs { - if !yield(org, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/forge/prs.go b/go/forge/prs.go similarity index 81% rename from forge/prs.go rename to go/forge/prs.go index 5a99a72..f98bf80 100644 --- a/forge/prs.go +++ b/go/forge/prs.go @@ -68,45 +68,20 @@ func (e *httpError) Error() string { } func (c *Client) ListPRReviews(owner, repo string, index int64) ([]*forgejo.PullReview, error) { - var all []*forgejo.PullReview - page := 1 - for { - reviews, resp, err := c.api.ListPullReviews(owner, repo, index, forgejo.ListPullReviewsOptions{ + return collectForgePages(func(page int) ([]*forgejo.PullReview, *forgeResponse, error) { + return c.api.ListPullReviews(owner, repo, index, forgejo.ListPullReviewsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, reviews...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListPRReviewsIter(owner, repo string, index int64) iter.Seq2[*forgejo.PullReview, error] { return func(yield func(*forgejo.PullReview, error) bool) { - page := 1 - for { - reviews, resp, err := c.api.ListPullReviews(owner, repo, index, forgejo.ListPullReviewsOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.PullReview, *forgeResponse, error) { + return c.api.ListPullReviews(owner, repo, index, forgejo.ListPullReviewsOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, review := range reviews { - if !yield(review, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/forge/repos.go b/go/forge/repos.go similarity index 56% rename from forge/repos.go rename to go/forge/repos.go index 17aecd2..066923c 100644 --- a/forge/repos.go +++ b/go/forge/repos.go @@ -25,88 +25,38 @@ func (c *Client) GetRepo(owner, name string) (*forgejo.Repository, error) { } func (c *Client) ListOrgRepos(org string) ([]*forgejo.Repository, error) { - var all []*forgejo.Repository - page := 1 - for { - repos, resp, err := c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ + return collectForgePages(func(page int) ([]*forgejo.Repository, *forgeResponse, error) { + return c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, repos...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListOrgReposIter(org string) iter.Seq2[*forgejo.Repository, error] { return func(yield func(*forgejo.Repository, error) bool) { - page := 1 - for { - repos, resp, err := c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Repository, *forgeResponse, error) { + return c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, repo := range repos { - if !yield(repo, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } func (c *Client) ListUserRepos() ([]*forgejo.Repository, error) { - var all []*forgejo.Repository - page := 1 - for { - repos, resp, err := c.api.ListMyRepos(forgejo.ListReposOptions{ + return collectForgePages(func(page int) ([]*forgejo.Repository, *forgeResponse, error) { + return c.api.ListMyRepos(forgejo.ListReposOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, repos...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListUserReposIter() iter.Seq2[*forgejo.Repository, error] { return func(yield func(*forgejo.Repository, error) bool) { - page := 1 - for { - repos, resp, err := c.api.ListMyRepos(forgejo.ListReposOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Repository, *forgeResponse, error) { + return c.api.ListMyRepos(forgejo.ListReposOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, repo := range repos { - if !yield(repo, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/forge/webhooks.go b/go/forge/webhooks.go similarity index 56% rename from forge/webhooks.go rename to go/forge/webhooks.go index 5779213..92e369d 100644 --- a/forge/webhooks.go +++ b/go/forge/webhooks.go @@ -15,44 +15,19 @@ func (c *Client) CreateRepoWebhook(owner, repo string, opts forgejo.CreateHookOp } func (c *Client) ListRepoWebhooks(owner, repo string) ([]*forgejo.Hook, error) { - var all []*forgejo.Hook - page := 1 - for { - hooks, resp, err := c.api.ListRepoHooks(owner, repo, forgejo.ListHooksOptions{ + return collectForgePages(func(page int) ([]*forgejo.Hook, *forgeResponse, error) { + return c.api.ListRepoHooks(owner, repo, forgejo.ListHooksOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, hooks...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListRepoWebhooksIter(owner, repo string) iter.Seq2[*forgejo.Hook, error] { return func(yield func(*forgejo.Hook, error) bool) { - page := 1 - for { - hooks, resp, err := c.api.ListRepoHooks(owner, repo, forgejo.ListHooksOptions{ + yieldForgePages(yield, func(page int) ([]*forgejo.Hook, *forgeResponse, error) { + return c.api.ListRepoHooks(owner, repo, forgejo.ListHooksOptions{ ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, hook := range hooks { - if !yield(hook, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/git/git.go b/go/git/git.go similarity index 100% rename from git/git.go rename to go/git/git.go diff --git a/git/git_test.go b/go/git/git_test.go similarity index 100% rename from git/git_test.go rename to go/git/git_test.go diff --git a/git/service.go b/go/git/service.go similarity index 83% rename from git/service.go rename to go/git/service.go index 7cbe5b8..013365e 100644 --- a/git/service.go +++ b/go/git/service.go @@ -12,6 +12,13 @@ import ( core "dappco.re/go" ) +const ( + sonarServiceGitPull = "git.pull" + sonarServiceGitPush = "git.push" + sonarServiceGitValidatepath = "git.validatePath" + sonarServicePathValidationFailed = "path validation failed" +) + type ServiceOptions struct { WorkDir string } @@ -145,10 +152,10 @@ func (s *Service) OnStartup(ctx context.Context) core.Result { c.RegisterQuery(s.handleQuery) c.RegisterAction(s.handleTaskMessage) - c.Action("git.push", func(ctx context.Context, opts core.Options) core.Result { + c.Action(sonarServiceGitPush, func(ctx context.Context, opts core.Options) core.Result { return s.runPush(ctx, opts.String("path")) }) - c.Action("git.pull", func(ctx context.Context, opts core.Options) core.Result { + c.Action(sonarServiceGitPull, func(ctx context.Context, opts core.Options) core.Result { return s.runPull(ctx, opts.String("path")) }) c.Action("git.push-multiple", func(ctx context.Context, opts core.Options) core.Result { @@ -178,7 +185,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) core.Result { switch m := q.(type) { case QueryStatus: if err := s.validatePaths(m.Paths); err != nil { - return c.LogError(err, "git.handleQuery", "path validation failed") + return c.LogError(err, "git.handleQuery", sonarServicePathValidationFailed) } statuses := Status(ctx, StatusOptions(m)) s.mu.Lock() @@ -213,27 +220,27 @@ func (s *Service) handleTaskMessage(c *core.Core, msg core.Message) core.Result func (s *Service) runPush(ctx context.Context, path string) core.Result { if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.push", "path validation failed") + return s.Core().LogError(err, sonarServiceGitPush, sonarServicePathValidationFailed) } if err := Push(ctx, path); err != nil { - return s.Core().LogError(err, "git.push", "push failed") + return s.Core().LogError(err, sonarServiceGitPush, "push failed") } return core.Ok(nil) } func (s *Service) runPull(ctx context.Context, path string) core.Result { if err := s.validatePath(path); err != nil { - return s.Core().LogError(err, "git.pull", "path validation failed") + return s.Core().LogError(err, sonarServiceGitPull, sonarServicePathValidationFailed) } if err := Pull(ctx, path); err != nil { - return s.Core().LogError(err, "git.pull", "pull failed") + return s.Core().LogError(err, sonarServiceGitPull, "pull failed") } return core.Ok(nil) } func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { if err := s.validatePaths(paths); err != nil { - return s.Core().LogError(err, "git.push-multiple", "path validation failed") + return s.Core().LogError(err, "git.push-multiple", sonarServicePathValidationFailed) } results := PushMultiple(ctx, paths, names) for _, result := range results { @@ -246,7 +253,7 @@ func (s *Service) runPushMultiple(ctx context.Context, paths []string, names map func (s *Service) runPullMultiple(ctx context.Context, paths []string, names map[string]string) core.Result { if err := s.validatePaths(paths); err != nil { - return s.Core().LogError(err, "git.pull-multiple", "path validation failed") + return s.Core().LogError(err, "git.pull-multiple", sonarServicePathValidationFailed) } results := PullMultiple(ctx, paths, names) for _, result := range results { @@ -264,15 +271,15 @@ func (s *Service) validatePath(path string) error { } workDir := s.Options().WorkDir if workDir == "" { - return core.E("git.validatePath", "path must be absolute", nil) + return core.E(sonarServiceGitValidatepath, "path must be absolute", nil) } workDir = core.CleanPath(workDir, ds) if !core.PathIsAbs(workDir) { - return core.E("git.validatePath", "WorkDir must be absolute", nil) + return core.E(sonarServiceGitValidatepath, "WorkDir must be absolute", nil) } rel, err := filepath.Rel(workDir, core.CleanPath(path, ds)) if err != nil || rel == ".." || core.HasPrefix(rel, ".."+ds) { - return core.E("git.validatePath", "path is outside of allowed WorkDir", nil) + return core.E(sonarServiceGitValidatepath, "path is outside of allowed WorkDir", nil) } return nil } diff --git a/git/service_test.go b/go/git/service_test.go similarity index 100% rename from git/service_test.go rename to go/git/service_test.go diff --git a/gitea/client.go b/go/gitea/client.go similarity index 100% rename from gitea/client.go rename to go/gitea/client.go diff --git a/gitea/config.go b/go/gitea/config.go similarity index 77% rename from gitea/config.go rename to go/gitea/config.go index 347e10a..b0bd9af 100644 --- a/gitea/config.go +++ b/go/gitea/config.go @@ -20,23 +20,7 @@ const ( ) func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - home, homeErr := os.UserHomeDir() - if homeErr == nil { - path := filepath.Join(home, ".core", "config.yaml") - if raw, readErr := os.ReadFile(path); readErr == nil { - var data map[string]any - if yamlErr := yaml.Unmarshal(raw, &data); yamlErr == nil { - if giteaCfg, ok := data["gitea"].(map[string]any); ok { - if v, ok := giteaCfg["url"].(string); ok { - url = v - } - if v, ok := giteaCfg["token"].(string); ok { - token = v - } - } - } - } - } + url, token = loadGiteaConfigValues() if v := os.Getenv("GITEA_URL"); v != "" { url = v @@ -56,6 +40,25 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { return url, token, nil } +func loadGiteaConfigValues() (string, string) { + home, err := os.UserHomeDir() + if err != nil { + return "", "" + } + raw, err := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + if err != nil { + return "", "" + } + var data map[string]any + if err := yaml.Unmarshal(raw, &data); err != nil { + return "", "" + } + giteaCfg, _ := data["gitea"].(map[string]any) + url, _ := giteaCfg["url"].(string) + token, _ := giteaCfg["token"].(string) + return url, token +} + func NewFromConfig(flagURL, flagToken string) (*Client, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { diff --git a/gitea/gitea_test.go b/go/gitea/gitea_test.go similarity index 93% rename from gitea/gitea_test.go rename to go/gitea/gitea_test.go index 4737eb4..9051a9c 100644 --- a/gitea/gitea_test.go +++ b/go/gitea/gitea_test.go @@ -12,11 +12,18 @@ import ( core "dappco.re/go" ) +const ( + sonarGiteaTestApplicationJson = "application/json" + sonarGiteaTestConfigYaml = "config.yaml" + sonarGiteaTestContentType = "Content-Type" + sonarGiteaTestHttpsExampleTestRepoGit = "https://example.test/repo.git" +) + func ax7GiteaClient(t *core.T) *Client { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/version" { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarGiteaTestContentType, sonarGiteaTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) return } @@ -31,7 +38,7 @@ func ax7GiteaClient(t *core.T) *Client { func TestGitea_New_Good(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarGiteaTestContentType, sonarGiteaTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -48,7 +55,7 @@ func TestGitea_New_Bad(t *core.T) { func TestGitea_New_Ugly(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarGiteaTestContentType, sonarGiteaTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -59,7 +66,7 @@ func TestGitea_New_Ugly(t *core.T) { func TestGitea_NewFromConfig_Good(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarGiteaTestContentType, sonarGiteaTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -76,7 +83,7 @@ func TestGitea_NewFromConfig_Bad(t *core.T) { func TestGitea_NewFromConfig_Ugly(t *core.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(sonarGiteaTestContentType, sonarGiteaTestApplicationJson) _, _ = w.Write([]byte(`{"version":"1.22.0"}`)) })) t.Cleanup(server.Close) @@ -109,7 +116,7 @@ func TestGitea_ResolveConfig_Ugly(t *core.T) { t.Setenv("GITEA_URL", "") t.Setenv("GITEA_TOKEN", "") core.RequireNoError(t, os.MkdirAll(filepath.Join(home, ".core"), 0o755)) - core.RequireNoError(t, os.WriteFile(filepath.Join(home, ".core", "config.yaml"), []byte("gitea:\n url: http://file.test\n token: file-token\n"), 0o600)) + core.RequireNoError(t, os.WriteFile(filepath.Join(home, ".core", sonarGiteaTestConfigYaml), []byte("gitea:\n url: http://file.test\n token: file-token\n"), 0o600)) url, token, err := ResolveConfig("", "") core.AssertNoError(t, err) core.AssertEqual(t, "http://file.test", url) @@ -121,7 +128,7 @@ func TestGitea_SaveConfig_Good(t *core.T) { t.Setenv("HOME", home) err := SaveConfig("http://save.test", "saved-token") core.AssertNoError(t, err) - raw, readErr := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + raw, readErr := os.ReadFile(filepath.Join(home, ".core", sonarGiteaTestConfigYaml)) core.RequireNoError(t, readErr) core.AssertContains(t, string(raw), "saved-token") } @@ -137,7 +144,7 @@ func TestGitea_SaveConfig_Ugly(t *core.T) { t.Setenv("HOME", home) err := SaveConfig("", "token-only") core.AssertNoError(t, err) - raw, readErr := os.ReadFile(filepath.Join(home, ".core", "config.yaml")) + raw, readErr := os.ReadFile(filepath.Join(home, ".core", sonarGiteaTestConfigYaml)) core.RequireNoError(t, readErr) core.AssertContains(t, string(raw), "token-only") } @@ -210,6 +217,7 @@ func TestGitea_Client_ListIssuesIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListIssuesIter("owner", "repo", ListIssuesOpts{}) { + break } }) core.AssertNil(t, client.API()) @@ -219,6 +227,7 @@ func TestGitea_Client_ListIssuesIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListIssuesIter("owner", "repo", ListIssuesOpts{}) { + break } }) core.AssertEqual(t, "", client.URL()) @@ -346,6 +355,7 @@ func TestGitea_Client_ListIssueCommentsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListIssueCommentsIter("owner", "repo", 7) { + break } }) core.AssertNil(t, client.API()) @@ -355,6 +365,7 @@ func TestGitea_Client_ListIssueCommentsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListIssueCommentsIter("owner", "repo", 7) { + break } }) core.AssertEqual(t, "", client.URL()) @@ -482,6 +493,7 @@ func TestGitea_Client_ListPullRequestsIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListPullRequestsIter("owner", "repo", "closed") { + break } }) core.AssertNil(t, client.API()) @@ -491,6 +503,7 @@ func TestGitea_Client_ListPullRequestsIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListPullRequestsIter("owner", "repo", "") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -582,6 +595,7 @@ func TestGitea_Client_ListOrgReposIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListOrgReposIter("org") { + break } }) core.AssertNil(t, client.API()) @@ -591,6 +605,7 @@ func TestGitea_Client_ListOrgReposIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListOrgReposIter("org") { + break } }) core.AssertEqual(t, "", client.URL()) @@ -628,6 +643,7 @@ func TestGitea_Client_ListUserReposIter_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { for range client.ListUserReposIter() { + break } }) core.AssertNil(t, client.API()) @@ -637,6 +653,7 @@ func TestGitea_Client_ListUserReposIter_Ugly(t *core.T) { var client *Client core.AssertPanics(t, func() { for range client.ListUserReposIter() { + break } }) core.AssertEqual(t, "", client.URL()) @@ -644,13 +661,13 @@ func TestGitea_Client_ListUserReposIter_Ugly(t *core.T) { func TestGitea_Client_CreateMirror_Good(t *core.T) { client := ax7GiteaClient(t) - _, err := client.CreateMirror("owner", "repo", "https://example.test/repo.git", "token") + _, err := client.CreateMirror("owner", "repo", sonarGiteaTestHttpsExampleTestRepoGit, "token") core.AssertError(t, err) } func TestGitea_Client_CreateMirror_Bad(t *core.T) { client := &Client{} - core.AssertPanics(t, func() { _, _ = client.CreateMirror("owner", "repo", "https://example.test/repo.git", "") }) + core.AssertPanics(t, func() { _, _ = client.CreateMirror("owner", "repo", sonarGiteaTestHttpsExampleTestRepoGit, "") }) core.AssertNil(t, client.API()) } @@ -662,14 +679,14 @@ func TestGitea_Client_CreateMirror_Ugly(t *core.T) { func TestGitea_Client_CreateMirrorFromService_Good(t *core.T) { client := ax7GiteaClient(t) - _, err := client.CreateMirrorFromService("owner", "repo", "https://example.test/repo.git", sdk.GitServicePlain, "token") + _, err := client.CreateMirrorFromService("owner", "repo", sonarGiteaTestHttpsExampleTestRepoGit, sdk.GitServicePlain, "token") core.AssertError(t, err) } func TestGitea_Client_CreateMirrorFromService_Bad(t *core.T) { client := &Client{} core.AssertPanics(t, func() { - _, _ = client.CreateMirrorFromService("owner", "repo", "https://example.test/repo.git", sdk.GitServicePlain, "") + _, _ = client.CreateMirrorFromService("owner", "repo", sonarGiteaTestHttpsExampleTestRepoGit, sdk.GitServicePlain, "") }) core.AssertNil(t, client.API()) } diff --git a/gitea/issues.go b/go/gitea/issues.go similarity index 59% rename from gitea/issues.go rename to go/gitea/issues.go index 3f12816..a7d0feb 100644 --- a/gitea/issues.go +++ b/go/gitea/issues.go @@ -16,15 +16,18 @@ type ListIssuesOpts struct { Limit int } -func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.Issue, error) { - state := gitea.StateOpen - switch opts.State { +func giteaStateFromString(state string) gitea.StateType { + switch state { case "closed": - state = gitea.StateClosed + return gitea.StateClosed case "all": - state = gitea.StateAll + return gitea.StateAll + default: + return gitea.StateOpen } +} +func normalizeGiteaListIssuesOpts(opts ListIssuesOpts) (gitea.StateType, int, int) { limit := opts.Limit if limit == 0 { limit = 50 @@ -33,73 +36,32 @@ func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.I if page == 0 { page = 1 } + return giteaStateFromString(opts.State), page, limit +} - var all []*gitea.Issue - for { - issues, resp, err := c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ +func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.Issue, error) { + state, page, limit := normalizeGiteaListIssuesOpts(opts) + return collectGiteaLimitedPages(page, limit, func(page int) ([]*gitea.Issue, *gitea.Response, error) { + return c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ ListOptions: gitea.ListOptions{Page: page, PageSize: limit}, State: state, Type: gitea.IssueTypeIssue, Labels: opts.Labels, }) - if err != nil { - return nil, err - } - all = append(all, issues...) - if len(issues) < limit || len(issues) == 0 { - break - } - if resp != nil && resp.LastPage > 0 && page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListIssuesIter(owner, repo string, opts ListIssuesOpts) iter.Seq2[*gitea.Issue, error] { - state := gitea.StateOpen - switch opts.State { - case "closed": - state = gitea.StateClosed - case "all": - state = gitea.StateAll - } - - limit := opts.Limit - if limit == 0 { - limit = 50 - } - page := opts.Page - if page == 0 { - page = 1 - } - + state, page, limit := normalizeGiteaListIssuesOpts(opts) return func(yield func(*gitea.Issue, error) bool) { - for { - issues, resp, err := c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ + yieldGiteaLimitedPages(yield, page, limit, func(page int) ([]*gitea.Issue, *gitea.Response, error) { + return c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ ListOptions: gitea.ListOptions{Page: page, PageSize: limit}, State: state, Type: gitea.IssueTypeIssue, Labels: opts.Labels, }) - if err != nil { - yield(nil, err) - return - } - for _, issue := range issues { - if !yield(issue, nil) { - return - } - } - if len(issues) < limit || len(issues) == 0 { - break - } - if resp != nil && resp.LastPage > 0 && page >= resp.LastPage { - break - } - page++ - } + }) } } @@ -129,45 +91,20 @@ func (c *Client) CreateIssueComment(owner, repo string, issue int64, body string } func (c *Client) ListIssueComments(owner, repo string, number int64) ([]*gitea.Comment, error) { - var all []*gitea.Comment - page := 1 - for { - comments, resp, err := c.api.ListIssueComments(owner, repo, number, gitea.ListIssueCommentOptions{ + return collectGiteaPages(func(page int) ([]*gitea.Comment, *gitea.Response, error) { + return c.api.ListIssueComments(owner, repo, number, gitea.ListIssueCommentOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, }) - if err != nil { - return nil, err - } - all = append(all, comments...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListIssueCommentsIter(owner, repo string, number int64) iter.Seq2[*gitea.Comment, error] { return func(yield func(*gitea.Comment, error) bool) { - page := 1 - for { - comments, resp, err := c.api.ListIssueComments(owner, repo, number, gitea.ListIssueCommentOptions{ + yieldGiteaPages(yield, func(page int) ([]*gitea.Comment, *gitea.Response, error) { + return c.api.ListIssueComments(owner, repo, number, gitea.ListIssueCommentOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, }) - if err != nil { - yield(nil, err) - return - } - for _, comment := range comments { - if !yield(comment, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } @@ -198,62 +135,23 @@ func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRe } func (c *Client) ListPullRequests(owner, repo string, state string) ([]*gitea.PullRequest, error) { - st := gitea.StateOpen - switch state { - case "closed": - st = gitea.StateClosed - case "all": - st = gitea.StateAll - } - - var all []*gitea.PullRequest - page := 1 - for { - prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ + st := giteaStateFromString(state) + return collectGiteaPages(func(page int) ([]*gitea.PullRequest, *gitea.Response, error) { + return c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, State: st, }) - if err != nil { - return nil, err - } - all = append(all, prs...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListPullRequestsIter(owner, repo string, state string) iter.Seq2[*gitea.PullRequest, error] { - st := gitea.StateOpen - switch state { - case "closed": - st = gitea.StateClosed - case "all": - st = gitea.StateAll - } - + st := giteaStateFromString(state) return func(yield func(*gitea.PullRequest, error) bool) { - page := 1 - for { - prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ + yieldGiteaPages(yield, func(page int) ([]*gitea.PullRequest, *gitea.Response, error) { + return c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, State: st, }) - if err != nil { - yield(nil, err) - return - } - for _, pr := range prs { - if !yield(pr, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/gitea/meta.go b/go/gitea/meta.go similarity index 60% rename from gitea/meta.go rename to go/gitea/meta.go index 9d8162c..a7881ed 100644 --- a/gitea/meta.go +++ b/go/gitea/meta.go @@ -34,6 +34,85 @@ type PRMeta struct { const commentPageSize = 50 +func collectGiteaPages[T any](fetch func(page int) ([]T, *gitea.Response, error)) ([]T, error) { + var all []T + for page := 1; ; page++ { + items, resp, err := fetch(page) + if err != nil { + return nil, err + } + all = append(all, items...) + if !hasNextGiteaPage(resp, page) { + return all, nil + } + } +} + +func collectGiteaLimitedPages[T any](page, limit int, fetch func(page int) ([]T, *gitea.Response, error)) ([]T, error) { + var all []T + for { + items, resp, err := fetch(page) + if err != nil { + return nil, err + } + all = append(all, items...) + if !hasMoreGiteaItems(items, resp, page, limit) { + return all, nil + } + page++ + } +} + +func yieldGiteaPages[T any](yield func(T, error) bool, fetch func(page int) ([]T, *gitea.Response, error)) { + for page := 1; ; page++ { + items, resp, err := fetch(page) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + if !hasNextGiteaPage(resp, page) { + return + } + } +} + +func yieldGiteaLimitedPages[T any](yield func(T, error) bool, page, limit int, fetch func(page int) ([]T, *gitea.Response, error)) { + for { + items, resp, err := fetch(page) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + if !hasMoreGiteaItems(items, resp, page, limit) { + return + } + page++ + } +} + +func hasNextGiteaPage(resp *gitea.Response, page int) bool { + return resp != nil && page < resp.LastPage +} + +func hasMoreGiteaItems[T any](items []T, resp *gitea.Response, page, limit int) bool { + if len(items) == 0 || len(items) < limit { + return false + } + return resp == nil || resp.LastPage <= 0 || page < resp.LastPage +} + func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) { iss, _, err := c.api.GetIssue(owner, repo, issue) if err != nil { diff --git a/gitea/repos.go b/go/gitea/repos.go similarity index 65% rename from gitea/repos.go rename to go/gitea/repos.go index 7844bf8..20f3b53 100644 --- a/gitea/repos.go +++ b/go/gitea/repos.go @@ -25,88 +25,38 @@ func (c *Client) GetRepo(owner, name string) (*gitea.Repository, error) { } func (c *Client) ListOrgRepos(org string) ([]*gitea.Repository, error) { - var all []*gitea.Repository - page := 1 - for { - repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ + return collectGiteaPages(func(page int) ([]*gitea.Repository, *gitea.Response, error) { + return c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, repos...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListOrgReposIter(org string) iter.Seq2[*gitea.Repository, error] { return func(yield func(*gitea.Repository, error) bool) { - page := 1 - for { - repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ + yieldGiteaPages(yield, func(page int) ([]*gitea.Repository, *gitea.Response, error) { + return c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, repo := range repos { - if !yield(repo, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } func (c *Client) ListUserRepos() ([]*gitea.Repository, error) { - var all []*gitea.Repository - page := 1 - for { - repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{ + return collectGiteaPages(func(page int) ([]*gitea.Repository, *gitea.Response, error) { + return c.api.ListMyRepos(gitea.ListReposOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - return nil, err - } - all = append(all, repos...) - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - return all, nil + }) } func (c *Client) ListUserReposIter() iter.Seq2[*gitea.Repository, error] { return func(yield func(*gitea.Repository, error) bool) { - page := 1 - for { - repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{ + yieldGiteaPages(yield, func(page int) ([]*gitea.Repository, *gitea.Response, error) { + return c.api.ListMyRepos(gitea.ListReposOptions{ ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, }) - if err != nil { - yield(nil, err) - return - } - for _, repo := range repos { - if !yield(repo, nil) { - return - } - } - if resp == nil || page >= resp.LastPage { - break - } - page++ - } + }) } } diff --git a/go.mod b/go/go.mod similarity index 100% rename from go.mod rename to go/go.mod diff --git a/go.sum b/go/go.sum similarity index 100% rename from go.sum rename to go/go.sum diff --git a/internal/ax/filepathx/filepathx.go b/go/internal/ax/filepathx/filepathx.go similarity index 100% rename from internal/ax/filepathx/filepathx.go rename to go/internal/ax/filepathx/filepathx.go diff --git a/internal/ax/filepathx/filepathx_test.go b/go/internal/ax/filepathx/filepathx_test.go similarity index 100% rename from internal/ax/filepathx/filepathx_test.go rename to go/internal/ax/filepathx/filepathx_test.go diff --git a/internal/ax/fmtx/fmtx.go b/go/internal/ax/fmtx/fmtx.go similarity index 100% rename from internal/ax/fmtx/fmtx.go rename to go/internal/ax/fmtx/fmtx.go diff --git a/internal/ax/fmtx/fmtx_test.go b/go/internal/ax/fmtx/fmtx_test.go similarity index 83% rename from internal/ax/fmtx/fmtx_test.go rename to go/internal/ax/fmtx/fmtx_test.go index d8e1b81..bc2782a 100644 --- a/internal/ax/fmtx/fmtx_test.go +++ b/go/internal/ax/fmtx/fmtx_test.go @@ -4,12 +4,17 @@ package fmtx import core "dappco.re/go" +const ( + sonarFmtxTestAgentCodex = "agent=codex" + sonarFmtxTestAgentS = "agent=%s" +) + func TestFmtx_Fprintf_Good(t *core.T) { builder := core.NewBuilder() - n, err := Fprintf(builder, "agent=%s", "codex") + n, err := Fprintf(builder, sonarFmtxTestAgentS, "codex") core.AssertNoError(t, err) core.AssertEqual(t, 11, n) - core.AssertEqual(t, "agent=codex", builder.String()) + core.AssertEqual(t, sonarFmtxTestAgentCodex, builder.String()) } func TestFmtx_Fprintf_Bad(t *core.T) { @@ -29,7 +34,7 @@ func TestFmtx_Fprintf_Ugly(t *core.T) { } func TestFmtx_Printf_Good(t *core.T) { - n, err := Printf("agent=%s", "codex") + n, err := Printf(sonarFmtxTestAgentS, "codex") core.AssertNoError(t, err) core.AssertEqual(t, 11, n) } @@ -68,7 +73,7 @@ func TestFmtx_Println_Ugly(t *core.T) { func TestFmtx_Sprint_Good(t *core.T) { got := Sprint("agent", "=", "codex") core.AssertEqual( - t, "agent=codex", got, + t, sonarFmtxTestAgentCodex, got, ) } @@ -87,9 +92,9 @@ func TestFmtx_Sprint_Ugly(t *core.T) { } func TestFmtx_Sprintf_Good(t *core.T) { - got := Sprintf("agent=%s", "codex") + got := Sprintf(sonarFmtxTestAgentS, "codex") core.AssertEqual( - t, "agent=codex", got, + t, sonarFmtxTestAgentCodex, got, ) } diff --git a/internal/ax/jsonx/jsonx.go b/go/internal/ax/jsonx/jsonx.go similarity index 100% rename from internal/ax/jsonx/jsonx.go rename to go/internal/ax/jsonx/jsonx.go diff --git a/internal/ax/jsonx/jsonx_test.go b/go/internal/ax/jsonx/jsonx_test.go similarity index 95% rename from internal/ax/jsonx/jsonx_test.go rename to go/internal/ax/jsonx/jsonx_test.go index 4dc48bf..7e198e8 100644 --- a/internal/ax/jsonx/jsonx_test.go +++ b/go/internal/ax/jsonx/jsonx_test.go @@ -2,7 +2,11 @@ package jsonx -import core "dappco.re/go" +import ( + "math" + + core "dappco.re/go" +) func TestJsonx_Marshal_Good(t *core.T) { got, err := Marshal(map[string]string{"agent": "codex"}) @@ -11,7 +15,7 @@ func TestJsonx_Marshal_Good(t *core.T) { } func TestJsonx_Marshal_Bad(t *core.T) { - _, err := Marshal(func() {}) + _, err := Marshal(math.Inf(1)) core.AssertError( t, err, ) @@ -75,7 +79,7 @@ func TestJsonx_NewEncoder_Good(t *core.T) { func TestJsonx_NewEncoder_Bad(t *core.T) { builder := core.NewBuilder() encoder := NewEncoder(builder) - err := encoder.Encode(func() {}) + err := encoder.Encode(math.Inf(1)) core.AssertError(t, err) } diff --git a/internal/ax/osx/osx.go b/go/internal/ax/osx/osx.go similarity index 100% rename from internal/ax/osx/osx.go rename to go/internal/ax/osx/osx.go diff --git a/internal/ax/osx/osx_test.go b/go/internal/ax/osx/osx_test.go similarity index 80% rename from internal/ax/osx/osx_test.go rename to go/internal/ax/osx/osx_test.go index a317d57..bc63317 100644 --- a/internal/ax/osx/osx_test.go +++ b/go/internal/ax/osx/osx_test.go @@ -8,6 +8,11 @@ import ( core "dappco.re/go" ) +const ( + sonarOsxTestBadPath = "bad\x00path" + sonarOsxTestCoreJson = "core.json" +) + func TestOsx_Getenv_Good(t *core.T) { t.Setenv("SCM_AX7_ENV", "ready") got := Getenv("SCM_AX7_ENV") @@ -82,21 +87,21 @@ func TestOsx_MkdirAll_Bad(t *core.T) { } func TestOsx_MkdirAll_Ugly(t *core.T) { - err := MkdirAll("bad\x00path", 0o755) + err := MkdirAll(sonarOsxTestBadPath, 0o755) core.AssertError( t, err, ) } func TestOsx_Open_Good(t *core.T) { - path := core.Path(t.TempDir(), "core.json") + path := core.Path(t.TempDir(), sonarOsxTestCoreJson) core.RequireNoError(t, WriteFile(path, []byte("ok"), 0o600)) file, err := Open(path) core.RequireNoError(t, err) - defer file.Close() + defer func() { core.AssertNoError(t, file.Close()) }() info, statErr := file.Stat() core.RequireNoError(t, statErr) - core.AssertEqual(t, "core.json", info.Name()) + core.AssertEqual(t, sonarOsxTestCoreJson, info.Name()) } func TestOsx_Open_Bad(t *core.T) { @@ -107,17 +112,17 @@ func TestOsx_Open_Bad(t *core.T) { } func TestOsx_Open_Ugly(t *core.T) { - _, err := Open("bad\x00path") + _, err := Open(sonarOsxTestBadPath) core.AssertError( t, err, ) } func TestOsx_OpenFile_Good(t *core.T) { - path := core.Path(t.TempDir(), "core.json") + path := core.Path(t.TempDir(), sonarOsxTestCoreJson) file, err := OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) core.RequireNoError(t, err) - defer file.Close() + defer func() { core.AssertNoError(t, file.Close()) }() _, err = file.Write([]byte("ok")) core.AssertNoError(t, err) } @@ -130,7 +135,7 @@ func TestOsx_OpenFile_Bad(t *core.T) { } func TestOsx_OpenFile_Ugly(t *core.T) { - _, err := OpenFile("bad\x00path", os.O_RDONLY, 0) + _, err := OpenFile(sonarOsxTestBadPath, os.O_RDONLY, 0) core.AssertError( t, err, ) @@ -138,7 +143,7 @@ func TestOsx_OpenFile_Ugly(t *core.T) { func TestOsx_ReadDir_Good(t *core.T) { dir := t.TempDir() - core.RequireNoError(t, WriteFile(core.Path(dir, "core.json"), []byte("ok"), 0o600)) + core.RequireNoError(t, WriteFile(core.Path(dir, sonarOsxTestCoreJson), []byte("ok"), 0o600)) entries, err := ReadDir(dir) core.AssertNoError(t, err) core.AssertLen(t, entries, 1) @@ -152,14 +157,14 @@ func TestOsx_ReadDir_Bad(t *core.T) { } func TestOsx_ReadDir_Ugly(t *core.T) { - _, err := ReadDir("bad\x00path") + _, err := ReadDir(sonarOsxTestBadPath) core.AssertError( t, err, ) } func TestOsx_ReadFile_Good(t *core.T) { - path := core.Path(t.TempDir(), "core.json") + path := core.Path(t.TempDir(), sonarOsxTestCoreJson) core.RequireNoError(t, WriteFile(path, []byte("ok"), 0o600)) got, err := ReadFile(path) core.AssertNoError(t, err) @@ -174,18 +179,18 @@ func TestOsx_ReadFile_Bad(t *core.T) { } func TestOsx_ReadFile_Ugly(t *core.T) { - _, err := ReadFile("bad\x00path") + _, err := ReadFile(sonarOsxTestBadPath) core.AssertError( t, err, ) } func TestOsx_Stat_Good(t *core.T) { - path := core.Path(t.TempDir(), "core.json") + path := core.Path(t.TempDir(), sonarOsxTestCoreJson) core.RequireNoError(t, WriteFile(path, []byte("ok"), 0o600)) info, err := Stat(path) core.AssertNoError(t, err) - core.AssertEqual(t, "core.json", info.Name()) + core.AssertEqual(t, sonarOsxTestCoreJson, info.Name()) } func TestOsx_Stat_Bad(t *core.T) { @@ -196,7 +201,7 @@ func TestOsx_Stat_Bad(t *core.T) { } func TestOsx_Stat_Ugly(t *core.T) { - _, err := Stat("bad\x00path") + _, err := Stat(sonarOsxTestBadPath) core.AssertError( t, err, ) @@ -221,7 +226,7 @@ func TestOsx_UserHomeDir_Ugly(t *core.T) { } func TestOsx_WriteFile_Good(t *core.T) { - path := core.Path(t.TempDir(), "core.json") + path := core.Path(t.TempDir(), sonarOsxTestCoreJson) err := WriteFile(path, []byte("ok"), 0o600) core.AssertNoError(t, err) got, readErr := ReadFile(path) @@ -230,14 +235,14 @@ func TestOsx_WriteFile_Good(t *core.T) { } func TestOsx_WriteFile_Bad(t *core.T) { - err := WriteFile(core.Path(t.TempDir(), "missing", "core.json"), []byte("ok"), 0o600) + err := WriteFile(core.Path(t.TempDir(), "missing", sonarOsxTestCoreJson), []byte("ok"), 0o600) core.AssertError( t, err, ) } func TestOsx_WriteFile_Ugly(t *core.T) { - err := WriteFile("bad\x00path", []byte("ok"), 0o600) + err := WriteFile(sonarOsxTestBadPath, []byte("ok"), 0o600) core.AssertError( t, err, ) diff --git a/internal/ax/stringsx/stringsx.go b/go/internal/ax/stringsx/stringsx.go similarity index 98% rename from internal/ax/stringsx/stringsx.go rename to go/internal/ax/stringsx/stringsx.go index fd62330..d63024f 100644 --- a/internal/ax/stringsx/stringsx.go +++ b/go/internal/ax/stringsx/stringsx.go @@ -20,7 +20,7 @@ func Join(elems []string, sep string) string { return strings.Join(elems, sep) func LastIndex(s, substr string) int { return strings.LastIndex(s, substr) } func NewReader(s string) *bytes.Reader { return bytes.NewReader([]byte(s)) } func Repeat(s string, count int) string { return strings.Repeat(s, count) } -func Replace(s, old, new string, _ int) string { return strings.Replace(s, old, new, -1) } +func Replace(s, old, new string, _ int) string { return strings.ReplaceAll(s, old, new) } func ReplaceAll(s, old, new string) string { return strings.ReplaceAll(s, old, new) } func Split(s, sep string) []string { return strings.Split(s, sep) } func SplitN(s, sep string, n int) []string { return strings.SplitN(s, sep, n) } diff --git a/internal/ax/stringsx/stringsx_test.go b/go/internal/ax/stringsx/stringsx_test.go similarity index 79% rename from internal/ax/stringsx/stringsx_test.go rename to go/internal/ax/stringsx/stringsx_test.go index 201ec3f..1aa0ed3 100644 --- a/internal/ax/stringsx/stringsx_test.go +++ b/go/internal/ax/stringsx/stringsx_test.go @@ -4,15 +4,21 @@ package stringsx import core "dappco.re/go" +const ( + sonarStringsxTestAgentDispatch = "agent.dispatch" + sonarStringsxTestAgentDispatch2 = "agent/dispatch" + sonarStringsxTestManifestYaml = "manifest.yaml" +) + func TestStringsx_Contains_Good(t *core.T) { - got := Contains("agent.dispatch", "dispatch") + got := Contains(sonarStringsxTestAgentDispatch, "dispatch") core.AssertTrue( t, got, ) } func TestStringsx_Contains_Bad(t *core.T) { - got := Contains("agent.dispatch", "missing") + got := Contains(sonarStringsxTestAgentDispatch, "missing") core.AssertFalse( t, got, ) @@ -26,7 +32,7 @@ func TestStringsx_Contains_Ugly(t *core.T) { } func TestStringsx_ContainsAny_Good(t *core.T) { - got := ContainsAny("agent.dispatch", ".:") + got := ContainsAny(sonarStringsxTestAgentDispatch, ".:") core.AssertTrue( t, got, ) @@ -89,42 +95,42 @@ func TestStringsx_Fields_Ugly(t *core.T) { } func TestStringsx_HasPrefix_Good(t *core.T) { - got := HasPrefix("agent.dispatch", "agent") + got := HasPrefix(sonarStringsxTestAgentDispatch, "agent") core.AssertTrue( t, got, ) } func TestStringsx_HasPrefix_Bad(t *core.T) { - got := HasPrefix("agent.dispatch", "task") + got := HasPrefix(sonarStringsxTestAgentDispatch, "task") core.AssertFalse( t, got, ) } func TestStringsx_HasPrefix_Ugly(t *core.T) { - got := HasPrefix("agent.dispatch", "") + got := HasPrefix(sonarStringsxTestAgentDispatch, "") core.AssertTrue( t, got, ) } func TestStringsx_HasSuffix_Good(t *core.T) { - got := HasSuffix("manifest.yaml", ".yaml") + got := HasSuffix(sonarStringsxTestManifestYaml, ".yaml") core.AssertTrue( t, got, ) } func TestStringsx_HasSuffix_Bad(t *core.T) { - got := HasSuffix("manifest.yaml", ".json") + got := HasSuffix(sonarStringsxTestManifestYaml, ".json") core.AssertFalse( t, got, ) } func TestStringsx_HasSuffix_Ugly(t *core.T) { - got := HasSuffix("manifest.yaml", "") + got := HasSuffix(sonarStringsxTestManifestYaml, "") core.AssertTrue( t, got, ) @@ -133,7 +139,7 @@ func TestStringsx_HasSuffix_Ugly(t *core.T) { func TestStringsx_Join_Good(t *core.T) { got := Join([]string{"agent", "dispatch"}, ".") core.AssertEqual( - t, "agent.dispatch", got, + t, sonarStringsxTestAgentDispatch, got, ) } @@ -159,16 +165,16 @@ func TestStringsx_LastIndex_Good(t *core.T) { } func TestStringsx_LastIndex_Bad(t *core.T) { - got := LastIndex("agent.dispatch", "/") + got := LastIndex(sonarStringsxTestAgentDispatch, "/") core.AssertEqual( t, -1, got, ) } func TestStringsx_LastIndex_Ugly(t *core.T) { - got := LastIndex("agent.dispatch", "") + got := LastIndex(sonarStringsxTestAgentDispatch, "") core.AssertEqual( - t, len("agent.dispatch"), got, + t, len(sonarStringsxTestAgentDispatch), got, ) } @@ -217,16 +223,16 @@ func TestStringsx_Repeat_Ugly(t *core.T) { } func TestStringsx_Replace_Good(t *core.T) { - got := Replace("agent/dispatch", "/", ".", 1) + got := Replace(sonarStringsxTestAgentDispatch2, "/", ".", 1) core.AssertEqual( - t, "agent.dispatch", got, + t, sonarStringsxTestAgentDispatch, got, ) } func TestStringsx_Replace_Bad(t *core.T) { - got := Replace("agent/dispatch", ".", "/", 1) + got := Replace(sonarStringsxTestAgentDispatch2, ".", "/", 1) core.AssertEqual( - t, "agent/dispatch", got, + t, sonarStringsxTestAgentDispatch2, got, ) } @@ -259,16 +265,16 @@ func TestStringsx_ReplaceAll_Ugly(t *core.T) { } func TestStringsx_Split_Good(t *core.T) { - got := Split("agent/dispatch", "/") + got := Split(sonarStringsxTestAgentDispatch2, "/") core.AssertEqual( t, []string{"agent", "dispatch"}, got, ) } func TestStringsx_Split_Bad(t *core.T) { - got := Split("agent/dispatch", ".") + got := Split(sonarStringsxTestAgentDispatch2, ".") core.AssertEqual( - t, []string{"agent/dispatch"}, got, + t, []string{sonarStringsxTestAgentDispatch2}, got, ) } @@ -367,23 +373,23 @@ func TestStringsx_ToUpper_Ugly(t *core.T) { } func TestStringsx_TrimPrefix_Good(t *core.T) { - got := TrimPrefix("agent.dispatch", "agent.") + got := TrimPrefix(sonarStringsxTestAgentDispatch, "agent.") core.AssertEqual( t, "dispatch", got, ) } func TestStringsx_TrimPrefix_Bad(t *core.T) { - got := TrimPrefix("agent.dispatch", "task.") + got := TrimPrefix(sonarStringsxTestAgentDispatch, "task.") core.AssertEqual( - t, "agent.dispatch", got, + t, sonarStringsxTestAgentDispatch, got, ) } func TestStringsx_TrimPrefix_Ugly(t *core.T) { - got := TrimPrefix("agent.dispatch", "") + got := TrimPrefix(sonarStringsxTestAgentDispatch, "") core.AssertEqual( - t, "agent.dispatch", got, + t, sonarStringsxTestAgentDispatch, got, ) } @@ -409,22 +415,22 @@ func TestStringsx_TrimSpace_Ugly(t *core.T) { } func TestStringsx_TrimSuffix_Good(t *core.T) { - got := TrimSuffix("manifest.yaml", ".yaml") + got := TrimSuffix(sonarStringsxTestManifestYaml, ".yaml") core.AssertEqual( t, "manifest", got, ) } func TestStringsx_TrimSuffix_Bad(t *core.T) { - got := TrimSuffix("manifest.yaml", ".json") + got := TrimSuffix(sonarStringsxTestManifestYaml, ".json") core.AssertEqual( - t, "manifest.yaml", got, + t, sonarStringsxTestManifestYaml, got, ) } func TestStringsx_TrimSuffix_Ugly(t *core.T) { - got := TrimSuffix("manifest.yaml", "") + got := TrimSuffix(sonarStringsxTestManifestYaml, "") core.AssertEqual( - t, "manifest.yaml", got, + t, sonarStringsxTestManifestYaml, got, ) } diff --git a/jobrunner/forgejo/forgejo.go b/go/jobrunner/forgejo/forgejo.go similarity index 84% rename from jobrunner/forgejo/forgejo.go rename to go/jobrunner/forgejo/forgejo.go index d7d4193..507ff6e 100644 --- a/jobrunner/forgejo/forgejo.go +++ b/go/jobrunner/forgejo/forgejo.go @@ -42,34 +42,45 @@ func (s *ForgejoSource) Poll(ctx context.Context) ([]*jobrunner.PipelineSignal, var signals []*jobrunner.PipelineSignal for _, repoRef := range s.repos { - owner, repo, err := splitRepoRef(repoRef) + repoSignals, err := s.pollRepo(ctx, repoRef) if err != nil { return nil, err } + signals = append(signals, repoSignals...) + } + return signals, nil +} - issues, err := s.forge.ListIssues(owner, repo, coreforge.ListIssuesOpts{State: "open", Limit: 100}) - if err != nil { - return nil, err - } +func (s *ForgejoSource) pollRepo(ctx context.Context, repoRef string) ([]*jobrunner.PipelineSignal, error) { + owner, repo, err := splitRepoRef(repoRef) + if err != nil { + return nil, err + } + issues, err := s.forge.ListIssues(owner, repo, coreforge.ListIssuesOpts{State: "open", Limit: 100}) + if err != nil { + return nil, err + } + var signals []*jobrunner.PipelineSignal + for _, epic := range issues { + signals = append(signals, s.signalsForEpic(ctx, owner, repo, epic)...) + } + return signals, nil +} - for _, epic := range issues { - if epic == nil || strings.TrimSpace(epic.Body) == "" { - continue - } - childNumbers := parseChildIssueNumbers(epic.Body) - if len(childNumbers) == 0 { - continue - } - for _, childNumber := range childNumbers { - childSignal, err := s.signalForChild(ctx, owner, repo, epic.Index, childNumber) - if err != nil { - continue - } - signals = append(signals, childSignal) - } +func (s *ForgejoSource) signalsForEpic(ctx context.Context, owner, repo string, epic *forgejo.Issue) []*jobrunner.PipelineSignal { + if epic == nil || strings.TrimSpace(epic.Body) == "" { + return nil + } + childNumbers := parseChildIssueNumbers(epic.Body) + signals := make([]*jobrunner.PipelineSignal, 0, len(childNumbers)) + for _, childNumber := range childNumbers { + childSignal, err := s.signalForChild(ctx, owner, repo, epic.Index, childNumber) + if err != nil { + continue } + signals = append(signals, childSignal) } - return signals, nil + return signals } func (s *ForgejoSource) Report(ctx context.Context, result *jobrunner.ActionResult) error { diff --git a/jobrunner/forgejo/forgejo_test.go b/go/jobrunner/forgejo/forgejo_test.go similarity index 86% rename from jobrunner/forgejo/forgejo_test.go rename to go/jobrunner/forgejo/forgejo_test.go index f512da6..e2d2397 100644 --- a/jobrunner/forgejo/forgejo_test.go +++ b/go/jobrunner/forgejo/forgejo_test.go @@ -12,6 +12,10 @@ import ( "dappco.re/go/scm/jobrunner" ) +const ( + sonarForgejoTestCoreGoScm = "core/go-scm" +) + func ax7ForgeClient(t *core.T) *coreforge.Client { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -30,8 +34,8 @@ func ax7ForgeClient(t *core.T) *coreforge.Client { } func TestForgejo_New_Good(t *core.T) { - source := New(Config{Repos: []string{"core/go-scm"}}, nil) - core.AssertEqual(t, []string{"core/go-scm"}, source.repos) + source := New(Config{Repos: []string{sonarForgejoTestCoreGoScm}}, nil) + core.AssertEqual(t, []string{sonarForgejoTestCoreGoScm}, source.repos) core.AssertNil(t, source.forge) } @@ -42,10 +46,10 @@ func TestForgejo_New_Bad(t *core.T) { } func TestForgejo_New_Ugly(t *core.T) { - repos := []string{"core/go-scm"} + repos := []string{sonarForgejoTestCoreGoScm} source := New(Config{Repos: repos}, nil) repos[0] = "mutated/repo" - core.AssertEqual(t, "core/go-scm", source.repos[0]) + core.AssertEqual(t, sonarForgejoTestCoreGoScm, source.repos[0]) } func TestForgejo_ForgejoSource_Name_Good(t *core.T) { @@ -67,7 +71,7 @@ func TestForgejo_ForgejoSource_Name_Ugly(t *core.T) { } func TestForgejo_ForgejoSource_Poll_Good(t *core.T) { - source := New(Config{Repos: []string{"core/go-scm"}}, nil) + source := New(Config{Repos: []string{sonarForgejoTestCoreGoScm}}, nil) signals, err := source.Poll(context.Background()) core.AssertNoError(t, err) core.AssertNil(t, signals) @@ -76,7 +80,7 @@ func TestForgejo_ForgejoSource_Poll_Good(t *core.T) { func TestForgejo_ForgejoSource_Poll_Bad(t *core.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - source := New(Config{Repos: []string{"core/go-scm"}}, nil) + source := New(Config{Repos: []string{sonarForgejoTestCoreGoScm}}, nil) signals, err := source.Poll(ctx) core.AssertError(t, err) core.AssertNil(t, signals) diff --git a/jobrunner/handlers/handlers.go b/go/jobrunner/handlers/handlers.go similarity index 100% rename from jobrunner/handlers/handlers.go rename to go/jobrunner/handlers/handlers.go diff --git a/jobrunner/handlers/handlers_test.go b/go/jobrunner/handlers/handlers_test.go similarity index 81% rename from jobrunner/handlers/handlers_test.go rename to go/jobrunner/handlers/handlers_test.go index 7861cfa..624f143 100644 --- a/jobrunner/handlers/handlers_test.go +++ b/go/jobrunner/handlers/handlers_test.go @@ -15,6 +15,16 @@ import ( "dappco.re/go/scm/jobrunner" ) +const ( + sonarHandlersTestCodexBot = "codex-bot" + sonarHandlersTestDismissReviews = "dismiss-reviews" + sonarHandlersTestEnableAutoMerge = "enable-auto-merge" + sonarHandlersTestHttpForgeTest = "http://forge.test" + sonarHandlersTestPublishDraft = "publish-draft" + sonarHandlersTestSendFixCommand = "send-fix-command" + sonarHandlersTestTickParent = "tick-parent" +) + func ax7HandlersForgeClient(t *core.T) *coreforge.Client { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -44,7 +54,7 @@ func ax7HandlersSignal() *jobrunner.PipelineSignal { Mergeable: "MERGEABLE", ThreadsTotal: 1, ThreadsResolved: 1, - Assignee: "codex-bot", + Assignee: sonarHandlersTestCodexBot, IssueTitle: "Implement task", IssueBody: "body", } @@ -66,7 +76,7 @@ func ax7HandlersFakeSSH(t *core.T) { func ax7HandlersSpinner() *agentci.Spinner { return agentci.NewSpinner(agentci.ClothoConfig{}, map[string]agentci.AgentConfig{ - "codex": {ForgejoUser: "codex-bot", Host: "worker.example.test", QueueDir: "~/queue"}, + "codex": {ForgejoUser: sonarHandlersTestCodexBot, Host: "worker.example.test", QueueDir: "~/queue"}, }) } @@ -105,13 +115,14 @@ func TestHandlers_NewDismissReviewsHandler_Bad(t *core.T) { func TestHandlers_NewDismissReviewsHandler_Ugly(t *core.T) { handler := NewDismissReviewsHandler(nil) got := handler.Name() - core.AssertEqual(t, "dismiss-reviews", got) + core.AssertEqual(t, sonarHandlersTestDismissReviews, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", ThreadsTotal: 1})) } func TestHandlers_NewDispatchHandler_Good(t *core.T) { spinner := ax7HandlersSpinner() - handler := NewDispatchHandler(nil, "http://forge.test", "token", spinner) - core.AssertEqual(t, "http://forge.test", handler.forgeURL) + handler := NewDispatchHandler(nil, sonarHandlersTestHttpForgeTest, "token", spinner) + core.AssertEqual(t, sonarHandlersTestHttpForgeTest, handler.forgeURL) core.AssertEqual(t, spinner, handler.spinner) } @@ -122,7 +133,7 @@ func TestHandlers_NewDispatchHandler_Bad(t *core.T) { } func TestHandlers_NewDispatchHandler_Ugly(t *core.T) { - handler := NewDispatchHandler(nil, "http://forge.test", "", ax7HandlersSpinner()) + handler := NewDispatchHandler(nil, sonarHandlersTestHttpForgeTest, "", ax7HandlersSpinner()) got := handler.Name() core.AssertEqual(t, "dispatch", got) } @@ -143,7 +154,8 @@ func TestHandlers_NewEnableAutoMergeHandler_Bad(t *core.T) { func TestHandlers_NewEnableAutoMergeHandler_Ugly(t *core.T) { handler := NewEnableAutoMergeHandler(nil) got := handler.Name() - core.AssertEqual(t, "enable-auto-merge", got) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE"})) } func TestHandlers_NewPublishDraftHandler_Good(t *core.T) { @@ -162,7 +174,8 @@ func TestHandlers_NewPublishDraftHandler_Bad(t *core.T) { func TestHandlers_NewPublishDraftHandler_Ugly(t *core.T) { handler := NewPublishDraftHandler(nil) got := handler.Name() - core.AssertEqual(t, "publish-draft", got) + core.AssertEqual(t, sonarHandlersTestPublishDraft, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", IsDraft: true, CheckStatus: "SUCCESS"})) } func TestHandlers_NewSendFixCommandHandler_Good(t *core.T) { @@ -181,7 +194,8 @@ func TestHandlers_NewSendFixCommandHandler_Bad(t *core.T) { func TestHandlers_NewSendFixCommandHandler_Ugly(t *core.T) { handler := NewSendFixCommandHandler(nil) got := handler.Name() - core.AssertEqual(t, "send-fix-command", got) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", Mergeable: "CONFLICTING"})) } func TestHandlers_NewTickParentHandler_Good(t *core.T) { @@ -200,7 +214,8 @@ func TestHandlers_NewTickParentHandler_Bad(t *core.T) { func TestHandlers_NewTickParentHandler_Ugly(t *core.T) { handler := NewTickParentHandler(nil) got := handler.Name() - core.AssertEqual(t, "tick-parent", got) + core.AssertEqual(t, sonarHandlersTestTickParent, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "MERGED"})) } func TestHandlers_CompletionHandler_Name_Good(t *core.T) { @@ -224,19 +239,20 @@ func TestHandlers_CompletionHandler_Name_Ugly(t *core.T) { func TestHandlers_DismissReviewsHandler_Name_Good(t *core.T) { handler := NewDismissReviewsHandler(nil) got := handler.Name() - core.AssertEqual(t, "dismiss-reviews", got) + core.AssertEqual(t, sonarHandlersTestDismissReviews, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", ThreadsTotal: 2, ThreadsResolved: 1})) } func TestHandlers_DismissReviewsHandler_Name_Bad(t *core.T) { handler := &DismissReviewsHandler{} got := handler.Name() - core.AssertEqual(t, "dismiss-reviews", got) + core.AssertEqual(t, sonarHandlersTestDismissReviews, got) } func TestHandlers_DismissReviewsHandler_Name_Ugly(t *core.T) { var handler *DismissReviewsHandler got := handler.Name() - core.AssertEqual(t, "dismiss-reviews", got) + core.AssertEqual(t, sonarHandlersTestDismissReviews, got) } func TestHandlers_DispatchHandler_Name_Good(t *core.T) { @@ -260,73 +276,77 @@ func TestHandlers_DispatchHandler_Name_Ugly(t *core.T) { func TestHandlers_EnableAutoMergeHandler_Name_Good(t *core.T) { handler := NewEnableAutoMergeHandler(nil) got := handler.Name() - core.AssertEqual(t, "enable-auto-merge", got) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", CheckStatus: "SUCCESS", Mergeable: "MERGEABLE"})) } func TestHandlers_EnableAutoMergeHandler_Name_Bad(t *core.T) { handler := &EnableAutoMergeHandler{} got := handler.Name() - core.AssertEqual(t, "enable-auto-merge", got) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, got) } func TestHandlers_EnableAutoMergeHandler_Name_Ugly(t *core.T) { var handler *EnableAutoMergeHandler got := handler.Name() - core.AssertEqual(t, "enable-auto-merge", got) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, got) } func TestHandlers_PublishDraftHandler_Name_Good(t *core.T) { handler := NewPublishDraftHandler(nil) got := handler.Name() - core.AssertEqual(t, "publish-draft", got) + core.AssertEqual(t, sonarHandlersTestPublishDraft, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", IsDraft: true, CheckStatus: "SUCCESS"})) } func TestHandlers_PublishDraftHandler_Name_Bad(t *core.T) { handler := &PublishDraftHandler{} got := handler.Name() - core.AssertEqual(t, "publish-draft", got) + core.AssertEqual(t, sonarHandlersTestPublishDraft, got) } func TestHandlers_PublishDraftHandler_Name_Ugly(t *core.T) { var handler *PublishDraftHandler got := handler.Name() - core.AssertEqual(t, "publish-draft", got) + core.AssertEqual(t, sonarHandlersTestPublishDraft, got) } func TestHandlers_SendFixCommandHandler_Name_Good(t *core.T) { handler := NewSendFixCommandHandler(nil) got := handler.Name() - core.AssertEqual(t, "send-fix-command", got) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "OPEN", Mergeable: "CONFLICTING"})) } func TestHandlers_SendFixCommandHandler_Name_Bad(t *core.T) { handler := &SendFixCommandHandler{} got := handler.Name() - core.AssertEqual(t, "send-fix-command", got) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, got) } func TestHandlers_SendFixCommandHandler_Name_Ugly(t *core.T) { var handler *SendFixCommandHandler got := handler.Name() - core.AssertEqual(t, "send-fix-command", got) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, got) } func TestHandlers_TickParentHandler_Name_Good(t *core.T) { handler := NewTickParentHandler(nil) got := handler.Name() - core.AssertEqual(t, "tick-parent", got) + core.AssertEqual(t, sonarHandlersTestTickParent, got) + core.AssertTrue(t, handler.Match(&jobrunner.PipelineSignal{PRState: "MERGED"})) } func TestHandlers_TickParentHandler_Name_Bad(t *core.T) { handler := &TickParentHandler{} got := handler.Name() - core.AssertEqual(t, "tick-parent", got) + core.AssertEqual(t, sonarHandlersTestTickParent, got) } func TestHandlers_TickParentHandler_Name_Ugly(t *core.T) { var handler *TickParentHandler got := handler.Name() - core.AssertEqual(t, "tick-parent", got) + core.AssertEqual(t, sonarHandlersTestTickParent, got) } func TestHandlers_CompletionHandler_Match_Good(t *core.T) { @@ -367,13 +387,13 @@ func TestHandlers_DismissReviewsHandler_Match_Ugly(t *core.T) { func TestHandlers_DispatchHandler_Match_Good(t *core.T) { handler := NewDispatchHandler(nil, "", "", nil) - got := handler.Match(&jobrunner.PipelineSignal{NeedsCoding: true, Assignee: "codex-bot"}) + got := handler.Match(&jobrunner.PipelineSignal{NeedsCoding: true, Assignee: sonarHandlersTestCodexBot}) core.AssertTrue(t, got) } func TestHandlers_DispatchHandler_Match_Bad(t *core.T) { handler := NewDispatchHandler(nil, "", "", nil) - got := handler.Match(&jobrunner.PipelineSignal{NeedsCoding: false, Assignee: "codex-bot"}) + got := handler.Match(&jobrunner.PipelineSignal{NeedsCoding: false, Assignee: sonarHandlersTestCodexBot}) core.AssertFalse(t, got) } @@ -480,7 +500,7 @@ func TestHandlers_DismissReviewsHandler_Execute_Good(t *core.T) { handler := NewDismissReviewsHandler(ax7HandlersForgeClient(t)) result, err := handler.Execute(context.Background(), ax7HandlersSignal()) core.AssertError(t, err) - core.AssertEqual(t, "dismiss-reviews", result.Action) + core.AssertEqual(t, sonarHandlersTestDismissReviews, result.Action) } func TestHandlers_DismissReviewsHandler_Execute_Bad(t *core.T) { @@ -494,12 +514,12 @@ func TestHandlers_DismissReviewsHandler_Execute_Ugly(t *core.T) { handler := NewDismissReviewsHandler(nil) result, err := handler.Execute(context.Background(), nil) core.AssertError(t, err) - core.AssertEqual(t, "dismiss-reviews", result.Action) + core.AssertEqual(t, sonarHandlersTestDismissReviews, result.Action) } func TestHandlers_DispatchHandler_Execute_Good(t *core.T) { ax7HandlersFakeSSH(t) - handler := NewDispatchHandler(nil, "http://forge.test", "token", ax7HandlersSpinner()) + handler := NewDispatchHandler(nil, sonarHandlersTestHttpForgeTest, "token", ax7HandlersSpinner()) result, err := handler.Execute(context.Background(), ax7HandlersSignal()) core.AssertNoError(t, err) core.AssertTrue(t, result.Success) @@ -523,7 +543,7 @@ func TestHandlers_EnableAutoMergeHandler_Execute_Good(t *core.T) { handler := NewEnableAutoMergeHandler(ax7HandlersForgeClient(t)) result, err := handler.Execute(context.Background(), ax7HandlersSignal()) core.AssertNoError(t, err) - core.AssertEqual(t, "enable-auto-merge", result.Action) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, result.Action) } func TestHandlers_EnableAutoMergeHandler_Execute_Bad(t *core.T) { @@ -537,7 +557,7 @@ func TestHandlers_EnableAutoMergeHandler_Execute_Ugly(t *core.T) { handler := NewEnableAutoMergeHandler(nil) result, err := handler.Execute(context.Background(), nil) core.AssertError(t, err) - core.AssertEqual(t, "enable-auto-merge", result.Action) + core.AssertEqual(t, sonarHandlersTestEnableAutoMerge, result.Action) } func TestHandlers_PublishDraftHandler_Execute_Good(t *core.T) { @@ -546,7 +566,7 @@ func TestHandlers_PublishDraftHandler_Execute_Good(t *core.T) { signal.IsDraft = true result, err := handler.Execute(context.Background(), signal) core.AssertError(t, err) - core.AssertEqual(t, "publish-draft", result.Action) + core.AssertEqual(t, sonarHandlersTestPublishDraft, result.Action) } func TestHandlers_PublishDraftHandler_Execute_Bad(t *core.T) { @@ -560,7 +580,7 @@ func TestHandlers_PublishDraftHandler_Execute_Ugly(t *core.T) { handler := NewPublishDraftHandler(nil) result, err := handler.Execute(context.Background(), nil) core.AssertError(t, err) - core.AssertEqual(t, "publish-draft", result.Action) + core.AssertEqual(t, sonarHandlersTestPublishDraft, result.Action) } func TestHandlers_SendFixCommandHandler_Execute_Good(t *core.T) { @@ -569,7 +589,7 @@ func TestHandlers_SendFixCommandHandler_Execute_Good(t *core.T) { signal.Mergeable = "CONFLICTING" result, err := handler.Execute(context.Background(), signal) core.AssertError(t, err) - core.AssertEqual(t, "send-fix-command", result.Action) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, result.Action) } func TestHandlers_SendFixCommandHandler_Execute_Bad(t *core.T) { @@ -583,14 +603,14 @@ func TestHandlers_SendFixCommandHandler_Execute_Ugly(t *core.T) { handler := NewSendFixCommandHandler(nil) result, err := handler.Execute(context.Background(), nil) core.AssertError(t, err) - core.AssertEqual(t, "send-fix-command", result.Action) + core.AssertEqual(t, sonarHandlersTestSendFixCommand, result.Action) } func TestHandlers_TickParentHandler_Execute_Good(t *core.T) { handler := NewTickParentHandler(ax7HandlersForgeClient(t)) result, err := handler.Execute(context.Background(), ax7HandlersSignal()) core.AssertError(t, err) - core.AssertEqual(t, "tick-parent", result.Action) + core.AssertEqual(t, sonarHandlersTestTickParent, result.Action) } func TestHandlers_TickParentHandler_Execute_Bad(t *core.T) { @@ -604,5 +624,5 @@ func TestHandlers_TickParentHandler_Execute_Ugly(t *core.T) { handler := NewTickParentHandler(nil) result, err := handler.Execute(context.Background(), nil) core.AssertError(t, err) - core.AssertEqual(t, "tick-parent", result.Action) + core.AssertEqual(t, sonarHandlersTestTickParent, result.Action) } diff --git a/jobrunner/jobrunner_test.go b/go/jobrunner/jobrunner_test.go similarity index 97% rename from jobrunner/jobrunner_test.go rename to go/jobrunner/jobrunner_test.go index d84f110..d10636f 100644 --- a/jobrunner/jobrunner_test.go +++ b/go/jobrunner/jobrunner_test.go @@ -13,6 +13,10 @@ import ( core "dappco.re/go" ) +const ( + sonarJobrunnerTestGoScm = "go-scm" +) + type ax7Source struct { name string signals []*PipelineSignal @@ -122,7 +126,7 @@ func TestJobrunner_PipelineSignal_HasUnresolvedThreads_Ugly(t *core.T) { } func TestJobrunner_PipelineSignal_RepoFullName_Good(t *core.T) { - signal := &PipelineSignal{RepoOwner: "core", RepoName: "go-scm"} + signal := &PipelineSignal{RepoOwner: "core", RepoName: sonarJobrunnerTestGoScm} got := signal.RepoFullName() core.AssertEqual(t, "core/go-scm", got) } @@ -161,8 +165,8 @@ func TestJobrunner_NewJournal_Ugly(t *core.T) { func TestJobrunner_Journal_Append_Good(t *core.T) { journal, err := NewJournal(t.TempDir()) core.RequireNoError(t, err) - result := &ActionResult{Action: "dispatch", RepoOwner: "core", RepoName: "go-scm", Timestamp: time.Date(2026, 4, 15, 8, 0, 0, 0, time.UTC), Success: true} - err = journal.Append(&PipelineSignal{RepoOwner: "core", RepoName: "go-scm"}, result) + result := &ActionResult{Action: "dispatch", RepoOwner: "core", RepoName: sonarJobrunnerTestGoScm, Timestamp: time.Date(2026, 4, 15, 8, 0, 0, 0, time.UTC), Success: true} + err = journal.Append(&PipelineSignal{RepoOwner: "core", RepoName: sonarJobrunnerTestGoScm}, result) core.AssertNoError(t, err) _, statErr := os.Stat(filepath.Join(journal.baseDir, "2026", "04", "15.jsonl")) core.AssertNoError(t, statErr) diff --git a/jobrunner/journal.go b/go/jobrunner/journal.go similarity index 82% rename from jobrunner/journal.go rename to go/jobrunner/journal.go index 9825832..0e7d4fc 100644 --- a/jobrunner/journal.go +++ b/go/jobrunner/journal.go @@ -12,6 +12,10 @@ import ( core "dappco.re/go" ) +const ( + sonarJournalJobrunnerJournalAppend = "jobrunner.Journal.Append" +) + // Journal writes ActionResult entries to date-partitioned JSONL files. type Journal struct { baseDir string @@ -59,10 +63,10 @@ func NewJournal(baseDir string) (*Journal, error) { // Append writes a journal entry for the given signal and result. func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { if j == nil { - return core.E("jobrunner.Journal.Append", "journal is required", nil) + return core.E(sonarJournalJobrunnerJournalAppend, "journal is required", nil) } if result == nil { - return core.E("jobrunner.Journal.Append", "result is required", nil) + return core.E(sonarJournalJobrunnerJournalAppend, "result is required", nil) } ts := result.Timestamp @@ -97,11 +101,11 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { marshalResult := core.JSONMarshal(entry) if !marshalResult.OK { - return core.E("jobrunner.Journal.Append", "marshal entry", resultCause(marshalResult)) + return core.E(sonarJournalJobrunnerJournalAppend, "marshal entry", resultCause(marshalResult)) } payload, ok := marshalResult.Value.([]byte) if !ok { - return core.E("jobrunner.Journal.Append", "marshal entry returned invalid payload", nil) + return core.E(sonarJournalJobrunnerJournalAppend, "marshal entry returned invalid payload", nil) } filePath := core.Path(j.baseDir, ts.Format("2006"), ts.Format("01"), ts.Format("02")+".jsonl") @@ -111,26 +115,28 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error { fs := (&core.Fs{}).NewUnrestricted() if r := fs.EnsureDir(core.PathDir(filePath)); !r.OK { - return core.E("jobrunner.Journal.Append", "create directories", resultCause(r)) + return core.E(sonarJournalJobrunnerJournalAppend, "create directories", resultCause(r)) } if !fs.Exists(filePath) { if r := fs.WriteMode(filePath, "", 0o600); !r.OK { - return core.E("jobrunner.Journal.Append", "create journal", resultCause(r)) + return core.E(sonarJournalJobrunnerJournalAppend, "create journal", resultCause(r)) } } openResult := fs.Append(filePath) if !openResult.OK { - return core.E("jobrunner.Journal.Append", "open journal", resultCause(openResult)) + return core.E(sonarJournalJobrunnerJournalAppend, "open journal", resultCause(openResult)) } f, ok := openResult.Value.(journalWriteCloser) if !ok { - return core.E("jobrunner.Journal.Append", "open journal returned invalid writer", nil) + return core.E(sonarJournalJobrunnerJournalAppend, "open journal returned invalid writer", nil) } - defer f.Close() + defer func() { + _ = f.Close() + }() if _, err := f.Write(append(payload, '\n')); err != nil { - return core.E("jobrunner.Journal.Append", "write journal", err) + return core.E(sonarJournalJobrunnerJournalAppend, "write journal", err) } return nil } diff --git a/jobrunner/journal_test.go b/go/jobrunner/journal_test.go similarity index 100% rename from jobrunner/journal_test.go rename to go/jobrunner/journal_test.go diff --git a/jobrunner/poller.go b/go/jobrunner/poller.go similarity index 69% rename from jobrunner/poller.go rename to go/jobrunner/poller.go index 81f8ee9..b9c0289 100644 --- a/jobrunner/poller.go +++ b/go/jobrunner/poller.go @@ -14,6 +14,10 @@ import ( core "dappco.re/go" ) +const ( + sonarPollerJobrunnerPollerRunonce = "jobrunner.Poller.RunOnce" +) + // Poller discovers signals from sources and dispatches them to handlers. type Poller struct { mu sync.RWMutex @@ -138,43 +142,8 @@ func (p *Poller) RunOnce(ctx context.Context) error { if source == nil { continue } - - signals, err := source.Poll(ctx) - if err != nil { - return core.E("jobrunner.Poller.RunOnce", core.Sprintf("poll %s", source.Name()), err) - } - - for _, signal := range signals { - if err := ctx.Err(); err != nil { - return err - } - if signal == nil { - continue - } - - handler := firstMatchingHandler(handlers, signal) - if handler == nil { - continue - } - if dryRun { - continue - } - - result, err := handler.Execute(ctx, signal) - if err != nil { - return core.E("jobrunner.Poller.RunOnce", core.Sprintf("execute %s", handler.Name()), err) - } - if result == nil { - return core.E("jobrunner.Poller.RunOnce", "handler returned nil result", nil) - } - if journal != nil { - if err := journal.Append(signal, result); err != nil { - return err - } - } - if err := source.Report(ctx, result); err != nil { - return core.E("jobrunner.Poller.RunOnce", core.Sprintf("report %s", source.Name()), err) - } + if err := p.runSource(ctx, source, handlers, journal, dryRun); err != nil { + return err } } @@ -184,6 +153,53 @@ func (p *Poller) RunOnce(ctx context.Context) error { return nil } +func (p *Poller) runSource(ctx context.Context, source JobSource, handlers []JobHandler, journal *Journal, dryRun bool) error { + signals, err := source.Poll(ctx) + if err != nil { + return core.E(sonarPollerJobrunnerPollerRunonce, core.Sprintf("poll %s", source.Name()), err) + } + for _, signal := range signals { + if err := ctx.Err(); err != nil { + return err + } + if signal == nil { + continue + } + if err := dispatchSignal(ctx, source, handlers, journal, dryRun, signal); err != nil { + return err + } + } + return nil +} + +func dispatchSignal(ctx context.Context, source JobSource, handlers []JobHandler, journal *Journal, dryRun bool, signal *PipelineSignal) error { + handler := firstMatchingHandler(handlers, signal) + if handler == nil || dryRun { + return nil + } + result, err := handler.Execute(ctx, signal) + if err != nil { + return core.E(sonarPollerJobrunnerPollerRunonce, core.Sprintf("execute %s", handler.Name()), err) + } + if result == nil { + return core.E(sonarPollerJobrunnerPollerRunonce, "handler returned nil result", nil) + } + if err := appendJournal(journal, signal, result); err != nil { + return err + } + if err := source.Report(ctx, result); err != nil { + return core.E(sonarPollerJobrunnerPollerRunonce, core.Sprintf("report %s", source.Name()), err) + } + return nil +} + +func appendJournal(journal *Journal, signal *PipelineSignal, result *ActionResult) error { + if journal == nil { + return nil + } + return journal.Append(signal, result) +} + func (p *Poller) snapshot() ([]JobSource, []JobHandler, *Journal, bool) { if p == nil { return nil, nil, nil, false diff --git a/jobrunner/poller_test.go b/go/jobrunner/poller_test.go similarity index 100% rename from jobrunner/poller_test.go rename to go/jobrunner/poller_test.go diff --git a/jobrunner/types.go b/go/jobrunner/types.go similarity index 100% rename from jobrunner/types.go rename to go/jobrunner/types.go diff --git a/manifest/compile.go b/go/manifest/compile.go similarity index 100% rename from manifest/compile.go rename to go/manifest/compile.go diff --git a/manifest/compile_test.go b/go/manifest/compile_test.go similarity index 100% rename from manifest/compile_test.go rename to go/manifest/compile_test.go diff --git a/manifest/loader.go b/go/manifest/loader.go similarity index 100% rename from manifest/loader.go rename to go/manifest/loader.go diff --git a/manifest/manifest.go b/go/manifest/manifest.go similarity index 100% rename from manifest/manifest.go rename to go/manifest/manifest.go diff --git a/manifest/manifest_test.go b/go/manifest/manifest_test.go similarity index 87% rename from manifest/manifest_test.go rename to go/manifest/manifest_test.go index 2902ea3..444dc19 100644 --- a/manifest/manifest_test.go +++ b/go/manifest/manifest_test.go @@ -12,16 +12,25 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarManifestTestCodeGoScmNameCore = "code: go-scm\nname: Core SCM\nversion: 0.9.0\n" + sonarManifestTestCoreIO = "Core I/O" + sonarManifestTestCoreManifestYaml = ".core/manifest.yaml" + sonarManifestTestGoScm = "go-scm" + sonarManifestTestLinuxAmd64 = "linux/amd64" + sonarManifestTestSignV = "sign: %v" +) + func TestManifest_Compile_Good(t *testing.T) { m := &Manifest{ Code: "go-io", - Name: "Core I/O", + Name: sonarManifestTestCoreIO, Description: "I/O provider", Version: "0.3.0", Modules: []string{"scm"}, } info := BuildInfo{ - Targets: []string{"linux/amd64", "darwin/arm64"}, + Targets: []string{sonarManifestTestLinuxAmd64, "darwin/arm64"}, Checksums: "checksums.txt", SHA256: "7b1f", } @@ -50,7 +59,7 @@ func TestManifest_Compile_Good(t *testing.T) { } func TestManifest_Compile_Bad_InvalidManifest(t *testing.T) { - _, err := Compile(&Manifest{Name: "Core I/O", Version: "0.3.0"}, BuildInfo{}) + _, err := Compile(&Manifest{Name: sonarManifestTestCoreIO, Version: "0.3.0"}, BuildInfo{}) if err == nil { t.Fatal("expected invalid manifest error") } @@ -59,10 +68,10 @@ func TestManifest_Compile_Bad_InvalidManifest(t *testing.T) { func TestManifest_ParseCoreJSON_Good(t *testing.T) { raw, err := Compile(&Manifest{ Code: "go-io", - Name: "Core I/O", + Name: sonarManifestTestCoreIO, Version: "0.3.0", }, BuildInfo{ - Targets: []string{"linux/amd64"}, + Targets: []string{sonarManifestTestLinuxAmd64}, Checksums: "checksums.txt", SHA256: "7b1f", }) @@ -77,7 +86,7 @@ func TestManifest_ParseCoreJSON_Good(t *testing.T) { if got.Code != "go-io" || got.Build.SHA256 != "7b1f" { t.Fatalf("unexpected parsed manifest: %#v", got) } - if len(got.Build.Targets) != 1 || got.Build.Targets[0] != "linux/amd64" { + if len(got.Build.Targets) != 1 || got.Build.Targets[0] != sonarManifestTestLinuxAmd64 { t.Fatalf("unexpected parsed build targets: %#v", got.Build.Targets) } } @@ -98,7 +107,7 @@ func TestManifest_Verify_Good(t *testing.T) { payload := []byte(`{"code":"go-io","version":"0.3.0"}`) if err := Sign(m, payload, priv); err != nil { - t.Fatalf("sign: %v", err) + t.Fatalf(sonarManifestTestSignV, err) } if m.Sign == "" { t.Fatal("expected signature to be populated") @@ -121,7 +130,7 @@ func TestManifest_Verify_Bad_WrongKey(t *testing.T) { payload := []byte(`{"code":"go-io","version":"0.3.0"}`) if err := Sign(m, payload, priv); err != nil { - t.Fatalf("sign: %v", err) + t.Fatalf(sonarManifestTestSignV, err) } if err := Verify(m, payload); err == nil { t.Fatal("expected wrong key verification to fail") @@ -137,7 +146,7 @@ func TestManifest_Verify_Bad_TamperedPayload(t *testing.T) { payload := []byte(`{"code":"go-io","version":"0.3.0"}`) if err := Sign(m, payload, priv); err != nil { - t.Fatalf("sign: %v", err) + t.Fatalf(sonarManifestTestSignV, err) } if err := Verify(m, []byte(`{"code":"go-io","version":"0.3.1"}`)); err == nil { t.Fatal("expected tampered payload verification to fail") @@ -145,13 +154,13 @@ func TestManifest_Verify_Bad_TamperedPayload(t *testing.T) { } func ax7Manifest() *Manifest { - return &Manifest{Code: "go-scm", Name: "Core SCM", Version: "0.9.0"} + return &Manifest{Code: sonarManifestTestGoScm, Name: "Core SCM", Version: "0.9.0"} } func TestManifestV090_Parse_Good(t *core.T) { - m, err := Parse([]byte("code: go-scm\nname: Core SCM\nversion: 0.9.0\n")) + m, err := Parse([]byte(sonarManifestTestCodeGoScmNameCore)) core.AssertNoError(t, err) - core.AssertEqual(t, "go-scm", m.Code) + core.AssertEqual(t, sonarManifestTestGoScm, m.Code) } func TestManifestV090_Parse_Bad(t *core.T) { @@ -252,11 +261,11 @@ func TestManifestV090_Compile_Ugly(t *core.T) { } func TestManifestV090_ParseCoreJSON_Good(t *core.T) { - raw, err := Compile(ax7Manifest(), BuildInfo{Targets: []string{"linux/amd64"}}) + raw, err := Compile(ax7Manifest(), BuildInfo{Targets: []string{sonarManifestTestLinuxAmd64}}) core.RequireNoError(t, err) m, err := ParseCoreJSON(raw) core.AssertNoError(t, err) - core.AssertEqual(t, "go-scm", m.Code) + core.AssertEqual(t, sonarManifestTestGoScm, m.Code) } func TestManifestV090_ParseCoreJSON_Bad(t *core.T) { @@ -279,7 +288,7 @@ func TestManifestV090_CompileWithOptions_Good(t *core.T) { } func TestManifestV090_CompileWithOptions_Bad(t *core.T) { - _, err := CompileWithOptions(&Manifest{Code: "go-scm"}, CompileOptions{}) + _, err := CompileWithOptions(&Manifest{Code: sonarManifestTestGoScm}, CompileOptions{}) core.AssertError( t, err, ) @@ -366,7 +375,7 @@ func TestManifestV090_WriteCompiled_Good(t *core.T) { core.AssertNoError(t, err) raw, readErr := medium.Read("pkg/core.json") core.AssertNoError(t, readErr) - core.AssertContains(t, raw, "go-scm") + core.AssertContains(t, raw, sonarManifestTestGoScm) } func TestManifestV090_WriteCompiled_Bad(t *core.T) { @@ -384,10 +393,10 @@ func TestManifestV090_WriteCompiled_Ugly(t *core.T) { func TestManifestV090_Load_Good(t *core.T) { medium := coreio.NewMemoryMedium() - core.RequireNoError(t, medium.Write(".core/manifest.yaml", "code: go-scm\nname: Core SCM\nversion: 0.9.0\n")) + core.RequireNoError(t, medium.Write(sonarManifestTestCoreManifestYaml, sonarManifestTestCodeGoScmNameCore)) m, err := Load(medium, ".") core.AssertNoError(t, err) - core.AssertEqual(t, "go-scm", m.Code) + core.AssertEqual(t, sonarManifestTestGoScm, m.Code) } func TestManifestV090_Load_Bad(t *core.T) { @@ -414,15 +423,15 @@ func TestManifestV090_LoadVerified_Good(t *core.T) { raw, err := MarshalYAML(m) core.RequireNoError(t, err) medium := coreio.NewMemoryMedium() - core.RequireNoError(t, medium.Write(".core/manifest.yaml", string(raw))) + core.RequireNoError(t, medium.Write(sonarManifestTestCoreManifestYaml, string(raw))) got, err := LoadVerified(medium, ".", pub) core.AssertNoError(t, err) - core.AssertEqual(t, "go-scm", got.Code) + core.AssertEqual(t, sonarManifestTestGoScm, got.Code) } func TestManifestV090_LoadVerified_Bad(t *core.T) { medium := coreio.NewMemoryMedium() - core.RequireNoError(t, medium.Write(".core/manifest.yaml", "code: go-scm\nname: Core SCM\nversion: 0.9.0\n")) + core.RequireNoError(t, medium.Write(sonarManifestTestCoreManifestYaml, sonarManifestTestCodeGoScmNameCore)) _, err := LoadVerified(medium, ".", nil) core.AssertError(t, err) } @@ -437,7 +446,7 @@ func TestManifestV090_LoadVerified_Ugly(t *core.T) { func TestManifestV090_MarshalYAML_Good(t *core.T) { raw, err := MarshalYAML(ax7Manifest()) core.AssertNoError(t, err) - core.AssertContains(t, string(raw), "go-scm") + core.AssertContains(t, string(raw), sonarManifestTestGoScm) } func TestManifestV090_MarshalYAML_Bad(t *core.T) { diff --git a/manifest/sign.go b/go/manifest/sign.go similarity index 65% rename from manifest/sign.go rename to go/manifest/sign.go index 5f22748..e1622aa 100644 --- a/manifest/sign.go +++ b/go/manifest/sign.go @@ -11,6 +11,10 @@ import ( core "dappco.re/go" ) +const ( + sonarSignManifestVerify = "manifest.Verify" +) + func canonicalManifestBytes(m *Manifest) ([]byte, error) { if err := validateManifest(m); err != nil { return nil, err @@ -35,30 +39,30 @@ func Sign(m *Manifest, payload []byte, priv ed25519.PrivateKey) error { func Verify(m *Manifest, payload []byte) error { if m == nil { - return core.E("manifest.Verify", "manifest is required", nil) + return core.E(sonarSignManifestVerify, "manifest is required", nil) } if m.SignKey == "" { - return core.E("manifest.Verify", "sign key is required", nil) + return core.E(sonarSignManifestVerify, "sign key is required", nil) } if m.Sign == "" { - return core.E("manifest.Verify", "signature is required", nil) + return core.E(sonarSignManifestVerify, "signature is required", nil) } pub, err := base64.StdEncoding.DecodeString(m.SignKey) if err != nil { - return core.E("manifest.Verify", "decode sign key", err) + return core.E(sonarSignManifestVerify, "decode sign key", err) } sig, err := base64.StdEncoding.DecodeString(m.Sign) if err != nil { - return core.E("manifest.Verify", "decode signature", err) + return core.E(sonarSignManifestVerify, "decode signature", err) } if len(pub) != ed25519.PublicKeySize { - return core.E("manifest.Verify", "invalid sign key", nil) + return core.E(sonarSignManifestVerify, "invalid sign key", nil) } if len(sig) != ed25519.SignatureSize { - return core.E("manifest.Verify", "invalid signature", nil) + return core.E(sonarSignManifestVerify, "invalid signature", nil) } if !ed25519.Verify(ed25519.PublicKey(pub), payload, sig) { - return core.E("manifest.Verify", "signature verification failed", nil) + return core.E(sonarSignManifestVerify, "signature verification failed", nil) } return nil } diff --git a/marketplace/builder.go b/go/marketplace/builder.go similarity index 56% rename from marketplace/builder.go rename to go/marketplace/builder.go index 01fb846..244a153 100644 --- a/marketplace/builder.go +++ b/go/marketplace/builder.go @@ -4,6 +4,7 @@ package marketplace import ( "errors" + "io/fs" "path/filepath" "strings" @@ -22,37 +23,56 @@ func BuildFromManifests(manifests []*manifest.Manifest) *Index { } func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) { + manifests, err := loadManifestsFromDirs(dirs) + if err != nil { + return nil, err + } + idx := BuildIndexFromManifests(manifests) + b.fillModuleRepos(idx) + return idx, nil +} + +func loadManifestsFromDirs(dirs []string) ([]*manifest.Manifest, error) { var manifests []*manifest.Manifest for _, dir := range dirs { entries, err := osx.ReadDir(dir) if err != nil { return nil, err } - for _, entry := range entries { - if entry == nil || !entry.IsDir() { - continue - } - root := filepath.Join(dir, entry.Name()) - if m, err := loadManifestFromRoot(root); err == nil && m != nil { - if b.BaseURL != "" && m.Code != "" { - // repo path is derived below in BuildFromManifests - } - manifests = append(manifests, m) - continue - } + manifests = append(manifests, loadManifestsFromEntries(dir, entries)...) + } + return manifests, nil +} + +func loadManifestsFromEntries(dir string, entries []fs.DirEntry) []*manifest.Manifest { + var manifests []*manifest.Manifest + for _, entry := range entries { + if entry == nil || !entry.IsDir() { + continue + } + root := filepath.Join(dir, entry.Name()) + if m, err := loadManifestFromRoot(root); err == nil && m != nil { + manifests = append(manifests, m) } } - idx := BuildIndexFromManifests(manifests) + return manifests +} + +func (b *Builder) fillModuleRepos(idx *Index) { for i := range idx.Modules { - if idx.Modules[i].Repo == "" && idx.Modules[i].Code != "" && b.BaseURL != "" { - org := b.Org - if org == "" { - org = "core" - } - idx.Modules[i].Repo = strings.TrimRight(b.BaseURL, "/") + "/" + org + "/" + idx.Modules[i].Code + if idx.Modules[i].Repo != "" || idx.Modules[i].Code == "" || b.BaseURL == "" { + continue } + idx.Modules[i].Repo = b.moduleRepo(idx.Modules[i].Code) } - return idx, nil +} + +func (b *Builder) moduleRepo(code string) string { + org := b.Org + if org == "" { + org = "core" + } + return strings.TrimRight(b.BaseURL, "/") + "/" + org + "/" + code } func loadManifestFromRoot(root string) (*manifest.Manifest, error) { diff --git a/marketplace/discovery.go b/go/marketplace/discovery.go similarity index 100% rename from marketplace/discovery.go rename to go/marketplace/discovery.go diff --git a/marketplace/discovery_test.go b/go/marketplace/discovery_test.go similarity index 100% rename from marketplace/discovery_test.go rename to go/marketplace/discovery_test.go diff --git a/marketplace/index.go b/go/marketplace/index.go similarity index 100% rename from marketplace/index.go rename to go/marketplace/index.go diff --git a/marketplace/index_test.go b/go/marketplace/index_test.go similarity index 76% rename from marketplace/index_test.go rename to go/marketplace/index_test.go index 2daa3eb..3ff910a 100644 --- a/marketplace/index_test.go +++ b/go/marketplace/index_test.go @@ -9,6 +9,10 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarIndexTestMarketplaceIndexJson = "marketplace/index.json" +) + type readWriteFileMedium struct { *coreio.MockMedium readFileCalled bool @@ -28,11 +32,11 @@ func (m *readWriteFileMedium) WriteFile(path string, data []byte, _ fs.FileMode) func TestLoadIndexUsesReadFile(t *testing.T) { medium := &readWriteFileMedium{MockMedium: coreio.NewMockMedium()} - if err := medium.Write("marketplace/index.json", `{"version":1,"modules":[{"code":"go-io","name":"Core I/O"}]}`); err != nil { + if err := medium.Write(sonarIndexTestMarketplaceIndexJson, `{"version":1,"modules":[{"code":"go-io","name":"Core I/O"}]}`); err != nil { t.Fatalf("seed index: %v", err) } - idx, err := LoadIndex(medium, "marketplace/index.json") + idx, err := LoadIndex(medium, sonarIndexTestMarketplaceIndexJson) if err != nil { t.Fatalf("LoadIndex: %v", err) } @@ -47,7 +51,7 @@ func TestLoadIndexUsesReadFile(t *testing.T) { func TestWriteIndexToMediumUsesWriteFile(t *testing.T) { medium := &readWriteFileMedium{MockMedium: coreio.NewMockMedium()} - if err := WriteIndexToMedium(medium, "marketplace/index.json", &Index{ + if err := WriteIndexToMedium(medium, sonarIndexTestMarketplaceIndexJson, &Index{ Version: 1, Modules: []Module{{Code: "go-io", Name: "Core I/O"}}, }); err != nil { @@ -56,7 +60,7 @@ func TestWriteIndexToMediumUsesWriteFile(t *testing.T) { if !medium.writeFileCalled { t.Fatalf("expected WriteIndexToMedium to use WriteFile") } - if !medium.IsFile("marketplace/index.json") { + if !medium.IsFile(sonarIndexTestMarketplaceIndexJson) { t.Fatalf("expected index file to be written") } } diff --git a/marketplace/installer.go b/go/marketplace/installer.go similarity index 94% rename from marketplace/installer.go rename to go/marketplace/installer.go index 5186542..9f5ff3f 100644 --- a/marketplace/installer.go +++ b/go/marketplace/installer.go @@ -14,6 +14,10 @@ import ( "dappco.re/go/scm/manifest" ) +const ( + sonarInstallerModuleJson = "module.json" +) + type InstalledModule struct { Code string `json:"code"` Name string `json:"name"` @@ -65,7 +69,7 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { if err != nil { return err } - if err := writeMediumFile(i.medium, filepath.Join(i.modulesDir, mod.Code, "module.json"), raw); err != nil { + if err := writeMediumFile(i.medium, filepath.Join(i.modulesDir, mod.Code, sonarInstallerModuleJson), raw); err != nil { return err } return nil @@ -101,7 +105,7 @@ func (i *Installer) Installed() ([]InstalledModule, error) { if entry == nil || !entry.IsDir() { continue } - raw, err := readMediumFile(i.medium, filepath.Join(i.modulesDir, entry.Name(), "module.json")) + raw, err := readMediumFile(i.medium, filepath.Join(i.modulesDir, entry.Name(), sonarInstallerModuleJson)) if err != nil { continue } @@ -136,7 +140,7 @@ func (i *Installer) Update(ctx context.Context, code string) error { if i.medium == nil { return errors.New("marketplace.Installer.Update: medium is required") } - path := filepath.Join(i.modulesDir, code, "module.json") + path := filepath.Join(i.modulesDir, code, sonarInstallerModuleJson) raw, err := readMediumFile(i.medium, path) if err != nil { return err diff --git a/marketplace/installer_test.go b/go/marketplace/installer_test.go similarity index 100% rename from marketplace/installer_test.go rename to go/marketplace/installer_test.go diff --git a/marketplace/marketplace.go b/go/marketplace/marketplace.go similarity index 100% rename from marketplace/marketplace.go rename to go/marketplace/marketplace.go diff --git a/marketplace/marketplace_test.go b/go/marketplace/marketplace_test.go similarity index 91% rename from marketplace/marketplace_test.go rename to go/marketplace/marketplace_test.go index 5c4d6f0..c470550 100644 --- a/marketplace/marketplace_test.go +++ b/go/marketplace/marketplace_test.go @@ -15,6 +15,12 @@ import ( "dappco.re/go/scm/manifest" ) +const ( + sonarMarketplaceTestIndexJson = "index.json" + sonarMarketplaceTestMarketplaceIndexJson = "marketplace/index.json" + sonarMarketplaceTestProvidersYaml = "providers.yaml" +) + func TestBuildIndexFromManifestsCarriesSignKey(t *testing.T) { idx := BuildIndexFromManifests([]*manifest.Manifest{ { @@ -187,7 +193,7 @@ func TestMarketplace_Builder_BuildFromDirs_Ugly(t *core.T) { } func TestMarketplace_WriteIndex_Good(t *core.T) { - path := filepath.Join(t.TempDir(), "index.json") + path := filepath.Join(t.TempDir(), sonarMarketplaceTestIndexJson) err := WriteIndex(path, &Index{Version: 1, Modules: []Module{{Code: "demo"}}}) core.AssertNoError(t, err) raw, readErr := os.ReadFile(path) @@ -196,14 +202,14 @@ func TestMarketplace_WriteIndex_Good(t *core.T) { } func TestMarketplace_WriteIndex_Bad(t *core.T) { - err := WriteIndex(filepath.Join(t.TempDir(), "index.json"), nil) + err := WriteIndex(filepath.Join(t.TempDir(), sonarMarketplaceTestIndexJson), nil) core.AssertError( t, err, ) } func TestMarketplace_WriteIndex_Ugly(t *core.T) { - err := WriteIndex(filepath.Join(t.TempDir(), "missing", "index.json"), &Index{Version: 1}) + err := WriteIndex(filepath.Join(t.TempDir(), "missing", sonarMarketplaceTestIndexJson), &Index{Version: 1}) core.AssertError( t, err, ) @@ -211,14 +217,14 @@ func TestMarketplace_WriteIndex_Ugly(t *core.T) { func TestMarketplace_LoadIndex_Good(t *core.T) { medium := coreio.NewMemoryMedium() - core.RequireNoError(t, medium.Write("marketplace/index.json", `{"version":1,"modules":[{"code":"demo"}]}`)) - idx, err := LoadIndex(medium, "marketplace/index.json") + core.RequireNoError(t, medium.Write(sonarMarketplaceTestMarketplaceIndexJson, `{"version":1,"modules":[{"code":"demo"}]}`)) + idx, err := LoadIndex(medium, sonarMarketplaceTestMarketplaceIndexJson) core.AssertNoError(t, err) core.AssertEqual(t, "demo", idx.Modules[0].Code) } func TestMarketplace_LoadIndex_Bad(t *core.T) { - _, err := LoadIndex(nil, "marketplace/index.json") + _, err := LoadIndex(nil, sonarMarketplaceTestMarketplaceIndexJson) core.AssertError( t, err, ) @@ -232,22 +238,22 @@ func TestMarketplace_LoadIndex_Ugly(t *core.T) { func TestMarketplace_WriteIndexToMedium_Good(t *core.T) { medium := coreio.NewMemoryMedium() - err := WriteIndexToMedium(medium, "marketplace/index.json", &Index{Version: 1, Modules: []Module{{Code: "demo"}}}) + err := WriteIndexToMedium(medium, sonarMarketplaceTestMarketplaceIndexJson, &Index{Version: 1, Modules: []Module{{Code: "demo"}}}) core.AssertNoError(t, err) - raw, readErr := medium.Read("marketplace/index.json") + raw, readErr := medium.Read(sonarMarketplaceTestMarketplaceIndexJson) core.RequireNoError(t, readErr) core.AssertContains(t, raw, "demo") } func TestMarketplace_WriteIndexToMedium_Bad(t *core.T) { - err := WriteIndexToMedium(nil, "marketplace/index.json", &Index{Version: 1}) + err := WriteIndexToMedium(nil, sonarMarketplaceTestMarketplaceIndexJson, &Index{Version: 1}) core.AssertError( t, err, ) } func TestMarketplace_WriteIndexToMedium_Ugly(t *core.T) { - err := WriteIndexToMedium(coreio.NewMemoryMedium(), "marketplace/index.json", nil) + err := WriteIndexToMedium(coreio.NewMemoryMedium(), sonarMarketplaceTestMarketplaceIndexJson, nil) core.AssertError( t, err, ) @@ -485,7 +491,7 @@ func TestMarketplace_DiscoverProviders_Ugly(t *core.T) { } func TestMarketplace_LoadProviderRegistry_Good(t *core.T) { - path := filepath.Join(t.TempDir(), "providers.yaml") + path := filepath.Join(t.TempDir(), sonarMarketplaceTestProvidersYaml) core.RequireNoError(t, os.WriteFile(path, []byte("version: 1\nproviders:\n demo:\n version: 1.0.0\n"), 0o600)) reg, err := LoadProviderRegistry(path) core.AssertNoError(t, err) @@ -501,14 +507,14 @@ func TestMarketplace_LoadProviderRegistry_Bad(t *core.T) { } func TestMarketplace_LoadProviderRegistry_Ugly(t *core.T) { - path := filepath.Join(t.TempDir(), "providers.yaml") + path := filepath.Join(t.TempDir(), sonarMarketplaceTestProvidersYaml) core.RequireNoError(t, os.WriteFile(path, []byte("providers: ["), 0o600)) _, err := LoadProviderRegistry(path) core.AssertError(t, err) } func TestMarketplace_SaveProviderRegistry_Good(t *core.T) { - path := filepath.Join(t.TempDir(), "providers.yaml") + path := filepath.Join(t.TempDir(), sonarMarketplaceTestProvidersYaml) reg := &ProviderRegistryFile{Version: 1, Providers: map[string]ProviderRegistryEntry{"demo": {Version: "1.0.0"}}} err := SaveProviderRegistry(path, reg) core.AssertNoError(t, err) @@ -518,14 +524,14 @@ func TestMarketplace_SaveProviderRegistry_Good(t *core.T) { } func TestMarketplace_SaveProviderRegistry_Bad(t *core.T) { - err := SaveProviderRegistry(filepath.Join(t.TempDir(), "missing", "providers.yaml"), &ProviderRegistryFile{}) + err := SaveProviderRegistry(filepath.Join(t.TempDir(), "missing", sonarMarketplaceTestProvidersYaml), &ProviderRegistryFile{}) core.AssertError( t, err, ) } func TestMarketplace_SaveProviderRegistry_Ugly(t *core.T) { - path := filepath.Join(t.TempDir(), "providers.yaml") + path := filepath.Join(t.TempDir(), sonarMarketplaceTestProvidersYaml) err := SaveProviderRegistry(path, nil) core.AssertNoError(t, err) raw, readErr := os.ReadFile(path) diff --git a/pkg/api/doc.go b/go/pkg/api/doc.go similarity index 100% rename from pkg/api/doc.go rename to go/pkg/api/doc.go diff --git a/pkg/api/embed.go b/go/pkg/api/embed.go similarity index 100% rename from pkg/api/embed.go rename to go/pkg/api/embed.go diff --git a/pkg/api/provider.go b/go/pkg/api/provider.go similarity index 91% rename from pkg/api/provider.go rename to go/pkg/api/provider.go index 2e836e9..26ecc35 100644 --- a/pkg/api/provider.go +++ b/go/pkg/api/provider.go @@ -7,19 +7,21 @@ import ( "net/http" core "dappco.re/go" - coreio "dappco.re/go/io" "dappco.re/go/scm/marketplace" "dappco.re/go/scm/repos" "dappco.re/go/ws" "github.com/gin-gonic/gin" ) +const ( + sonarProviderModuleNotFound = "module not found" +) + type ScmProvider struct { index *marketplace.Index installer *marketplace.Installer registry *repos.Registry hub *ws.Hub - medium coreio.Medium } type elementSpec struct { @@ -86,7 +88,7 @@ func (p *ScmProvider) getMarketplaceModule(c *gin.Context) { return } if p == nil || p.index == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": sonarProviderModuleNotFound}) return } code := core.Trim(c.Param("code")) @@ -96,7 +98,7 @@ func (p *ScmProvider) getMarketplaceModule(c *gin.Context) { } mod, ok := p.index.Find(code) if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": sonarProviderModuleNotFound}) return } c.JSON(http.StatusOK, mod) @@ -160,7 +162,7 @@ func (p *ScmProvider) getInstalledModule(c *gin.Context) { return } if p == nil || p.installer == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": sonarProviderModuleNotFound}) return } modules, err := p.installer.Installed() @@ -174,5 +176,5 @@ func (p *ScmProvider) getInstalledModule(c *gin.Context) { return } } - c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": sonarProviderModuleNotFound}) } diff --git a/pkg/api/provider_test.go b/go/pkg/api/provider_test.go similarity index 89% rename from pkg/api/provider_test.go rename to go/pkg/api/provider_test.go index a850e59..f168c13 100644 --- a/pkg/api/provider_test.go +++ b/go/pkg/api/provider_test.go @@ -3,6 +3,7 @@ package api import ( + "context" "crypto/ed25519" "encoding/base64" "encoding/json" @@ -19,6 +20,12 @@ import ( "github.com/gin-gonic/gin" ) +const ( + sonarProviderTestCoreIO = "Core I/O" + sonarProviderTestCoreScm = "core-scm" + sonarProviderTestUiAppJs = "ui/app.js" +) + func TestScmProviderRoutesExposeState(t *testing.T) { gin.SetMode(gin.TestMode) @@ -26,10 +33,11 @@ func TestScmProviderRoutesExposeState(t *testing.T) { installer := marketplace.NewInstaller(medium, "modules") mod := signedMarketplaceModule(t, marketplace.Module{ Code: "go-io", - Name: "Core I/O", + Name: sonarProviderTestCoreIO, Repo: "ssh://git@example.com/core/go-io.git", }) - if err := installer.Install(nil, mod); err != nil { + var ctx context.Context + if err := installer.Install(ctx, mod); err != nil { t.Fatalf("install module: %v", err) } @@ -56,11 +64,11 @@ func TestScmProviderRoutesExposeState(t *testing.T) { assertRouteOK(t, router, "/scm/health", "ok") assertRouteOK(t, router, "/scm/ui", "Source control operations") assertRouteOK(t, router, "/scm/marketplace", "go-io") - assertRouteOK(t, router, "/scm/marketplace/go-io", "Core I/O") + assertRouteOK(t, router, "/scm/marketplace/go-io", sonarProviderTestCoreIO) assertRouteOK(t, router, "/scm/repos", "go-io") assertRouteOK(t, router, "/scm/repos/go-io", "/workspace/core/go-io") assertRouteOK(t, router, "/scm/modules", "go-io") - assertRouteOK(t, router, "/scm/modules/go-io", "Core I/O") + assertRouteOK(t, router, "/scm/modules/go-io", sonarProviderTestCoreIO) } func TestScmProviderMetadataExposesStreamAndElement(t *testing.T) { @@ -71,10 +79,10 @@ func TestScmProviderMetadataExposesStreamAndElement(t *testing.T) { } element := provider.Element() - if element.Tag != "core-scm" { + if element.Tag != sonarProviderTestCoreScm { t.Fatalf("unexpected element tag: %q", element.Tag) } - if element.Source != "ui/app.js" { + if element.Source != sonarProviderTestUiAppJs { t.Fatalf("unexpected element source: %q", element.Source) } } @@ -212,18 +220,18 @@ func TestProvider_ScmProvider_Channels_Ugly(t *core.T) { func TestProvider_ScmProvider_Element_Good(t *core.T) { provider := NewProvider(nil, nil, nil, nil) got := provider.Element() - core.AssertEqual(t, "core-scm", got.Tag) - core.AssertEqual(t, "ui/app.js", got.Source) + core.AssertEqual(t, sonarProviderTestCoreScm, got.Tag) + core.AssertEqual(t, sonarProviderTestUiAppJs, got.Source) } func TestProvider_ScmProvider_Element_Bad(t *core.T) { provider := &ScmProvider{} got := provider.Element() - core.AssertEqual(t, "core-scm", got.Tag) + core.AssertEqual(t, sonarProviderTestCoreScm, got.Tag) } func TestProvider_ScmProvider_Element_Ugly(t *core.T) { var provider *ScmProvider got := provider.Element() - core.AssertEqual(t, "ui/app.js", got.Source) + core.AssertEqual(t, sonarProviderTestUiAppJs, got.Source) } diff --git a/pkg/api/ui/app.css b/go/pkg/api/ui/app.css similarity index 100% rename from pkg/api/ui/app.css rename to go/pkg/api/ui/app.css diff --git a/go/pkg/api/ui/app.js b/go/pkg/api/ui/app.js new file mode 100644 index 0000000..da38ada --- /dev/null +++ b/go/pkg/api/ui/app.js @@ -0,0 +1,4 @@ +const statusElement = document.getElementById("status"); +if (statusElement) { + statusElement.textContent = "Embedded UI loaded"; +} diff --git a/pkg/api/ui/index.html b/go/pkg/api/ui/index.html similarity index 100% rename from pkg/api/ui/index.html rename to go/pkg/api/ui/index.html diff --git a/plugin/config.go b/go/plugin/config.go similarity index 100% rename from plugin/config.go rename to go/plugin/config.go diff --git a/plugin/installer.go b/go/plugin/installer.go similarity index 100% rename from plugin/installer.go rename to go/plugin/installer.go diff --git a/plugin/installer_test.go b/go/plugin/installer_test.go similarity index 82% rename from plugin/installer_test.go rename to go/plugin/installer_test.go index 7689a3c..d36b7ee 100644 --- a/plugin/installer_test.go +++ b/go/plugin/installer_test.go @@ -13,6 +13,10 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarInstallerTestPluginsRegistryJson = "plugins/registry.json" +) + func TestInstallerPersistsInstallUpdateAndRemove(t *testing.T) { medium := coreio.NewMockMedium() registry := NewRegistry(medium, "plugins") @@ -22,7 +26,7 @@ func TestInstallerPersistsInstallUpdateAndRemove(t *testing.T) { t.Fatalf("Install: %v", err) } - raw, ok := medium.Files["plugins/registry.json"] + raw, ok := medium.Files[sonarInstallerTestPluginsRegistryJson] if !ok { t.Fatalf("expected registry to be saved after install") } @@ -35,7 +39,7 @@ func TestInstallerPersistsInstallUpdateAndRemove(t *testing.T) { t.Fatalf("Update: %v", err) } - after := medium.Files["plugins/registry.json"] + after := medium.Files[sonarInstallerTestPluginsRegistryJson] if before == after { t.Fatalf("expected update to change persisted registry") } @@ -43,7 +47,7 @@ func TestInstallerPersistsInstallUpdateAndRemove(t *testing.T) { if err := inst.Remove("foo"); err != nil { t.Fatalf("Remove: %v", err) } - final := medium.Files["plugins/registry.json"] + final := medium.Files[sonarInstallerTestPluginsRegistryJson] if strings.Contains(final, `"foo"`) { t.Fatalf("expected plugin entry to be removed: %s", final) } diff --git a/plugin/loader.go b/go/plugin/loader.go similarity index 100% rename from plugin/loader.go rename to go/plugin/loader.go diff --git a/plugin/manifest.go b/go/plugin/manifest.go similarity index 100% rename from plugin/manifest.go rename to go/plugin/manifest.go diff --git a/plugin/plugin.go b/go/plugin/plugin.go similarity index 100% rename from plugin/plugin.go rename to go/plugin/plugin.go diff --git a/plugin/plugin_test.go b/go/plugin/plugin_test.go similarity index 89% rename from plugin/plugin_test.go rename to go/plugin/plugin_test.go index 8f3ba6d..be5c35e 100644 --- a/plugin/plugin_test.go +++ b/go/plugin/plugin_test.go @@ -9,6 +9,12 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarPluginTestCorePluginV1 = "core/plugin@v1" + sonarPluginTestPluginsDemoPluginJson = "plugins/demo/plugin.json" + sonarPluginTestPluginsRegistryJson = "plugins/registry.json" +) + func ax7PluginManifestJSON(name string) string { return `{"name":"` + name + `","version":"1.0.0","entrypoint":"plugin.so"}` } @@ -71,7 +77,8 @@ func TestPlugin_BasePlugin_Init_Bad(t *core.T) { func TestPlugin_BasePlugin_Init_Ugly(t *core.T) { var plugin *BasePlugin - err := plugin.Init(nil) + var ctx context.Context + err := plugin.Init(ctx) core.AssertNoError(t, err) } @@ -91,7 +98,8 @@ func TestPlugin_BasePlugin_Start_Bad(t *core.T) { func TestPlugin_BasePlugin_Start_Ugly(t *core.T) { var plugin *BasePlugin - err := plugin.Start(nil) + var ctx context.Context + err := plugin.Start(ctx) core.AssertNoError(t, err) } @@ -111,7 +119,8 @@ func TestPlugin_BasePlugin_Stop_Bad(t *core.T) { func TestPlugin_BasePlugin_Stop_Ugly(t *core.T) { var plugin *BasePlugin - err := plugin.Stop(nil) + var ctx context.Context + err := plugin.Stop(ctx) core.AssertNoError(t, err) } @@ -137,14 +146,14 @@ func TestPlugin_Manifest_Validate_Ugly(t *core.T) { func TestPlugin_LoadManifest_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/demo/plugin.json", ax7PluginManifestJSON("demo"))) - manifest, err := LoadManifest(medium, "plugins/demo/plugin.json") + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsDemoPluginJson, ax7PluginManifestJSON("demo"))) + manifest, err := LoadManifest(medium, sonarPluginTestPluginsDemoPluginJson) core.AssertNoError(t, err) core.AssertEqual(t, "demo", manifest.Name) } func TestPlugin_LoadManifest_Bad(t *core.T) { - _, err := LoadManifest(nil, "plugins/demo/plugin.json") + _, err := LoadManifest(nil, sonarPluginTestPluginsDemoPluginJson) core.AssertError( t, err, ) @@ -152,8 +161,8 @@ func TestPlugin_LoadManifest_Bad(t *core.T) { func TestPlugin_LoadManifest_Ugly(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/demo/plugin.json", `{"name":"demo"}`)) - _, err := LoadManifest(medium, "plugins/demo/plugin.json") + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsDemoPluginJson, `{"name":"demo"}`)) + _, err := LoadManifest(medium, sonarPluginTestPluginsDemoPluginJson) core.AssertError(t, err) } @@ -178,7 +187,7 @@ func TestPlugin_NewLoader_Ugly(t *core.T) { func TestPlugin_Loader_Discover_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/demo/plugin.json", ax7PluginManifestJSON("demo"))) + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsDemoPluginJson, ax7PluginManifestJSON("demo"))) loader := NewLoader(medium, "plugins") got, err := loader.Discover() core.AssertNoError(t, err) @@ -201,7 +210,7 @@ func TestPlugin_Loader_Discover_Ugly(t *core.T) { func TestPlugin_Loader_LoadPlugin_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/demo/plugin.json", ax7PluginManifestJSON("demo"))) + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsDemoPluginJson, ax7PluginManifestJSON("demo"))) loader := NewLoader(medium, "plugins") manifest, err := loader.LoadPlugin("demo") core.AssertNoError(t, err) @@ -302,7 +311,7 @@ func TestPlugin_Registry_List_Ugly(t *core.T) { func TestPlugin_Registry_Load_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/registry.json", `{"plugins":{"demo":{"name":"demo"}}}`)) + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsRegistryJson, `{"plugins":{"demo":{"name":"demo"}}}`)) registry := NewRegistry(medium, "plugins") err := registry.Load() core.AssertNoError(t, err) @@ -319,7 +328,7 @@ func TestPlugin_Registry_Load_Bad(t *core.T) { func TestPlugin_Registry_Load_Ugly(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("plugins/registry.json", `{`)) + core.RequireNoError(t, medium.Write(sonarPluginTestPluginsRegistryJson, `{`)) registry := NewRegistry(medium, "plugins") err := registry.Load() core.AssertError(t, err) @@ -352,7 +361,7 @@ func TestPlugin_Registry_Save_Good(t *core.T) { core.RequireNoError(t, registry.Add(&PluginConfig{Name: "demo"})) err := registry.Save() core.AssertNoError(t, err) - raw, readErr := medium.Read("plugins/registry.json") + raw, readErr := medium.Read(sonarPluginTestPluginsRegistryJson) core.RequireNoError(t, readErr) core.AssertContains(t, raw, "demo") } @@ -390,7 +399,7 @@ func TestPlugin_NewInstaller_Ugly(t *core.T) { } func TestPlugin_ParseSource_Good(t *core.T) { - org, repo, version, err := ParseSource("core/plugin@v1") + org, repo, version, err := ParseSource(sonarPluginTestCorePluginV1) core.AssertNoError(t, err) core.AssertEqual(t, "core", org) core.AssertEqual(t, "plugin", repo) @@ -415,7 +424,7 @@ func TestPlugin_ParseSource_Ugly(t *core.T) { func TestPlugin_Installer_Install_Good(t *core.T) { registry := NewRegistry(coreio.NewMockMedium(), "plugins") installer := NewInstaller(coreio.NewMockMedium(), registry) - err := installer.Install(context.Background(), "core/plugin@v1") + err := installer.Install(context.Background(), sonarPluginTestCorePluginV1) core.AssertNoError(t, err) cfg, ok := registry.Get("plugin") core.AssertTrue(t, ok) @@ -439,7 +448,7 @@ func TestPlugin_Installer_Install_Ugly(t *core.T) { func TestPlugin_Installer_Remove_Good(t *core.T) { registry := NewRegistry(coreio.NewMockMedium(), "plugins") installer := NewInstaller(coreio.NewMockMedium(), registry) - core.RequireNoError(t, installer.Install(context.Background(), "core/plugin@v1")) + core.RequireNoError(t, installer.Install(context.Background(), sonarPluginTestCorePluginV1)) err := installer.Remove("plugin") core.AssertNoError(t, err) _, ok := registry.Get("plugin") @@ -462,7 +471,7 @@ func TestPlugin_Installer_Remove_Ugly(t *core.T) { func TestPlugin_Installer_Update_Good(t *core.T) { registry := NewRegistry(coreio.NewMockMedium(), "plugins") installer := NewInstaller(coreio.NewMockMedium(), registry) - core.RequireNoError(t, installer.Install(context.Background(), "core/plugin@v1")) + core.RequireNoError(t, installer.Install(context.Background(), sonarPluginTestCorePluginV1)) err := installer.Update(context.Background(), "plugin") core.AssertNoError(t, err) } diff --git a/plugin/registry.go b/go/plugin/registry.go similarity index 100% rename from plugin/registry.go rename to go/plugin/registry.go diff --git a/repos/gitstate.go b/go/repos/gitstate.go similarity index 100% rename from repos/gitstate.go rename to go/repos/gitstate.go diff --git a/repos/kbconfig.go b/go/repos/kbconfig.go similarity index 100% rename from repos/kbconfig.go rename to go/repos/kbconfig.go diff --git a/repos/registry.go b/go/repos/registry.go similarity index 68% rename from repos/registry.go rename to go/repos/registry.go index 4cc8e70..d193412 100644 --- a/repos/registry.go +++ b/go/repos/registry.go @@ -18,6 +18,12 @@ import ( "gopkg.in/yaml.v3" ) +const ( + sonarRegistryMediumIsRequired = "medium is required" + sonarRegistryReposRegistrySyncrepo = "repos.Registry.SyncRepo" + sonarRegistryReposYaml = "repos.yaml" +) + type RepoType string type RegistryDefaults struct { @@ -131,49 +137,59 @@ func (r *Registry) TopologicalOrder() ([]*Repo, error) { if len(repos) == 0 { return nil, nil } + byName := reposByName(repos) + var ordered []*Repo + seen := map[string]bool{} + for _, repo := range repos { + if err := visitRepo(repo.Name, byName, seen, nil, &ordered); err != nil { + return nil, err + } + } + return ordered, nil +} + +func reposByName(repos []*Repo) map[string]*Repo { byName := make(map[string]*Repo, len(repos)) for _, repo := range repos { byName[repo.Name] = repo } - var ordered []*Repo - seen := map[string]bool{} - var visit func(string, map[string]bool) error - visit = func(name string, stack map[string]bool) error { - if seen[name] { - return nil - } - if stack[name] { - return core.E("repos.Registry.TopologicalOrder", "dependency cycle", nil) - } - repo := byName[name] - if repo == nil { - return nil - } - if stack == nil { - stack = make(map[string]bool) - } - stack[name] = true - for _, dep := range repo.DependsOn { - if err := visit(dep, stack); err != nil { - return err - } - } - delete(stack, name) - seen[name] = true - ordered = append(ordered, repo) + return byName +} + +func visitRepo(name string, byName map[string]*Repo, seen map[string]bool, stack map[string]bool, ordered *[]*Repo) error { + if seen[name] { return nil } - for _, repo := range repos { - if err := visit(repo.Name, nil); err != nil { - return nil, err + if stack[name] { + return core.E("repos.Registry.TopologicalOrder", "dependency cycle", nil) + } + repo := byName[name] + if repo == nil { + return nil + } + nextStack := repoVisitStack(stack, name) + for _, dep := range repo.DependsOn { + if err := visitRepo(dep, byName, seen, nextStack, ordered); err != nil { + return err } } - return ordered, nil + delete(nextStack, name) + seen[name] = true + *ordered = append(*ordered, repo) + return nil +} + +func repoVisitStack(stack map[string]bool, name string) map[string]bool { + if stack == nil { + stack = make(map[string]bool) + } + stack[name] = true + return stack } func LoadRegistry(m coreio.Medium, path string) (*Registry, error) { if m == nil { - return nil, core.E("repos.LoadRegistry", "medium is required", nil) + return nil, core.E("repos.LoadRegistry", sonarRegistryMediumIsRequired, nil) } raw, err := m.Read(path) if err != nil { @@ -202,42 +218,57 @@ func LoadRegistry(m coreio.Medium, path string) (*Registry, error) { func FindRegistry(m coreio.Medium) (string, error) { if m == nil { - return "", core.E("repos.FindRegistry", "medium is required", nil) - } - candidates := []string{"repos.yaml", filepathx.Join(".core", "repos.yaml")} - if env := core.Trim(osx.Getenv("CORE_REPOS")); env != "" { - for _, candidate := range core.Split(env, core.Env("PS")) { - candidate = core.Trim(candidate) - if candidate != "" { - candidates = append([]string{candidate}, candidates...) - } - } + return "", core.E("repos.FindRegistry", sonarRegistryMediumIsRequired, nil) } - if cwd, err := osx.Getwd(); err == nil { - dir := cwd - for { - candidates = append(candidates, filepathx.Join(dir, ".core", "repos.yaml")) - parent := filepathx.Dir(dir) - if parent == dir { - break - } - dir = parent + for _, candidate := range registryCandidates() { + if m.Exists(candidate) { + return candidate, nil } } + return "", fs.ErrNotExist +} + +func registryCandidates() []string { + candidates := []string{sonarRegistryReposYaml, filepathx.Join(".core", sonarRegistryReposYaml)} + candidates = prependEnvRegistryCandidates(candidates) + candidates = append(candidates, cwdRegistryCandidates()...) if home, err := osx.UserHomeDir(); err == nil { - candidates = append(candidates, filepathx.Join(home, ".core", "repos.yaml")) + candidates = append(candidates, filepathx.Join(home, ".core", sonarRegistryReposYaml)) } - for _, candidate := range candidates { - if m.Exists(candidate) { - return candidate, nil + return candidates +} + +func prependEnvRegistryCandidates(candidates []string) []string { + env := core.Trim(osx.Getenv("CORE_REPOS")) + if env == "" { + return candidates + } + for _, candidate := range core.Split(env, core.Env("PS")) { + candidate = core.Trim(candidate) + if candidate != "" { + candidates = append([]string{candidate}, candidates...) + } + } + return candidates +} + +func cwdRegistryCandidates() []string { + cwd, err := osx.Getwd() + if err != nil { + return nil + } + var candidates []string + for dir := cwd; ; dir = filepathx.Dir(dir) { + candidates = append(candidates, filepathx.Join(dir, ".core", sonarRegistryReposYaml)) + if parent := filepathx.Dir(dir); parent == dir { + return candidates } } - return "", fs.ErrNotExist } func ScanDirectory(m coreio.Medium, dir string) (*Registry, error) { if m == nil { - return nil, core.E("repos.ScanDirectory", "medium is required", nil) + return nil, core.E("repos.ScanDirectory", sonarRegistryMediumIsRequired, nil) } entries, err := m.List(dir) if err != nil { @@ -274,14 +305,14 @@ func (r *Registry) Save(path string) error { // SyncRepo fetches and resets a named repo to match its Forge remote branch. func (r *Registry) SyncRepo(ctx context.Context, name, remote, branch string) error { if r == nil { - return core.E("repos.Registry.SyncRepo", "registry is required", nil) + return core.E(sonarRegistryReposRegistrySyncrepo, "registry is required", nil) } repo, ok := r.Get(name) if !ok { - return core.E("repos.Registry.SyncRepo", core.Sprintf("repo %q not found", name), nil) + return core.E(sonarRegistryReposRegistrySyncrepo, core.Sprintf("repo %q not found", name), nil) } if repo.Path == "" { - return core.E("repos.Registry.SyncRepo", core.Sprintf("repo %q has no path", name), nil) + return core.E(sonarRegistryReposRegistrySyncrepo, core.Sprintf("repo %q has no path", name), nil) } return git.SyncWithRemote(ctx, repo.Path, remote, branch) } diff --git a/repos/registry_test.go b/go/repos/registry_test.go similarity index 100% rename from repos/registry_test.go rename to go/repos/registry_test.go diff --git a/repos/repos_test.go b/go/repos/repos_test.go similarity index 92% rename from repos/repos_test.go rename to go/repos/repos_test.go index edc7f73..26f5710 100644 --- a/repos/repos_test.go +++ b/go/repos/repos_test.go @@ -13,6 +13,12 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarReposTestCoreWiki = ".core/wiki" + sonarReposTestGoScm = "go-scm" + sonarReposTestReposYaml = "repos.yaml" +) + func ax7ReposRegistry() *Registry { return &Registry{ Version: 1, @@ -168,8 +174,8 @@ func TestRepos_Registry_TopologicalOrder_Ugly(t *core.T) { func TestRepos_LoadRegistry_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("repos.yaml", "version: 1\nbase_path: /work\nrepos:\n core:\n type: library\n")) - registry, err := LoadRegistry(medium, "repos.yaml") + core.RequireNoError(t, medium.Write(sonarReposTestReposYaml, "version: 1\nbase_path: /work\nrepos:\n core:\n type: library\n")) + registry, err := LoadRegistry(medium, sonarReposTestReposYaml) core.AssertNoError(t, err) repo, ok := registry.Get("core") core.AssertTrue(t, ok) @@ -177,7 +183,7 @@ func TestRepos_LoadRegistry_Good(t *core.T) { } func TestRepos_LoadRegistry_Bad(t *core.T) { - _, err := LoadRegistry(nil, "repos.yaml") + _, err := LoadRegistry(nil, sonarReposTestReposYaml) core.AssertError( t, err, ) @@ -185,17 +191,17 @@ func TestRepos_LoadRegistry_Bad(t *core.T) { func TestRepos_LoadRegistry_Ugly(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("repos.yaml", "repos: [")) - _, err := LoadRegistry(medium, "repos.yaml") + core.RequireNoError(t, medium.Write(sonarReposTestReposYaml, "repos: [")) + _, err := LoadRegistry(medium, sonarReposTestReposYaml) core.AssertError(t, err) } func TestRepos_FindRegistry_Good(t *core.T) { medium := coreio.NewMockMedium() - core.RequireNoError(t, medium.Write("repos.yaml", "version: 1\nrepos: {}\n")) + core.RequireNoError(t, medium.Write(sonarReposTestReposYaml, "version: 1\nrepos: {}\n")) path, err := FindRegistry(medium) core.AssertNoError(t, err) - core.AssertEqual(t, "repos.yaml", path) + core.AssertEqual(t, sonarReposTestReposYaml, path) } func TestRepos_FindRegistry_Bad(t *core.T) { @@ -239,22 +245,22 @@ func TestRepos_Registry_Save_Good(t *core.T) { medium := coreio.NewMockMedium() registry := ax7ReposRegistry() registry.medium = medium - err := registry.Save("repos.yaml") + err := registry.Save(sonarReposTestReposYaml) core.AssertNoError(t, err) - raw, readErr := medium.Read("repos.yaml") + raw, readErr := medium.Read(sonarReposTestReposYaml) core.RequireNoError(t, readErr) core.AssertContains(t, raw, "repos:") } func TestRepos_Registry_Save_Bad(t *core.T) { var registry *Registry - err := registry.Save("repos.yaml") + err := registry.Save(sonarReposTestReposYaml) core.AssertError(t, err) } func TestRepos_Registry_Save_Ugly(t *core.T) { registry := ax7ReposRegistry() - path := filepath.Join(t.TempDir(), "repos.yaml") + path := filepath.Join(t.TempDir(), sonarReposTestReposYaml) err := registry.Save(path) core.AssertNoError(t, err) } @@ -593,7 +599,7 @@ func TestRepos_SaveWorkConfig_Ugly(t *core.T) { func TestRepos_DefaultKBConfig_Good(t *core.T) { cfg := DefaultKBConfig() core.AssertEqual(t, 1, cfg.Version) - core.AssertEqual(t, ".core/wiki", cfg.Wiki.Dir) + core.AssertEqual(t, sonarReposTestCoreWiki, cfg.Wiki.Dir) } func TestRepos_DefaultKBConfig_Bad(t *core.T) { @@ -612,38 +618,38 @@ func TestRepos_DefaultKBConfig_Ugly(t *core.T) { func TestRepos_KBConfig_WikiRepoURL_Good(t *core.T) { cfg := &KBConfig{Wiki: WikiConfig{Remote: "https://forge.example/core"}} - got := cfg.WikiRepoURL("go-scm") + got := cfg.WikiRepoURL(sonarReposTestGoScm) core.AssertEqual(t, "https://forge.example/core/go-scm.wiki.git", got) } func TestRepos_KBConfig_WikiRepoURL_Bad(t *core.T) { cfg := &KBConfig{} - got := cfg.WikiRepoURL("go-scm") + got := cfg.WikiRepoURL(sonarReposTestGoScm) core.AssertEqual(t, "", got) } func TestRepos_KBConfig_WikiRepoURL_Ugly(t *core.T) { var cfg *KBConfig - got := cfg.WikiRepoURL("go-scm") + got := cfg.WikiRepoURL(sonarReposTestGoScm) core.AssertEqual(t, "", got) } func TestRepos_KBConfig_WikiLocalPath_Good(t *core.T) { cfg := &KBConfig{Wiki: WikiConfig{Dir: "wiki"}} - got := cfg.WikiLocalPath("/root", "go-scm") - core.AssertEqual(t, filepath.Join("/root", "wiki", "go-scm"), got) + got := cfg.WikiLocalPath("/root", sonarReposTestGoScm) + core.AssertEqual(t, filepath.Join("/root", "wiki", sonarReposTestGoScm), got) } func TestRepos_KBConfig_WikiLocalPath_Bad(t *core.T) { cfg := &KBConfig{} - got := cfg.WikiLocalPath("/root", "go-scm") - core.AssertEqual(t, filepath.Join("/root", ".core/wiki", "go-scm"), got) + got := cfg.WikiLocalPath("/root", sonarReposTestGoScm) + core.AssertEqual(t, filepath.Join("/root", sonarReposTestCoreWiki, sonarReposTestGoScm), got) } func TestRepos_KBConfig_WikiLocalPath_Ugly(t *core.T) { var cfg *KBConfig got := cfg.WikiLocalPath("", "") - core.AssertEqual(t, filepath.Join(".core/wiki"), got) + core.AssertEqual(t, filepath.Join(sonarReposTestCoreWiki), got) } func TestRepos_LoadKBConfig_Good(t *core.T) { @@ -713,7 +719,7 @@ func TestRepos_NewService_Ugly(t *core.T) { func TestRepos_Service_OnStartup_Good(t *core.T) { root := t.TempDir() core.RequireNoError(t, os.MkdirAll(filepath.Join(root, ".core"), 0o755)) - core.RequireNoError(t, os.WriteFile(filepath.Join(root, ".core", "repos.yaml"), []byte("version: 1\nrepos: {}\n"), 0o600)) + core.RequireNoError(t, os.WriteFile(filepath.Join(root, ".core", sonarReposTestReposYaml), []byte("version: 1\nrepos: {}\n"), 0o600)) c := core.New(core.WithService(NewService(ServiceOptions{Root: root}))) result := c.ServiceStartup(context.Background(), nil) core.AssertTrue(t, result.OK) diff --git a/repos/service.go b/go/repos/service.go similarity index 72% rename from repos/service.go rename to go/repos/service.go index c576eb8..76a220b 100644 --- a/repos/service.go +++ b/go/repos/service.go @@ -15,6 +15,12 @@ import ( "gopkg.in/yaml.v3" ) +const ( + sonarServiceReposServiceSyncrepo = "repos.Service.syncRepo" + sonarServiceReposYaml = "repos.yaml" + sonarServiceServiceIsRequired = "service is required" +) + // ServiceOptions configures the repo sync service. type ServiceOptions struct { Root string @@ -121,7 +127,7 @@ func (s *Service) syncWorkspace(ctx context.Context, pushed WorkspacePushed) cor func (s *Service) syncRepo(ctx context.Context, opts core.Options) (*git.SyncResult, error) { if s == nil { - return nil, core.E("repos.Service.syncRepo", "service is required", nil) + return nil, core.E(sonarServiceReposServiceSyncrepo, sonarServiceServiceIsRequired, nil) } if err := ctx.Err(); err != nil { return nil, err @@ -132,43 +138,66 @@ func (s *Service) syncRepo(ctx context.Context, opts core.Options) (*git.SyncRes workspacePath, workspaceOK := workspaceRepoPath(opts, s.Options().Root) if path := core.Trim(opts.String("path")); path != "" { - if err := git.SyncWithRemote(ctx, path, remote, branch); err != nil { - return &git.SyncResult{Name: filepathx.Base(path), Path: path, Success: false, Error: err}, err - } - return &git.SyncResult{Name: filepathx.Base(path), Path: path, Success: true}, nil + return syncPath(ctx, path, filepathx.Base(path), remote, branch) } if repoName := core.Trim(opts.String("repo")); repoName != "" { - reg, err := s.registryForPath(opts.String("root")) - if err != nil && !core.Is(err, fs.ErrNotExist) { - return nil, err - } - if reg != nil { - if repo, ok := reg.Get(repoName); ok { - if err := git.SyncWithRemote(ctx, repo.Path, remote, branch); err != nil { - return &git.SyncResult{Name: repo.Name, Path: repo.Path, Success: false, Error: err}, err - } - return &git.SyncResult{Name: repo.Name, Path: repo.Path, Success: true}, nil - } - } - if workspaceOK { - if err := git.SyncWithRemote(ctx, workspacePath, remote, branch); err != nil { - return &git.SyncResult{Name: repoName, Path: workspacePath, Success: false, Error: err}, err - } - return &git.SyncResult{Name: repoName, Path: workspacePath, Success: true}, nil - } - if reg == nil { - return nil, core.E("repos.Service.syncRepo", "registry not loaded", nil) - } - return nil, core.E("repos.Service.syncRepo", core.Sprintf("repo %q not found in registry", repoName), nil) + return s.syncNamedRepo(ctx, opts, repoName, workspacePath, workspaceOK, remote, branch) + } + + return nil, core.E(sonarServiceReposServiceSyncrepo, "repo or path is required", nil) +} + +func (s *Service) syncNamedRepo( + ctx context.Context, + opts core.Options, + repoName string, + workspacePath string, + workspaceOK bool, + remote string, + branch string, +) (*git.SyncResult, error) { + reg, err := s.registryForPath(opts.String("root")) + if err != nil && !core.Is(err, fs.ErrNotExist) { + return nil, err + } + if result, ok, err := syncRegistryRepo(ctx, reg, repoName, remote, branch); ok || err != nil { + return result, err + } + if workspaceOK { + return syncPath(ctx, workspacePath, repoName, remote, branch) + } + if reg == nil { + return nil, core.E(sonarServiceReposServiceSyncrepo, "registry not loaded", nil) + } + return nil, core.E(sonarServiceReposServiceSyncrepo, core.Sprintf("repo %q not found in registry", repoName), nil) +} + +func syncRegistryRepo(ctx context.Context, reg *Registry, repoName, remote, branch string) (*git.SyncResult, bool, error) { + if reg == nil { + return nil, false, nil + } + repo, ok := reg.Get(repoName) + if !ok { + return nil, false, nil } + result, err := syncPath(ctx, repo.Path, repo.Name, remote, branch) + return result, true, err +} - return nil, core.E("repos.Service.syncRepo", "repo or path is required", nil) +func syncPath(ctx context.Context, path, name, remote, branch string) (*git.SyncResult, error) { + result := &git.SyncResult{Name: name, Path: path, Success: true} + if err := git.SyncWithRemote(ctx, path, remote, branch); err != nil { + result.Success = false + result.Error = err + return result, err + } + return result, nil } func (s *Service) syncAll(ctx context.Context, opts core.Options) ([]SyncResult, error) { if s == nil { - return nil, core.E("repos.Service.syncAll", "service is required", nil) + return nil, core.E("repos.Service.syncAll", sonarServiceServiceIsRequired, nil) } if err := ctx.Err(); err != nil { return nil, err @@ -189,7 +218,7 @@ func (s *Service) syncAll(ctx context.Context, opts core.Options) ([]SyncResult, func (s *Service) registryForPath(root string) (*Registry, error) { if s == nil { - return nil, core.E("repos.Service.registryForPath", "service is required", nil) + return nil, core.E("repos.Service.registryForPath", sonarServiceServiceIsRequired, nil) } if s.registry != nil { return s.registry, nil @@ -224,20 +253,7 @@ func (s *Service) loadRegistryAt(root string) (*Registry, error) { merged = reg continue } - for name, repo := range reg.Repos { - if repo == nil { - continue - } - if _, exists := merged.Repos[name]; exists { - continue - } - cp := *repo - cp.registry = merged - merged.Repos[name] = &cp - } - if merged.BasePath == "" { - merged.BasePath = reg.BasePath - } + mergeRegistry(merged, reg) } if merged == nil { return nil, fs.ErrNotExist @@ -245,20 +261,26 @@ func (s *Service) loadRegistryAt(root string) (*Registry, error) { return merged, nil } -func (s *Service) registryPath(root string) (string, error) { - paths, err := s.registryPaths(root) - if err != nil { - return "", err +func mergeRegistry(merged, reg *Registry) { + for name, repo := range reg.Repos { + if repo == nil { + continue + } + if _, exists := merged.Repos[name]; exists { + continue + } + cp := *repo + cp.registry = merged + merged.Repos[name] = &cp } - if len(paths) == 0 { - return "", fs.ErrNotExist + if merged.BasePath == "" { + merged.BasePath = reg.BasePath } - return paths[0], nil } func (s *Service) registryPaths(root string) ([]string, error) { if s == nil { - return nil, core.E("repos.Service.registryPaths", "service is required", nil) + return nil, core.E("repos.Service.registryPaths", sonarServiceServiceIsRequired, nil) } opts := s.Options() candidates := []string{} @@ -266,22 +288,12 @@ func (s *Service) registryPaths(root string) ([]string, error) { candidates = append(candidates, opts.RegistryPath) return cleanExistingCandidates(candidates), nil } - if root != "" { - candidates = append(candidates, - filepathx.Join(root, ".core", "repos.yaml"), - filepathx.Join(root, "repos.yaml"), - ) - } - if opts.Root != "" { - candidates = append(candidates, - filepathx.Join(opts.Root, ".core", "repos.yaml"), - filepathx.Join(opts.Root, "repos.yaml"), - ) - } + candidates = append(candidates, rootRegistryCandidates(root)...) + candidates = append(candidates, rootRegistryCandidates(opts.Root)...) if cwd, err := osx.Getwd(); err == nil { dir := cwd for { - candidates = append(candidates, filepathx.Join(dir, ".core", "repos.yaml")) + candidates = append(candidates, filepathx.Join(dir, ".core", sonarServiceReposYaml)) parent := filepathx.Dir(dir) if parent == dir { break @@ -290,12 +302,22 @@ func (s *Service) registryPaths(root string) ([]string, error) { } } if home, err := osx.UserHomeDir(); err == nil { - candidates = append(candidates, filepathx.Join(home, ".core", "repos.yaml")) + candidates = append(candidates, filepathx.Join(home, ".core", sonarServiceReposYaml)) } return cleanExistingCandidates(candidates), nil } +func rootRegistryCandidates(root string) []string { + if root == "" { + return nil + } + return []string{ + filepathx.Join(root, ".core", sonarServiceReposYaml), + filepathx.Join(root, sonarServiceReposYaml), + } +} + func cleanExistingCandidates(candidates []string) []string { seen := map[string]struct{}{} paths := make([]string, 0, len(candidates)) diff --git a/repos/service_test.go b/go/repos/service_test.go similarity index 64% rename from repos/service_test.go rename to go/repos/service_test.go index feeed78..69c5ae7 100644 --- a/repos/service_test.go +++ b/go/repos/service_test.go @@ -13,25 +13,42 @@ import ( "gopkg.in/yaml.v3" ) +const ( + sonarServiceTestBare = "--bare" + sonarServiceTestLocalChange = "local change" + sonarServiceTestLocalChanges = "local changes\n" + sonarServiceTestReadSyncedFileV = "read synced file: %v" + sonarServiceTestRemoteGit = "remote.git" + sonarServiceTestRemoteState = "remote state\n" + sonarServiceTestStateTxt = "state.txt" + sonarServiceTestTestExampleCom = "test@example.com" + sonarServiceTestTestUser = "Test User" + sonarServiceTestUnexpectedSyncedFileContentsQ = "unexpected synced file contents: %q" + sonarServiceTestUserEmail = "user.email" + sonarServiceTestUserName = "user.name" + sonarServiceTestWriteLocalChangeV = "write local change: %v" + sonarServiceTestWriteSeedFileV = "write seed file: %v" +) + func TestServiceRegistersRepoSyncActions(t *testing.T) { root := t.TempDir() repoPath := filepath.Join(root, "repo1") - remotePath := filepath.Join(root, "remote.git") - filePath := filepath.Join(repoPath, "state.txt") + remotePath := filepath.Join(root, sonarServiceTestRemoteGit) + filePath := filepath.Join(repoPath, sonarServiceTestStateTxt) - runGitCmd(t, root, "git", "init", "--bare", remotePath) + runGitCmd(t, root, "git", "init", sonarServiceTestBare, remotePath) if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil { t.Fatalf("mkdir workspace parent: %v", err) } runGitCmd(t, root, "git", "clone", remotePath, repoPath) - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.name", "Test User") - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.email", "test@example.com") + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserName, sonarServiceTestTestUser) + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserEmail, sonarServiceTestTestExampleCom) runGitCmd(t, repoPath, "git", "-C", repoPath, "checkout", "-b", "dev") - if err := os.WriteFile(filePath, []byte("remote state\n"), 0o600); err != nil { - t.Fatalf("write seed file: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestRemoteState), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteSeedFileV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "add", "state.txt") + runGitCmd(t, repoPath, "git", "-C", repoPath, "add", sonarServiceTestStateTxt) runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-m", "initial") runGitCmd(t, repoPath, "git", "-C", repoPath, "push", "-u", "origin", "dev") @@ -59,10 +76,10 @@ func TestServiceRegistersRepoSyncActions(t *testing.T) { t.Fatalf("repo.sync.all action was not registered") } - if err := os.WriteFile(filePath, []byte("local changes\n"), 0o600); err != nil { - t.Fatalf("write local change: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestLocalChanges), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteLocalChangeV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", "local change") + runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", sonarServiceTestLocalChange) syncResult := c.Action("repo.sync").Run(context.Background(), core.NewOptions(core.Option{Key: "repo", Value: "repo1"})) if !syncResult.OK { @@ -71,10 +88,10 @@ func TestServiceRegistersRepoSyncActions(t *testing.T) { raw, err := os.ReadFile(filePath) if err != nil { - t.Fatalf("read synced file: %v", err) + t.Fatalf(sonarServiceTestReadSyncedFileV, err) } - if got := string(raw); got != "remote state\n" { - t.Fatalf("unexpected synced file contents: %q", got) + if got := string(raw); got != sonarServiceTestRemoteState { + t.Fatalf(sonarServiceTestUnexpectedSyncedFileContentsQ, got) } if err := os.WriteFile(filePath, []byte("local changes again\n"), 0o600); err != nil { @@ -90,7 +107,7 @@ func TestServiceRegistersRepoSyncActions(t *testing.T) { if err != nil { t.Fatalf("read ipc-synced file: %v", err) } - if got := string(raw); got != "remote state\n" { + if got := string(raw); got != sonarServiceTestRemoteState { t.Fatalf("unexpected ipc synced file contents: %q", got) } } @@ -163,19 +180,19 @@ func TestServiceSyncRepoFallsBackToWorkspacePath(t *testing.T) { org := "core" repoName := "repo1" repoPath := filepath.Join(root, org, repoName) - remotePath := filepath.Join(root, "remote.git") - filePath := filepath.Join(repoPath, "state.txt") + remotePath := filepath.Join(root, sonarServiceTestRemoteGit) + filePath := filepath.Join(repoPath, sonarServiceTestStateTxt) - runGitCmd(t, root, "git", "init", "--bare", remotePath) + runGitCmd(t, root, "git", "init", sonarServiceTestBare, remotePath) runGitCmd(t, root, "git", "clone", remotePath, repoPath) - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.name", "Test User") - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.email", "test@example.com") + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserName, sonarServiceTestTestUser) + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserEmail, sonarServiceTestTestExampleCom) runGitCmd(t, repoPath, "git", "-C", repoPath, "checkout", "-b", "dev") - if err := os.WriteFile(filePath, []byte("remote state\n"), 0o600); err != nil { - t.Fatalf("write seed file: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestRemoteState), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteSeedFileV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "add", "state.txt") + runGitCmd(t, repoPath, "git", "-C", repoPath, "add", sonarServiceTestStateTxt) runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-m", "initial") runGitCmd(t, repoPath, "git", "-C", repoPath, "push", "-u", "origin", "dev") @@ -184,10 +201,10 @@ func TestServiceSyncRepoFallsBackToWorkspacePath(t *testing.T) { RegistryPath: filepath.Join(root, ".core", "missing.yaml"), })} - if err := os.WriteFile(filePath, []byte("local changes\n"), 0o600); err != nil { - t.Fatalf("write local change: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestLocalChanges), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteLocalChangeV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", "local change") + runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", sonarServiceTestLocalChange) result, err := svc.syncRepo(context.Background(), core.NewOptions( core.Option{Key: "root", Value: root}, @@ -205,10 +222,10 @@ func TestServiceSyncRepoFallsBackToWorkspacePath(t *testing.T) { raw, err := os.ReadFile(filePath) if err != nil { - t.Fatalf("read synced file: %v", err) + t.Fatalf(sonarServiceTestReadSyncedFileV, err) } - if got := string(raw); got != "remote state\n" { - t.Fatalf("unexpected synced file contents: %q", got) + if got := string(raw); got != sonarServiceTestRemoteState { + t.Fatalf(sonarServiceTestUnexpectedSyncedFileContentsQ, got) } } @@ -218,19 +235,19 @@ func TestServiceSyncRepoFallsBackWhenRegistryMissesRepo(t *testing.T) { org := "core" repoName := "repo1" repoPath := filepath.Join(root, org, repoName) - remotePath := filepath.Join(root, "remote.git") - filePath := filepath.Join(repoPath, "state.txt") + remotePath := filepath.Join(root, sonarServiceTestRemoteGit) + filePath := filepath.Join(repoPath, sonarServiceTestStateTxt) - runGitCmd(t, root, "git", "init", "--bare", remotePath) + runGitCmd(t, root, "git", "init", sonarServiceTestBare, remotePath) runGitCmd(t, root, "git", "clone", remotePath, repoPath) - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.name", "Test User") - runGitCmd(t, repoPath, "git", "-C", repoPath, "config", "user.email", "test@example.com") + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserName, sonarServiceTestTestUser) + runGitCmd(t, repoPath, "git", "-C", repoPath, "config", sonarServiceTestUserEmail, sonarServiceTestTestExampleCom) runGitCmd(t, repoPath, "git", "-C", repoPath, "checkout", "-b", "dev") - if err := os.WriteFile(filePath, []byte("remote state\n"), 0o600); err != nil { - t.Fatalf("write seed file: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestRemoteState), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteSeedFileV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "add", "state.txt") + runGitCmd(t, repoPath, "git", "-C", repoPath, "add", sonarServiceTestStateTxt) runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-m", "initial") runGitCmd(t, repoPath, "git", "-C", repoPath, "push", "-u", "origin", "dev") @@ -251,10 +268,10 @@ func TestServiceSyncRepoFallsBackWhenRegistryMissesRepo(t *testing.T) { Remote: "origin", })} - if err := os.WriteFile(filePath, []byte("local changes\n"), 0o600); err != nil { - t.Fatalf("write local change: %v", err) + if err := os.WriteFile(filePath, []byte(sonarServiceTestLocalChanges), 0o600); err != nil { + t.Fatalf(sonarServiceTestWriteLocalChangeV, err) } - runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", "local change") + runGitCmd(t, repoPath, "git", "-C", repoPath, "commit", "-am", sonarServiceTestLocalChange) result, err := svc.syncRepo(context.Background(), core.NewOptions( core.Option{Key: "root", Value: root}, @@ -275,10 +292,10 @@ func TestServiceSyncRepoFallsBackWhenRegistryMissesRepo(t *testing.T) { raw, err := os.ReadFile(filePath) if err != nil { - t.Fatalf("read synced file: %v", err) + t.Fatalf(sonarServiceTestReadSyncedFileV, err) } - if got := string(raw); got != "remote state\n" { - t.Fatalf("unexpected synced file contents: %q", got) + if got := string(raw); got != sonarServiceTestRemoteState { + t.Fatalf(sonarServiceTestUnexpectedSyncedFileContentsQ, got) } } diff --git a/repos/workconfig.go b/go/repos/workconfig.go similarity index 100% rename from repos/workconfig.go rename to go/repos/workconfig.go diff --git a/scm.go b/go/scm.go similarity index 64% rename from scm.go rename to go/scm.go index c1a6cfe..b624f74 100644 --- a/scm.go +++ b/go/scm.go @@ -76,53 +76,65 @@ func NewCoreService(opts Options) func(*core.Core) core.Result { if c == nil { return core.Fail(errors.New("scm.NewCoreService: core is required")) } - - if opts.RegistryPath != "" || opts.Root != "" || opts.Remote != "" || opts.Branch != "" { - repoResult := repos.NewService(repos.ServiceOptions{ - Root: opts.Root, - RegistryPath: opts.RegistryPath, - Remote: opts.Remote, - Branch: opts.Branch, - })(c) - if !repoResult.OK { - return repoResult - } - if repoResult.Value != nil && !c.Service("repos").OK { - if r := c.RegisterService("repos", repoResult.Value); !r.OK { - return r - } - } + if result := registerReposService(c, opts); !result.OK { + return result } - - if opts.Root != "" { - gitResult := git.NewService(git.ServiceOptions{WorkDir: opts.Root})(c) - if !gitResult.OK { - return gitResult - } - if gitResult.Value != nil && !c.Service("git").OK { - if r := c.RegisterService("git", gitResult.Value); !r.OK { - return r - } - } + if result := registerGitService(c, opts); !result.OK { + return result } - return core.Ok(&Service{ServiceRuntime: core.NewServiceRuntime(c, opts)}) } } -// OnStartup satisfies the Core lifecycle contract. -func (s *Service) OnStartup(ctx context.Context) core.Result { - if s == nil { +func registerReposService(c *core.Core, opts Options) core.Result { + if opts.RegistryPath == "" && opts.Root == "" && opts.Remote == "" && opts.Branch == "" { return core.Ok(nil) } - if err := ctx.Err(); err != nil { - return core.Fail(err) + result := repos.NewService(repos.ServiceOptions{ + Root: opts.Root, + RegistryPath: opts.RegistryPath, + Remote: opts.Remote, + Branch: opts.Branch, + })(c) + if !result.OK { + return result } - return core.Ok(nil) + return registerServiceIfMissing(c, "repos", result.Value) +} + +func registerGitService(c *core.Core, opts Options) core.Result { + if opts.Root == "" { + return core.Ok(nil) + } + result := git.NewService(git.ServiceOptions{WorkDir: opts.Root})(c) + if !result.OK { + return result + } + return registerServiceIfMissing(c, "git", result.Value) +} + +func registerServiceIfMissing(c *core.Core, name string, service any) core.Result { + if service == nil || c.Service(name).OK { + return core.Ok(nil) + } + return c.RegisterService(name, service) +} + +// OnStartup satisfies the Core lifecycle contract. +func (s *Service) OnStartup(ctx context.Context) core.Result { + return serviceStartupResult(s, ctx) } // OnShutdown satisfies the Core lifecycle contract. func (s *Service) OnShutdown(ctx context.Context) core.Result { + return serviceLifecycleResult(s, ctx) +} + +func serviceStartupResult(s *Service, ctx context.Context) core.Result { + return serviceLifecycleResult(s, ctx) +} + +func serviceLifecycleResult(s *Service, ctx context.Context) core.Result { if s == nil { return core.Ok(nil) } diff --git a/scm_test.go b/go/scm_test.go similarity index 96% rename from scm_test.go rename to go/scm_test.go index 48736d1..a4ebbc1 100644 --- a/scm_test.go +++ b/go/scm_test.go @@ -12,6 +12,10 @@ import ( coreio "dappco.re/go/io" ) +const ( + sonarScmTestExpectedRegistry = "expected registry" +) + func TestScm_NewCoreService_Good(t *testing.T) { c := core.New(core.WithService(NewCoreService(Options{Root: t.TempDir()}))) if r := c.ServiceStartup(context.Background(), nil); !r.OK { @@ -66,7 +70,7 @@ func TestScm_WithMedium_Good(t *testing.T) { registry := NewRegistry(WithMedium(medium)) if registry == nil { - t.Fatal("expected registry") + t.Fatal(sonarScmTestExpectedRegistry) } if registry.Medium() != medium { t.Fatalf("expected registry medium to be preserved") @@ -76,7 +80,7 @@ func TestScm_WithMedium_Good(t *testing.T) { func TestScm_WithMedium_Bad(t *testing.T) { registry := NewRegistry(WithMedium(nil)) if registry == nil { - t.Fatal("expected registry") + t.Fatal(sonarScmTestExpectedRegistry) } if registry.Medium() != nil { t.Fatalf("expected nil medium to be ignored") @@ -88,7 +92,7 @@ func TestScm_WithMedium_Ugly(t *testing.T) { registry := NewRegistry(WithMedium(medium), WithMedium(nil)) if registry == nil { - t.Fatal("expected registry") + t.Fatal(sonarScmTestExpectedRegistry) } if registry.Medium() != medium { t.Fatalf("expected nil medium option not to clear an existing medium") diff --git a/tests/cli/collect/Taskfile.yaml b/go/tests/cli/collect/Taskfile.yaml similarity index 99% rename from tests/cli/collect/Taskfile.yaml rename to go/tests/cli/collect/Taskfile.yaml index df33e68..45d197c 100644 --- a/tests/cli/collect/Taskfile.yaml +++ b/go/tests/cli/collect/Taskfile.yaml @@ -1,3 +1,4 @@ +--- version: "3" tasks: diff --git a/tests/cli/forge/Taskfile.yaml b/go/tests/cli/forge/Taskfile.yaml similarity index 99% rename from tests/cli/forge/Taskfile.yaml rename to go/tests/cli/forge/Taskfile.yaml index 9dbdd62..7607950 100644 --- a/tests/cli/forge/Taskfile.yaml +++ b/go/tests/cli/forge/Taskfile.yaml @@ -1,3 +1,4 @@ +--- version: "3" tasks: diff --git a/tests/cli/gitea/Taskfile.yaml b/go/tests/cli/gitea/Taskfile.yaml similarity index 89% rename from tests/cli/gitea/Taskfile.yaml rename to go/tests/cli/gitea/Taskfile.yaml index da6e112..6664ce1 100644 --- a/tests/cli/gitea/Taskfile.yaml +++ b/go/tests/cli/gitea/Taskfile.yaml @@ -1,3 +1,4 @@ +--- version: "3" tasks: @@ -31,7 +32,8 @@ tasks: "$bin" repos --help >/dev/null "$bin" issues --help >/dev/null if [ -z "${GITEA_TOKEN:-}" ] || [ -z "${GITEA_SMOKE_REPO:-}" ]; then - echo "Skipping gitea authenticated smoke: GITEA_TOKEN/GITEA_SMOKE_REPO unset" + echo "Skipping gitea authenticated smoke: \ + GITEA_TOKEN/GITEA_SMOKE_REPO unset" exit 0 fi "$bin" repos >/dev/null diff --git a/tests/cli/scm/Taskfile.yaml b/go/tests/cli/scm/Taskfile.yaml similarity index 99% rename from tests/cli/scm/Taskfile.yaml rename to go/tests/cli/scm/Taskfile.yaml index f303603..229b2ca 100644 --- a/tests/cli/scm/Taskfile.yaml +++ b/go/tests/cli/scm/Taskfile.yaml @@ -1,3 +1,4 @@ +--- version: "3" tasks: diff --git a/third_party/forgejo/forgejo/types.go b/go/third_party/forgejo/forgejo/types.go similarity index 100% rename from third_party/forgejo/forgejo/types.go rename to go/third_party/forgejo/forgejo/types.go diff --git a/third_party/forgejo/go.mod b/go/third_party/forgejo/go.mod similarity index 100% rename from third_party/forgejo/go.mod rename to go/third_party/forgejo/go.mod diff --git a/pkg/api/ui/app.js b/pkg/api/ui/app.js deleted file mode 100644 index ab9373c..0000000 --- a/pkg/api/ui/app.js +++ /dev/null @@ -1,4 +0,0 @@ -const status = document.getElementById("status"); -if (status) { - status.textContent = "Embedded UI loaded"; -} diff --git a/sonar-project.properties b/sonar-project.properties index 7295a35..8594031 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,4 +5,4 @@ sonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/n sonar.tests=. sonar.test.inclusions=**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js sonar.test.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/** -sonar.go.coverage.reportPaths=coverage.out +sonar.go.coverage.reportPaths=go/coverage.out