diff --git a/agentci/clotho.go b/agentci/clotho.go index 41ce261..a9ac28c 100644 --- a/agentci/clotho.go +++ b/agentci/clotho.go @@ -1,27 +1,34 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package agentci import ( "context" - "strings" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "dappco.re/go/core/scm/jobrunner" ) // RunMode determines the execution strategy for a dispatched task. +// type RunMode string const ( + // ModeStandard RunMode = "standard" - ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification + // + ModeDual RunMode = "dual" // The Clotho Protocol — dual-run verification ) // Spinner is the Clotho orchestrator that determines the fate of each task. +// type Spinner struct { Config ClothoConfig Agents map[string]AgentConfig } // NewSpinner creates a new Clotho orchestrator. +// func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner { return &Spinner{ Config: cfg, diff --git a/agentci/config.go b/agentci/config.go index a7386b2..0f2485f 100644 --- a/agentci/config.go +++ b/agentci/config.go @@ -1,14 +1,17 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package agentci provides configuration, security, and orchestration for AgentCI dispatch targets. package agentci import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/config" coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/config" ) // AgentConfig represents a single agent machine in the config file. +// type AgentConfig struct { Host string `yaml:"host" mapstructure:"host"` QueueDir string `yaml:"queue_dir" mapstructure:"queue_dir"` @@ -23,6 +26,7 @@ type AgentConfig struct { } // ClothoConfig controls the orchestration strategy. +// type ClothoConfig struct { Strategy string `yaml:"strategy" mapstructure:"strategy"` // direct, clotho-verified ValidationThreshold float64 `yaml:"validation_threshold" mapstructure:"validation_threshold"` // divergence limit (0.0-1.0) @@ -31,6 +35,7 @@ type ClothoConfig struct { // LoadAgents reads agent targets from config and returns a map of AgentConfig. // Returns an empty map (not an error) if no agents are configured. +// func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) { var agents map[string]AgentConfig if err := cfg.Get("agentci.agents", &agents); err != nil { @@ -61,6 +66,7 @@ func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) { } // LoadActiveAgents returns only active agents. +// func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) { all, err := LoadAgents(cfg) if err != nil { @@ -77,6 +83,7 @@ func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) { // LoadClothoConfig loads the Clotho orchestrator settings. // Returns sensible defaults if no config is present. +// func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) { var cc ClothoConfig if err := cfg.Get("agentci.clotho", &cc); err != nil { @@ -95,6 +102,7 @@ func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) { } // SaveAgent writes an agent config entry to the config file. +// func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error { key := fmt.Sprintf("agentci.agents.%s", name) data := map[string]any{ @@ -123,6 +131,7 @@ func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error { } // RemoveAgent removes an agent from the config file. +// func RemoveAgent(cfg *config.Config, name string) error { var agents map[string]AgentConfig if err := cfg.Get("agentci.agents", &agents); err != nil { @@ -136,6 +145,7 @@ func RemoveAgent(cfg *config.Config, name string) error { } // ListAgents returns all configured agents (active and inactive). +// func ListAgents(cfg *config.Config) (map[string]AgentConfig, error) { var agents map[string]AgentConfig if err := cfg.Get("agentci.agents", &agents); err != nil { diff --git a/agentci/config_test.go b/agentci/config_test.go index 034ff09..18c5db6 100644 --- a/agentci/config_test.go +++ b/agentci/config_test.go @@ -3,8 +3,8 @@ package agentci import ( "testing" - "forge.lthn.ai/core/config" "dappco.re/go/core/io" + "forge.lthn.ai/core/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -299,7 +299,7 @@ func TestListAgents_Good_Empty(t *testing.T) { assert.Empty(t, agents) } -func TestRoundTrip_SaveThenLoad(t *testing.T) { +func TestRoundTrip_Good_SaveThenLoad(t *testing.T) { cfg := newTestConfig(t, "") err := SaveAgent(cfg, "alpha", AgentConfig{ diff --git a/agentci/security.go b/agentci/security.go index 52c106c..a77de6d 100644 --- a/agentci/security.go +++ b/agentci/security.go @@ -1,36 +1,150 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package agentci import ( - "os/exec" - "path/filepath" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" + "path" "regexp" - "strings" coreerr "dappco.re/go/core/log" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" ) var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`) // SanitizePath ensures a filename or directory name is safe and prevents path traversal. -// Returns filepath.Base of the input after validation. +// Returns the validated input unchanged. +// func SanitizePath(input string) (string, error) { - base := filepath.Base(input) - if !safeNameRegex.MatchString(base) { + if input == "" { + return "", coreerr.E("agentci.SanitizePath", "path element is required", nil) + } + if strings.ContainsAny(input, `/\`) { + return "", coreerr.E("agentci.SanitizePath", "path separators are not allowed: "+input, nil) + } + if input == "." || input == ".." { + return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+input, nil) + } + if !safeNameRegex.MatchString(input) { return "", coreerr.E("agentci.SanitizePath", "invalid characters in path element: "+input, nil) } - if base == "." || base == ".." || base == "/" { - return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+base, nil) + return input, nil +} + +// ValidatePathElement validates a single local path element and returns its safe form. +// +func ValidatePathElement(input string) (string, error) { + return SanitizePath(input) +} + +// ResolvePathWithinRoot resolves a validated path element beneath a root directory. +// +func ResolvePathWithinRoot(root string, input string) (string, string, error) { + safeName, err := ValidatePathElement(input) + if err != nil { + return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "invalid path element", err) } - return base, nil + + absRoot, err := filepath.Abs(root) + if err != nil { + return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolve root", err) + } + + resolved := filepath.Clean(filepath.Join(absRoot, safeName)) + cleanRoot := filepath.Clean(absRoot) + rootPrefix := cleanRoot + string(filepath.Separator) + if resolved != cleanRoot && !strings.HasPrefix(resolved, rootPrefix) { + return "", "", coreerr.E("agentci.ResolvePathWithinRoot", "resolved path escaped root", nil) + } + + return safeName, resolved, nil +} + +// ValidateRemoteDir validates a remote directory path used over SSH. +// +func ValidateRemoteDir(dir string) (string, error) { + if strings.TrimSpace(dir) == "" { + return "", coreerr.E("agentci.ValidateRemoteDir", "directory is required", nil) + } + if strings.ContainsAny(dir, `\`) { + return "", coreerr.E("agentci.ValidateRemoteDir", "backslashes are not allowed", nil) + } + + switch dir { + case "/", "~": + return dir, nil + } + + cleaned := path.Clean(dir) + prefix := "" + rest := cleaned + + if strings.HasPrefix(dir, "~/") { + prefix = "~/" + rest = strings.TrimPrefix(cleaned, "~/") + } + if strings.HasPrefix(dir, "/") { + prefix = "/" + rest = strings.TrimPrefix(cleaned, "/") + } + + if rest == "." || rest == ".." || strings.HasPrefix(rest, "../") { + return "", coreerr.E("agentci.ValidateRemoteDir", "directory escaped root", nil) + } + + for _, part := range strings.Split(rest, "/") { + if part == "" { + continue + } + if _, err := ValidatePathElement(part); err != nil { + return "", coreerr.E("agentci.ValidateRemoteDir", "invalid directory segment", err) + } + } + + if rest == "" || rest == "." { + return prefix, nil + } + + return prefix + rest, nil +} + +// JoinRemotePath joins validated remote path elements using forward slashes. +// +func JoinRemotePath(base string, parts ...string) (string, error) { + safeBase, err := ValidateRemoteDir(base) + if err != nil { + return "", coreerr.E("agentci.JoinRemotePath", "invalid base directory", err) + } + + cleanParts := make([]string, 0, len(parts)) + for _, part := range parts { + safePart, partErr := ValidatePathElement(part) + if partErr != nil { + return "", coreerr.E("agentci.JoinRemotePath", "invalid path element", partErr) + } + cleanParts = append(cleanParts, safePart) + } + + if safeBase == "~" { + return path.Join("~", path.Join(cleanParts...)), nil + } + if strings.HasPrefix(safeBase, "~/") { + return "~/" + path.Join(strings.TrimPrefix(safeBase, "~/"), path.Join(cleanParts...)), nil + } + return path.Join(append([]string{safeBase}, cleanParts...)...), nil } // EscapeShellArg wraps a string in single quotes for safe remote shell insertion. // Prefer exec.Command arguments over constructing shell strings where possible. +// func EscapeShellArg(arg string) string { return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" } // SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode. +// func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd { return exec.Command("ssh", "-o", "StrictHostKeyChecking=yes", @@ -42,6 +156,7 @@ func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd { } // MaskToken returns a masked version of a token for safe logging. +// func MaskToken(token string) string { if len(token) < 8 { return "*****" diff --git a/agentci/security_test.go b/agentci/security_test.go index 6d0b68e..1167e50 100644 --- a/agentci/security_test.go +++ b/agentci/security_test.go @@ -20,7 +20,6 @@ func TestSanitizePath_Good(t *testing.T) { {"with.dot", "with.dot"}, {"CamelCase", "CamelCase"}, {"123", "123"}, - {"path/to/file.txt", "file.txt"}, } for _, tt := range tests { @@ -44,8 +43,11 @@ func TestSanitizePath_Bad(t *testing.T) { {"pipe", "file|name"}, {"ampersand", "file&name"}, {"dollar", "file$name"}, + {"slash", "path/to/file.txt"}, + {"backslash", `path\to\file.txt`}, {"parent traversal base", ".."}, {"root", "/"}, + {"empty", ""}, } for _, tt := range tests { diff --git a/cmd/collect/cmd.go b/cmd/collect/cmd.go index 8f7e43c..0725107 100644 --- a/cmd/collect/cmd.go +++ b/cmd/collect/cmd.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" "dappco.re/go/core/io" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) func init() { @@ -28,6 +30,7 @@ var ( ) // AddCollectCommands registers the 'collect' command and all subcommands. +// func AddCollectCommands(root *cli.Command) { collectCmd := &cli.Command{ Use: "collect", diff --git a/cmd/collect/cmd_bitcointalk.go b/cmd/collect/cmd_bitcointalk.go index 0bab644..3059f3d 100644 --- a/cmd/collect/cmd_bitcointalk.go +++ b/cmd/collect/cmd_bitcointalk.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "strings" + strings "dappco.re/go/core/scm/internal/ax/stringsx" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // BitcoinTalk command flags diff --git a/cmd/collect/cmd_dispatch.go b/cmd/collect/cmd_dispatch.go index 9467e7f..6a790d4 100644 --- a/cmd/collect/cmd_dispatch.go +++ b/cmd/collect/cmd_dispatch.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" - "forge.lthn.ai/core/cli/pkg/cli" - collectpkg "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + collectpkg "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // addDispatchCommand adds the 'dispatch' subcommand to the collect parent. diff --git a/cmd/collect/cmd_excavate.go b/cmd/collect/cmd_excavate.go index a9bf038..870b558 100644 --- a/cmd/collect/cmd_excavate.go +++ b/cmd/collect/cmd_excavate.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // Excavate command flags diff --git a/cmd/collect/cmd_github.go b/cmd/collect/cmd_github.go index 22c1f7d..6ab8522 100644 --- a/cmd/collect/cmd_github.go +++ b/cmd/collect/cmd_github.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "strings" + strings "dappco.re/go/core/scm/internal/ax/stringsx" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // GitHub command flags diff --git a/cmd/collect/cmd_market.go b/cmd/collect/cmd_market.go index 9a8fd47..1605eeb 100644 --- a/cmd/collect/cmd_market.go +++ b/cmd/collect/cmd_market.go @@ -1,11 +1,13 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // Market command flags diff --git a/cmd/collect/cmd_papers.go b/cmd/collect/cmd_papers.go index ef270ba..fb1b76e 100644 --- a/cmd/collect/cmd_papers.go +++ b/cmd/collect/cmd_papers.go @@ -1,11 +1,13 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // Papers command flags diff --git a/cmd/collect/cmd_process.go b/cmd/collect/cmd_process.go index 8ac116f..ab53dcb 100644 --- a/cmd/collect/cmd_process.go +++ b/cmd/collect/cmd_process.go @@ -1,11 +1,13 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/scm/collect" "dappco.re/go/core/i18n" + "dappco.re/go/core/scm/collect" + "forge.lthn.ai/core/cli/pkg/cli" ) // addProcessCommand adds the 'process' subcommand to the collect parent. diff --git a/cmd/forge/cmd_auth.go b/cmd/forge/cmd_auth.go index a707bf5..ffb62c3 100644 --- a/cmd/forge/cmd_auth.go +++ b/cmd/forge/cmd_auth.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Auth command flags. diff --git a/cmd/forge/cmd_config.go b/cmd/forge/cmd_config.go index 94c5679..1347888 100644 --- a/cmd/forge/cmd_config.go +++ b/cmd/forge/cmd_config.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Config command flags. diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go index 65e0440..654915d 100644 --- a/cmd/forge/cmd_forge.go +++ b/cmd/forge/cmd_forge.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package forge provides CLI commands for managing a Forgejo instance. // // Commands: @@ -33,6 +35,7 @@ var ( ) // AddForgeCommands registers the 'forge' command and all subcommands. +// func AddForgeCommands(root *cli.Command) { forgeCmd := &cli.Command{ Use: "forge", diff --git a/cmd/forge/cmd_issues.go b/cmd/forge/cmd_issues.go index 108d237..85ae4cd 100644 --- a/cmd/forge/cmd_issues.go +++ b/cmd/forge/cmd_issues.go @@ -1,13 +1,15 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Issues command flags. diff --git a/cmd/forge/cmd_labels.go b/cmd/forge/cmd_labels.go index 745ab61..ac2e90e 100644 --- a/cmd/forge/cmd_labels.go +++ b/cmd/forge/cmd_labels.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Labels command flags. diff --git a/cmd/forge/cmd_migrate.go b/cmd/forge/cmd_migrate.go index 8f8a8bb..2d6cf64 100644 --- a/cmd/forge/cmd_migrate.go +++ b/cmd/forge/cmd_migrate.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Migrate command flags. diff --git a/cmd/forge/cmd_orgs.go b/cmd/forge/cmd_orgs.go index 27389a3..c119dce 100644 --- a/cmd/forge/cmd_orgs.go +++ b/cmd/forge/cmd_orgs.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // addOrgsCommand adds the 'orgs' subcommand for listing organisations. diff --git a/cmd/forge/cmd_prs.go b/cmd/forge/cmd_prs.go index e32db77..9b5811f 100644 --- a/cmd/forge/cmd_prs.go +++ b/cmd/forge/cmd_prs.go @@ -1,13 +1,15 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // PRs command flags. diff --git a/cmd/forge/cmd_repos.go b/cmd/forge/cmd_repos.go index 71cb2a9..d9f84c7 100644 --- a/cmd/forge/cmd_repos.go +++ b/cmd/forge/cmd_repos.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // Repos command flags. diff --git a/cmd/forge/cmd_status.go b/cmd/forge/cmd_status.go index 4777894..34eeb7d 100644 --- a/cmd/forge/cmd_status.go +++ b/cmd/forge/cmd_status.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" fg "dappco.re/go/core/scm/forge" + "forge.lthn.ai/core/cli/pkg/cli" ) // addStatusCommand adds the 'status' subcommand for instance info. diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go index 390d8c9..d00345c 100644 --- a/cmd/forge/cmd_sync.go +++ b/cmd/forge/cmd_sync.go @@ -1,17 +1,21 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" + "net/url" + + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + fg "dappco.re/go/core/scm/forge" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" - "forge.lthn.ai/core/cli/pkg/cli" - coreerr "dappco.re/go/core/log" - fg "dappco.re/go/core/scm/forge" ) // Sync command flags. @@ -95,11 +99,14 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn if len(args) > 0 { for _, arg := range args { - name := arg - if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { - name = parts[1] + name, err := syncRepoNameFromArg(arg) + if err != nil { + return nil, coreerr.E("forge.buildSyncRepoList", "invalid repo argument", err) + } + _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name) + if err != nil { + return nil, coreerr.E("forge.buildSyncRepoList", "resolve local path", err) } - localPath := filepath.Join(basePath, name) branch := syncDetectDefaultBranch(localPath) repos = append(repos, syncRepoEntry{ name: name, @@ -113,10 +120,17 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn return nil, err } for _, r := range orgRepos { - localPath := filepath.Join(basePath, r.Name) + name, err := agentci.ValidatePathElement(r.Name) + if err != nil { + return nil, coreerr.E("forge.buildSyncRepoList", "invalid repo name from org list", err) + } + _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name) + if err != nil { + return nil, coreerr.E("forge.buildSyncRepoList", "resolve local path", err) + } branch := syncDetectDefaultBranch(localPath) repos = append(repos, syncRepoEntry{ - name: r.Name, + name: name, localPath: localPath, defaultBranch: branch, }) @@ -333,3 +347,27 @@ func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error { return nil } + +func syncRepoNameFromArg(arg string) (string, error) { + decoded, err := url.PathUnescape(arg) + if err != nil { + return "", coreerr.E("forge.syncRepoNameFromArg", "decode repo argument", err) + } + + parts := strings.Split(decoded, "/") + switch len(parts) { + case 1: + return agentci.ValidatePathElement(parts[0]) + case 2: + if _, err := agentci.ValidatePathElement(parts[0]); err != nil { + return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo owner", err) + } + name, err := agentci.ValidatePathElement(parts[1]) + if err != nil { + return "", coreerr.E("forge.syncRepoNameFromArg", "invalid repo name", err) + } + return name, nil + default: + return "", coreerr.E("forge.syncRepoNameFromArg", "repo argument must be repo or owner/repo", nil) + } +} diff --git a/cmd/forge/cmd_sync_test.go b/cmd/forge/cmd_sync_test.go new file mode 100644 index 0000000..d079c7c --- /dev/null +++ b/cmd/forge/cmd_sync_test.go @@ -0,0 +1,53 @@ +package forge + +import ( + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSyncRepoList_Good(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + repos, err := buildSyncRepoList(nil, []string{"host-uk/core"}, basePath) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "core", repos[0].name) + assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath) +} + +func TestBuildSyncRepoList_Bad_PathTraversal(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildSyncRepoList(nil, []string{"../escape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} + +func TestBuildSyncRepoList_Good_OwnerRepo(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + repos, err := buildSyncRepoList(nil, []string{"Host-UK/core"}, basePath) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "core", repos[0].name) + assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath) +} + +func TestBuildSyncRepoList_Bad_PathTraversal_OwnerRepo(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildSyncRepoList(nil, []string{"host-uk/../escape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} + +func TestBuildSyncRepoList_Bad_PathTraversal_OwnerRepoEncoded(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildSyncRepoList(nil, []string{"host-uk%2F..%2Fescape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} diff --git a/cmd/forge/helpers.go b/cmd/forge/helpers.go index eec2d68..afc8a40 100644 --- a/cmd/forge/helpers.go +++ b/cmd/forge/helpers.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( + strings "dappco.re/go/core/scm/internal/ax/stringsx" "path" - "strings" "forge.lthn.ai/core/cli/pkg/cli" ) diff --git a/cmd/gitea/cmd_config.go b/cmd/gitea/cmd_config.go index 69f07f3..8d21ebf 100644 --- a/cmd/gitea/cmd_config.go +++ b/cmd/gitea/cmd_config.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" gt "dappco.re/go/core/scm/gitea" + "forge.lthn.ai/core/cli/pkg/cli" ) // Config command flags. diff --git a/cmd/gitea/cmd_gitea.go b/cmd/gitea/cmd_gitea.go index 9268653..caa86d0 100644 --- a/cmd/gitea/cmd_gitea.go +++ b/cmd/gitea/cmd_gitea.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package gitea provides CLI commands for managing a Gitea instance. // // Commands: @@ -30,6 +32,7 @@ var ( ) // AddGiteaCommands registers the 'gitea' command and all subcommands. +// func AddGiteaCommands(root *cli.Command) { giteaCmd := &cli.Command{ Use: "gitea", diff --git a/cmd/gitea/cmd_issues.go b/cmd/gitea/cmd_issues.go index a4fd5e2..984d8ea 100644 --- a/cmd/gitea/cmd_issues.go +++ b/cmd/gitea/cmd_issues.go @@ -1,13 +1,15 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "code.gitea.io/sdk/gitea" - "forge.lthn.ai/core/cli/pkg/cli" gt "dappco.re/go/core/scm/gitea" + "forge.lthn.ai/core/cli/pkg/cli" ) // Issues command flags. diff --git a/cmd/gitea/cmd_mirror.go b/cmd/gitea/cmd_mirror.go index e108686..40bce5a 100644 --- a/cmd/gitea/cmd_mirror.go +++ b/cmd/gitea/cmd_mirror.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" - "os/exec" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" - "forge.lthn.ai/core/cli/pkg/cli" gt "dappco.re/go/core/scm/gitea" + "forge.lthn.ai/core/cli/pkg/cli" ) // Mirror command flags. diff --git a/cmd/gitea/cmd_prs.go b/cmd/gitea/cmd_prs.go index 00e059e..226f3a2 100644 --- a/cmd/gitea/cmd_prs.go +++ b/cmd/gitea/cmd_prs.go @@ -1,13 +1,15 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" sdk "code.gitea.io/sdk/gitea" - "forge.lthn.ai/core/cli/pkg/cli" gt "dappco.re/go/core/scm/gitea" + "forge.lthn.ai/core/cli/pkg/cli" ) // PRs command flags. diff --git a/cmd/gitea/cmd_repos.go b/cmd/gitea/cmd_repos.go index 892bdfe..ce0a324 100644 --- a/cmd/gitea/cmd_repos.go +++ b/cmd/gitea/cmd_repos.go @@ -1,10 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" gt "dappco.re/go/core/scm/gitea" + "forge.lthn.ai/core/cli/pkg/cli" ) // Repos command flags. diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go index b9b4c8f..cdcfa4b 100644 --- a/cmd/gitea/cmd_sync.go +++ b/cmd/gitea/cmd_sync.go @@ -1,17 +1,21 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" + "net/url" + + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + gt "dappco.re/go/core/scm/gitea" "code.gitea.io/sdk/gitea" - "forge.lthn.ai/core/cli/pkg/cli" - coreerr "dappco.re/go/core/log" - gt "dappco.re/go/core/scm/gitea" ) // Sync command flags. @@ -96,12 +100,14 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt if len(args) > 0 { // Specific repos from args for _, arg := range args { - name := arg - // Strip owner/ prefix if given - if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { - name = parts[1] + name, err := repoNameFromArg(arg) + if err != nil { + return nil, coreerr.E("gitea.buildRepoList", "invalid repo argument", err) + } + _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name) + if err != nil { + return nil, coreerr.E("gitea.buildRepoList", "resolve local path", err) } - localPath := filepath.Join(basePath, name) branch := detectDefaultBranch(localPath) repos = append(repos, repoEntry{ name: name, @@ -116,10 +122,17 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt return nil, err } for _, r := range orgRepos { - localPath := filepath.Join(basePath, r.Name) + name, err := agentci.ValidatePathElement(r.Name) + if err != nil { + return nil, coreerr.E("gitea.buildRepoList", "invalid repo name from org list", err) + } + _, localPath, err := agentci.ResolvePathWithinRoot(basePath, name) + if err != nil { + return nil, coreerr.E("gitea.buildRepoList", "resolve local path", err) + } branch := detectDefaultBranch(localPath) repos = append(repos, repoEntry{ - name: r.Name, + name: name, localPath: localPath, defaultBranch: branch, }) @@ -352,3 +365,27 @@ func createMainFromUpstream(client *gt.Client, org, repo string) error { } func strPtr(s string) *string { return &s } + +func repoNameFromArg(arg string) (string, error) { + decoded, err := url.PathUnescape(arg) + if err != nil { + return "", coreerr.E("gitea.repoNameFromArg", "decode repo argument", err) + } + + parts := strings.Split(decoded, "/") + switch len(parts) { + case 1: + return agentci.ValidatePathElement(parts[0]) + case 2: + if _, err := agentci.ValidatePathElement(parts[0]); err != nil { + return "", coreerr.E("gitea.repoNameFromArg", "invalid repo owner", err) + } + name, err := agentci.ValidatePathElement(parts[1]) + if err != nil { + return "", coreerr.E("gitea.repoNameFromArg", "invalid repo name", err) + } + return name, nil + default: + return "", coreerr.E("gitea.repoNameFromArg", "repo argument must be repo or owner/repo", nil) + } +} diff --git a/cmd/gitea/cmd_sync_test.go b/cmd/gitea/cmd_sync_test.go new file mode 100644 index 0000000..a8269bc --- /dev/null +++ b/cmd/gitea/cmd_sync_test.go @@ -0,0 +1,53 @@ +package gitea + +import ( + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRepoList_Good(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + repos, err := buildRepoList(nil, []string{"host-uk/core"}, basePath) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "core", repos[0].name) + assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath) +} + +func TestBuildRepoList_Bad_PathTraversal(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildRepoList(nil, []string{"../escape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} + +func TestBuildRepoList_Good_OwnerRepo(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + repos, err := buildRepoList(nil, []string{"Host-UK/core"}, basePath) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "core", repos[0].name) + assert.Equal(t, filepath.Join(basePath, "core"), repos[0].localPath) +} + +func TestBuildRepoList_Bad_PathTraversal_OwnerRepo(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildRepoList(nil, []string{"host-uk/../escape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} + +func TestBuildRepoList_Bad_PathTraversal_OwnerRepoEncoded(t *testing.T) { + basePath := filepath.Join(t.TempDir(), "repos") + + _, err := buildRepoList(nil, []string{"host-uk%2F..%2Fescape"}, basePath) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo argument") +} diff --git a/cmd/scm/cmd_compile.go b/cmd/scm/cmd_compile.go index e5cce30..4f5c8eb 100644 --- a/cmd/scm/cmd_compile.go +++ b/cmd/scm/cmd_compile.go @@ -1,14 +1,16 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package scm import ( "crypto/ed25519" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "encoding/hex" - "os/exec" - "strings" + exec "golang.org/x/sys/execabs" - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/io" "dappco.re/go/core/scm/manifest" + "forge.lthn.ai/core/cli/pkg/cli" ) func addCompileCommand(parent *cli.Command) { diff --git a/cmd/scm/cmd_export.go b/cmd/scm/cmd_export.go index 79da671..fcc8089 100644 --- a/cmd/scm/cmd_export.go +++ b/cmd/scm/cmd_export.go @@ -1,11 +1,13 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package scm import ( - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/io" "dappco.re/go/core/scm/manifest" + "forge.lthn.ai/core/cli/pkg/cli" ) func addExportCommand(parent *cli.Command) { diff --git a/cmd/scm/cmd_index.go b/cmd/scm/cmd_index.go index dd8784b..d6ff3a4 100644 --- a/cmd/scm/cmd_index.go +++ b/cmd/scm/cmd_index.go @@ -1,11 +1,13 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package scm import ( - "fmt" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/scm/marketplace" + "forge.lthn.ai/core/cli/pkg/cli" ) func addIndexCommand(parent *cli.Command) { diff --git a/cmd/scm/cmd_scm.go b/cmd/scm/cmd_scm.go index 4a7ad1b..9564d15 100644 --- a/cmd/scm/cmd_scm.go +++ b/cmd/scm/cmd_scm.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package scm provides CLI commands for manifest compilation and marketplace // index generation. // @@ -25,6 +27,7 @@ var ( ) // AddScmCommands registers the 'scm' command and all subcommands. +// func AddScmCommands(root *cli.Command) { scmCmd := &cli.Command{ Use: "scm", diff --git a/collect/bitcointalk.go b/collect/bitcointalk.go index 8c189d6..7d76eca 100644 --- a/collect/bitcointalk.go +++ b/collect/bitcointalk.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "fmt" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "iter" "net/http" - "path/filepath" - "strings" "time" core "dappco.re/go/core/log" @@ -20,6 +22,7 @@ var httpClient = &http.Client{ } // BitcoinTalkCollector collects forum posts from BitcoinTalk. +// type BitcoinTalkCollector struct { // TopicID is the numeric topic identifier. TopicID string @@ -281,6 +284,7 @@ func formatPostMarkdown(num int, post btPost) string { // ParsePostsFromHTML parses BitcoinTalk posts from raw HTML content. // This is exported for testing purposes. +// func ParsePostsFromHTML(htmlContent string) ([]btPost, error) { doc, err := html.Parse(strings.NewReader(htmlContent)) if err != nil { @@ -290,14 +294,17 @@ func ParsePostsFromHTML(htmlContent string) ([]btPost, error) { } // FormatPostMarkdown is exported for testing purposes. +// func FormatPostMarkdown(num int, author, date, content string) string { return formatPostMarkdown(num, btPost{Author: author, Date: date, Content: content}) } // FetchPageFunc is an injectable function type for fetching pages, used in testing. +// type FetchPageFunc func(ctx context.Context, url string) ([]btPost, error) // BitcoinTalkCollectorWithFetcher wraps BitcoinTalkCollector with a custom fetcher for testing. +// type BitcoinTalkCollectorWithFetcher struct { BitcoinTalkCollector Fetcher FetchPageFunc @@ -305,6 +312,7 @@ type BitcoinTalkCollectorWithFetcher struct { // SetHTTPClient replaces the package-level HTTP client. // Use this in tests to inject a custom transport or timeout. +// func SetHTTPClient(c *http.Client) { httpClient = c } diff --git a/collect/bitcointalk_http_test.go b/collect/bitcointalk_http_test.go index 61a7a08..6b202cd 100644 --- a/collect/bitcointalk_http_test.go +++ b/collect/bitcointalk_http_test.go @@ -2,10 +2,10 @@ package collect import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" "net/http/httptest" - "strings" "testing" "dappco.re/go/core/io" diff --git a/collect/collect.go b/collect/collect.go index d1acd04..8b37ac3 100644 --- a/collect/collect.go +++ b/collect/collect.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package collect provides a data collection subsystem for gathering information // from multiple sources including GitHub, BitcoinTalk, CoinGecko, and academic // paper repositories. It supports rate limiting, incremental state tracking, @@ -6,12 +8,13 @@ package collect import ( "context" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" "dappco.re/go/core/io" ) // Collector is the interface all collection sources implement. +// type Collector interface { // Name returns a human-readable name for this collector. Name() string @@ -21,6 +24,7 @@ type Collector interface { } // Config holds shared configuration for all collectors. +// type Config struct { // Output is the storage medium for writing collected data. Output io.Medium @@ -45,6 +49,7 @@ type Config struct { } // Result holds the output of a collection run. +// type Result struct { // Source identifies which collector produced this result. Source string @@ -65,6 +70,7 @@ type Result struct { // NewConfig creates a Config with sensible defaults. // It initialises a MockMedium for output if none is provided, // sets up a rate limiter, state tracker, and event dispatcher. +// func NewConfig(outputDir string) *Config { m := io.NewMockMedium() return &Config{ @@ -77,6 +83,7 @@ func NewConfig(outputDir string) *Config { } // NewConfigWithMedium creates a Config using the specified storage medium. +// func NewConfigWithMedium(m io.Medium, outputDir string) *Config { return &Config{ Output: m, @@ -88,6 +95,7 @@ func NewConfigWithMedium(m io.Medium, outputDir string) *Config { } // MergeResults combines multiple results into a single aggregated result. +// func MergeResults(source string, results ...*Result) *Result { merged := &Result{Source: source} for _, r := range results { diff --git a/collect/coverage_boost_test.go b/collect/coverage_boost_test.go index 70bae86..1e73324 100644 --- a/collect/coverage_boost_test.go +++ b/collect/coverage_boost_test.go @@ -2,7 +2,7 @@ package collect import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/collect/coverage_phase2_test.go b/collect/coverage_phase2_test.go index 68b5469..b55db04 100644 --- a/collect/coverage_phase2_test.go +++ b/collect/coverage_phase2_test.go @@ -2,13 +2,14 @@ package collect import ( "context" - "encoding/json" - "fmt" + core "dappco.re/go/core" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" goio "io" "io/fs" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -17,6 +18,14 @@ import ( "github.com/stretchr/testify/require" ) +func testErr(msg string) error { + return core.E("collect.test", msg, nil) +} + +func testErrf(format string, args ...any) error { + return core.E("collect.test", fmt.Sprintf(format, args...), nil) +} + // errorMedium wraps MockMedium and injects errors on specific operations. type errorMedium struct { *io.MockMedium @@ -50,16 +59,18 @@ func (e *errorMedium) Read(path string) (string, error) { } return e.MockMedium.Read(path) } -func (e *errorMedium) FileGet(path string) (string, error) { return e.MockMedium.FileGet(path) } -func (e *errorMedium) FileSet(path, content string) error { return e.MockMedium.FileSet(path, content) } -func (e *errorMedium) Delete(path string) error { return e.MockMedium.Delete(path) } -func (e *errorMedium) DeleteAll(path string) error { return e.MockMedium.DeleteAll(path) } -func (e *errorMedium) Rename(old, new string) error { return e.MockMedium.Rename(old, new) } -func (e *errorMedium) Stat(path string) (fs.FileInfo, error) { return e.MockMedium.Stat(path) } -func (e *errorMedium) Open(path string) (fs.File, error) { return e.MockMedium.Open(path) } -func (e *errorMedium) Create(path string) (goio.WriteCloser, error) { return e.MockMedium.Create(path) } -func (e *errorMedium) Append(path string) (goio.WriteCloser, error) { return e.MockMedium.Append(path) } -func (e *errorMedium) ReadStream(path string) (goio.ReadCloser, error) { return e.MockMedium.ReadStream(path) } +func (e *errorMedium) FileGet(path string) (string, error) { return e.MockMedium.FileGet(path) } +func (e *errorMedium) FileSet(path, content string) error { return e.MockMedium.FileSet(path, content) } +func (e *errorMedium) Delete(path string) error { return e.MockMedium.Delete(path) } +func (e *errorMedium) DeleteAll(path string) error { return e.MockMedium.DeleteAll(path) } +func (e *errorMedium) Rename(old, new string) error { return e.MockMedium.Rename(old, new) } +func (e *errorMedium) Stat(path string) (fs.FileInfo, error) { return e.MockMedium.Stat(path) } +func (e *errorMedium) Open(path string) (fs.File, error) { return e.MockMedium.Open(path) } +func (e *errorMedium) Create(path string) (goio.WriteCloser, error) { return e.MockMedium.Create(path) } +func (e *errorMedium) Append(path string) (goio.WriteCloser, error) { return e.MockMedium.Append(path) } +func (e *errorMedium) ReadStream(path string) (goio.ReadCloser, error) { + return e.MockMedium.ReadStream(path) +} func (e *errorMedium) WriteStream(path string) (goio.WriteCloser, error) { return e.MockMedium.WriteStream(path) } @@ -74,7 +85,7 @@ type errorLimiterWaiter struct{} // --- Processor: list error --- func TestProcessor_Process_Bad_ListError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), listErr: fmt.Errorf("list denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), listErr: testErr("list denied")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} p := &Processor{Source: "test", Dir: "/input"} @@ -86,7 +97,7 @@ func TestProcessor_Process_Bad_ListError(t *testing.T) { // --- Processor: ensureDir error --- func TestProcessor_Process_Bad_EnsureDirError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")} // Need to ensure List returns entries em.MockMedium.Dirs["/input"] = true em.MockMedium.Files["/input/test.html"] = "

Test

" @@ -121,7 +132,7 @@ func TestProcessor_Process_Bad_ContextCancelledDuringLoop(t *testing.T) { // --- Processor: read error during file processing --- func TestProcessor_Process_Bad_ReadError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: fmt.Errorf("read denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: testErr("read denied")} em.MockMedium.Dirs["/input"] = true em.MockMedium.Files["/input/test.html"] = "

Test

" @@ -154,7 +165,7 @@ func TestProcessor_Process_Bad_InvalidJSONFile(t *testing.T) { // --- Processor: write error during output --- func TestProcessor_Process_Bad_WriteError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} em.MockMedium.Dirs["/input"] = true em.MockMedium.Files["/input/page.html"] = "

Title

" @@ -255,13 +266,13 @@ func TestPapersCollector_CollectIACR_Bad_WriteError(t *testing.T) { httpClient = &http.Client{Transport: transport} defer func() { httpClient = old }() - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil p := &PapersCollector{Source: PaperSourceIACR, Query: "test"} result, err := p.Collect(context.Background(), cfg) - require.NoError(t, err) // Write errors increment Errors, not returned + require.NoError(t, err) // Write errors increment Errors, not returned assert.Equal(t, 2, result.Errors) // 2 papers both fail to write } @@ -279,7 +290,7 @@ func TestPapersCollector_CollectIACR_Bad_EnsureDirError(t *testing.T) { httpClient = &http.Client{Transport: transport} defer func() { httpClient = old }() - em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -303,7 +314,7 @@ func TestPapersCollector_CollectArXiv_Bad_WriteError(t *testing.T) { httpClient = &http.Client{Transport: transport} defer func() { httpClient = old }() - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -327,7 +338,7 @@ func TestPapersCollector_CollectArXiv_Bad_EnsureDirError(t *testing.T) { httpClient = &http.Client{Transport: transport} defer func() { httpClient = old }() - em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -453,7 +464,7 @@ func TestMarketCollector_Collect_Bad_WriteError(t *testing.T) { coinGeckoBaseURL = server.URL defer func() { coinGeckoBaseURL = oldURL }() - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -477,7 +488,7 @@ func TestMarketCollector_Collect_Bad_EnsureDirError(t *testing.T) { coinGeckoBaseURL = server.URL defer func() { coinGeckoBaseURL = oldURL }() - em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -552,7 +563,7 @@ func TestMarketCollector_Collect_Good_HistoricalCustomDate(t *testing.T) { // --- BitcoinTalk: EnsureDir error --- func TestBitcoinTalkCollector_Collect_Bad_EnsureDirError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: fmt.Errorf("mkdir denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), ensureDirErr: testErr("mkdir denied")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil @@ -592,13 +603,13 @@ func TestBitcoinTalkCollector_Collect_Bad_WriteErrorOnPosts(t *testing.T) { httpClient = &http.Client{Transport: transport} defer func() { httpClient = old }() - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()} cfg.Limiter = nil b := &BitcoinTalkCollector{TopicID: "12345"} result, err := b.Collect(context.Background(), cfg) - require.NoError(t, err) // write errors are counted + require.NoError(t, err) // write errors are counted assert.Equal(t, 3, result.Errors) // 3 posts all fail to write assert.Equal(t, 0, result.Items) } @@ -968,34 +979,44 @@ func TestBitcoinTalkCollector_Collect_Bad_LimiterBlocks(t *testing.T) { // writeCountMedium fails after N successful writes. type writeCountMedium struct { *io.MockMedium - writeCount int - failAfterN int + writeCount int + failAfterN int } func (w *writeCountMedium) Write(path, content string) error { w.writeCount++ if w.writeCount > w.failAfterN { - return fmt.Errorf("write %d: disk full", w.writeCount) + return testErrf("write %d: disk full", w.writeCount) } return w.MockMedium.Write(path, content) } -func (w *writeCountMedium) EnsureDir(path string) error { return w.MockMedium.EnsureDir(path) } -func (w *writeCountMedium) Read(path string) (string, error) { return w.MockMedium.Read(path) } -func (w *writeCountMedium) List(path string) ([]fs.DirEntry, error) { return w.MockMedium.List(path) } -func (w *writeCountMedium) IsFile(path string) bool { return w.MockMedium.IsFile(path) } -func (w *writeCountMedium) FileGet(path string) (string, error) { return w.MockMedium.FileGet(path) } -func (w *writeCountMedium) FileSet(path, content string) error { return w.MockMedium.FileSet(path, content) } -func (w *writeCountMedium) Delete(path string) error { return w.MockMedium.Delete(path) } -func (w *writeCountMedium) DeleteAll(path string) error { return w.MockMedium.DeleteAll(path) } -func (w *writeCountMedium) Rename(old, new string) error { return w.MockMedium.Rename(old, new) } -func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) } -func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) } -func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) { return w.MockMedium.Create(path) } -func (w *writeCountMedium) Append(path string) (goio.WriteCloser, error) { return w.MockMedium.Append(path) } -func (w *writeCountMedium) ReadStream(path string) (goio.ReadCloser, error) { return w.MockMedium.ReadStream(path) } -func (w *writeCountMedium) WriteStream(path string) (goio.WriteCloser, error) { return w.MockMedium.WriteStream(path) } -func (w *writeCountMedium) Exists(path string) bool { return w.MockMedium.Exists(path) } -func (w *writeCountMedium) IsDir(path string) bool { return w.MockMedium.IsDir(path) } +func (w *writeCountMedium) EnsureDir(path string) error { return w.MockMedium.EnsureDir(path) } +func (w *writeCountMedium) Read(path string) (string, error) { return w.MockMedium.Read(path) } +func (w *writeCountMedium) List(path string) ([]fs.DirEntry, error) { return w.MockMedium.List(path) } +func (w *writeCountMedium) IsFile(path string) bool { return w.MockMedium.IsFile(path) } +func (w *writeCountMedium) FileGet(path string) (string, error) { return w.MockMedium.FileGet(path) } +func (w *writeCountMedium) FileSet(path, content string) error { + return w.MockMedium.FileSet(path, content) +} +func (w *writeCountMedium) Delete(path string) error { return w.MockMedium.Delete(path) } +func (w *writeCountMedium) DeleteAll(path string) error { return w.MockMedium.DeleteAll(path) } +func (w *writeCountMedium) Rename(old, new string) error { return w.MockMedium.Rename(old, new) } +func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) } +func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) } +func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) { + return w.MockMedium.Create(path) +} +func (w *writeCountMedium) Append(path string) (goio.WriteCloser, error) { + return w.MockMedium.Append(path) +} +func (w *writeCountMedium) ReadStream(path string) (goio.ReadCloser, error) { + return w.MockMedium.ReadStream(path) +} +func (w *writeCountMedium) WriteStream(path string) (goio.WriteCloser, error) { + return w.MockMedium.WriteStream(path) +} +func (w *writeCountMedium) Exists(path string) bool { return w.MockMedium.Exists(path) } +func (w *writeCountMedium) IsDir(path string) bool { return w.MockMedium.IsDir(path) } // Test that the summary.md write error in collectCurrent is handled. func TestMarketCollector_Collect_Bad_SummaryWriteError(t *testing.T) { @@ -1075,7 +1096,7 @@ func TestMarketCollector_Collect_Bad_HistoricalWriteError(t *testing.T) { // --- State: Save write error --- func TestState_Save_Bad_WriteError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("disk full")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("disk full")} s := NewState(em, "/state.json") s.Set("test", &StateEntry{Source: "test", Items: 1}) @@ -1134,7 +1155,7 @@ func TestBitcoinTalkCollector_Collect_Good_ZeroPostsPage(t *testing.T) { // --- Excavator: state save error after collection --- func TestExcavator_Run_Bad_StateSaveError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: fmt.Errorf("state write failed")} + em := &errorMedium{MockMedium: io.NewMockMedium(), writeErr: testErr("state write failed")} cfg := &Config{ Output: io.NewMockMedium(), // Use regular medium for output OutputDir: "/output", @@ -1158,7 +1179,7 @@ func TestExcavator_Run_Bad_StateSaveError(t *testing.T) { // --- State: Load with read error --- func TestState_Load_Bad_ReadError(t *testing.T) { - em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: fmt.Errorf("read denied")} + em := &errorMedium{MockMedium: io.NewMockMedium(), readErr: testErr("read denied")} em.MockMedium.Files["/state.json"] = "{}" // File exists but read will fail s := NewState(em, "/state.json") diff --git a/collect/events.go b/collect/events.go index 7083986..d5b9f1c 100644 --- a/collect/events.go +++ b/collect/events.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( @@ -8,22 +10,28 @@ import ( // Event types used by the collection subsystem. const ( // EventStart is emitted when a collector begins its run. + // EventStart = "start" // EventProgress is emitted to report incremental progress. + // EventProgress = "progress" // EventItem is emitted when a single item is collected. + // EventItem = "item" // EventError is emitted when an error occurs during collection. + // EventError = "error" // EventComplete is emitted when a collector finishes its run. + // EventComplete = "complete" ) // Event represents a collection event. +// type Event struct { // Type is one of the Event* constants. Type string `json:"type"` @@ -42,16 +50,19 @@ type Event struct { } // EventHandler handles collection events. +// type EventHandler func(Event) // Dispatcher manages event dispatch. Handlers are registered per event type // and are called synchronously when an event is emitted. +// type Dispatcher struct { mu sync.RWMutex handlers map[string][]EventHandler } // NewDispatcher creates a new event dispatcher. +// func NewDispatcher() *Dispatcher { return &Dispatcher{ handlers: make(map[string][]EventHandler), diff --git a/collect/excavate.go b/collect/excavate.go index e491ba3..b2a8f34 100644 --- a/collect/excavate.go +++ b/collect/excavate.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" core "dappco.re/go/core/log" @@ -11,6 +13,7 @@ import ( // 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 diff --git a/collect/excavate_test.go b/collect/excavate_test.go index 0bebb30..8c556ea 100644 --- a/collect/excavate_test.go +++ b/collect/excavate_test.go @@ -2,7 +2,8 @@ package collect import ( "context" - "fmt" + core "dappco.re/go/core" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "testing" "dappco.re/go/core/io" @@ -126,7 +127,7 @@ func TestExcavator_Run_Good_WithErrors(t *testing.T) { cfg.Limiter = nil c1 := &mockCollector{name: "good", items: 5} - c2 := &mockCollector{name: "bad", err: fmt.Errorf("network error")} + c2 := &mockCollector{name: "bad", err: core.E("collect.mockCollector.Collect", "network error", nil)} c3 := &mockCollector{name: "also-good", items: 3} e := &Excavator{ diff --git a/collect/github.go b/collect/github.go index cad8fa7..bd450c3 100644 --- a/collect/github.go +++ b/collect/github.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "encoding/json" - "fmt" - "os/exec" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" "time" core "dappco.re/go/core/log" @@ -38,6 +40,7 @@ type ghRepo struct { } // GitHubCollector collects issues and PRs from GitHub repositories. +// type GitHubCollector struct { // Org is the GitHub organisation. Org string diff --git a/collect/market.go b/collect/market.go index e38e162..f6105a8 100644 --- a/collect/market.go +++ b/collect/market.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "encoding/json" - "fmt" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" - "path/filepath" - "strings" "time" core "dappco.re/go/core/log" @@ -17,6 +19,7 @@ import ( var coinGeckoBaseURL = "https://api.coingecko.com/api/v3" // MarketCollector collects market data from CoinGecko. +// type MarketCollector struct { // CoinID is the CoinGecko coin identifier (e.g. "bitcoin", "ethereum"). CoinID string @@ -272,6 +275,7 @@ func formatMarketSummary(data *coinData) string { } // FormatMarketSummary is exported for testing. +// func FormatMarketSummary(data *coinData) string { return formatMarketSummary(data) } diff --git a/collect/market_extra_test.go b/collect/market_extra_test.go index bbbcac2..d25dc2d 100644 --- a/collect/market_extra_test.go +++ b/collect/market_extra_test.go @@ -2,7 +2,7 @@ package collect import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/collect/market_test.go b/collect/market_test.go index c945a5f..3d9ca6e 100644 --- a/collect/market_test.go +++ b/collect/market_test.go @@ -2,7 +2,7 @@ package collect import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/collect/papers.go b/collect/papers.go index bfbf663..2183d92 100644 --- a/collect/papers.go +++ b/collect/papers.go @@ -1,14 +1,16 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "encoding/xml" - "fmt" "iter" "net/http" "net/url" - "path/filepath" - "strings" core "dappco.re/go/core/log" "golang.org/x/net/html" @@ -16,12 +18,16 @@ import ( // Paper source identifiers. const ( - PaperSourceIACR = "iacr" + // + PaperSourceIACR = "iacr" + // PaperSourceArXiv = "arxiv" - PaperSourceAll = "all" + // + PaperSourceAll = "all" ) // PapersCollector collects papers from IACR and arXiv. +// type PapersCollector struct { // Source is one of PaperSourceIACR, PaperSourceArXiv, or PaperSourceAll. Source string @@ -403,6 +409,7 @@ func formatPaperMarkdown(ppr paper) string { } // FormatPaperMarkdown is exported for testing. +// func FormatPaperMarkdown(title string, authors []string, date, paperURL, source, abstract string) string { return formatPaperMarkdown(paper{ Title: title, diff --git a/collect/papers_http_test.go b/collect/papers_http_test.go index b755413..0eb2a9b 100644 --- a/collect/papers_http_test.go +++ b/collect/papers_http_test.go @@ -2,9 +2,9 @@ package collect import ( "context" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" "net/http/httptest" - "strings" "testing" "dappco.re/go/core/io" diff --git a/collect/process.go b/collect/process.go index c0fb8d2..f41055b 100644 --- a/collect/process.go +++ b/collect/process.go @@ -1,19 +1,22 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "encoding/json" - "fmt" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "maps" - "path/filepath" "slices" - "strings" core "dappco.re/go/core/log" "golang.org/x/net/html" ) // Processor converts collected data to clean markdown. +// type Processor struct { // Source identifies the data source directory to process. Source string @@ -331,11 +334,13 @@ func jsonValueToMarkdown(b *strings.Builder, data any, depth int) { } // HTMLToMarkdown is exported for testing. +// func HTMLToMarkdown(content string) (string, error) { return htmlToMarkdown(content) } // JSONToMarkdown is exported for testing. +// func JSONToMarkdown(content string) (string, error) { return jsonToMarkdown(content) } diff --git a/collect/ratelimit.go b/collect/ratelimit.go index 5fc4969..163974e 100644 --- a/collect/ratelimit.go +++ b/collect/ratelimit.go @@ -1,12 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" "maps" - "os/exec" "strconv" - "strings" "sync" "time" @@ -14,6 +16,7 @@ import ( ) // RateLimiter tracks per-source rate limiting to avoid overwhelming APIs. +// type RateLimiter struct { mu sync.Mutex delays map[string]time.Duration @@ -30,6 +33,7 @@ var defaultDelays = map[string]time.Duration{ } // NewRateLimiter creates a limiter with default delays. +// func NewRateLimiter() *RateLimiter { delays := make(map[string]time.Duration, len(defaultDelays)) maps.Copy(delays, defaultDelays) diff --git a/collect/state.go b/collect/state.go index 08e2b95..d6db5ba 100644 --- a/collect/state.go +++ b/collect/state.go @@ -1,17 +1,20 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package collect import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "sync" "time" - core "dappco.re/go/core/log" "dappco.re/go/core/io" + core "dappco.re/go/core/log" ) // State tracks collection progress for incremental runs. // It persists entries to disk so that subsequent runs can resume // where they left off. +// type State struct { mu sync.Mutex medium io.Medium @@ -20,6 +23,7 @@ type State struct { } // StateEntry tracks state for one source. +// type StateEntry struct { // Source identifies the collector. Source string `json:"source"` @@ -39,6 +43,7 @@ type StateEntry struct { // NewState creates a state tracker that persists to the given path // using the provided storage medium. +// func NewState(m io.Medium, path string) *State { return &State{ medium: m, diff --git a/docs/verification-pass-2026-03-27.md b/docs/verification-pass-2026-03-27.md new file mode 100644 index 0000000..9a4628a --- /dev/null +++ b/docs/verification-pass-2026-03-27.md @@ -0,0 +1,141 @@ +# Verification Pass 2026-03-27 + +- Repository note: `CODEX.md` was not present under `/workspace`; conventions were taken from `CLAUDE.md`. +- Commands run: `go build ./...`, `go vet ./...`, `go test ./...` +- Command status: all passed + +## Banned imports + +ZERO FINDINGS for banned imports: `os`, `os/exec`, `encoding/json`, `fmt`, `errors`, `strings`, `path/filepath`. + +## Test names not matching `TestFile_Function_{Good,Bad,Ugly}` + +597 findings across 67 files: + +```text +agentci/config_test.go:23,46,67,85,102,120,127,140,162,177,185,205,223,237,257,270,277,295,302 +agentci/security_test.go:12,34,61,79,92,109 +cmd/forge/cmd_sync_test.go:11,21,29,39,47 +cmd/gitea/cmd_sync_test.go:11,21,29,39,47 +collect/bitcointalk_http_test.go:36,76,104,128,152,183,192,210,225 +collect/bitcointalk_test.go:16,21,30,42,73,79,87 +collect/collect_test.go:10,23,35,57,63 +collect/coverage_boost_test.go:18,34,50,62,79,92,109,120,125,130,141,151,176,198,230,264,275,283,291,298,305,313,322,330,335,343,351,358,368,384,400,419,434,452,457,481,497,508,517,533,557,572,592,611,625,639 +collect/coverage_phase2_test.go:87,99,115,134,149,167,182,202,216,232,257,281,305,329,353,388,416,444,480,503,530,565,578,594,619,633,646,665,692,718,738,751,764,778,797,807,817,826,835,846,856,866,876,886,896,928,945,962,1022,1059,1098,1110,1132,1157,1181,1193,1230,1246,1261,1300 +collect/events_test.go:44,57,72,88,129 +collect/excavate_extra_test.go:13,42,68,86,104 +collect/excavate_test.go:67,78,99,124,147,164,185 +collect/github_test.go:17,22,41,53,65,91 +collect/market_extra_test.go:15,59,101,140,175,200,232 +collect/market_test.go:19,28,40,95,145,167 +collect/papers_http_test.go:112,168,188,208,229,249,281,298,306 +collect/papers_test.go:16,21,26,35,44,56,75,83 +collect/process_extra_test.go:12,21,29,36,43,51,60,68,76,84,92,101,108,118,134,158,175 +collect/process_test.go:16,25,37,58,78,97,114,173,182,191,198 +collect/ratelimit_test.go:30,54,63,69,78 +collect/state_extra_test.go:11,27,43,56 +collect/state_test.go:76,89,98,126,138 +forge/client_test.go:16,28,64,92,100,124,135,159,185,211,225,263,326,356,387,409,435,441 +forge/config_test.go:19,28,39,50,61,71,77,85,100 +forge/issues_test.go:22,44,55,73,94,116,135,154,176,194,211,230,247 +forge/labels_test.go:27,45,63,72,81,91,111,127,144 +forge/meta_test.go:26,48,66 +forge/orgs_test.go:22,40,62 +forge/prs_test.go:22,30,38,61,79,96,105,133,142 +forge/repos_test.go:22,42,60,81,100,122 +forge/webhooks_test.go:26,46 +gitea/client_test.go:10,21 +gitea/config_test.go:18,27,38,49,59,69,75,83,95 +gitea/coverage_boost_test.go:15,26,35,44,90,124,176,220,239,264,294,306 +gitea/issues_test.go:22,44,55,73,94,115,137,155,166 +gitea/meta_test.go:26,48,66,77,103,115 +gitea/repos_test.go:22,42,60,69,79,89,106,127 +jobrunner/forgejo/source_test.go:38,109,155,162,166 +jobrunner/handlers/dispatch_test.go:38,50,63,76,88,100,120,145,210,232,255,276,302,339 +jobrunner/handlers/enable_auto_merge_test.go:29,42,84 +jobrunner/handlers/publish_draft_test.go:26,36 +jobrunner/handlers/resolve_threads_test.go:26 +jobrunner/handlers/send_fix_command_test.go:16,25,37,49 +jobrunner/handlers/tick_parent_test.go:26 +jobrunner/journal_test.go:116,198,233,249 +jobrunner/poller_test.go:121,152,193 +jobrunner/types_test.go:28 +manifest/compile_test.go:14,38,61,67,74,81,106,123,128,152,163,169 +manifest/loader_test.go:12,28,34,51 +manifest/manifest_test.go:10,49,67,127,135,146,157,205,210,215,220 +manifest/sign_test.go:11,32,44 +marketplace/builder_test.go:39,56,71,87,102,121,138,147,154,166,178,189,198,221 +marketplace/discovery_test.go:25,59,84,105,122,130,136,147,195,234 +marketplace/installer_test.go:78,104,125,142,166,189,212,223,243,270,281,309 +marketplace/marketplace_test.go:10,26,38,50,61 +pkg/api/provider_handlers_test.go:20,47,66,79,90,105,118,131,142,155,169,183 +pkg/api/provider_security_test.go:12,18,23 +pkg/api/provider_test.go:92,116,167,181,200,230 +plugin/installer_test.go:14,26,36,47,60,81,95,105,120,132,140,148,156,162,168,174,180,186,192,198,204 +plugin/loader_test.go:46,71,92,123,132 +plugin/manifest_test.go:10,33,50,58,78,89,100 +plugin/plugin_test.go:10,25,37 +plugin/registry_test.go:28,69,78,129,138,149,160,173 +repos/gitstate_test.go:14,50,61,110,132,158,178,189,199,204,210 +repos/kbconfig_test.go:13,48,57,76,93 +repos/registry_test.go:13,40,72,93,120,127,181,201,209,243,266,286,309,334,350,363,373,382,390,398,403,411,421,427,474,481 +repos/workconfig_test.go:14,51,60,79,97,102 +``` + +## Exported functions missing usage-example comments + +199 findings across 51 files: + +```text +agentci/clotho.go:39,61,71,90 +collect/bitcointalk.go:37,46 +collect/events.go:72,81,96,105,115,125,135 +collect/excavate.go:27,33 +collect/github.go:57,65 +collect/market.go:33,67 +collect/papers.go:41,57 +collect/process.go:27,33 +collect/ratelimit.go:46,80,87,100,107 +collect/state.go:55,81,99,112 +forge/client.go:37,40,43,46,55,69 +forge/issues.go:21,56,66,76,86,97,130,164,174,185,209 +forge/labels.go:15,31,55,65,81,94,105 +forge/meta.go:43,100,139 +forge/orgs.go:10,34,44 +forge/prs.go:18,43,84,108,117 +forge/repos.go:12,36,61,85,110,120,130,141 +forge/webhooks.go:10,20 +git/git.go:32,37,42,283,293 +git/service.go:75,113,116,121,132,145,156 +gitea/client.go:36,39 +gitea/issues.go:20,52,62,72,105,139 +gitea/meta.go:43,101,141 +gitea/repos.go:12,36,61,85,110,122,145,155 +internal/ax/stdio/stdio.go:12,24 +jobrunner/forgejo/source.go:36,42,65 +jobrunner/handlers/completion.go:33,38,43 +jobrunner/handlers/dispatch.go:76,82,91 +jobrunner/handlers/enable_auto_merge.go:25,31,40 +jobrunner/handlers/publish_draft.go:25,30,37 +jobrunner/handlers/resolve_threads.go:30,35,40 +jobrunner/handlers/send_fix_command.go:26,32,46 +jobrunner/handlers/tick_parent.go:30,35,41 +jobrunner/journal.go:98 +jobrunner/poller.go:51,58,65,72,79,88,110 +jobrunner/types.go:37,42 +manifest/manifest.go:46,79,95 +marketplace/builder.go:35 +marketplace/discovery.go:132,140,145,151,160 +marketplace/installer.go:53,115,133,179 +marketplace/marketplace.go:39,53,64 +pkg/api/provider.go:61,64,67,75,86,108 +plugin/installer.go:35,99,133 +plugin/loader.go:28,53 +plugin/manifest.go:41 +plugin/plugin.go:44,47,50,53,56 +plugin/registry.go:35,48,54,63,78,105 +repos/gitstate.go:101,106,111,120,131,144,162 +repos/kbconfig.go:116,121 +repos/registry.go:255,265,271,283,325,330 +repos/workconfig.go:104 +``` diff --git a/forge/client.go b/forge/client.go index 2ee2bb9..4f260fa 100644 --- a/forge/client.go +++ b/forge/client.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package forge provides a thin wrapper around the Forgejo Go SDK // for managing repositories, issues, and pull requests on a Forgejo instance. // @@ -15,6 +17,7 @@ import ( ) // Client wraps the Forgejo SDK client with config-based auth. +// type Client struct { api *forgejo.Client url string @@ -22,6 +25,7 @@ type Client struct { } // New creates a new Forgejo API client for the given URL and token. +// func New(url, token string) (*Client, error) { api, err := forgejo.NewClient(url, forgejo.SetToken(token)) if err != nil { diff --git a/forge/client_test.go b/forge/client_test.go index daf05c8..7302fc1 100644 --- a/forge/client_test.go +++ b/forge/client_test.go @@ -1,8 +1,8 @@ package forge import ( - "encoding/json" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" @@ -132,7 +132,7 @@ func TestClient_SetPRDraft_Bad_ConnectionRefused(t *testing.T) { assert.Error(t, err) } -func TestClient_SetPRDraft_URLConstruction(t *testing.T) { +func TestClient_SetPRDraft_Good_URLConstruction(t *testing.T) { // Verify the URL is constructed correctly by checking the request path. var capturedPath string mux := http.NewServeMux() @@ -156,7 +156,7 @@ func TestClient_SetPRDraft_URLConstruction(t *testing.T) { assert.Equal(t, "/api/v1/repos/my-org/my-repo/pulls/42", capturedPath) } -func TestClient_SetPRDraft_AuthHeader(t *testing.T) { +func TestClient_SetPRDraft_Good_AuthHeader(t *testing.T) { // Verify the authorisation header is set correctly. var capturedAuth string mux := http.NewServeMux() @@ -182,7 +182,7 @@ func TestClient_SetPRDraft_AuthHeader(t *testing.T) { // --- PRMeta and Comment struct tests --- -func TestPRMeta_Fields(t *testing.T) { +func TestPRMeta_Good_Fields(t *testing.T) { meta := &PRMeta{ Number: 42, Title: "Test PR", @@ -208,7 +208,7 @@ func TestPRMeta_Fields(t *testing.T) { assert.Equal(t, 5, meta.CommentCount) } -func TestComment_Fields(t *testing.T) { +func TestComment_Good_Fields(t *testing.T) { comment := Comment{ ID: 123, Author: "reviewer", @@ -222,7 +222,7 @@ func TestComment_Fields(t *testing.T) { // --- MergePullRequest merge style mapping --- -func TestMergePullRequest_StyleMapping(t *testing.T) { +func TestMergePullRequest_Good_StyleMapping(t *testing.T) { // We can't easily test the SDK call, but we can verify the method // errors when the server returns failure. This exercises the style mapping code. tests := []struct { @@ -260,7 +260,7 @@ func TestMergePullRequest_StyleMapping(t *testing.T) { // --- ListIssuesOpts defaulting --- -func TestListIssuesOpts_Defaults(t *testing.T) { +func TestListIssuesOpts_Good_Defaults(t *testing.T) { tests := []struct { name string opts ListIssuesOpts @@ -432,13 +432,13 @@ func TestClient_CreatePullRequest_Bad_ServerError(t *testing.T) { // --- commentPageSize constant test --- -func TestCommentPageSize(t *testing.T) { +func TestCommentPageSize_Good(t *testing.T) { assert.Equal(t, 50, commentPageSize, "comment page size should be 50") } // --- ListPullRequests state mapping --- -func TestListPullRequests_StateMapping(t *testing.T) { +func TestListPullRequests_Good_StateMapping(t *testing.T) { // Verify state mapping via error path (server returns error). tests := []struct { name string diff --git a/forge/config.go b/forge/config.go index 1e97bda..9205432 100644 --- a/forge/config.go +++ b/forge/config.go @@ -1,19 +1,24 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "os" + os "dappco.re/go/core/scm/internal/ax/osx" - "forge.lthn.ai/core/config" "dappco.re/go/core/log" + "forge.lthn.ai/core/config" ) const ( // ConfigKeyURL is the config key for the Forgejo instance URL. + // ConfigKeyURL = "forge.url" // ConfigKeyToken is the config key for the Forgejo API token. + // ConfigKeyToken = "forge.token" // DefaultURL is the default Forgejo instance URL. + // DefaultURL = "http://localhost:4000" ) @@ -22,6 +27,8 @@ const ( // 1. ~/.core/config.yaml keys: forge.token, forge.url // 2. FORGE_TOKEN + FORGE_URL environment variables (override config file) // 3. Provided flag overrides (highest priority; pass empty to skip) +// +// func NewFromConfig(flagURL, flagToken string) (*Client, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { @@ -37,6 +44,7 @@ func NewFromConfig(flagURL, flagToken string) (*Client, error) { // ResolveConfig resolves the Forgejo URL and token from all config sources. // Flag values take highest priority, then env vars, then config file. +// func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { // Start with config file values cfg, cfgErr := config.New() @@ -70,6 +78,7 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { } // SaveConfig persists the Forgejo URL and/or token to the config file. +// func SaveConfig(url, token string) error { cfg, err := config.New() if err != nil { diff --git a/forge/config_test.go b/forge/config_test.go index ace6e30..4f35c80 100644 --- a/forge/config_test.go +++ b/forge/config_test.go @@ -68,7 +68,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) { assert.Equal(t, "some-token", token) } -func TestConstants(t *testing.T) { +func TestConstants_Good(t *testing.T) { assert.Equal(t, "forge.url", ConfigKeyURL) assert.Equal(t, "forge.token", ConfigKeyToken) assert.Equal(t, "http://localhost:4000", DefaultURL) diff --git a/forge/issues.go b/forge/issues.go index 664e140..9b07237 100644 --- a/forge/issues.go +++ b/forge/issues.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( @@ -9,6 +11,7 @@ import ( ) // ListIssuesOpts configures issue listing. +// type ListIssuesOpts struct { State string // "open", "closed", "all" Labels []string // filter by label names diff --git a/forge/labels.go b/forge/labels.go index 063cb46..2f57fb0 100644 --- a/forge/labels.go +++ b/forge/labels.go @@ -1,7 +1,9 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( - "strings" + strings "dappco.re/go/core/scm/internal/ax/stringsx" forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" diff --git a/forge/meta.go b/forge/meta.go index 0cece76..6b0f068 100644 --- a/forge/meta.go +++ b/forge/meta.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( @@ -10,6 +12,7 @@ import ( // PRMeta holds structural signals from a pull request, // used by the pipeline MetaReader for AI-driven workflows. +// type PRMeta struct { Number int64 Title string @@ -26,6 +29,7 @@ type PRMeta struct { } // Comment represents a comment with metadata. +// type Comment struct { ID int64 Author string diff --git a/forge/orgs.go b/forge/orgs.go index db20323..c1ce33b 100644 --- a/forge/orgs.go +++ b/forge/orgs.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( diff --git a/forge/prs.go b/forge/prs.go index 070662f..9a0b649 100644 --- a/forge/prs.go +++ b/forge/prs.go @@ -1,14 +1,19 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( "bytes" - "encoding/json" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" - - forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + "net/url" + "strconv" "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" ) // MergePullRequest merges a pull request with the given method ("squash", "rebase", "merge"). @@ -38,14 +43,27 @@ func (c *Client) MergePullRequest(owner, repo string, index int64, method string // The Forgejo SDK v2.2.0 doesn't expose the draft field on EditPullRequestOption, // so we use a raw HTTP PATCH request. func (c *Client) SetPRDraft(owner, repo string, index int64, draft bool) error { + safeOwner, err := agentci.ValidatePathElement(owner) + if err != nil { + return log.E("forge.SetPRDraft", "invalid owner", err) + } + safeRepo, err := agentci.ValidatePathElement(repo) + if err != nil { + return log.E("forge.SetPRDraft", "invalid repo", err) + } + payload := map[string]bool{"draft": draft} body, err := json.Marshal(payload) if err != nil { return log.E("forge.SetPRDraft", "marshal payload", err) } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.url, owner, repo, index) - req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(body)) + path, err := url.JoinPath(c.url, "api", "v1", "repos", safeOwner, safeRepo, "pulls", strconv.FormatInt(index, 10)) + if err != nil { + return log.E("forge.SetPRDraft", "failed to build request path", err) + } + + req, err := http.NewRequest(http.MethodPatch, path, bytes.NewReader(body)) if err != nil { return log.E("forge.SetPRDraft", "create request", err) } diff --git a/forge/prs_test.go b/forge/prs_test.go index 14f30be..10d3aeb 100644 --- a/forge/prs_test.go +++ b/forge/prs_test.go @@ -1,7 +1,10 @@ package forge import ( - "strings" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -98,3 +101,49 @@ func TestClient_DismissReview_Bad_ServerError(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to dismiss review") } + +func TestClient_SetPRDraft_Good_Request(t *testing.T) { + var method, path string + var payload map[string]any + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, map[string]string{"version": "1.21.0"}) + }) + mux.HandleFunc("/api/v1/repos/test-org/org-repo/pulls/3", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + jsonResponse(w, map[string]any{"number": 3}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client, err := New(srv.URL, "test-token") + require.NoError(t, err) + + err = client.SetPRDraft("test-org", "org-repo", 3, false) + assert.NoError(t, err) + assert.Equal(t, http.MethodPatch, method) + assert.Equal(t, "/api/v1/repos/test-org/org-repo/pulls/3", path) + assert.Equal(t, false, payload["draft"]) +} + +func TestClient_SetPRDraft_Bad_PathTraversalOwner(t *testing.T) { + client, srv := newTestClient(t) + defer srv.Close() + + err := client.SetPRDraft("../owner", "org-repo", 3, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid owner") +} + +func TestClient_SetPRDraft_Bad_PathTraversalRepo(t *testing.T) { + client, srv := newTestClient(t) + defer srv.Close() + + err := client.SetPRDraft("test-org", "..", 3, true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo") +} diff --git a/forge/repos.go b/forge/repos.go index d894aee..8a92433 100644 --- a/forge/repos.go +++ b/forge/repos.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( diff --git a/forge/testhelper_test.go b/forge/testhelper_test.go index e38db64..6f31a7b 100644 --- a/forge/testhelper_test.go +++ b/forge/testhelper_test.go @@ -1,10 +1,10 @@ package forge import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" "net/http/httptest" - "strings" "testing" ) diff --git a/forge/webhooks.go b/forge/webhooks.go index 464c690..27a35fa 100644 --- a/forge/webhooks.go +++ b/forge/webhooks.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forge import ( diff --git a/git/git.go b/git/git.go index 53ded5f..564914f 100644 --- a/git/git.go +++ b/git/git.go @@ -1,20 +1,23 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package git provides utilities for git operations across multiple repositories. package git import ( "bytes" "context" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" "io" "iter" - "os" - "os/exec" "slices" "strconv" - "strings" "sync" ) // RepoStatus represents the git status of a single repository. +// type RepoStatus struct { Name string Path string @@ -43,6 +46,7 @@ func (s *RepoStatus) HasUnpulled() bool { } // StatusOptions configures the status check. +// type StatusOptions struct { // Paths is a list of repo paths to check Paths []string @@ -51,6 +55,7 @@ type StatusOptions struct { } // Status checks git status for multiple repositories in parallel. +// func Status(ctx context.Context, opts StatusOptions) []RepoStatus { var wg sync.WaitGroup results := make([]RepoStatus, len(opts.Paths)) @@ -72,6 +77,7 @@ func Status(ctx context.Context, opts StatusOptions) []RepoStatus { } // StatusIter returns an iterator over git status for multiple repositories. +// func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] { return func(yield func(RepoStatus) bool) { results := Status(ctx, opts) @@ -156,17 +162,20 @@ func getAheadBehind(ctx context.Context, path string) (ahead, behind int) { // Push pushes commits for a single repository. // Uses interactive mode to support SSH passphrase prompts. +// func Push(ctx context.Context, path string) error { return gitInteractive(ctx, path, "push") } // Pull pulls changes for a single repository. // Uses interactive mode to support SSH passphrase prompts. +// func Pull(ctx context.Context, path string) error { return gitInteractive(ctx, path, "pull", "--rebase") } // IsNonFastForward checks if an error is a non-fast-forward rejection. +// func IsNonFastForward(err error) bool { if err == nil { return false @@ -201,6 +210,7 @@ func gitInteractive(ctx context.Context, dir string, args ...string) error { } // PushResult represents the result of a push operation. +// type PushResult struct { Name string Path string @@ -210,11 +220,13 @@ type PushResult struct { // PushMultiple pushes multiple repositories sequentially. // Sequential because SSH passphrase prompts need user interaction. +// func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult { return slices.Collect(PushMultipleIter(ctx, paths, names)) } // PushMultipleIter returns an iterator that pushes repositories sequentially and yields results. +// func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] { return func(yield func(PushResult) bool) { for _, path := range paths { @@ -263,6 +275,7 @@ func gitCommand(ctx context.Context, dir string, args ...string) (string, error) } // GitError wraps a git command error with stderr output. +// type GitError struct { Err error Stderr string diff --git a/git/service.go b/git/service.go index 13d66c6..a2fa160 100644 --- a/git/service.go +++ b/git/service.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package git import ( @@ -11,49 +13,58 @@ import ( // Queries for git service // QueryStatus requests git status for paths. +// type QueryStatus struct { Paths []string Names map[string]string } // QueryDirtyRepos requests repos with uncommitted changes. +// type QueryDirtyRepos struct{} // QueryAheadRepos requests repos with unpushed commits. +// type QueryAheadRepos struct{} // Tasks for git service // TaskPush requests git push for a path. +// type TaskPush struct { Path string Name string } // TaskPull requests git pull for a path. +// type TaskPull struct { Path string Name string } // TaskPushMultiple requests git push for multiple paths. +// type TaskPushMultiple struct { Paths []string Names map[string]string } // ServiceOptions for configuring the git service. +// type ServiceOptions struct { WorkDir string } // Service provides git operations as a Core service. +// type Service struct { *core.ServiceRuntime[ServiceOptions] lastStatus []RepoStatus } // NewService creates a git service factory. +// func NewService(opts ServiceOptions) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/gitea/client.go b/gitea/client.go index 6d752ab..c93a017 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package gitea provides a thin wrapper around the Gitea Go SDK // for managing repositories, issues, and pull requests on a Gitea instance. // @@ -15,12 +17,14 @@ import ( ) // Client wraps the Gitea SDK client with config-based auth. +// type Client struct { api *gitea.Client url string } // New creates a new Gitea API client for the given URL and token. +// func New(url, token string) (*Client, error) { api, err := gitea.NewClient(url, gitea.SetToken(token)) if err != nil { diff --git a/gitea/config.go b/gitea/config.go index 80d4127..d2de11e 100644 --- a/gitea/config.go +++ b/gitea/config.go @@ -1,19 +1,24 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( - "os" + os "dappco.re/go/core/scm/internal/ax/osx" - "forge.lthn.ai/core/config" "dappco.re/go/core/log" + "forge.lthn.ai/core/config" ) const ( // ConfigKeyURL is the config key for the Gitea instance URL. + // ConfigKeyURL = "gitea.url" // ConfigKeyToken is the config key for the Gitea API token. + // ConfigKeyToken = "gitea.token" // DefaultURL is the default Gitea instance URL. + // DefaultURL = "https://gitea.snider.dev" ) @@ -22,6 +27,8 @@ const ( // 1. ~/.core/config.yaml keys: gitea.token, gitea.url // 2. GITEA_TOKEN + GITEA_URL environment variables (override config file) // 3. Provided flag overrides (highest priority; pass empty to skip) +// +// func NewFromConfig(flagURL, flagToken string) (*Client, error) { url, token, err := ResolveConfig(flagURL, flagToken) if err != nil { @@ -37,6 +44,7 @@ func NewFromConfig(flagURL, flagToken string) (*Client, error) { // ResolveConfig resolves the Gitea URL and token from all config sources. // Flag values take highest priority, then env vars, then config file. +// func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { // Start with config file values cfg, cfgErr := config.New() @@ -70,6 +78,7 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { } // SaveConfig persists the Gitea URL and/or token to the config file. +// func SaveConfig(url, token string) error { cfg, err := config.New() if err != nil { diff --git a/gitea/config_test.go b/gitea/config_test.go index 9272ca2..bda2bc0 100644 --- a/gitea/config_test.go +++ b/gitea/config_test.go @@ -66,7 +66,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) { assert.Equal(t, "some-token", token) } -func TestConstants(t *testing.T) { +func TestConstants_Good(t *testing.T) { assert.Equal(t, "gitea.url", ConfigKeyURL) assert.Equal(t, "gitea.token", ConfigKeyToken) assert.Equal(t, "https://gitea.snider.dev", DefaultURL) diff --git a/gitea/coverage_boost_test.go b/gitea/coverage_boost_test.go index 82a4763..4dcac22 100644 --- a/gitea/coverage_boost_test.go +++ b/gitea/coverage_boost_test.go @@ -1,7 +1,7 @@ package gitea import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" @@ -146,12 +146,12 @@ func newPRMetaWithManyCommentsServer(t *testing.T) *httptest.Server { mux.HandleFunc("/api/v1/repos/test-org/test-repo/pulls/1", func(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]any{ "id": 1, "number": 1, "title": "Many Comments PR", "state": "open", - "merged": false, - "head": map[string]any{"ref": "feature", "label": "feature"}, - "base": map[string]any{"ref": "main", "label": "main"}, - "user": map[string]any{"login": "author"}, - "labels": []map[string]any{}, - "assignees": []map[string]any{}, + "merged": false, + "head": map[string]any{"ref": "feature", "label": "feature"}, + "base": map[string]any{"ref": "main", "label": "main"}, + "user": map[string]any{"login": "author"}, + "labels": []map[string]any{}, + "assignees": []map[string]any{}, "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-16T12:00:00Z", }) diff --git a/gitea/issues.go b/gitea/issues.go index 611b912..2cefa23 100644 --- a/gitea/issues.go +++ b/gitea/issues.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( @@ -9,6 +11,7 @@ import ( ) // ListIssuesOpts configures issue listing. +// type ListIssuesOpts struct { State string // "open", "closed", "all" Page int diff --git a/gitea/issues_test.go b/gitea/issues_test.go index ef22b64..0675846 100644 --- a/gitea/issues_test.go +++ b/gitea/issues_test.go @@ -163,7 +163,7 @@ func TestClient_GetPullRequest_Bad_ServerError(t *testing.T) { // --- ListIssuesOpts defaulting --- -func TestListIssuesOpts_Defaults(t *testing.T) { +func TestListIssuesOpts_Good_Defaults(t *testing.T) { tests := []struct { name string opts ListIssuesOpts diff --git a/gitea/meta.go b/gitea/meta.go index a050ef8..80e1956 100644 --- a/gitea/meta.go +++ b/gitea/meta.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( @@ -10,6 +12,7 @@ import ( // PRMeta holds structural signals from a pull request, // used by the pipeline MetaReader for AI-driven workflows. +// type PRMeta struct { Number int64 Title string @@ -26,6 +29,7 @@ type PRMeta struct { } // Comment represents a comment with metadata. +// type Comment struct { ID int64 Author string diff --git a/gitea/meta_test.go b/gitea/meta_test.go index bebb112..7a13945 100644 --- a/gitea/meta_test.go +++ b/gitea/meta_test.go @@ -74,7 +74,7 @@ func TestClient_GetIssueBody_Bad_ServerError(t *testing.T) { // --- PRMeta struct tests --- -func TestPRMeta_Fields(t *testing.T) { +func TestPRMeta_Good_Fields(t *testing.T) { meta := &PRMeta{ Number: 42, Title: "Test PR", @@ -100,7 +100,7 @@ func TestPRMeta_Fields(t *testing.T) { assert.Equal(t, 5, meta.CommentCount) } -func TestComment_Fields(t *testing.T) { +func TestComment_Good_Fields(t *testing.T) { comment := Comment{ ID: 123, Author: "reviewer", @@ -112,6 +112,6 @@ func TestComment_Fields(t *testing.T) { assert.Equal(t, "LGTM", comment.Body) } -func TestCommentPageSize(t *testing.T) { +func TestCommentPageSize_Good(t *testing.T) { assert.Equal(t, 50, commentPageSize, "comment page size should be 50") } diff --git a/gitea/repos.go b/gitea/repos.go index ad3bfba..5606342 100644 --- a/gitea/repos.go +++ b/gitea/repos.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package gitea import ( diff --git a/gitea/testhelper_test.go b/gitea/testhelper_test.go index daea37b..e119f72 100644 --- a/gitea/testhelper_test.go +++ b/gitea/testhelper_test.go @@ -1,10 +1,10 @@ package gitea import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" "net/http/httptest" - "strings" "testing" ) @@ -130,7 +130,7 @@ func newGiteaMux() *http.ServeMux { w.WriteHeader(http.StatusCreated) jsonResponse(w, map[string]any{ "id": 40, "name": "mirrored-repo", "full_name": "test-org/mirrored-repo", - "owner": map[string]any{"login": "test-org"}, + "owner": map[string]any{"login": "test-org"}, "mirror": true, }) }) diff --git a/go.mod b/go.mod index 9e7770a..0b97fe0 100644 --- a/go.mod +++ b/go.mod @@ -5,25 +5,27 @@ go 1.26.0 require ( code.gitea.io/sdk/gitea v0.23.2 codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 - dappco.re/go/core v0.4.7 - dappco.re/go/core/api v0.1.5 - dappco.re/go/core/i18n v0.1.7 - dappco.re/go/core/io v0.1.7 - dappco.re/go/core/log v0.0.4 - dappco.re/go/core/ws v0.2.5 + dappco.re/go/core v0.5.0 + dappco.re/go/core/api v0.2.0 + dappco.re/go/core/i18n v0.2.0 + dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 + dappco.re/go/core/ws v0.3.0 forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/config v0.1.8 github.com/gin-gonic/gin v1.12.0 + github.com/goccy/go-json v0.10.6 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - forge.lthn.ai/core/go v0.3.2 // indirect + forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect - forge.lthn.ai/core/go-io v0.1.5 // indirect + forge.lthn.ai/core/go-io v0.1.7 // indirect forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/99designs/gqlgen v0.17.88 // indirect @@ -86,7 +88,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect @@ -145,7 +146,6 @@ require ( golang.org/x/mod v0.34.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect @@ -155,15 +155,3 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.47.0 // indirect ) - -replace ( - dappco.re/go/core => ../go - dappco.re/go/core/api => ../api - dappco.re/go/core/i18n => ../go-i18n - dappco.re/go/core/io => ../go-io - dappco.re/go/core/log => ../go-log - dappco.re/go/core/ws => ../go-ws - forge.lthn.ai/core/cli => ../cli - forge.lthn.ai/core/config => ../config - forge.lthn.ai/core/go-inference => ../go-inference -) diff --git a/internal/ax/filepathx/filepathx.go b/internal/ax/filepathx/filepathx.go new file mode 100644 index 0000000..784d948 --- /dev/null +++ b/internal/ax/filepathx/filepathx.go @@ -0,0 +1,55 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package filepathx + +import ( + "path" + "syscall" +) + +// Separator mirrors filepath.Separator for Unix-style Core paths. +// +const Separator = '/' + +// Abs mirrors filepath.Abs for the paths used in this repo. +// +func Abs(p string) (string, error) { + if path.IsAbs(p) { + return path.Clean(p), nil + } + cwd, err := syscall.Getwd() + if err != nil { + return "", err + } + return path.Clean(path.Join(cwd, p)), nil +} + +// Base mirrors filepath.Base. +// +func Base(p string) string { + return path.Base(p) +} + +// Clean mirrors filepath.Clean. +// +func Clean(p string) string { + return path.Clean(p) +} + +// Dir mirrors filepath.Dir. +// +func Dir(p string) string { + return path.Dir(p) +} + +// Ext mirrors filepath.Ext. +// +func Ext(p string) string { + return path.Ext(p) +} + +// Join mirrors filepath.Join. +// +func Join(elem ...string) string { + return path.Join(elem...) +} diff --git a/internal/ax/fmtx/fmtx.go b/internal/ax/fmtx/fmtx.go new file mode 100644 index 0000000..7476794 --- /dev/null +++ b/internal/ax/fmtx/fmtx.go @@ -0,0 +1,40 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package fmtx + +import ( + "io" + + core "dappco.re/go/core" + "dappco.re/go/core/scm/internal/ax/stdio" +) + +// Sprint mirrors fmt.Sprint using Core primitives. +// +func Sprint(args ...any) string { + return core.Sprint(args...) +} + +// Sprintf mirrors fmt.Sprintf using Core primitives. +// +func Sprintf(format string, args ...any) string { + return core.Sprintf(format, args...) +} + +// Fprintf mirrors fmt.Fprintf using Core primitives. +// +func Fprintf(w io.Writer, format string, args ...any) (int, error) { + return io.WriteString(w, Sprintf(format, args...)) +} + +// Printf mirrors fmt.Printf. +// +func Printf(format string, args ...any) (int, error) { + return Fprintf(stdio.Stdout, format, args...) +} + +// Println mirrors fmt.Println. +// +func Println(args ...any) (int, error) { + return io.WriteString(stdio.Stdout, Sprint(args...)+"\n") +} diff --git a/internal/ax/jsonx/jsonx.go b/internal/ax/jsonx/jsonx.go new file mode 100644 index 0000000..8a90048 --- /dev/null +++ b/internal/ax/jsonx/jsonx.go @@ -0,0 +1,39 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package jsonx + +import ( + "io" + + json "github.com/goccy/go-json" +) + +// Marshal mirrors encoding/json.Marshal. +// +func Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +// MarshalIndent mirrors encoding/json.MarshalIndent. +// +func MarshalIndent(v any, prefix, indent string) ([]byte, error) { + return json.MarshalIndent(v, prefix, indent) +} + +// NewDecoder mirrors encoding/json.NewDecoder. +// +func NewDecoder(r io.Reader) *json.Decoder { + return json.NewDecoder(r) +} + +// NewEncoder mirrors encoding/json.NewEncoder. +// +func NewEncoder(w io.Writer) *json.Encoder { + return json.NewEncoder(w) +} + +// Unmarshal mirrors encoding/json.Unmarshal. +// +func Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} diff --git a/internal/ax/osx/osx.go b/internal/ax/osx/osx.go new file mode 100644 index 0000000..bea0f7f --- /dev/null +++ b/internal/ax/osx/osx.go @@ -0,0 +1,113 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package osx + +import ( + "io" + "io/fs" + "os/user" + "syscall" + + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" + "dappco.re/go/core/scm/internal/ax/stdio" +) + +const ( + // + O_APPEND = syscall.O_APPEND + // + O_CREATE = syscall.O_CREAT + // + O_WRONLY = syscall.O_WRONLY +) + +// Stdin exposes process stdin without importing os. +// +var Stdin = stdio.Stdin + +// Stdout exposes process stdout without importing os. +// +var Stdout = stdio.Stdout + +// Stderr exposes process stderr without importing os. +// +var Stderr = stdio.Stderr + +// Getenv mirrors os.Getenv. +// +func Getenv(key string) string { + value, _ := syscall.Getenv(key) + return value +} + +// Getwd mirrors os.Getwd. +// +func Getwd() (string, error) { + return syscall.Getwd() +} + +// IsNotExist mirrors os.IsNotExist. +// +func IsNotExist(err error) bool { + return core.Is(err, fs.ErrNotExist) +} + +// MkdirAll mirrors os.MkdirAll. +// +func MkdirAll(path string, _ fs.FileMode) error { + return coreio.Local.EnsureDir(path) +} + +// Open mirrors os.Open. +// +func Open(path string) (fs.File, error) { + return coreio.Local.Open(path) +} + +// OpenFile mirrors the append/create/write mode used in this repo. +// +func OpenFile(path string, flag int, _ fs.FileMode) (io.WriteCloser, error) { + if flag&O_APPEND != 0 { + return coreio.Local.Append(path) + } + return coreio.Local.Create(path) +} + +// ReadDir mirrors os.ReadDir. +// +func ReadDir(path string) ([]fs.DirEntry, error) { + return coreio.Local.List(path) +} + +// ReadFile mirrors os.ReadFile. +// +func ReadFile(path string) ([]byte, error) { + content, err := coreio.Local.Read(path) + return []byte(content), err +} + +// Stat mirrors os.Stat. +// +func Stat(path string) (fs.FileInfo, error) { + return coreio.Local.Stat(path) +} + +// UserHomeDir mirrors os.UserHomeDir. +// +func UserHomeDir() (string, error) { + if home := Getenv("HOME"); home != "" { + return home, nil + } + current, err := user.Current() + if err != nil { + return "", err + } + return current.HomeDir, nil +} + +// WriteFile mirrors os.WriteFile. +// +func WriteFile(path string, data []byte, perm fs.FileMode) error { + return coreio.Local.WriteMode(path, string(data), perm) +} diff --git a/internal/ax/stdio/stdio.go b/internal/ax/stdio/stdio.go new file mode 100644 index 0000000..ecebd5a --- /dev/null +++ b/internal/ax/stdio/stdio.go @@ -0,0 +1,40 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package stdio + +import ( + "io" + "syscall" +) + +type fdReader struct { + fd int +} + +func (r fdReader) Read(p []byte) (int, error) { + n, err := syscall.Read(r.fd, p) + if n == 0 && err == nil { + return 0, io.EOF + } + return n, err +} + +type fdWriter struct { + fd int +} + +func (w fdWriter) Write(p []byte) (int, error) { + return syscall.Write(w.fd, p) +} + +// Stdin exposes process stdin without importing os. +// +var Stdin io.Reader = fdReader{fd: 0} + +// Stdout exposes process stdout without importing os. +// +var Stdout io.Writer = fdWriter{fd: 1} + +// Stderr exposes process stderr without importing os. +// +var Stderr io.Writer = fdWriter{fd: 2} diff --git a/internal/ax/stringsx/stringsx.go b/internal/ax/stringsx/stringsx.go new file mode 100644 index 0000000..95300e0 --- /dev/null +++ b/internal/ax/stringsx/stringsx.go @@ -0,0 +1,151 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package stringsx + +import ( + "bufio" + "bytes" + "iter" + + core "dappco.re/go/core" +) + +// Builder provides a strings.Builder-like type without importing strings. +// +type Builder = bytes.Buffer + +// Contains mirrors strings.Contains. +// +func Contains(s, substr string) bool { + return core.Contains(s, substr) +} + +// ContainsAny mirrors strings.ContainsAny. +// +func ContainsAny(s, chars string) bool { + return bytes.IndexAny([]byte(s), chars) >= 0 +} + +// EqualFold mirrors strings.EqualFold. +// +func EqualFold(s, t string) bool { + return bytes.EqualFold([]byte(s), []byte(t)) +} + +// Fields mirrors strings.Fields. +// +func Fields(s string) []string { + scanner := bufio.NewScanner(NewReader(s)) + scanner.Split(bufio.ScanWords) + fields := make([]string, 0) + for scanner.Scan() { + fields = append(fields, scanner.Text()) + } + return fields +} + +// HasPrefix mirrors strings.HasPrefix. +// +func HasPrefix(s, prefix string) bool { + return core.HasPrefix(s, prefix) +} + +// HasSuffix mirrors strings.HasSuffix. +// +func HasSuffix(s, suffix string) bool { + return core.HasSuffix(s, suffix) +} + +// Join mirrors strings.Join. +// +func Join(elems []string, sep string) string { + return core.Join(sep, elems...) +} + +// LastIndex mirrors strings.LastIndex. +// +func LastIndex(s, substr string) int { + return bytes.LastIndex([]byte(s), []byte(substr)) +} + +// NewReader mirrors strings.NewReader. +// +func NewReader(s string) *bytes.Reader { + return bytes.NewReader([]byte(s)) +} + +// Repeat mirrors strings.Repeat. +// +func Repeat(s string, count int) string { + if count <= 0 { + return "" + } + return string(bytes.Repeat([]byte(s), count)) +} + +// ReplaceAll mirrors strings.ReplaceAll. +// +func ReplaceAll(s, old, new string) string { + return core.Replace(s, old, new) +} + +// Replace mirrors strings.Replace for replace-all call sites. +// +func Replace(s, old, new string, _ int) string { + return ReplaceAll(s, old, new) +} + +// Split mirrors strings.Split. +// +func Split(s, sep string) []string { + return core.Split(s, sep) +} + +// SplitN mirrors strings.SplitN. +// +func SplitN(s, sep string, n int) []string { + return core.SplitN(s, sep, n) +} + +// SplitSeq mirrors strings.SplitSeq. +// +func SplitSeq(s, sep string) iter.Seq[string] { + parts := Split(s, sep) + return func(yield func(string) bool) { + for _, part := range parts { + if !yield(part) { + return + } + } + } +} + +// ToLower mirrors strings.ToLower. +// +func ToLower(s string) string { + return core.Lower(s) +} + +// ToUpper mirrors strings.ToUpper. +// +func ToUpper(s string) string { + return core.Upper(s) +} + +// TrimPrefix mirrors strings.TrimPrefix. +// +func TrimPrefix(s, prefix string) string { + return core.TrimPrefix(s, prefix) +} + +// TrimSpace mirrors strings.TrimSpace. +// +func TrimSpace(s string) string { + return core.Trim(s) +} + +// TrimSuffix mirrors strings.TrimSuffix. +// +func TrimSuffix(s, suffix string) string { + return core.TrimSuffix(s, suffix) +} diff --git a/jobrunner/forgejo/signals.go b/jobrunner/forgejo/signals.go index 48faa79..875672b 100644 --- a/jobrunner/forgejo/signals.go +++ b/jobrunner/forgejo/signals.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forgejo import ( diff --git a/jobrunner/forgejo/source.go b/jobrunner/forgejo/source.go index 61f8970..f8e65fe 100644 --- a/jobrunner/forgejo/source.go +++ b/jobrunner/forgejo/source.go @@ -1,27 +1,32 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package forgejo import ( "context" - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + "dappco.re/go/core/log" "dappco.re/go/core/scm/forge" "dappco.re/go/core/scm/jobrunner" - "dappco.re/go/core/log" ) // Config configures a ForgejoSource. +// type Config struct { Repos []string // "owner/repo" format } // ForgejoSource polls a Forgejo instance for pipeline signals from epic issues. +// type ForgejoSource struct { repos []string forge *forge.Client } // New creates a ForgejoSource using the given forge client. +// func New(cfg Config, client *forge.Client) *ForgejoSource { return &ForgejoSource{ repos: cfg.Repos, diff --git a/jobrunner/forgejo/source_test.go b/jobrunner/forgejo/source_test.go index 965e765..3656891 100644 --- a/jobrunner/forgejo/source_test.go +++ b/jobrunner/forgejo/source_test.go @@ -2,10 +2,10 @@ package forgejo import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -35,7 +35,7 @@ func newTestClient(t *testing.T, url string) *forge.Client { return client } -func TestForgejoSource_Name(t *testing.T) { +func TestForgejoSource_Good_Name(t *testing.T) { s := New(Config{}, nil) assert.Equal(t, "forgejo", s.Name()) } @@ -106,7 +106,7 @@ func TestForgejoSource_Poll_Good(t *testing.T) { assert.Equal(t, "abc123", sig.LastCommitSHA) } -func TestForgejoSource_Poll_NoEpics(t *testing.T) { +func TestForgejoSource_Poll_Good_NoEpics(t *testing.T) { srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode([]any{}) @@ -152,18 +152,18 @@ func TestForgejoSource_Report_Good(t *testing.T) { assert.Contains(t, capturedBody, "succeeded") } -func TestParseEpicChildren(t *testing.T) { +func TestParseEpicChildren_Good(t *testing.T) { body := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n- [x] #3\n" unchecked, checked := parseEpicChildren(body) assert.Equal(t, []int{7, 8}, unchecked) assert.Equal(t, []int{1, 3}, checked) } -func TestFindLinkedPR(t *testing.T) { +func TestFindLinkedPR_Good(t *testing.T) { assert.Nil(t, findLinkedPR(nil, 7)) } -func TestSplitRepo(t *testing.T) { +func TestSplitRepo_Good(t *testing.T) { owner, repo, err := splitRepo("host-uk/core") require.NoError(t, err) assert.Equal(t, "host-uk", owner) diff --git a/jobrunner/handlers/completion.go b/jobrunner/handlers/completion.go index 0c9b40e..61e5ec3 100644 --- a/jobrunner/handlers/completion.go +++ b/jobrunner/handlers/completion.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" coreerr "dappco.re/go/core/log" @@ -11,15 +13,18 @@ import ( ) const ( + // ColorAgentComplete = "#0e8a16" // Green ) // CompletionHandler manages issue state when an agent finishes work. +// type CompletionHandler struct { forge *forge.Client } // NewCompletionHandler creates a handler for agent completion events. +// func NewCompletionHandler(client *forge.Client) *CompletionHandler { return &CompletionHandler{ forge: client, diff --git a/jobrunner/handlers/dispatch.go b/jobrunner/handlers/dispatch.go index fbd83e2..e1196e7 100644 --- a/jobrunner/handlers/dispatch.go +++ b/jobrunner/handlers/dispatch.go @@ -1,11 +1,14 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "bytes" "context" - "encoding/json" - "fmt" - "path/filepath" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + "path" "time" coreerr "dappco.re/go/core/log" @@ -15,17 +18,24 @@ import ( ) const ( - LabelAgentReady = "agent-ready" - LabelInProgress = "in-progress" - LabelAgentFailed = "agent-failed" + // + LabelAgentReady = "agent-ready" + // + LabelInProgress = "in-progress" + // + LabelAgentFailed = "agent-failed" + // LabelAgentComplete = "agent-completed" - ColorInProgress = "#1d76db" // Blue + // + ColorInProgress = "#1d76db" // Blue + // ColorAgentFailed = "#c0392b" // Red ) // DispatchTicket is the JSON payload written to the agent's queue. // The ForgeToken is transferred separately via a .env file with 0600 permissions. +// type DispatchTicket struct { ID string `json:"id"` RepoOwner string `json:"repo_owner"` @@ -45,6 +55,7 @@ type DispatchTicket struct { } // DispatchHandler dispatches coding work to remote agent machines via SSH. +// type DispatchHandler struct { forge *forge.Client forgeURL string @@ -53,6 +64,7 @@ type DispatchHandler struct { } // NewDispatchHandler creates a handler that dispatches tickets to agent machines. +// func NewDispatchHandler(client *forge.Client, forgeURL, token string, spinner *agentci.Spinner) *DispatchHandler { return &DispatchHandler{ forge: client, @@ -85,6 +97,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin if !ok { return nil, coreerr.E("dispatch.Execute", "unknown agent: "+signal.Assignee, nil) } + queueDir, err := agentci.ValidateRemoteDir(agent.QueueDir) + if err != nil { + return nil, coreerr.E("dispatch.Execute", "invalid agent queue dir", err) + } // Sanitize inputs to prevent path traversal. safeOwner, err := agentci.SanitizePath(signal.RepoOwner) @@ -184,7 +200,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin } // Transfer ticket JSON. - remoteTicketPath := filepath.Join(agent.QueueDir, ticketName) + remoteTicketPath, err := agentci.JoinRemotePath(queueDir, ticketName) + if err != nil { + return nil, coreerr.E("dispatch.Execute", "ticket path", err) + } if err := h.secureTransfer(ctx, agent, remoteTicketPath, ticketJSON, 0644); err != nil { h.failDispatch(signal, fmt.Sprintf("Ticket transfer failed: %v", err)) return &jobrunner.ActionResult{ @@ -202,10 +221,13 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin // Transfer token via separate .env file with 0600 permissions. envContent := fmt.Sprintf("FORGE_TOKEN=%s\n", h.token) - remoteEnvPath := filepath.Join(agent.QueueDir, fmt.Sprintf(".env.%s", ticketID)) + remoteEnvPath, err := agentci.JoinRemotePath(queueDir, fmt.Sprintf(".env.%s", ticketID)) + if err != nil { + return nil, coreerr.E("dispatch.Execute", "env path", err) + } if err := h.secureTransfer(ctx, agent, remoteEnvPath, []byte(envContent), 0600); err != nil { // Clean up the ticket if env transfer fails. - _ = h.runRemote(ctx, agent, fmt.Sprintf("rm -f %s", agentci.EscapeShellArg(remoteTicketPath))) + _ = h.runRemote(ctx, agent, "rm", "-f", remoteTicketPath) h.failDispatch(signal, fmt.Sprintf("Token transfer failed: %v", err)) return &jobrunner.ActionResult{ Action: "dispatch", @@ -255,8 +277,8 @@ func (h *DispatchHandler) failDispatch(signal *jobrunner.PipelineSignal, reason // secureTransfer writes data to a remote path via SSH stdin, preventing command injection. func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.AgentConfig, remotePath string, data []byte, mode int) error { - safeRemotePath := agentci.EscapeShellArg(remotePath) - remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safeRemotePath, mode, safeRemotePath) + safePath := agentci.EscapeShellArg(remotePath) + remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safePath, mode, safePath) cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd) cmd.Stdin = bytes.NewReader(data) @@ -269,21 +291,55 @@ func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.Agen } // runRemote executes a command on the agent via SSH. -func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, cmdStr string) error { - cmd := agentci.SecureSSHCommand(agent.Host, cmdStr) +func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, command string, args ...string) error { + remoteCmd := command + if len(args) > 0 { + escaped := make([]string, 0, 1+len(args)) + escaped = append(escaped, command) + for _, arg := range args { + escaped = append(escaped, agentci.EscapeShellArg(arg)) + } + remoteCmd = strings.Join(escaped, " ") + } + + cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd) return cmd.Run() } // ticketExists checks if a ticket file already exists in queue, active, or done. func (h *DispatchHandler) ticketExists(ctx context.Context, agent agentci.AgentConfig, ticketName string) bool { - safeTicket, err := agentci.SanitizePath(ticketName) + queueDir, err := agentci.ValidateRemoteDir(agent.QueueDir) if err != nil { return false } - qDir := agent.QueueDir + safeTicket, err := agentci.ValidatePathElement(ticketName) + if err != nil { + return false + } + + queuePath, err := agentci.JoinRemotePath(queueDir, safeTicket) + if err != nil { + return false + } + parentDir := queueDir + if queueDir != "/" && queueDir != "~" { + parentDir = path.Dir(queueDir) + } + activePath, err := agentci.JoinRemotePath(parentDir, "active", safeTicket) + if err != nil { + return false + } + donePath, err := agentci.JoinRemotePath(parentDir, "done", safeTicket) + if err != nil { + return false + } + + queuePath = agentci.EscapeShellArg(queuePath) + activePath = agentci.EscapeShellArg(activePath) + donePath = agentci.EscapeShellArg(donePath) checkCmd := fmt.Sprintf( - "test -f %s/%s || test -f %s/../active/%s || test -f %s/../done/%s", - qDir, safeTicket, qDir, safeTicket, qDir, safeTicket, + "test -f %s || test -f %s || test -f %s", + queuePath, activePath, donePath, ) cmd := agentci.SecureSSHCommand(agent.Host, checkCmd) return cmd.Run() == nil diff --git a/jobrunner/handlers/dispatch_test.go b/jobrunner/handlers/dispatch_test.go index f981207..251a838 100644 --- a/jobrunner/handlers/dispatch_test.go +++ b/jobrunner/handlers/dispatch_test.go @@ -2,9 +2,12 @@ package handlers import ( "context" - "encoding/json" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + os "dappco.re/go/core/scm/internal/ax/osx" "net/http" "net/http/httptest" + "strconv" "testing" "dappco.re/go/core/scm/agentci" @@ -13,6 +16,18 @@ import ( "github.com/stretchr/testify/require" ) +func writeFakeSSHCommand(t *testing.T, outputPath string) string { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "ssh") + scriptContent := "#!/bin/sh\n" + + "OUT=" + strconv.Quote(outputPath) + "\n" + + "printf '%s\n' \"$@\" >> \"$OUT\"\n" + + "cat >> \"${OUT}.stdin\"\n" + require.NoError(t, os.WriteFile(script, []byte(scriptContent), 0o755)) + return dir +} + // newTestSpinner creates a Spinner with the given agents for testing. func newTestSpinner(agents map[string]agentci.AgentConfig) *agentci.Spinner { return agentci.NewSpinner(agentci.ClothoConfig{Strategy: "direct"}, agents) @@ -127,6 +142,29 @@ func TestDispatch_Execute_Bad_UnknownAgent(t *testing.T) { assert.Contains(t, err.Error(), "unknown agent") } +func TestDispatch_Execute_Bad_InvalidQueueDir(t *testing.T) { + spinner := newTestSpinner(map[string]agentci.AgentConfig{ + "darbs-claude": { + Host: "localhost", + QueueDir: "/tmp/queue; touch /tmp/pwned", + Active: true, + }, + }) + h := NewDispatchHandler(nil, "", "", spinner) + + sig := &jobrunner.PipelineSignal{ + NeedsCoding: true, + Assignee: "darbs-claude", + RepoOwner: "host-uk", + RepoName: "core", + ChildNumber: 1, + } + + _, err := h.Execute(context.Background(), sig) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid agent queue dir") +} + func TestDispatch_TicketJSON_Good(t *testing.T) { ticket := DispatchTicket{ ID: "host-uk-core-5-1234567890", @@ -214,6 +252,53 @@ func TestDispatch_TicketJSON_Good_OmitsEmptyModelRunner(t *testing.T) { assert.False(t, hasRunner, "runner should be omitted when empty") } +func TestDispatch_runRemote_Good_EscapesPath(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "ssh-output.txt") + toolPath := writeFakeSSHCommand(t, outputPath) + t.Setenv("PATH", toolPath+":"+os.Getenv("PATH")) + + h := NewDispatchHandler(nil, "", "", newTestSpinner(nil)) + dangerousPath := "/tmp/queue with spaces; touch /tmp/pwned" + err := h.runRemote( + context.Background(), + agentci.AgentConfig{Host: "localhost"}, + "rm", + "-f", + dangerousPath, + ) + require.NoError(t, err) + + output, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Contains(t, string(output), "rm '-f' '"+dangerousPath+"'\n") +} + +func TestDispatch_secureTransfer_Good_EscapesPath(t *testing.T) { + outputPath := filepath.Join(t.TempDir(), "ssh-output.txt") + toolPath := writeFakeSSHCommand(t, outputPath) + t.Setenv("PATH", toolPath+":"+os.Getenv("PATH")) + + h := NewDispatchHandler(nil, "", "", newTestSpinner(nil)) + dangerousPath := "/tmp/queue with spaces; touch /tmp/pwned" + err := h.secureTransfer( + context.Background(), + agentci.AgentConfig{Host: "localhost"}, + dangerousPath, + []byte("hello"), + 0644, + ) + require.NoError(t, err) + + output, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Contains(t, string(output), "cat > '"+dangerousPath+"' && chmod 644 '"+dangerousPath+"'") + + inputPath := outputPath + ".stdin" + input, err := os.ReadFile(inputPath) + require.NoError(t, err) + assert.Equal(t, "hello", string(input)) +} + func TestDispatch_TicketJSON_Good_ModelRunnerVariants(t *testing.T) { tests := []struct { name string diff --git a/jobrunner/handlers/enable_auto_merge.go b/jobrunner/handlers/enable_auto_merge.go index 7ab4d30..fcae794 100644 --- a/jobrunner/handlers/enable_auto_merge.go +++ b/jobrunner/handlers/enable_auto_merge.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" "dappco.re/go/core/scm/forge" @@ -10,11 +12,13 @@ import ( ) // EnableAutoMergeHandler merges a PR that is ready using squash strategy. +// type EnableAutoMergeHandler struct { forge *forge.Client } // NewEnableAutoMergeHandler creates a handler that merges ready PRs. +// func NewEnableAutoMergeHandler(f *forge.Client) *EnableAutoMergeHandler { return &EnableAutoMergeHandler{forge: f} } diff --git a/jobrunner/handlers/enable_auto_merge_test.go b/jobrunner/handlers/enable_auto_merge_test.go index 9a5feac..0242e60 100644 --- a/jobrunner/handlers/enable_auto_merge_test.go +++ b/jobrunner/handlers/enable_auto_merge_test.go @@ -2,7 +2,7 @@ package handlers import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/jobrunner/handlers/publish_draft.go b/jobrunner/handlers/publish_draft.go index 202726b..419e726 100644 --- a/jobrunner/handlers/publish_draft.go +++ b/jobrunner/handlers/publish_draft.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" "dappco.re/go/core/scm/forge" @@ -10,11 +12,13 @@ import ( ) // PublishDraftHandler marks a draft PR as ready for review once its checks pass. +// type PublishDraftHandler struct { forge *forge.Client } // NewPublishDraftHandler creates a handler that publishes draft PRs. +// func NewPublishDraftHandler(f *forge.Client) *PublishDraftHandler { return &PublishDraftHandler{forge: f} } diff --git a/jobrunner/handlers/resolve_threads.go b/jobrunner/handlers/resolve_threads.go index 19f8480..51dc23d 100644 --- a/jobrunner/handlers/resolve_threads.go +++ b/jobrunner/handlers/resolve_threads.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" @@ -15,11 +17,13 @@ import ( // DismissReviewsHandler dismisses stale "request changes" reviews on a PR. // This replaces the GitHub-only ResolveThreadsHandler because Forgejo does // not have a thread resolution API. +// type DismissReviewsHandler struct { forge *forge.Client } // NewDismissReviewsHandler creates a handler that dismisses stale reviews. +// func NewDismissReviewsHandler(f *forge.Client) *DismissReviewsHandler { return &DismissReviewsHandler{forge: f} } diff --git a/jobrunner/handlers/resolve_threads_test.go b/jobrunner/handlers/resolve_threads_test.go index ec9dfd6..6e85c6a 100644 --- a/jobrunner/handlers/resolve_threads_test.go +++ b/jobrunner/handlers/resolve_threads_test.go @@ -2,7 +2,7 @@ package handlers import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/jobrunner/handlers/send_fix_command.go b/jobrunner/handlers/send_fix_command.go index 5b65eab..b10b222 100644 --- a/jobrunner/handlers/send_fix_command.go +++ b/jobrunner/handlers/send_fix_command.go @@ -1,8 +1,10 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" "time" "dappco.re/go/core/scm/forge" @@ -11,11 +13,13 @@ import ( // SendFixCommandHandler posts a comment on a PR asking for conflict or // review fixes. +// type SendFixCommandHandler struct { forge *forge.Client } // NewSendFixCommandHandler creates a handler that posts fix commands. +// func NewSendFixCommandHandler(f *forge.Client) *SendFixCommandHandler { return &SendFixCommandHandler{forge: f} } diff --git a/jobrunner/handlers/testhelper_test.go b/jobrunner/handlers/testhelper_test.go index 20d966b..fb78991 100644 --- a/jobrunner/handlers/testhelper_test.go +++ b/jobrunner/handlers/testhelper_test.go @@ -1,8 +1,8 @@ package handlers import ( + strings "dappco.re/go/core/scm/internal/ax/stringsx" "net/http" - "strings" "testing" "github.com/stretchr/testify/require" diff --git a/jobrunner/handlers/tick_parent.go b/jobrunner/handlers/tick_parent.go index 2d4bb74..9b04599 100644 --- a/jobrunner/handlers/tick_parent.go +++ b/jobrunner/handlers/tick_parent.go @@ -1,9 +1,11 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package handlers import ( "context" - "fmt" - "strings" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "time" forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" @@ -15,11 +17,13 @@ import ( // TickParentHandler ticks a child checkbox in the parent epic issue body // after the child's PR has been merged. +// type TickParentHandler struct { forge *forge.Client } // NewTickParentHandler creates a handler that ticks parent epic checkboxes. +// func NewTickParentHandler(f *forge.Client) *TickParentHandler { return &TickParentHandler{forge: f} } diff --git a/jobrunner/handlers/tick_parent_test.go b/jobrunner/handlers/tick_parent_test.go index 836ecdf..3155210 100644 --- a/jobrunner/handlers/tick_parent_test.go +++ b/jobrunner/handlers/tick_parent_test.go @@ -2,11 +2,11 @@ package handlers import ( "context" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "io" "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/assert" diff --git a/jobrunner/journal.go b/jobrunner/journal.go index 2e3976b..e3605a5 100644 --- a/jobrunner/journal.go +++ b/jobrunner/journal.go @@ -1,21 +1,24 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package jobrunner import ( - "encoding/json" - "os" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "regexp" - "strings" "sync" - coreerr "dappco.re/go/core/log" coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) // validPathComponent matches safe repo owner/name characters (alphanumeric, hyphen, underscore, dot). var validPathComponent = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) // JournalEntry is a single line in the JSONL audit log. +// type JournalEntry struct { Timestamp string `json:"ts"` Epic int `json:"epic"` @@ -29,6 +32,7 @@ type JournalEntry struct { } // SignalSnapshot captures the structural state of a PR at the time of action. +// type SignalSnapshot struct { PRState string `json:"pr_state"` IsDraft bool `json:"is_draft"` @@ -39,6 +43,7 @@ type SignalSnapshot struct { } // ResultSnapshot captures the outcome of an action. +// type ResultSnapshot struct { Success bool `json:"success"` Error string `json:"error,omitempty"` @@ -46,12 +51,14 @@ type ResultSnapshot struct { } // Journal writes ActionResult entries to date-partitioned JSONL files. +// type Journal struct { baseDir string mu sync.Mutex } // NewJournal creates a new Journal rooted at baseDir. +// func NewJournal(baseDir string) (*Journal, error) { if baseDir == "" { return nil, coreerr.E("jobrunner.NewJournal", "base directory is required", nil) diff --git a/jobrunner/journal_test.go b/jobrunner/journal_test.go index a17a88b..b127eb7 100644 --- a/jobrunner/journal_test.go +++ b/jobrunner/journal_test.go @@ -2,10 +2,10 @@ package jobrunner import ( "bufio" - "encoding/json" - "os" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "testing" "time" diff --git a/jobrunner/poller.go b/jobrunner/poller.go index 302c563..bb0700d 100644 --- a/jobrunner/poller.go +++ b/jobrunner/poller.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package jobrunner import ( @@ -9,6 +11,7 @@ import ( ) // PollerConfig configures a Poller. +// type PollerConfig struct { Sources []JobSource Handlers []JobHandler @@ -18,6 +21,7 @@ type PollerConfig struct { } // Poller discovers signals from sources and dispatches them to handlers. +// type Poller struct { mu sync.RWMutex sources []JobSource @@ -29,6 +33,7 @@ type Poller struct { } // NewPoller creates a Poller from the given config. +// func NewPoller(cfg PollerConfig) *Poller { interval := cfg.PollInterval if interval <= 0 { diff --git a/jobrunner/types.go b/jobrunner/types.go index ce51caf..023793b 100644 --- a/jobrunner/types.go +++ b/jobrunner/types.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package jobrunner import ( @@ -7,6 +9,7 @@ import ( // PipelineSignal is the structural snapshot of a child issue/PR. // Carries structural state plus issue title/body for dispatch prompts. +// type PipelineSignal struct { EpicNumber int ChildNumber int @@ -43,6 +46,7 @@ func (s *PipelineSignal) HasUnresolvedThreads() bool { } // ActionResult carries the outcome of a handler execution. +// type ActionResult struct { Action string `json:"action"` RepoOwner string `json:"repo_owner"` @@ -58,6 +62,7 @@ type ActionResult struct { } // JobSource discovers actionable work from an external system. +// type JobSource interface { Name() string Poll(ctx context.Context) ([]*PipelineSignal, error) @@ -65,6 +70,7 @@ type JobSource interface { } // JobHandler processes a single pipeline signal. +// type JobHandler interface { Name() string Match(signal *PipelineSignal) bool diff --git a/jobrunner/types_test.go b/jobrunner/types_test.go index c81a840..c16c4d4 100644 --- a/jobrunner/types_test.go +++ b/jobrunner/types_test.go @@ -1,7 +1,7 @@ package jobrunner import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "testing" "time" diff --git a/locales/embed.go b/locales/embed.go index 410cb55..4829926 100644 --- a/locales/embed.go +++ b/locales/embed.go @@ -1,7 +1,11 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package locales embeds translation files for this module. package locales import "embed" +// +// //go:embed *.json var FS embed.FS diff --git a/manifest/compile.go b/manifest/compile.go index d9f00ea..fff7da6 100644 --- a/manifest/compile.go +++ b/manifest/compile.go @@ -1,18 +1,21 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package manifest import ( "crypto/ed25519" - "encoding/json" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" "time" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) // CompiledManifest is the distribution-ready form of a manifest, written as // core.json at the repository root (not inside .core/). It embeds the // original Manifest and adds build metadata stapled at compile time. +// type CompiledManifest struct { Manifest `json:",inline" yaml:",inline"` @@ -24,15 +27,17 @@ type CompiledManifest struct { } // CompileOptions controls how Compile populates the build metadata. +// type CompileOptions struct { - Commit string // Git commit hash - Tag string // Git tag (e.g. v1.0.0) - BuiltBy string // Builder identity (e.g. "core build") + Commit string // Git commit hash + Tag string // Git tag (e.g. v1.0.0) + BuiltBy string // Builder identity (e.g. "core build") SignKey ed25519.PrivateKey // Optional — signs before compiling } // Compile produces a CompiledManifest from a source manifest and build // options. If opts.SignKey is provided the manifest is signed first. +// func Compile(m *Manifest, opts CompileOptions) (*CompiledManifest, error) { if m == nil { return nil, coreerr.E("manifest.Compile", "nil manifest", nil) @@ -61,11 +66,13 @@ func Compile(m *Manifest, opts CompileOptions) (*CompiledManifest, error) { } // MarshalJSON serialises a CompiledManifest to JSON bytes. +// func MarshalJSON(cm *CompiledManifest) ([]byte, error) { return json.MarshalIndent(cm, "", " ") } // ParseCompiled decodes a core.json into a CompiledManifest. +// func ParseCompiled(data []byte) (*CompiledManifest, error) { var cm CompiledManifest if err := json.Unmarshal(data, &cm); err != nil { @@ -78,6 +85,7 @@ const compiledPath = "core.json" // WriteCompiled writes a CompiledManifest as core.json to the given root // directory. The file lives at the distribution root, not inside .core/. +// func WriteCompiled(medium io.Medium, root string, cm *CompiledManifest) error { data, err := MarshalJSON(cm) if err != nil { @@ -88,6 +96,7 @@ func WriteCompiled(medium io.Medium, root string, cm *CompiledManifest) error { } // LoadCompiled reads and parses a core.json from the given root directory. +// func LoadCompiled(medium io.Medium, root string) (*CompiledManifest, error) { path := filepath.Join(root, compiledPath) data, err := medium.Read(path) diff --git a/manifest/compile_test.go b/manifest/compile_test.go index 09cdaa0..8e8655f 100644 --- a/manifest/compile_test.go +++ b/manifest/compile_test.go @@ -3,7 +3,7 @@ package manifest import ( "crypto/ed25519" "crypto/rand" - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "testing" "dappco.re/go/core/io" diff --git a/manifest/loader.go b/manifest/loader.go index b454690..cef4efa 100644 --- a/manifest/loader.go +++ b/manifest/loader.go @@ -1,22 +1,26 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package manifest import ( "crypto/ed25519" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) const manifestPath = ".core/manifest.yaml" // MarshalYAML serializes a manifest to YAML bytes. +// func MarshalYAML(m *Manifest) ([]byte, error) { return yaml.Marshal(m) } // Load reads and parses a .core/manifest.yaml from the given root directory. +// func Load(medium io.Medium, root string) (*Manifest, error) { path := filepath.Join(root, manifestPath) data, err := medium.Read(path) @@ -27,6 +31,7 @@ func Load(medium io.Medium, root string) (*Manifest, error) { } // LoadVerified reads, parses, and verifies the ed25519 signature. +// func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) { m, err := Load(medium, root) if err != nil { diff --git a/manifest/manifest.go b/manifest/manifest.go index f0eede9..7a3bf14 100644 --- a/manifest/manifest.go +++ b/manifest/manifest.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package manifest import ( @@ -6,6 +8,7 @@ import ( ) // Manifest represents a .core/manifest.yaml application manifest. +// type Manifest struct { Code string `yaml:"code" json:"code"` Name string `yaml:"name" json:"name"` @@ -25,12 +28,13 @@ type Manifest struct { Element *ElementSpec `yaml:"element,omitempty" json:"element,omitempty"` // Custom element for GUI rendering Spec string `yaml:"spec,omitempty" json:"spec,omitempty"` // Path to OpenAPI spec file - Permissions Permissions `yaml:"permissions,omitempty" json:"permissions,omitempty"` - Modules []string `yaml:"modules,omitempty" json:"modules,omitempty"` - Daemons map[string]DaemonSpec `yaml:"daemons,omitempty" json:"daemons,omitempty"` + Permissions Permissions `yaml:"permissions,omitempty" json:"permissions,omitempty"` + Modules []string `yaml:"modules,omitempty" json:"modules,omitempty"` + Daemons map[string]DaemonSpec `yaml:"daemons,omitempty" json:"daemons,omitempty"` } // ElementSpec describes a web component for GUI rendering. +// type ElementSpec struct { // Tag is the custom element tag name, e.g. "core-cool-widget". Tag string `yaml:"tag" json:"tag"` @@ -46,6 +50,7 @@ func (m *Manifest) IsProvider() bool { } // Permissions declares the I/O capabilities a module requires. +// type Permissions struct { Read []string `yaml:"read" json:"read"` Write []string `yaml:"write" json:"write"` @@ -54,6 +59,7 @@ type Permissions struct { } // DaemonSpec describes a long-running process managed by the runtime. +// type DaemonSpec struct { Binary string `yaml:"binary,omitempty" json:"binary,omitempty"` Args []string `yaml:"args,omitempty" json:"args,omitempty"` @@ -62,6 +68,8 @@ type DaemonSpec struct { } // Parse decodes YAML bytes into a Manifest. +// +// m, err := manifest.Parse(yamlBytes) func Parse(data []byte) (*Manifest, error) { var m Manifest if err := yaml.Unmarshal(data, &m); err != nil { diff --git a/manifest/sign.go b/manifest/sign.go index 7eb922f..6934274 100644 --- a/manifest/sign.go +++ b/manifest/sign.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package manifest import ( @@ -16,6 +18,7 @@ func signable(m *Manifest) ([]byte, error) { } // Sign computes the ed25519 signature and stores it in m.Sign (base64). +// func Sign(m *Manifest, priv ed25519.PrivateKey) error { msg, err := signable(m) if err != nil { @@ -27,6 +30,7 @@ func Sign(m *Manifest, priv ed25519.PrivateKey) error { } // Verify checks the ed25519 signature in m.Sign against the public key. +// func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) { if m.Sign == "" { return false, coreerr.E("manifest.Verify", "no signature present", nil) diff --git a/marketplace/builder.go b/marketplace/builder.go index c85f182..8757a14 100644 --- a/marketplace/builder.go +++ b/marketplace/builder.go @@ -1,22 +1,27 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package marketplace import ( - "encoding/json" - "log" - "os" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + os "dappco.re/go/core/scm/internal/ax/osx" "sort" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "dappco.re/go/core/scm/manifest" ) // IndexVersion is the current marketplace index format version. +// const IndexVersion = 1 // Builder constructs a marketplace Index by crawling directories for // core.json (compiled manifests) or .core/manifest.yaml files. +// type Builder struct { // BaseURL is the prefix for constructing repository URLs, e.g. // "https://forge.lthn.ai". When set, module Repo is derived as @@ -50,7 +55,7 @@ func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) { m, err := b.loadFromDir(filepath.Join(dir, e.Name())) if err != nil { - log.Printf("marketplace: skipping %s: %v", e.Name(), err) + core.Warn(core.Sprintf("marketplace: skipping %s: %v", e.Name(), err)) continue } if m == nil { @@ -83,6 +88,7 @@ func (b *Builder) BuildFromDirs(dirs ...string) (*Index, error) { // BuildFromManifests constructs an Index from pre-loaded manifests. // This is useful when manifests have already been collected (e.g. from // a Forge API crawl). +// func BuildFromManifests(manifests []*manifest.Manifest) *Index { var modules []Module seen := make(map[string]bool) @@ -113,6 +119,7 @@ func BuildFromManifests(manifests []*manifest.Manifest) *Index { } // WriteIndex serialises an Index to JSON and writes it to the given path. +// func WriteIndex(path string, idx *Index) error { if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { return coreerr.E("marketplace.WriteIndex", "mkdir failed", err) diff --git a/marketplace/builder_test.go b/marketplace/builder_test.go index baf3121..97920c8 100644 --- a/marketplace/builder_test.go +++ b/marketplace/builder_test.go @@ -1,9 +1,9 @@ package marketplace import ( - "encoding/json" - "os" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + os "dappco.re/go/core/scm/internal/ax/osx" "testing" "dappco.re/go/core/scm/manifest" diff --git a/marketplace/discovery.go b/marketplace/discovery.go index e9e8d89..d315384 100644 --- a/marketplace/discovery.go +++ b/marketplace/discovery.go @@ -1,17 +1,20 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package marketplace import ( - "log" - "os" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + os "dappco.re/go/core/scm/internal/ax/osx" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "dappco.re/go/core/scm/manifest" "gopkg.in/yaml.v3" ) // DiscoveredProvider represents a runtime provider found on disk. +// type DiscoveredProvider struct { // Dir is the absolute path to the provider directory. Dir string @@ -24,6 +27,7 @@ type DiscoveredProvider struct { // Each subdirectory is checked for a .core/manifest.yaml file. Directories // without a valid manifest are skipped with a log warning. // Only manifests with provider fields (namespace + binary) are returned. +// func DiscoverProviders(dir string) ([]DiscoveredProvider, error) { entries, err := os.ReadDir(dir) if err != nil { @@ -44,18 +48,18 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) { raw, err := coreio.Local.Read(manifestPath) if err != nil { - log.Printf("marketplace: skipping %s: %v", e.Name(), err) + core.Warn(core.Sprintf("marketplace: skipping %s: %v", e.Name(), err)) continue } m, err := manifest.Parse([]byte(raw)) if err != nil { - log.Printf("marketplace: skipping %s: invalid manifest: %v", e.Name(), err) + core.Warn(core.Sprintf("marketplace: skipping %s: invalid manifest: %v", e.Name(), err)) continue } if !m.IsProvider() { - log.Printf("marketplace: skipping %s: not a provider (missing namespace or binary)", e.Name()) + core.Warn(core.Sprintf("marketplace: skipping %s: not a provider (missing namespace or binary)", e.Name())) continue } @@ -69,6 +73,7 @@ func DiscoverProviders(dir string) ([]DiscoveredProvider, error) { } // ProviderRegistryEntry records metadata about an installed provider. +// type ProviderRegistryEntry struct { Installed string `yaml:"installed" json:"installed"` Version string `yaml:"version" json:"version"` @@ -77,6 +82,7 @@ type ProviderRegistryEntry struct { } // ProviderRegistryFile represents the registry.yaml file tracking installed providers. +// type ProviderRegistryFile struct { Version int `yaml:"version" json:"version"` Providers map[string]ProviderRegistryEntry `yaml:"providers" json:"providers"` @@ -84,6 +90,7 @@ type ProviderRegistryFile struct { // LoadProviderRegistry reads a registry.yaml file from the given path. // Returns an empty registry if the file does not exist. +// func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) { raw, err := coreio.Local.Read(path) if err != nil { @@ -109,6 +116,7 @@ func LoadProviderRegistry(path string) (*ProviderRegistryFile, error) { } // SaveProviderRegistry writes the registry to the given path. +// func SaveProviderRegistry(path string, reg *ProviderRegistryFile) error { if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { return coreerr.E("marketplace.SaveProviderRegistry", "ensure directory", err) diff --git a/marketplace/discovery_test.go b/marketplace/discovery_test.go index c96c9c8..5edb8c8 100644 --- a/marketplace/discovery_test.go +++ b/marketplace/discovery_test.go @@ -1,8 +1,8 @@ package marketplace import ( - "os" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + os "dappco.re/go/core/scm/internal/ax/osx" "testing" "github.com/stretchr/testify/assert" diff --git a/marketplace/installer.go b/marketplace/installer.go index 50a9686..87ba272 100644 --- a/marketplace/installer.go +++ b/marketplace/installer.go @@ -1,23 +1,27 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package marketplace import ( "context" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" "encoding/hex" - "encoding/json" - "os/exec" - "path/filepath" - "strings" + exec "golang.org/x/sys/execabs" "time" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" - "dappco.re/go/core/scm/manifest" "dappco.re/go/core/io/store" + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" + "dappco.re/go/core/scm/manifest" ) const storeGroup = "_modules" // Installer handles module installation from Git repos. +// type Installer struct { medium io.Medium modulesDir string @@ -25,6 +29,7 @@ type Installer struct { } // NewInstaller creates a new module installer. +// func NewInstaller(m io.Medium, modulesDir string, st *store.Store) *Installer { return &Installer{ medium: m, @@ -34,6 +39,7 @@ func NewInstaller(m io.Medium, modulesDir string, st *store.Store) *Installer { } // InstalledModule holds stored metadata about an installed module. +// type InstalledModule struct { Code string `json:"code"` Name string `json:"name"` @@ -47,12 +53,16 @@ type InstalledModule struct { // Install clones a module repo, verifies its manifest signature, and registers it. func (i *Installer) Install(ctx context.Context, mod Module) error { - // Check if already installed - if _, err := i.store.Get(storeGroup, mod.Code); err == nil { - return coreerr.E("marketplace.Installer.Install", "module already installed: "+mod.Code, nil) + safeCode, dest, err := i.resolveModulePath(mod.Code) + if err != nil { + return coreerr.E("marketplace.Installer.Install", "invalid module code", err) + } + + // Check if already installed + if _, err := i.store.Get(storeGroup, safeCode); err == nil { + return coreerr.E("marketplace.Installer.Install", "module already installed: "+safeCode, nil) } - dest := filepath.Join(i.modulesDir, mod.Code) if err := i.medium.EnsureDir(i.modulesDir); err != nil { return coreerr.E("marketplace.Installer.Install", "mkdir", err) } @@ -80,7 +90,7 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { entryPoint := filepath.Join(dest, "main.ts") installed := InstalledModule{ - Code: mod.Code, + Code: safeCode, Name: m.Name, Version: m.Version, Repo: mod.Repo, @@ -95,7 +105,7 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { return coreerr.E("marketplace.Installer.Install", "marshal", err) } - if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil { + if err := i.store.Set(storeGroup, safeCode, string(data)); err != nil { return coreerr.E("marketplace.Installer.Install", "store", err) } @@ -105,21 +115,32 @@ func (i *Installer) Install(ctx context.Context, mod Module) error { // Remove uninstalls a module by deleting its files and store entry. func (i *Installer) Remove(code string) error { - if _, err := i.store.Get(storeGroup, code); err != nil { - return coreerr.E("marketplace.Installer.Remove", "module not installed: "+code, nil) + safeCode, dest, err := i.resolveModulePath(code) + if err != nil { + return coreerr.E("marketplace.Installer.Remove", "invalid module code", err) } - dest := filepath.Join(i.modulesDir, code) - _ = i.medium.DeleteAll(dest) + if _, err := i.store.Get(storeGroup, safeCode); err != nil { + return coreerr.E("marketplace.Installer.Remove", "module not installed: "+safeCode, nil) + } - return i.store.Delete(storeGroup, code) + if err := i.medium.DeleteAll(dest); err != nil { + return coreerr.E("marketplace.Installer.Remove", "delete module files", err) + } + + return i.store.Delete(storeGroup, safeCode) } // Update pulls latest changes and re-verifies the manifest. func (i *Installer) Update(ctx context.Context, code string) error { - raw, err := i.store.Get(storeGroup, code) + safeCode, dest, err := i.resolveModulePath(code) if err != nil { - return coreerr.E("marketplace.Installer.Update", "module not installed: "+code, nil) + return coreerr.E("marketplace.Installer.Update", "invalid module code", err) + } + + raw, err := i.store.Get(storeGroup, safeCode) + if err != nil { + return coreerr.E("marketplace.Installer.Update", "module not installed: "+safeCode, nil) } var installed InstalledModule @@ -127,8 +148,6 @@ func (i *Installer) Update(ctx context.Context, code string) error { return coreerr.E("marketplace.Installer.Update", "unmarshal", err) } - dest := filepath.Join(i.modulesDir, code) - cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only") if output, err := cmd.CombinedOutput(); err != nil { return coreerr.E("marketplace.Installer.Update", "pull: "+strings.TrimSpace(string(output)), err) @@ -145,6 +164,7 @@ func (i *Installer) Update(ctx context.Context, code string) error { } // Update stored metadata + installed.Code = safeCode installed.Name = m.Name installed.Version = m.Version installed.Permissions = m.Permissions @@ -154,7 +174,7 @@ func (i *Installer) Update(ctx context.Context, code string) error { return coreerr.E("marketplace.Installer.Update", "marshal", err) } - return i.store.Set(storeGroup, code, string(data)) + return i.store.Set(storeGroup, safeCode, string(data)) } // Installed returns all installed module metadata. @@ -195,3 +215,11 @@ func gitClone(ctx context.Context, repo, dest string) error { } return nil } + +func (i *Installer) resolveModulePath(code string) (string, string, error) { + safeCode, dest, err := agentci.ResolvePathWithinRoot(i.modulesDir, code) + if err != nil { + return "", "", coreerr.E("marketplace.Installer.resolveModulePath", "resolve module path", err) + } + return safeCode, dest, nil +} diff --git a/marketplace/installer_test.go b/marketplace/installer_test.go index 358e69a..803e2a7 100644 --- a/marketplace/installer_test.go +++ b/marketplace/installer_test.go @@ -3,15 +3,15 @@ package marketplace import ( "context" "crypto/ed25519" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + os "dappco.re/go/core/scm/internal/ax/osx" "encoding/hex" - "os" - "os/exec" - "path/filepath" + exec "golang.org/x/sys/execabs" "testing" "dappco.re/go/core/io" - "dappco.re/go/core/scm/manifest" "dappco.re/go/core/io/store" + "dappco.re/go/core/scm/manifest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -163,6 +163,29 @@ func TestInstall_Bad_InvalidSignature(t *testing.T) { assert.True(t, os.IsNotExist(statErr), "directory should be cleaned up on failure") } +func TestInstall_Bad_PathTraversalCode(t *testing.T) { + repo := createTestRepo(t, "safe-mod", "1.0") + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(io.Local, modulesDir, st) + err = inst.Install(context.Background(), Module{ + Code: "../escape", + Repo: repo, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid module code") + + _, err = st.Get("_modules", "escape") + assert.Error(t, err) + + _, err = os.Stat(filepath.Join(filepath.Dir(modulesDir), "escape")) + assert.True(t, os.IsNotExist(err)) +} + func TestRemove_Good(t *testing.T) { repo := createTestRepo(t, "rm-mod", "1.0") modulesDir := filepath.Join(t.TempDir(), "modules") @@ -197,6 +220,26 @@ func TestRemove_Bad_NotInstalled(t *testing.T) { assert.Contains(t, err.Error(), "not installed") } +func TestRemove_Bad_PathTraversalCode(t *testing.T) { + baseDir := t.TempDir() + modulesDir := filepath.Join(baseDir, "modules") + escapeDir := filepath.Join(baseDir, "escape") + require.NoError(t, os.MkdirAll(escapeDir, 0755)) + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(io.Local, modulesDir, st) + err = inst.Remove("../escape") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid module code") + + info, statErr := os.Stat(escapeDir) + require.NoError(t, statErr) + assert.True(t, info.IsDir()) +} + func TestInstalled_Good(t *testing.T) { modulesDir := filepath.Join(t.TempDir(), "modules") @@ -262,3 +305,16 @@ func TestUpdate_Good(t *testing.T) { assert.Equal(t, "2.0", installed[0].Version) assert.Equal(t, "Updated Module", installed[0].Name) } + +func TestUpdate_Bad_PathTraversalCode(t *testing.T) { + modulesDir := filepath.Join(t.TempDir(), "modules") + + st, err := store.New(":memory:") + require.NoError(t, err) + defer st.Close() + + inst := NewInstaller(io.Local, modulesDir, st) + err = inst.Update(context.Background(), "../escape") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid module code") +} diff --git a/marketplace/marketplace.go b/marketplace/marketplace.go index c97fe39..4e3d3f1 100644 --- a/marketplace/marketplace.go +++ b/marketplace/marketplace.go @@ -1,13 +1,16 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package marketplace import ( - "encoding/json" - "strings" + json "dappco.re/go/core/scm/internal/ax/jsonx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" coreerr "dappco.re/go/core/log" ) // Module is a marketplace entry pointing to a module's Git repo. +// type Module struct { Code string `json:"code"` Name string `json:"name"` @@ -17,6 +20,7 @@ type Module struct { } // Index is the root marketplace catalog. +// type Index struct { Version int `json:"version"` Modules []Module `json:"modules"` @@ -24,6 +28,7 @@ type Index struct { } // ParseIndex decodes a marketplace index.json. +// func ParseIndex(data []byte) (*Index, error) { var idx Index if err := json.Unmarshal(data, &idx); err != nil { diff --git a/pkg/api/embed.go b/pkg/api/embed.go index 981cfb7..c245535 100644 --- a/pkg/api/embed.go +++ b/pkg/api/embed.go @@ -7,5 +7,7 @@ import "embed" // Assets holds the built UI bundle (core-scm.js and related files). // The directory is populated by running `npm run build` in the ui/ directory. // +// +// //go:embed all:ui/dist var Assets embed.FS diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 77475c9..74927ec 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -10,10 +10,12 @@ import ( "crypto/ed25519" "encoding/hex" "net/http" + "net/url" "dappco.re/go/core/api" "dappco.re/go/core/api/pkg/provider" "dappco.re/go/core/io" + "dappco.re/go/core/scm/agentci" "dappco.re/go/core/scm/manifest" "dappco.re/go/core/scm/marketplace" "dappco.re/go/core/scm/repos" @@ -24,6 +26,7 @@ import ( // ScmProvider wraps go-scm marketplace, manifest, and registry operations // as a service provider. It implements Provider, Streamable, Describable, // and Renderable. +// type ScmProvider struct { index *marketplace.Index installer *marketplace.Installer @@ -43,6 +46,7 @@ var ( // NewProvider creates an SCM provider backed by the given marketplace index, // installer, and registry. The WS hub is used to emit real-time events. // Pass nil for any dependency that is not available. +// func NewProvider(idx *marketplace.Index, inst *marketplace.Installer, reg *repos.Registry, hub *ws.Hub) *ScmProvider { return &ScmProvider{ index: idx, @@ -228,7 +232,10 @@ func (p *ScmProvider) getMarketplaceItem(c *gin.Context) { return } - code := c.Param("code") + code, ok := marketplaceCodeParam(c) + if !ok { + return + } mod, ok := p.index.Find(code) if !ok { c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) @@ -243,7 +250,10 @@ func (p *ScmProvider) installItem(c *gin.Context) { return } - code := c.Param("code") + code, ok := marketplaceCodeParam(c) + if !ok { + return + } mod, ok := p.index.Find(code) if !ok { c.JSON(http.StatusNotFound, api.Fail("not_found", "provider not found in marketplace")) @@ -269,7 +279,10 @@ func (p *ScmProvider) removeItem(c *gin.Context) { return } - code := c.Param("code") + code, ok := marketplaceCodeParam(c) + if !ok { + return + } if err := p.installer.Remove(code); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("remove_failed", err.Error())) return @@ -393,7 +406,10 @@ func (p *ScmProvider) updateInstalled(c *gin.Context) { return } - code := c.Param("code") + code, ok := marketplaceCodeParam(c) + if !ok { + return + } if err := p.installer.Update(context.Background(), code); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("update_failed", err.Error())) return @@ -448,3 +464,21 @@ func (p *ScmProvider) emitEvent(channel string, data any) { Data: data, }) } + +func marketplaceCodeParam(c *gin.Context) (string, bool) { + code, err := normaliseMarketplaceCode(c.Param("code")) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_code", "invalid marketplace code")) + return "", false + } + return code, true +} + +func normaliseMarketplaceCode(raw string) (string, error) { + decoded, err := url.PathUnescape(raw) + if err != nil { + return "", err + } + + return agentci.ValidatePathElement(decoded) +} diff --git a/pkg/api/provider_handlers_test.go b/pkg/api/provider_handlers_test.go index 80fb970..8e7ef9d 100644 --- a/pkg/api/provider_handlers_test.go +++ b/pkg/api/provider_handlers_test.go @@ -3,7 +3,7 @@ package api_test import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 7e72509..d3177dc 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -3,7 +3,7 @@ package api_test import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" "net/http" "net/http/httptest" "testing" @@ -164,6 +164,18 @@ func TestScmProvider_GetMarketplaceItem_Bad(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +func TestScmProvider_GetMarketplaceItem_Bad_PathTraversal(t *testing.T) { + idx := &marketplace.Index{Version: 1} + p := scmapi.NewProvider(idx, nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/scm/marketplace/%2e%2e", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + // -- Installed Endpoints ------------------------------------------------------ func TestScmProvider_ListInstalled_NilInstaller_Good(t *testing.T) { diff --git a/plugin/config.go b/plugin/config.go index 3155489..2b5e4c5 100644 --- a/plugin/config.go +++ b/plugin/config.go @@ -1,6 +1,9 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package plugin // PluginConfig holds configuration for a single installed plugin. +// type PluginConfig struct { Name string `json:"name" yaml:"name"` Version string `json:"version" yaml:"version"` diff --git a/plugin/installer.go b/plugin/installer.go index d98c59c..723902e 100644 --- a/plugin/installer.go +++ b/plugin/installer.go @@ -1,24 +1,30 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package plugin import ( "context" - "fmt" - "os/exec" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" + exec "golang.org/x/sys/execabs" + "net/url" "time" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + "dappco.re/go/core/scm/agentci" ) // Installer handles plugin installation from GitHub. +// type Installer struct { medium io.Medium registry *Registry } // NewInstaller creates a new plugin installer. +// func NewInstaller(m io.Medium, registry *Registry) *Installer { return &Installer{ medium: m, @@ -40,7 +46,10 @@ func (i *Installer) Install(ctx context.Context, source string) error { } // Clone the repository - pluginDir := filepath.Join(i.registry.basePath, repo) + _, pluginDir, err := i.resolvePluginPath(repo) + if err != nil { + return coreerr.E("plugin.Installer.Install", "invalid plugin path", err) + } if err := i.medium.EnsureDir(pluginDir); err != nil { return coreerr.E("plugin.Installer.Install", "failed to create plugin directory", err) } @@ -90,14 +99,15 @@ func (i *Installer) Install(ctx context.Context, source string) error { // Update updates a plugin to the latest version. func (i *Installer) Update(ctx context.Context, name string) error { - cfg, ok := i.registry.Get(name) - if !ok { - return coreerr.E("plugin.Installer.Update", "plugin not found: "+name, nil) + safeName, pluginDir, err := i.resolvePluginPath(name) + if err != nil { + return coreerr.E("plugin.Installer.Update", "invalid plugin name", err) } - // Parse the source to get org/repo - source := strings.TrimPrefix(cfg.Source, "github:") - pluginDir := filepath.Join(i.registry.basePath, name) + cfg, ok := i.registry.Get(safeName) + if !ok { + return coreerr.E("plugin.Installer.Update", "plugin not found: "+safeName, nil) + } // Pull latest changes cmd := exec.CommandContext(ctx, "git", "-C", pluginDir, "pull", "--ff-only") @@ -118,18 +128,21 @@ func (i *Installer) Update(ctx context.Context, name string) error { return coreerr.E("plugin.Installer.Update", "failed to save registry", err) } - _ = source // used for context return nil } // Remove uninstalls a plugin by removing its files and registry entry. func (i *Installer) Remove(name string) error { - if _, ok := i.registry.Get(name); !ok { - return coreerr.E("plugin.Installer.Remove", "plugin not found: "+name, nil) + safeName, pluginDir, err := i.resolvePluginPath(name) + if err != nil { + return coreerr.E("plugin.Installer.Remove", "invalid plugin name", err) + } + + if _, ok := i.registry.Get(safeName); !ok { + return coreerr.E("plugin.Installer.Remove", "plugin not found: "+safeName, nil) } // Delete plugin directory - pluginDir := filepath.Join(i.registry.basePath, name) if i.medium.Exists(pluginDir) { if err := i.medium.DeleteAll(pluginDir); err != nil { return coreerr.E("plugin.Installer.Remove", "failed to delete plugin files", err) @@ -137,7 +150,7 @@ func (i *Installer) Remove(name string) error { } // Remove from registry - if err := i.registry.Remove(name); err != nil { + if err := i.registry.Remove(safeName); err != nil { return coreerr.E("plugin.Installer.Remove", "failed to unregister plugin", err) } @@ -169,7 +182,13 @@ func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest stri // Accepted formats: // - "org/repo" -> org="org", repo="repo", version="" // - "org/repo@v1.0" -> org="org", repo="repo", version="v1.0" +// +// func ParseSource(source string) (org, repo, version string, err error) { + source, err = url.PathUnescape(source) + if err != nil { + return "", "", "", coreerr.E("plugin.ParseSource", "invalid source path", err) + } if source == "" { return "", "", "", coreerr.E("plugin.ParseSource", "source is empty", nil) } @@ -191,5 +210,22 @@ func ParseSource(source string) (org, repo, version string, err error) { return "", "", "", coreerr.E("plugin.ParseSource", "source must be in format org/repo[@version]", nil) } - return parts[0], parts[1], version, nil + org, err = agentci.ValidatePathElement(parts[0]) + if err != nil { + return "", "", "", coreerr.E("plugin.ParseSource", "invalid org", err) + } + repo, err = agentci.ValidatePathElement(parts[1]) + if err != nil { + return "", "", "", coreerr.E("plugin.ParseSource", "invalid repo", err) + } + + return org, repo, version, nil +} + +func (i *Installer) resolvePluginPath(name string) (string, string, error) { + safeName, path, err := agentci.ResolvePathWithinRoot(i.registry.basePath, name) + if err != nil { + return "", "", coreerr.E("plugin.Installer.resolvePluginPath", "resolve plugin path", err) + } + return safeName, path, nil } diff --git a/plugin/loader.go b/plugin/loader.go index 3362886..f3bf677 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -1,19 +1,23 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package plugin import ( - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) // Loader loads plugins from the filesystem. +// type Loader struct { medium io.Medium baseDir string } // NewLoader creates a new plugin loader. +// func NewLoader(m io.Medium, baseDir string) *Loader { return &Loader{ medium: m, diff --git a/plugin/manifest.go b/plugin/manifest.go index 4e87c6f..4da3b7a 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -1,14 +1,17 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package plugin import ( - "encoding/json" + json "dappco.re/go/core/scm/internal/ax/jsonx" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) // Manifest represents a plugin.json manifest file. // Each plugin repository must contain a plugin.json at its root. +// type Manifest struct { Name string `json:"name"` Version string `json:"version"` @@ -20,6 +23,7 @@ type Manifest struct { } // LoadManifest reads and parses a plugin.json file from the given path. +// func LoadManifest(m io.Medium, path string) (*Manifest, error) { content, err := m.Read(path) if err != nil { diff --git a/plugin/plugin.go b/plugin/plugin.go index 9f060ec..60f71eb 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package plugin provides a plugin system for the core CLI. // // Plugins extend the CLI with additional commands and functionality. @@ -14,6 +16,7 @@ package plugin import "context" // Plugin is the interface that all plugins must implement. +// type Plugin interface { // Name returns the plugin's unique identifier. Name() string @@ -33,6 +36,7 @@ type Plugin interface { // BasePlugin provides a default implementation of Plugin. // Embed this in concrete plugin types to inherit default behaviour. +// type BasePlugin struct { PluginName string PluginVersion string diff --git a/plugin/registry.go b/plugin/registry.go index f81a025..9cd4725 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -1,19 +1,22 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package plugin import ( "cmp" - "encoding/json" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + json "dappco.re/go/core/scm/internal/ax/jsonx" "slices" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) const registryFilename = "registry.json" // Registry manages installed plugins. // Plugin metadata is stored in a registry.json file under the base path. +// type Registry struct { medium io.Medium basePath string // e.g., ~/.core/plugins/ @@ -21,6 +24,7 @@ type Registry struct { } // NewRegistry creates a new plugin registry. +// func NewRegistry(m io.Medium, basePath string) *Registry { return &Registry{ medium: m, diff --git a/repos/gitstate.go b/repos/gitstate.go index 15d8436..2c3f0b7 100644 --- a/repos/gitstate.go +++ b/repos/gitstate.go @@ -1,23 +1,27 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package repos import ( - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" "time" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) // GitState holds per-machine git sync state for a workspace. // Stored at .core/git.yaml and .gitignored (not shared across machines). +// type GitState struct { - Version int `yaml:"version"` - Repos map[string]*RepoGitState `yaml:"repos,omitempty"` - Agents map[string]*AgentState `yaml:"agents,omitempty"` + Version int `yaml:"version"` + Repos map[string]*RepoGitState `yaml:"repos,omitempty"` + Agents map[string]*AgentState `yaml:"agents,omitempty"` } // RepoGitState tracks the last known git state for a single repo. +// type RepoGitState struct { LastPull time.Time `yaml:"last_pull,omitempty"` LastPush time.Time `yaml:"last_push,omitempty"` @@ -28,6 +32,7 @@ type RepoGitState struct { } // AgentState tracks which agent last touched which repos. +// type AgentState struct { LastSeen time.Time `yaml:"last_seen"` Active []string `yaml:"active,omitempty"` @@ -35,6 +40,7 @@ type AgentState struct { // LoadGitState reads .core/git.yaml from the given workspace root directory. // Returns a new empty GitState if the file does not exist. +// func LoadGitState(m io.Medium, root string) (*GitState, error) { path := filepath.Join(root, ".core", "git.yaml") @@ -63,6 +69,7 @@ func LoadGitState(m io.Medium, root string) (*GitState, error) { } // SaveGitState writes .core/git.yaml to the given workspace root directory. +// func SaveGitState(m io.Medium, root string, gs *GitState) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { @@ -83,6 +90,7 @@ func SaveGitState(m io.Medium, root string, gs *GitState) error { } // NewGitState returns a new empty GitState with version 1. +// func NewGitState() *GitState { return &GitState{ Version: 1, diff --git a/repos/kbconfig.go b/repos/kbconfig.go index fd8ed3a..7442196 100644 --- a/repos/kbconfig.go +++ b/repos/kbconfig.go @@ -1,16 +1,19 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package repos import ( - "fmt" - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + fmt "dappco.re/go/core/scm/internal/ax/fmtx" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) // KBConfig holds knowledge base configuration for a workspace. // Stored at .core/kb.yaml and checked into git. +// type KBConfig struct { Version int `yaml:"version"` Wiki WikiConfig `yaml:"wiki"` @@ -18,6 +21,7 @@ type KBConfig struct { } // WikiConfig controls local wiki mirror behaviour. +// type WikiConfig struct { // Enabled toggles wiki cloning on sync. Enabled bool `yaml:"enabled"` @@ -29,6 +33,7 @@ type WikiConfig struct { } // KBSearch configures vector search against the OpenBrain Qdrant collection. +// type KBSearch struct { // QdrantHost is the Qdrant server (gRPC). QdrantHost string `yaml:"qdrant_host"` @@ -45,6 +50,7 @@ type KBSearch struct { } // DefaultKBConfig returns sensible defaults for knowledge base config. +// func DefaultKBConfig() *KBConfig { return &KBConfig{ Version: 1, @@ -66,6 +72,7 @@ func DefaultKBConfig() *KBConfig { // LoadKBConfig reads .core/kb.yaml from the given workspace root directory. // Returns defaults if the file does not exist. +// func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) { path := filepath.Join(root, ".core", "kb.yaml") @@ -87,6 +94,7 @@ func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) { } // SaveKBConfig writes .core/kb.yaml to the given workspace root directory. +// func SaveKBConfig(m io.Medium, root string, kb *KBConfig) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { diff --git a/repos/registry.go b/repos/registry.go index f6af2f7..e1d1d91 100644 --- a/repos/registry.go +++ b/repos/registry.go @@ -1,19 +1,22 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + // Package repos provides functionality for managing multi-repo workspaces. // It reads a repos.yaml registry file that defines repositories, their types, // dependencies, and metadata. package repos import ( - "os" - "path/filepath" - "strings" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + os "dappco.re/go/core/scm/internal/ax/osx" + strings "dappco.re/go/core/scm/internal/ax/stringsx" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) // Registry represents a collection of repositories defined in repos.yaml. +// type Registry struct { Version int `yaml:"version"` Org string `yaml:"org"` @@ -24,6 +27,7 @@ type Registry struct { } // RegistryDefaults contains default values applied to all repos. +// type RegistryDefaults struct { CI string `yaml:"ci"` License string `yaml:"license"` @@ -31,21 +35,27 @@ type RegistryDefaults struct { } // RepoType indicates the role of a repository in the ecosystem. +// type RepoType string // Repository type constants for ecosystem classification. const ( // RepoTypeFoundation indicates core foundation packages. + // RepoTypeFoundation RepoType = "foundation" // RepoTypeModule indicates reusable module packages. + // RepoTypeModule RepoType = "module" // RepoTypeProduct indicates end-user product applications. + // RepoTypeProduct RepoType = "product" // RepoTypeTemplate indicates starter templates. + // RepoTypeTemplate RepoType = "template" ) // Repo represents a single repository in the registry. +// type Repo struct { Name string `yaml:"-"` // Set from map key Type string `yaml:"type"` @@ -63,6 +73,8 @@ type Repo struct { // LoadRegistry reads and parses a repos.yaml file from the given medium. // The path should be a valid path for the provided medium. +// +// reg, err := repos.LoadRegistry(io.Local, ".core/repos.yaml") func LoadRegistry(m io.Medium, path string) (*Registry, error) { content, err := m.Read(path) if err != nil { @@ -102,6 +114,8 @@ func LoadRegistry(m io.Medium, path string) (*Registry, error) { // FindRegistry searches for repos.yaml in common locations. // It checks: current directory, parent directories, and home directory. // This function is primarily intended for use with io.Local or other local-like filesystems. +// +// path, err := repos.FindRegistry(io.Local) func FindRegistry(m io.Medium) (string, error) { // Check current directory and parents dir, err := os.Getwd() @@ -152,6 +166,8 @@ func FindRegistry(m io.Medium) (string, error) { // ScanDirectory creates a Registry by scanning a directory for git repos. // This is used as a fallback when no repos.yaml is found. // The dir should be a valid path for the provided medium. +// +// reg, err := repos.ScanDirectory(io.Local, "/home/user/Code/core") func ScanDirectory(m io.Medium, dir string) (*Registry, error) { entries, err := m.List(dir) if err != nil { @@ -241,6 +257,8 @@ func detectOrg(m io.Medium, repoPath string) string { } // List returns all repos in the registry. +// +// repos := reg.List() func (r *Registry) List() []*Repo { repos := make([]*Repo, 0, len(r.Repos)) for _, repo := range r.Repos { @@ -251,12 +269,16 @@ func (r *Registry) List() []*Repo { } // Get returns a repo by name. +// +// repo, ok := reg.Get("go-io") func (r *Registry) Get(name string) (*Repo, bool) { repo, ok := r.Repos[name] return repo, ok } // ByType returns repos filtered by type. +// +// goRepos := reg.ByType("go") func (r *Registry) ByType(t string) []*Repo { var repos []*Repo for _, repo := range r.Repos { @@ -269,6 +291,8 @@ func (r *Registry) ByType(t string) []*Repo { // TopologicalOrder returns repos sorted by dependency order. // Foundation repos come first, then modules, then products. +// +// ordered, err := reg.TopologicalOrder() func (r *Registry) TopologicalOrder() ([]*Repo, error) { // Build dependency graph visited := make(map[string]bool) diff --git a/repos/workconfig.go b/repos/workconfig.go index 7452245..022e99e 100644 --- a/repos/workconfig.go +++ b/repos/workconfig.go @@ -1,24 +1,28 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + package repos import ( - "path/filepath" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" "time" - coreerr "dappco.re/go/core/log" "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) // WorkConfig holds sync policy for a workspace. // Stored at .core/work.yaml and checked into git (shared across the team). +// type WorkConfig struct { - Version int `yaml:"version"` - Sync SyncConfig `yaml:"sync"` - Agents AgentPolicy `yaml:"agents"` - Triggers []string `yaml:"triggers,omitempty"` + Version int `yaml:"version"` + Sync SyncConfig `yaml:"sync"` + Agents AgentPolicy `yaml:"agents"` + Triggers []string `yaml:"triggers,omitempty"` } // SyncConfig controls how and when repos are synced. +// type SyncConfig struct { Interval time.Duration `yaml:"interval"` AutoPull bool `yaml:"auto_pull"` @@ -27,13 +31,15 @@ type SyncConfig struct { } // AgentPolicy controls multi-agent clash prevention. +// type AgentPolicy struct { - Heartbeat time.Duration `yaml:"heartbeat"` - StaleAfter time.Duration `yaml:"stale_after"` - WarnOnOverlap bool `yaml:"warn_on_overlap"` + Heartbeat time.Duration `yaml:"heartbeat"` + StaleAfter time.Duration `yaml:"stale_after"` + WarnOnOverlap bool `yaml:"warn_on_overlap"` } // DefaultWorkConfig returns sensible defaults for workspace sync. +// func DefaultWorkConfig() *WorkConfig { return &WorkConfig{ Version: 1, @@ -54,6 +60,7 @@ func DefaultWorkConfig() *WorkConfig { // LoadWorkConfig reads .core/work.yaml from the given workspace root directory. // Returns defaults if the file does not exist. +// func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) { path := filepath.Join(root, ".core", "work.yaml") @@ -75,6 +82,7 @@ func LoadWorkConfig(m io.Medium, root string) (*WorkConfig, error) { } // SaveWorkConfig writes .core/work.yaml to the given workspace root directory. +// func SaveWorkConfig(m io.Medium, root string, wc *WorkConfig) error { coreDir := filepath.Join(root, ".core") if err := m.EnsureDir(coreDir); err != nil { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 312f000..061ada4 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,3 +1,5 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + import { defineConfig } from 'vite'; import { resolve } from 'path';