refactor(ax): align code with AX principles
This commit is contained in:
parent
2dcb86738a
commit
d5f98c1341
136 changed files with 2064 additions and 421 deletions
|
|
@ -1,27 +1,34 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package agentci
|
package agentci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
"dappco.re/go/core/scm/jobrunner"
|
"dappco.re/go/core/scm/jobrunner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunMode determines the execution strategy for a dispatched task.
|
// RunMode determines the execution strategy for a dispatched task.
|
||||||
|
//
|
||||||
type RunMode string
|
type RunMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
//
|
||||||
ModeStandard RunMode = "standard"
|
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.
|
// Spinner is the Clotho orchestrator that determines the fate of each task.
|
||||||
|
//
|
||||||
type Spinner struct {
|
type Spinner struct {
|
||||||
Config ClothoConfig
|
Config ClothoConfig
|
||||||
Agents map[string]AgentConfig
|
Agents map[string]AgentConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpinner creates a new Clotho orchestrator.
|
// NewSpinner creates a new Clotho orchestrator.
|
||||||
|
//
|
||||||
func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner {
|
func NewSpinner(cfg ClothoConfig, agents map[string]AgentConfig) *Spinner {
|
||||||
return &Spinner{
|
return &Spinner{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package agentci provides configuration, security, and orchestration for AgentCI dispatch targets.
|
// Package agentci provides configuration, security, and orchestration for AgentCI dispatch targets.
|
||||||
package agentci
|
package agentci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
|
||||||
"forge.lthn.ai/core/config"
|
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
"forge.lthn.ai/core/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentConfig represents a single agent machine in the config file.
|
// AgentConfig represents a single agent machine in the config file.
|
||||||
|
//
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
Host string `yaml:"host" mapstructure:"host"`
|
Host string `yaml:"host" mapstructure:"host"`
|
||||||
QueueDir string `yaml:"queue_dir" mapstructure:"queue_dir"`
|
QueueDir string `yaml:"queue_dir" mapstructure:"queue_dir"`
|
||||||
|
|
@ -23,6 +26,7 @@ type AgentConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClothoConfig controls the orchestration strategy.
|
// ClothoConfig controls the orchestration strategy.
|
||||||
|
//
|
||||||
type ClothoConfig struct {
|
type ClothoConfig struct {
|
||||||
Strategy string `yaml:"strategy" mapstructure:"strategy"` // direct, clotho-verified
|
Strategy string `yaml:"strategy" mapstructure:"strategy"` // direct, clotho-verified
|
||||||
ValidationThreshold float64 `yaml:"validation_threshold" mapstructure:"validation_threshold"` // divergence limit (0.0-1.0)
|
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.
|
// LoadAgents reads agent targets from config and returns a map of AgentConfig.
|
||||||
// Returns an empty map (not an error) if no agents are configured.
|
// Returns an empty map (not an error) if no agents are configured.
|
||||||
|
//
|
||||||
func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
||||||
var agents map[string]AgentConfig
|
var agents map[string]AgentConfig
|
||||||
if err := cfg.Get("agentci.agents", &agents); err != nil {
|
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.
|
// LoadActiveAgents returns only active agents.
|
||||||
|
//
|
||||||
func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
||||||
all, err := LoadAgents(cfg)
|
all, err := LoadAgents(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -77,6 +83,7 @@ func LoadActiveAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
||||||
|
|
||||||
// LoadClothoConfig loads the Clotho orchestrator settings.
|
// LoadClothoConfig loads the Clotho orchestrator settings.
|
||||||
// Returns sensible defaults if no config is present.
|
// Returns sensible defaults if no config is present.
|
||||||
|
//
|
||||||
func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) {
|
func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) {
|
||||||
var cc ClothoConfig
|
var cc ClothoConfig
|
||||||
if err := cfg.Get("agentci.clotho", &cc); err != nil {
|
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.
|
// SaveAgent writes an agent config entry to the config file.
|
||||||
|
//
|
||||||
func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
|
func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
|
||||||
key := fmt.Sprintf("agentci.agents.%s", name)
|
key := fmt.Sprintf("agentci.agents.%s", name)
|
||||||
data := map[string]any{
|
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.
|
// RemoveAgent removes an agent from the config file.
|
||||||
|
//
|
||||||
func RemoveAgent(cfg *config.Config, name string) error {
|
func RemoveAgent(cfg *config.Config, name string) error {
|
||||||
var agents map[string]AgentConfig
|
var agents map[string]AgentConfig
|
||||||
if err := cfg.Get("agentci.agents", &agents); err != nil {
|
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).
|
// ListAgents returns all configured agents (active and inactive).
|
||||||
|
//
|
||||||
func ListAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
func ListAgents(cfg *config.Config) (map[string]AgentConfig, error) {
|
||||||
var agents map[string]AgentConfig
|
var agents map[string]AgentConfig
|
||||||
if err := cfg.Get("agentci.agents", &agents); err != nil {
|
if err := cfg.Get("agentci.agents", &agents); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package agentci
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/config"
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
"forge.lthn.ai/core/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -299,7 +299,7 @@ func TestListAgents_Good_Empty(t *testing.T) {
|
||||||
assert.Empty(t, agents)
|
assert.Empty(t, agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoundTrip_SaveThenLoad(t *testing.T) {
|
func TestRoundTrip_Good_SaveThenLoad(t *testing.T) {
|
||||||
cfg := newTestConfig(t, "")
|
cfg := newTestConfig(t, "")
|
||||||
|
|
||||||
err := SaveAgent(cfg, "alpha", AgentConfig{
|
err := SaveAgent(cfg, "alpha", AgentConfig{
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,150 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package agentci
|
package agentci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"path/filepath"
|
exec "golang.org/x/sys/execabs"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
|
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
|
||||||
|
|
||||||
// SanitizePath ensures a filename or directory name is safe and prevents path traversal.
|
// 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) {
|
func SanitizePath(input string) (string, error) {
|
||||||
base := filepath.Base(input)
|
if input == "" {
|
||||||
if !safeNameRegex.MatchString(base) {
|
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)
|
return "", coreerr.E("agentci.SanitizePath", "invalid characters in path element: "+input, nil)
|
||||||
}
|
}
|
||||||
if base == "." || base == ".." || base == "/" {
|
return input, nil
|
||||||
return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+base, 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.
|
// EscapeShellArg wraps a string in single quotes for safe remote shell insertion.
|
||||||
// Prefer exec.Command arguments over constructing shell strings where possible.
|
// Prefer exec.Command arguments over constructing shell strings where possible.
|
||||||
|
//
|
||||||
func EscapeShellArg(arg string) string {
|
func EscapeShellArg(arg string) string {
|
||||||
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
|
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode.
|
// SecureSSHCommand creates an SSH exec.Cmd with strict host key checking and batch mode.
|
||||||
|
//
|
||||||
func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
|
func SecureSSHCommand(host string, remoteCmd string) *exec.Cmd {
|
||||||
return exec.Command("ssh",
|
return exec.Command("ssh",
|
||||||
"-o", "StrictHostKeyChecking=yes",
|
"-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.
|
// MaskToken returns a masked version of a token for safe logging.
|
||||||
|
//
|
||||||
func MaskToken(token string) string {
|
func MaskToken(token string) string {
|
||||||
if len(token) < 8 {
|
if len(token) < 8 {
|
||||||
return "*****"
|
return "*****"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ func TestSanitizePath_Good(t *testing.T) {
|
||||||
{"with.dot", "with.dot"},
|
{"with.dot", "with.dot"},
|
||||||
{"CamelCase", "CamelCase"},
|
{"CamelCase", "CamelCase"},
|
||||||
{"123", "123"},
|
{"123", "123"},
|
||||||
{"path/to/file.txt", "file.txt"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -44,8 +43,11 @@ func TestSanitizePath_Bad(t *testing.T) {
|
||||||
{"pipe", "file|name"},
|
{"pipe", "file|name"},
|
||||||
{"ampersand", "file&name"},
|
{"ampersand", "file&name"},
|
||||||
{"dollar", "file$name"},
|
{"dollar", "file$name"},
|
||||||
|
{"slash", "path/to/file.txt"},
|
||||||
|
{"backslash", `path\to\file.txt`},
|
||||||
{"parent traversal base", ".."},
|
{"parent traversal base", ".."},
|
||||||
{"root", "/"},
|
{"root", "/"},
|
||||||
|
{"empty", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
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/i18n"
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -28,6 +30,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddCollectCommands registers the 'collect' command and all subcommands.
|
// AddCollectCommands registers the 'collect' command and all subcommands.
|
||||||
|
//
|
||||||
func AddCollectCommands(root *cli.Command) {
|
func AddCollectCommands(root *cli.Command) {
|
||||||
collectCmd := &cli.Command{
|
collectCmd := &cli.Command{
|
||||||
Use: "collect",
|
Use: "collect",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/i18n"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BitcoinTalk command flags
|
// BitcoinTalk command flags
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
collectpkg "dappco.re/go/core/scm/collect"
|
|
||||||
"dappco.re/go/core/i18n"
|
"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.
|
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/i18n"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Excavate command flags
|
// Excavate command flags
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/i18n"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHub command flags
|
// GitHub command flags
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
"dappco.re/go/core/scm/collect"
|
|
||||||
"dappco.re/go/core/i18n"
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Market command flags
|
// Market command flags
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
"dappco.re/go/core/scm/collect"
|
|
||||||
"dappco.re/go/core/i18n"
|
"dappco.re/go/core/i18n"
|
||||||
|
"dappco.re/go/core/scm/collect"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Papers command flags
|
// Papers command flags
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
"dappco.re/go/core/scm/collect"
|
|
||||||
"dappco.re/go/core/i18n"
|
"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.
|
// addProcessCommand adds the 'process' subcommand to the collect parent.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth command flags.
|
// Auth command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config command flags.
|
// Config command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package forge provides CLI commands for managing a Forgejo instance.
|
// Package forge provides CLI commands for managing a Forgejo instance.
|
||||||
//
|
//
|
||||||
// Commands:
|
// Commands:
|
||||||
|
|
@ -33,6 +35,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddForgeCommands registers the 'forge' command and all subcommands.
|
// AddForgeCommands registers the 'forge' command and all subcommands.
|
||||||
|
//
|
||||||
func AddForgeCommands(root *cli.Command) {
|
func AddForgeCommands(root *cli.Command) {
|
||||||
forgeCmd := &cli.Command{
|
forgeCmd := &cli.Command{
|
||||||
Use: "forge",
|
Use: "forge",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Issues command flags.
|
// Issues command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Labels command flags.
|
// Labels command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Migrate command flags.
|
// Migrate command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addOrgsCommand adds the 'orgs' subcommand for listing organisations.
|
// addOrgsCommand adds the 'orgs' subcommand for listing organisations.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PRs command flags.
|
// PRs command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repos command flags.
|
// Repos command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
fg "dappco.re/go/core/scm/forge"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addStatusCommand adds the 'status' subcommand for instance info.
|
// addStatusCommand adds the 'status' subcommand for instance info.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"os"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"os/exec"
|
os "dappco.re/go/core/scm/internal/ax/osx"
|
||||||
"path/filepath"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"strings"
|
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"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
fg "dappco.re/go/core/scm/forge"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync command flags.
|
// Sync command flags.
|
||||||
|
|
@ -95,11 +99,14 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn
|
||||||
|
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
name := arg
|
name, err := syncRepoNameFromArg(arg)
|
||||||
if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
|
if err != nil {
|
||||||
name = parts[1]
|
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)
|
branch := syncDetectDefaultBranch(localPath)
|
||||||
repos = append(repos, syncRepoEntry{
|
repos = append(repos, syncRepoEntry{
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -113,10 +120,17 @@ func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syn
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, r := range orgRepos {
|
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)
|
branch := syncDetectDefaultBranch(localPath)
|
||||||
repos = append(repos, syncRepoEntry{
|
repos = append(repos, syncRepoEntry{
|
||||||
name: r.Name,
|
name: name,
|
||||||
localPath: localPath,
|
localPath: localPath,
|
||||||
defaultBranch: branch,
|
defaultBranch: branch,
|
||||||
})
|
})
|
||||||
|
|
@ -333,3 +347,27 @@ func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error {
|
||||||
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
53
cmd/forge/cmd_sync_test.go
Normal file
53
cmd/forge/cmd_sync_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
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"
|
gt "dappco.re/go/core/scm/gitea"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config command flags.
|
// Config command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package gitea provides CLI commands for managing a Gitea instance.
|
// Package gitea provides CLI commands for managing a Gitea instance.
|
||||||
//
|
//
|
||||||
// Commands:
|
// Commands:
|
||||||
|
|
@ -30,6 +32,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddGiteaCommands registers the 'gitea' command and all subcommands.
|
// AddGiteaCommands registers the 'gitea' command and all subcommands.
|
||||||
|
//
|
||||||
func AddGiteaCommands(root *cli.Command) {
|
func AddGiteaCommands(root *cli.Command) {
|
||||||
giteaCmd := &cli.Command{
|
giteaCmd := &cli.Command{
|
||||||
Use: "gitea",
|
Use: "gitea",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
gt "dappco.re/go/core/scm/gitea"
|
gt "dappco.re/go/core/scm/gitea"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Issues command flags.
|
// Issues command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"os/exec"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"strings"
|
exec "golang.org/x/sys/execabs"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
gt "dappco.re/go/core/scm/gitea"
|
gt "dappco.re/go/core/scm/gitea"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mirror command flags.
|
// Mirror command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
sdk "code.gitea.io/sdk/gitea"
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
gt "dappco.re/go/core/scm/gitea"
|
gt "dappco.re/go/core/scm/gitea"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PRs command flags.
|
// PRs command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
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"
|
gt "dappco.re/go/core/scm/gitea"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repos command flags.
|
// Repos command flags.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"os"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"os/exec"
|
os "dappco.re/go/core/scm/internal/ax/osx"
|
||||||
"path/filepath"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"strings"
|
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"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreerr "dappco.re/go/core/log"
|
|
||||||
gt "dappco.re/go/core/scm/gitea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync command flags.
|
// Sync command flags.
|
||||||
|
|
@ -96,12 +100,14 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
// Specific repos from args
|
// Specific repos from args
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
name := arg
|
name, err := repoNameFromArg(arg)
|
||||||
// Strip owner/ prefix if given
|
if err != nil {
|
||||||
if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
|
return nil, coreerr.E("gitea.buildRepoList", "invalid repo argument", err)
|
||||||
name = parts[1]
|
}
|
||||||
|
_, 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)
|
branch := detectDefaultBranch(localPath)
|
||||||
repos = append(repos, repoEntry{
|
repos = append(repos, repoEntry{
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -116,10 +122,17 @@ func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEnt
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, r := range orgRepos {
|
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)
|
branch := detectDefaultBranch(localPath)
|
||||||
repos = append(repos, repoEntry{
|
repos = append(repos, repoEntry{
|
||||||
name: r.Name,
|
name: name,
|
||||||
localPath: localPath,
|
localPath: localPath,
|
||||||
defaultBranch: branch,
|
defaultBranch: branch,
|
||||||
})
|
})
|
||||||
|
|
@ -352,3 +365,27 @@ func createMainFromUpstream(client *gt.Client, org, repo string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func strPtr(s string) *string { return &s }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
53
cmd/gitea/cmd_sync_test.go
Normal file
53
cmd/gitea/cmd_sync_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package scm
|
package scm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"os/exec"
|
exec "golang.org/x/sys/execabs"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
"dappco.re/go/core/scm/manifest"
|
"dappco.re/go/core/scm/manifest"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addCompileCommand(parent *cli.Command) {
|
func addCompileCommand(parent *cli.Command) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package scm
|
package scm
|
||||||
|
|
||||||
import (
|
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/io"
|
||||||
"dappco.re/go/core/scm/manifest"
|
"dappco.re/go/core/scm/manifest"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addExportCommand(parent *cli.Command) {
|
func addExportCommand(parent *cli.Command) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package scm
|
package scm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"path/filepath"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
|
||||||
"dappco.re/go/core/scm/marketplace"
|
"dappco.re/go/core/scm/marketplace"
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addIndexCommand(parent *cli.Command) {
|
func addIndexCommand(parent *cli.Command) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package scm provides CLI commands for manifest compilation and marketplace
|
// Package scm provides CLI commands for manifest compilation and marketplace
|
||||||
// index generation.
|
// index generation.
|
||||||
//
|
//
|
||||||
|
|
@ -25,6 +27,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddScmCommands registers the 'scm' command and all subcommands.
|
// AddScmCommands registers the 'scm' command and all subcommands.
|
||||||
|
//
|
||||||
func AddScmCommands(root *cli.Command) {
|
func AddScmCommands(root *cli.Command) {
|
||||||
scmCmd := &cli.Command{
|
scmCmd := &cli.Command{
|
||||||
Use: "scm",
|
Use: "scm",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
|
|
@ -20,6 +22,7 @@ var httpClient = &http.Client{
|
||||||
}
|
}
|
||||||
|
|
||||||
// BitcoinTalkCollector collects forum posts from BitcoinTalk.
|
// BitcoinTalkCollector collects forum posts from BitcoinTalk.
|
||||||
|
//
|
||||||
type BitcoinTalkCollector struct {
|
type BitcoinTalkCollector struct {
|
||||||
// TopicID is the numeric topic identifier.
|
// TopicID is the numeric topic identifier.
|
||||||
TopicID string
|
TopicID string
|
||||||
|
|
@ -281,6 +284,7 @@ func formatPostMarkdown(num int, post btPost) string {
|
||||||
|
|
||||||
// ParsePostsFromHTML parses BitcoinTalk posts from raw HTML content.
|
// ParsePostsFromHTML parses BitcoinTalk posts from raw HTML content.
|
||||||
// This is exported for testing purposes.
|
// This is exported for testing purposes.
|
||||||
|
//
|
||||||
func ParsePostsFromHTML(htmlContent string) ([]btPost, error) {
|
func ParsePostsFromHTML(htmlContent string) ([]btPost, error) {
|
||||||
doc, err := html.Parse(strings.NewReader(htmlContent))
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -290,14 +294,17 @@ func ParsePostsFromHTML(htmlContent string) ([]btPost, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatPostMarkdown is exported for testing purposes.
|
// FormatPostMarkdown is exported for testing purposes.
|
||||||
|
//
|
||||||
func FormatPostMarkdown(num int, author, date, content string) string {
|
func FormatPostMarkdown(num int, author, date, content string) string {
|
||||||
return formatPostMarkdown(num, btPost{Author: author, Date: date, Content: content})
|
return formatPostMarkdown(num, btPost{Author: author, Date: date, Content: content})
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchPageFunc is an injectable function type for fetching pages, used in testing.
|
// FetchPageFunc is an injectable function type for fetching pages, used in testing.
|
||||||
|
//
|
||||||
type FetchPageFunc func(ctx context.Context, url string) ([]btPost, error)
|
type FetchPageFunc func(ctx context.Context, url string) ([]btPost, error)
|
||||||
|
|
||||||
// BitcoinTalkCollectorWithFetcher wraps BitcoinTalkCollector with a custom fetcher for testing.
|
// BitcoinTalkCollectorWithFetcher wraps BitcoinTalkCollector with a custom fetcher for testing.
|
||||||
|
//
|
||||||
type BitcoinTalkCollectorWithFetcher struct {
|
type BitcoinTalkCollectorWithFetcher struct {
|
||||||
BitcoinTalkCollector
|
BitcoinTalkCollector
|
||||||
Fetcher FetchPageFunc
|
Fetcher FetchPageFunc
|
||||||
|
|
@ -305,6 +312,7 @@ type BitcoinTalkCollectorWithFetcher struct {
|
||||||
|
|
||||||
// SetHTTPClient replaces the package-level HTTP client.
|
// SetHTTPClient replaces the package-level HTTP client.
|
||||||
// Use this in tests to inject a custom transport or timeout.
|
// Use this in tests to inject a custom transport or timeout.
|
||||||
|
//
|
||||||
func SetHTTPClient(c *http.Client) {
|
func SetHTTPClient(c *http.Client) {
|
||||||
httpClient = c
|
httpClient = c
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package collect provides a data collection subsystem for gathering information
|
// Package collect provides a data collection subsystem for gathering information
|
||||||
// from multiple sources including GitHub, BitcoinTalk, CoinGecko, and academic
|
// from multiple sources including GitHub, BitcoinTalk, CoinGecko, and academic
|
||||||
// paper repositories. It supports rate limiting, incremental state tracking,
|
// paper repositories. It supports rate limiting, incremental state tracking,
|
||||||
|
|
@ -6,12 +8,13 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Collector is the interface all collection sources implement.
|
// Collector is the interface all collection sources implement.
|
||||||
|
//
|
||||||
type Collector interface {
|
type Collector interface {
|
||||||
// Name returns a human-readable name for this collector.
|
// Name returns a human-readable name for this collector.
|
||||||
Name() string
|
Name() string
|
||||||
|
|
@ -21,6 +24,7 @@ type Collector interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds shared configuration for all collectors.
|
// Config holds shared configuration for all collectors.
|
||||||
|
//
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Output is the storage medium for writing collected data.
|
// Output is the storage medium for writing collected data.
|
||||||
Output io.Medium
|
Output io.Medium
|
||||||
|
|
@ -45,6 +49,7 @@ type Config struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result holds the output of a collection run.
|
// Result holds the output of a collection run.
|
||||||
|
//
|
||||||
type Result struct {
|
type Result struct {
|
||||||
// Source identifies which collector produced this result.
|
// Source identifies which collector produced this result.
|
||||||
Source string
|
Source string
|
||||||
|
|
@ -65,6 +70,7 @@ type Result struct {
|
||||||
// NewConfig creates a Config with sensible defaults.
|
// NewConfig creates a Config with sensible defaults.
|
||||||
// It initialises a MockMedium for output if none is provided,
|
// It initialises a MockMedium for output if none is provided,
|
||||||
// sets up a rate limiter, state tracker, and event dispatcher.
|
// sets up a rate limiter, state tracker, and event dispatcher.
|
||||||
|
//
|
||||||
func NewConfig(outputDir string) *Config {
|
func NewConfig(outputDir string) *Config {
|
||||||
m := io.NewMockMedium()
|
m := io.NewMockMedium()
|
||||||
return &Config{
|
return &Config{
|
||||||
|
|
@ -77,6 +83,7 @@ func NewConfig(outputDir string) *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfigWithMedium creates a Config using the specified storage medium.
|
// NewConfigWithMedium creates a Config using the specified storage medium.
|
||||||
|
//
|
||||||
func NewConfigWithMedium(m io.Medium, outputDir string) *Config {
|
func NewConfigWithMedium(m io.Medium, outputDir string) *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Output: m,
|
Output: m,
|
||||||
|
|
@ -88,6 +95,7 @@ func NewConfigWithMedium(m io.Medium, outputDir string) *Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeResults combines multiple results into a single aggregated result.
|
// MergeResults combines multiple results into a single aggregated result.
|
||||||
|
//
|
||||||
func MergeResults(source string, results ...*Result) *Result {
|
func MergeResults(source string, results ...*Result) *Result {
|
||||||
merged := &Result{Source: source}
|
merged := &Result{Source: source}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
core "dappco.re/go/core"
|
||||||
"fmt"
|
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"
|
goio "io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -17,6 +18,14 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"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.
|
// errorMedium wraps MockMedium and injects errors on specific operations.
|
||||||
type errorMedium struct {
|
type errorMedium struct {
|
||||||
*io.MockMedium
|
*io.MockMedium
|
||||||
|
|
@ -50,16 +59,18 @@ func (e *errorMedium) Read(path string) (string, error) {
|
||||||
}
|
}
|
||||||
return e.MockMedium.Read(path)
|
return e.MockMedium.Read(path)
|
||||||
}
|
}
|
||||||
func (e *errorMedium) FileGet(path string) (string, error) { return e.MockMedium.FileGet(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) 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) Delete(path string) error { return e.MockMedium.Delete(path) }
|
||||||
func (e *errorMedium) DeleteAll(path string) error { return e.MockMedium.DeleteAll(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) 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) 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) 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) 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) 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) ReadStream(path string) (goio.ReadCloser, error) {
|
||||||
|
return e.MockMedium.ReadStream(path)
|
||||||
|
}
|
||||||
func (e *errorMedium) WriteStream(path string) (goio.WriteCloser, error) {
|
func (e *errorMedium) WriteStream(path string) (goio.WriteCloser, error) {
|
||||||
return e.MockMedium.WriteStream(path)
|
return e.MockMedium.WriteStream(path)
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +85,7 @@ type errorLimiterWaiter struct{}
|
||||||
// --- Processor: list error ---
|
// --- Processor: list error ---
|
||||||
|
|
||||||
func TestProcessor_Process_Bad_ListError(t *testing.T) {
|
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()}
|
cfg := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
|
|
||||||
p := &Processor{Source: "test", Dir: "/input"}
|
p := &Processor{Source: "test", Dir: "/input"}
|
||||||
|
|
@ -86,7 +97,7 @@ func TestProcessor_Process_Bad_ListError(t *testing.T) {
|
||||||
// --- Processor: ensureDir error ---
|
// --- Processor: ensureDir error ---
|
||||||
|
|
||||||
func TestProcessor_Process_Bad_EnsureDirError(t *testing.T) {
|
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
|
// Need to ensure List returns entries
|
||||||
em.MockMedium.Dirs["/input"] = true
|
em.MockMedium.Dirs["/input"] = true
|
||||||
em.MockMedium.Files["/input/test.html"] = "<h1>Test</h1>"
|
em.MockMedium.Files["/input/test.html"] = "<h1>Test</h1>"
|
||||||
|
|
@ -121,7 +132,7 @@ func TestProcessor_Process_Bad_ContextCancelledDuringLoop(t *testing.T) {
|
||||||
// --- Processor: read error during file processing ---
|
// --- Processor: read error during file processing ---
|
||||||
|
|
||||||
func TestProcessor_Process_Bad_ReadError(t *testing.T) {
|
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.Dirs["/input"] = true
|
||||||
em.MockMedium.Files["/input/test.html"] = "<h1>Test</h1>"
|
em.MockMedium.Files["/input/test.html"] = "<h1>Test</h1>"
|
||||||
|
|
||||||
|
|
@ -154,7 +165,7 @@ func TestProcessor_Process_Bad_InvalidJSONFile(t *testing.T) {
|
||||||
// --- Processor: write error during output ---
|
// --- Processor: write error during output ---
|
||||||
|
|
||||||
func TestProcessor_Process_Bad_WriteError(t *testing.T) {
|
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.Dirs["/input"] = true
|
||||||
em.MockMedium.Files["/input/page.html"] = "<h1>Title</h1>"
|
em.MockMedium.Files["/input/page.html"] = "<h1>Title</h1>"
|
||||||
|
|
||||||
|
|
@ -255,13 +266,13 @@ func TestPapersCollector_CollectIACR_Bad_WriteError(t *testing.T) {
|
||||||
httpClient = &http.Client{Transport: transport}
|
httpClient = &http.Client{Transport: transport}
|
||||||
defer func() { httpClient = old }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
p := &PapersCollector{Source: PaperSourceIACR, Query: "test"}
|
p := &PapersCollector{Source: PaperSourceIACR, Query: "test"}
|
||||||
result, err := p.Collect(context.Background(), cfg)
|
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
|
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}
|
httpClient = &http.Client{Transport: transport}
|
||||||
defer func() { httpClient = old }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -303,7 +314,7 @@ func TestPapersCollector_CollectArXiv_Bad_WriteError(t *testing.T) {
|
||||||
httpClient = &http.Client{Transport: transport}
|
httpClient = &http.Client{Transport: transport}
|
||||||
defer func() { httpClient = old }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -327,7 +338,7 @@ func TestPapersCollector_CollectArXiv_Bad_EnsureDirError(t *testing.T) {
|
||||||
httpClient = &http.Client{Transport: transport}
|
httpClient = &http.Client{Transport: transport}
|
||||||
defer func() { httpClient = old }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -453,7 +464,7 @@ func TestMarketCollector_Collect_Bad_WriteError(t *testing.T) {
|
||||||
coinGeckoBaseURL = server.URL
|
coinGeckoBaseURL = server.URL
|
||||||
defer func() { coinGeckoBaseURL = oldURL }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -477,7 +488,7 @@ func TestMarketCollector_Collect_Bad_EnsureDirError(t *testing.T) {
|
||||||
coinGeckoBaseURL = server.URL
|
coinGeckoBaseURL = server.URL
|
||||||
defer func() { coinGeckoBaseURL = oldURL }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -552,7 +563,7 @@ func TestMarketCollector_Collect_Good_HistoricalCustomDate(t *testing.T) {
|
||||||
// --- BitcoinTalk: EnsureDir error ---
|
// --- BitcoinTalk: EnsureDir error ---
|
||||||
|
|
||||||
func TestBitcoinTalkCollector_Collect_Bad_EnsureDirError(t *testing.T) {
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
|
|
@ -592,13 +603,13 @@ func TestBitcoinTalkCollector_Collect_Bad_WriteErrorOnPosts(t *testing.T) {
|
||||||
httpClient = &http.Client{Transport: transport}
|
httpClient = &http.Client{Transport: transport}
|
||||||
defer func() { httpClient = old }()
|
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 := &Config{Output: em, OutputDir: "/output", Dispatcher: NewDispatcher()}
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
b := &BitcoinTalkCollector{TopicID: "12345"}
|
b := &BitcoinTalkCollector{TopicID: "12345"}
|
||||||
result, err := b.Collect(context.Background(), cfg)
|
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, 3, result.Errors) // 3 posts all fail to write
|
||||||
assert.Equal(t, 0, result.Items)
|
assert.Equal(t, 0, result.Items)
|
||||||
}
|
}
|
||||||
|
|
@ -968,34 +979,44 @@ func TestBitcoinTalkCollector_Collect_Bad_LimiterBlocks(t *testing.T) {
|
||||||
// writeCountMedium fails after N successful writes.
|
// writeCountMedium fails after N successful writes.
|
||||||
type writeCountMedium struct {
|
type writeCountMedium struct {
|
||||||
*io.MockMedium
|
*io.MockMedium
|
||||||
writeCount int
|
writeCount int
|
||||||
failAfterN int
|
failAfterN int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *writeCountMedium) Write(path, content string) error {
|
func (w *writeCountMedium) Write(path, content string) error {
|
||||||
w.writeCount++
|
w.writeCount++
|
||||||
if w.writeCount > w.failAfterN {
|
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)
|
return w.MockMedium.Write(path, content)
|
||||||
}
|
}
|
||||||
func (w *writeCountMedium) EnsureDir(path string) error { return w.MockMedium.EnsureDir(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) 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) 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) 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) 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) FileSet(path, content string) error {
|
||||||
func (w *writeCountMedium) Delete(path string) error { return w.MockMedium.Delete(path) }
|
return w.MockMedium.FileSet(path, content)
|
||||||
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) Delete(path string) error { return w.MockMedium.Delete(path) }
|
||||||
func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) }
|
func (w *writeCountMedium) DeleteAll(path string) error { return w.MockMedium.DeleteAll(path) }
|
||||||
func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) }
|
func (w *writeCountMedium) Rename(old, new string) error { return w.MockMedium.Rename(old, new) }
|
||||||
func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) { return w.MockMedium.Create(path) }
|
func (w *writeCountMedium) Stat(path string) (fs.FileInfo, error) { return w.MockMedium.Stat(path) }
|
||||||
func (w *writeCountMedium) Append(path string) (goio.WriteCloser, error) { return w.MockMedium.Append(path) }
|
func (w *writeCountMedium) Open(path string) (fs.File, error) { return w.MockMedium.Open(path) }
|
||||||
func (w *writeCountMedium) ReadStream(path string) (goio.ReadCloser, error) { return w.MockMedium.ReadStream(path) }
|
func (w *writeCountMedium) Create(path string) (goio.WriteCloser, error) {
|
||||||
func (w *writeCountMedium) WriteStream(path string) (goio.WriteCloser, error) { return w.MockMedium.WriteStream(path) }
|
return w.MockMedium.Create(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) 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.
|
// Test that the summary.md write error in collectCurrent is handled.
|
||||||
func TestMarketCollector_Collect_Bad_SummaryWriteError(t *testing.T) {
|
func TestMarketCollector_Collect_Bad_SummaryWriteError(t *testing.T) {
|
||||||
|
|
@ -1075,7 +1096,7 @@ func TestMarketCollector_Collect_Bad_HistoricalWriteError(t *testing.T) {
|
||||||
// --- State: Save write error ---
|
// --- State: Save write error ---
|
||||||
|
|
||||||
func TestState_Save_Bad_WriteError(t *testing.T) {
|
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 := NewState(em, "/state.json")
|
||||||
s.Set("test", &StateEntry{Source: "test", Items: 1})
|
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 ---
|
// --- Excavator: state save error after collection ---
|
||||||
|
|
||||||
func TestExcavator_Run_Bad_StateSaveError(t *testing.T) {
|
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{
|
cfg := &Config{
|
||||||
Output: io.NewMockMedium(), // Use regular medium for output
|
Output: io.NewMockMedium(), // Use regular medium for output
|
||||||
OutputDir: "/output",
|
OutputDir: "/output",
|
||||||
|
|
@ -1158,7 +1179,7 @@ func TestExcavator_Run_Bad_StateSaveError(t *testing.T) {
|
||||||
// --- State: Load with read error ---
|
// --- State: Load with read error ---
|
||||||
|
|
||||||
func TestState_Load_Bad_ReadError(t *testing.T) {
|
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
|
em.MockMedium.Files["/state.json"] = "{}" // File exists but read will fail
|
||||||
|
|
||||||
s := NewState(em, "/state.json")
|
s := NewState(em, "/state.json")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -8,22 +10,28 @@ import (
|
||||||
// Event types used by the collection subsystem.
|
// Event types used by the collection subsystem.
|
||||||
const (
|
const (
|
||||||
// EventStart is emitted when a collector begins its run.
|
// EventStart is emitted when a collector begins its run.
|
||||||
|
//
|
||||||
EventStart = "start"
|
EventStart = "start"
|
||||||
|
|
||||||
// EventProgress is emitted to report incremental progress.
|
// EventProgress is emitted to report incremental progress.
|
||||||
|
//
|
||||||
EventProgress = "progress"
|
EventProgress = "progress"
|
||||||
|
|
||||||
// EventItem is emitted when a single item is collected.
|
// EventItem is emitted when a single item is collected.
|
||||||
|
//
|
||||||
EventItem = "item"
|
EventItem = "item"
|
||||||
|
|
||||||
// EventError is emitted when an error occurs during collection.
|
// EventError is emitted when an error occurs during collection.
|
||||||
|
//
|
||||||
EventError = "error"
|
EventError = "error"
|
||||||
|
|
||||||
// EventComplete is emitted when a collector finishes its run.
|
// EventComplete is emitted when a collector finishes its run.
|
||||||
|
//
|
||||||
EventComplete = "complete"
|
EventComplete = "complete"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a collection event.
|
// Event represents a collection event.
|
||||||
|
//
|
||||||
type Event struct {
|
type Event struct {
|
||||||
// Type is one of the Event* constants.
|
// Type is one of the Event* constants.
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
@ -42,16 +50,19 @@ type Event struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventHandler handles collection events.
|
// EventHandler handles collection events.
|
||||||
|
//
|
||||||
type EventHandler func(Event)
|
type EventHandler func(Event)
|
||||||
|
|
||||||
// Dispatcher manages event dispatch. Handlers are registered per event type
|
// Dispatcher manages event dispatch. Handlers are registered per event type
|
||||||
// and are called synchronously when an event is emitted.
|
// and are called synchronously when an event is emitted.
|
||||||
|
//
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
handlers map[string][]EventHandler
|
handlers map[string][]EventHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDispatcher creates a new event dispatcher.
|
// NewDispatcher creates a new event dispatcher.
|
||||||
|
//
|
||||||
func NewDispatcher() *Dispatcher {
|
func NewDispatcher() *Dispatcher {
|
||||||
return &Dispatcher{
|
return &Dispatcher{
|
||||||
handlers: make(map[string][]EventHandler),
|
handlers: make(map[string][]EventHandler),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
|
|
@ -11,6 +13,7 @@ import (
|
||||||
// Excavator runs multiple collectors as a coordinated operation.
|
// Excavator runs multiple collectors as a coordinated operation.
|
||||||
// It provides sequential execution with rate limit respect, state tracking
|
// It provides sequential execution with rate limit respect, state tracking
|
||||||
// for resume support, and aggregated results.
|
// for resume support, and aggregated results.
|
||||||
|
//
|
||||||
type Excavator struct {
|
type Excavator struct {
|
||||||
// Collectors is the list of collectors to run.
|
// Collectors is the list of collectors to run.
|
||||||
Collectors []Collector
|
Collectors []Collector
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
core "dappco.re/go/core"
|
||||||
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
|
@ -126,7 +127,7 @@ func TestExcavator_Run_Good_WithErrors(t *testing.T) {
|
||||||
cfg.Limiter = nil
|
cfg.Limiter = nil
|
||||||
|
|
||||||
c1 := &mockCollector{name: "good", items: 5}
|
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}
|
c3 := &mockCollector{name: "also-good", items: 3}
|
||||||
|
|
||||||
e := &Excavator{
|
e := &Excavator{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"os/exec"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"path/filepath"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"strings"
|
exec "golang.org/x/sys/execabs"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
|
|
@ -38,6 +40,7 @@ type ghRepo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitHubCollector collects issues and PRs from GitHub repositories.
|
// GitHubCollector collects issues and PRs from GitHub repositories.
|
||||||
|
//
|
||||||
type GitHubCollector struct {
|
type GitHubCollector struct {
|
||||||
// Org is the GitHub organisation.
|
// Org is the GitHub organisation.
|
||||||
Org string
|
Org string
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"fmt"
|
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"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
|
|
@ -17,6 +19,7 @@ import (
|
||||||
var coinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
var coinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||||
|
|
||||||
// MarketCollector collects market data from CoinGecko.
|
// MarketCollector collects market data from CoinGecko.
|
||||||
|
//
|
||||||
type MarketCollector struct {
|
type MarketCollector struct {
|
||||||
// CoinID is the CoinGecko coin identifier (e.g. "bitcoin", "ethereum").
|
// CoinID is the CoinGecko coin identifier (e.g. "bitcoin", "ethereum").
|
||||||
CoinID string
|
CoinID string
|
||||||
|
|
@ -272,6 +275,7 @@ func formatMarketSummary(data *coinData) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatMarketSummary is exported for testing.
|
// FormatMarketSummary is exported for testing.
|
||||||
|
//
|
||||||
func FormatMarketSummary(data *coinData) string {
|
func FormatMarketSummary(data *coinData) string {
|
||||||
return formatMarketSummary(data)
|
return formatMarketSummary(data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
"encoding/xml"
|
||||||
"fmt"
|
|
||||||
"iter"
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
|
|
@ -16,12 +18,16 @@ import (
|
||||||
|
|
||||||
// Paper source identifiers.
|
// Paper source identifiers.
|
||||||
const (
|
const (
|
||||||
PaperSourceIACR = "iacr"
|
//
|
||||||
|
PaperSourceIACR = "iacr"
|
||||||
|
//
|
||||||
PaperSourceArXiv = "arxiv"
|
PaperSourceArXiv = "arxiv"
|
||||||
PaperSourceAll = "all"
|
//
|
||||||
|
PaperSourceAll = "all"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PapersCollector collects papers from IACR and arXiv.
|
// PapersCollector collects papers from IACR and arXiv.
|
||||||
|
//
|
||||||
type PapersCollector struct {
|
type PapersCollector struct {
|
||||||
// Source is one of PaperSourceIACR, PaperSourceArXiv, or PaperSourceAll.
|
// Source is one of PaperSourceIACR, PaperSourceArXiv, or PaperSourceAll.
|
||||||
Source string
|
Source string
|
||||||
|
|
@ -403,6 +409,7 @@ func formatPaperMarkdown(ppr paper) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatPaperMarkdown is exported for testing.
|
// FormatPaperMarkdown is exported for testing.
|
||||||
|
//
|
||||||
func FormatPaperMarkdown(title string, authors []string, date, paperURL, source, abstract string) string {
|
func FormatPaperMarkdown(title string, authors []string, date, paperURL, source, abstract string) string {
|
||||||
return formatPaperMarkdown(paper{
|
return formatPaperMarkdown(paper{
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
||||||
"fmt"
|
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"
|
"maps"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
core "dappco.re/go/core/log"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Processor converts collected data to clean markdown.
|
// Processor converts collected data to clean markdown.
|
||||||
|
//
|
||||||
type Processor struct {
|
type Processor struct {
|
||||||
// Source identifies the data source directory to process.
|
// Source identifies the data source directory to process.
|
||||||
Source string
|
Source string
|
||||||
|
|
@ -331,11 +334,13 @@ func jsonValueToMarkdown(b *strings.Builder, data any, depth int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTMLToMarkdown is exported for testing.
|
// HTMLToMarkdown is exported for testing.
|
||||||
|
//
|
||||||
func HTMLToMarkdown(content string) (string, error) {
|
func HTMLToMarkdown(content string) (string, error) {
|
||||||
return htmlToMarkdown(content)
|
return htmlToMarkdown(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONToMarkdown is exported for testing.
|
// JSONToMarkdown is exported for testing.
|
||||||
|
//
|
||||||
func JSONToMarkdown(content string) (string, error) {
|
func JSONToMarkdown(content string) (string, error) {
|
||||||
return jsonToMarkdown(content)
|
return jsonToMarkdown(content)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
"maps"
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -14,6 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimiter tracks per-source rate limiting to avoid overwhelming APIs.
|
// RateLimiter tracks per-source rate limiting to avoid overwhelming APIs.
|
||||||
|
//
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
delays map[string]time.Duration
|
delays map[string]time.Duration
|
||||||
|
|
@ -30,6 +33,7 @@ var defaultDelays = map[string]time.Duration{
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRateLimiter creates a limiter with default delays.
|
// NewRateLimiter creates a limiter with default delays.
|
||||||
|
//
|
||||||
func NewRateLimiter() *RateLimiter {
|
func NewRateLimiter() *RateLimiter {
|
||||||
delays := make(map[string]time.Duration, len(defaultDelays))
|
delays := make(map[string]time.Duration, len(defaultDelays))
|
||||||
maps.Copy(delays, defaultDelays)
|
maps.Copy(delays, defaultDelays)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core/log"
|
|
||||||
"dappco.re/go/core/io"
|
"dappco.re/go/core/io"
|
||||||
|
core "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// State tracks collection progress for incremental runs.
|
// State tracks collection progress for incremental runs.
|
||||||
// It persists entries to disk so that subsequent runs can resume
|
// It persists entries to disk so that subsequent runs can resume
|
||||||
// where they left off.
|
// where they left off.
|
||||||
|
//
|
||||||
type State struct {
|
type State struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
medium io.Medium
|
medium io.Medium
|
||||||
|
|
@ -20,6 +23,7 @@ type State struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateEntry tracks state for one source.
|
// StateEntry tracks state for one source.
|
||||||
|
//
|
||||||
type StateEntry struct {
|
type StateEntry struct {
|
||||||
// Source identifies the collector.
|
// Source identifies the collector.
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
|
@ -39,6 +43,7 @@ type StateEntry struct {
|
||||||
|
|
||||||
// NewState creates a state tracker that persists to the given path
|
// NewState creates a state tracker that persists to the given path
|
||||||
// using the provided storage medium.
|
// using the provided storage medium.
|
||||||
|
//
|
||||||
func NewState(m io.Medium, path string) *State {
|
func NewState(m io.Medium, path string) *State {
|
||||||
return &State{
|
return &State{
|
||||||
medium: m,
|
medium: m,
|
||||||
|
|
|
||||||
141
docs/verification-pass-2026-03-27.md
Normal file
141
docs/verification-pass-2026-03-27.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package forge provides a thin wrapper around the Forgejo Go SDK
|
// Package forge provides a thin wrapper around the Forgejo Go SDK
|
||||||
// for managing repositories, issues, and pull requests on a Forgejo instance.
|
// 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.
|
// Client wraps the Forgejo SDK client with config-based auth.
|
||||||
|
//
|
||||||
type Client struct {
|
type Client struct {
|
||||||
api *forgejo.Client
|
api *forgejo.Client
|
||||||
url string
|
url string
|
||||||
|
|
@ -22,6 +25,7 @@ type Client struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Forgejo API client for the given URL and token.
|
// New creates a new Forgejo API client for the given URL and token.
|
||||||
|
//
|
||||||
func New(url, token string) (*Client, error) {
|
func New(url, token string) (*Client, error) {
|
||||||
api, err := forgejo.NewClient(url, forgejo.SetToken(token))
|
api, err := forgejo.NewClient(url, forgejo.SetToken(token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"fmt"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -132,7 +132,7 @@ func TestClient_SetPRDraft_Bad_ConnectionRefused(t *testing.T) {
|
||||||
assert.Error(t, err)
|
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.
|
// Verify the URL is constructed correctly by checking the request path.
|
||||||
var capturedPath string
|
var capturedPath string
|
||||||
mux := http.NewServeMux()
|
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)
|
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.
|
// Verify the authorisation header is set correctly.
|
||||||
var capturedAuth string
|
var capturedAuth string
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
@ -182,7 +182,7 @@ func TestClient_SetPRDraft_AuthHeader(t *testing.T) {
|
||||||
|
|
||||||
// --- PRMeta and Comment struct tests ---
|
// --- PRMeta and Comment struct tests ---
|
||||||
|
|
||||||
func TestPRMeta_Fields(t *testing.T) {
|
func TestPRMeta_Good_Fields(t *testing.T) {
|
||||||
meta := &PRMeta{
|
meta := &PRMeta{
|
||||||
Number: 42,
|
Number: 42,
|
||||||
Title: "Test PR",
|
Title: "Test PR",
|
||||||
|
|
@ -208,7 +208,7 @@ func TestPRMeta_Fields(t *testing.T) {
|
||||||
assert.Equal(t, 5, meta.CommentCount)
|
assert.Equal(t, 5, meta.CommentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComment_Fields(t *testing.T) {
|
func TestComment_Good_Fields(t *testing.T) {
|
||||||
comment := Comment{
|
comment := Comment{
|
||||||
ID: 123,
|
ID: 123,
|
||||||
Author: "reviewer",
|
Author: "reviewer",
|
||||||
|
|
@ -222,7 +222,7 @@ func TestComment_Fields(t *testing.T) {
|
||||||
|
|
||||||
// --- MergePullRequest merge style mapping ---
|
// --- 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
|
// 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.
|
// errors when the server returns failure. This exercises the style mapping code.
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -260,7 +260,7 @@ func TestMergePullRequest_StyleMapping(t *testing.T) {
|
||||||
|
|
||||||
// --- ListIssuesOpts defaulting ---
|
// --- ListIssuesOpts defaulting ---
|
||||||
|
|
||||||
func TestListIssuesOpts_Defaults(t *testing.T) {
|
func TestListIssuesOpts_Good_Defaults(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
opts ListIssuesOpts
|
opts ListIssuesOpts
|
||||||
|
|
@ -432,13 +432,13 @@ func TestClient_CreatePullRequest_Bad_ServerError(t *testing.T) {
|
||||||
|
|
||||||
// --- commentPageSize constant test ---
|
// --- 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")
|
assert.Equal(t, 50, commentPageSize, "comment page size should be 50")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ListPullRequests state mapping ---
|
// --- 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).
|
// Verify state mapping via error path (server returns error).
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
os "dappco.re/go/core/scm/internal/ax/osx"
|
||||||
|
|
||||||
"forge.lthn.ai/core/config"
|
|
||||||
"dappco.re/go/core/log"
|
"dappco.re/go/core/log"
|
||||||
|
"forge.lthn.ai/core/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ConfigKeyURL is the config key for the Forgejo instance URL.
|
// ConfigKeyURL is the config key for the Forgejo instance URL.
|
||||||
|
//
|
||||||
ConfigKeyURL = "forge.url"
|
ConfigKeyURL = "forge.url"
|
||||||
// ConfigKeyToken is the config key for the Forgejo API token.
|
// ConfigKeyToken is the config key for the Forgejo API token.
|
||||||
|
//
|
||||||
ConfigKeyToken = "forge.token"
|
ConfigKeyToken = "forge.token"
|
||||||
|
|
||||||
// DefaultURL is the default Forgejo instance URL.
|
// DefaultURL is the default Forgejo instance URL.
|
||||||
|
//
|
||||||
DefaultURL = "http://localhost:4000"
|
DefaultURL = "http://localhost:4000"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +27,8 @@ const (
|
||||||
// 1. ~/.core/config.yaml keys: forge.token, forge.url
|
// 1. ~/.core/config.yaml keys: forge.token, forge.url
|
||||||
// 2. FORGE_TOKEN + FORGE_URL environment variables (override config file)
|
// 2. FORGE_TOKEN + FORGE_URL environment variables (override config file)
|
||||||
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
||||||
|
//
|
||||||
|
//
|
||||||
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
|
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
|
||||||
url, token, err := ResolveConfig(flagURL, flagToken)
|
url, token, err := ResolveConfig(flagURL, flagToken)
|
||||||
if err != nil {
|
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.
|
// ResolveConfig resolves the Forgejo URL and token from all config sources.
|
||||||
// Flag values take highest priority, then env vars, then config file.
|
// Flag values take highest priority, then env vars, then config file.
|
||||||
|
//
|
||||||
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
|
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
|
||||||
// Start with config file values
|
// Start with config file values
|
||||||
cfg, cfgErr := config.New()
|
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.
|
// SaveConfig persists the Forgejo URL and/or token to the config file.
|
||||||
|
//
|
||||||
func SaveConfig(url, token string) error {
|
func SaveConfig(url, token string) error {
|
||||||
cfg, err := config.New()
|
cfg, err := config.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) {
|
||||||
assert.Equal(t, "some-token", token)
|
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.url", ConfigKeyURL)
|
||||||
assert.Equal(t, "forge.token", ConfigKeyToken)
|
assert.Equal(t, "forge.token", ConfigKeyToken)
|
||||||
assert.Equal(t, "http://localhost:4000", DefaultURL)
|
assert.Equal(t, "http://localhost:4000", DefaultURL)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -9,6 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListIssuesOpts configures issue listing.
|
// ListIssuesOpts configures issue listing.
|
||||||
|
//
|
||||||
type ListIssuesOpts struct {
|
type ListIssuesOpts struct {
|
||||||
State string // "open", "closed", "all"
|
State string // "open", "closed", "all"
|
||||||
Labels []string // filter by label names
|
Labels []string // filter by label names
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,6 +12,7 @@ import (
|
||||||
|
|
||||||
// PRMeta holds structural signals from a pull request,
|
// PRMeta holds structural signals from a pull request,
|
||||||
// used by the pipeline MetaReader for AI-driven workflows.
|
// used by the pipeline MetaReader for AI-driven workflows.
|
||||||
|
//
|
||||||
type PRMeta struct {
|
type PRMeta struct {
|
||||||
Number int64
|
Number int64
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -26,6 +29,7 @@ type PRMeta struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment represents a comment with metadata.
|
// Comment represents a comment with metadata.
|
||||||
|
//
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID int64
|
ID int64
|
||||||
Author string
|
Author string
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
30
forge/prs.go
30
forge/prs.go
|
|
@ -1,14 +1,19 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"fmt"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
"strconv"
|
||||||
|
|
||||||
"dappco.re/go/core/log"
|
"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").
|
// 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,
|
// The Forgejo SDK v2.2.0 doesn't expose the draft field on EditPullRequestOption,
|
||||||
// so we use a raw HTTP PATCH request.
|
// so we use a raw HTTP PATCH request.
|
||||||
func (c *Client) SetPRDraft(owner, repo string, index int64, draft bool) error {
|
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}
|
payload := map[string]bool{"draft": draft}
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return log.E("forge.SetPRDraft", "marshal payload", err)
|
return log.E("forge.SetPRDraft", "marshal payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.url, owner, repo, index)
|
path, err := url.JoinPath(c.url, "api", "v1", "repos", safeOwner, safeRepo, "pulls", strconv.FormatInt(index, 10))
|
||||||
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(body))
|
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 {
|
if err != nil {
|
||||||
return log.E("forge.SetPRDraft", "create request", err)
|
return log.E("forge.SetPRDraft", "create request", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -98,3 +101,49 @@ func TestClient_DismissReview_Bad_ServerError(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to dismiss review")
|
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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forge
|
package forge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
19
git/git.go
19
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 provides utilities for git operations across multiple repositories.
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"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"
|
"io"
|
||||||
"iter"
|
"iter"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RepoStatus represents the git status of a single repository.
|
// RepoStatus represents the git status of a single repository.
|
||||||
|
//
|
||||||
type RepoStatus struct {
|
type RepoStatus struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
|
|
@ -43,6 +46,7 @@ func (s *RepoStatus) HasUnpulled() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusOptions configures the status check.
|
// StatusOptions configures the status check.
|
||||||
|
//
|
||||||
type StatusOptions struct {
|
type StatusOptions struct {
|
||||||
// Paths is a list of repo paths to check
|
// Paths is a list of repo paths to check
|
||||||
Paths []string
|
Paths []string
|
||||||
|
|
@ -51,6 +55,7 @@ type StatusOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status checks git status for multiple repositories in parallel.
|
// Status checks git status for multiple repositories in parallel.
|
||||||
|
//
|
||||||
func Status(ctx context.Context, opts StatusOptions) []RepoStatus {
|
func Status(ctx context.Context, opts StatusOptions) []RepoStatus {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
results := make([]RepoStatus, len(opts.Paths))
|
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.
|
// StatusIter returns an iterator over git status for multiple repositories.
|
||||||
|
//
|
||||||
func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] {
|
func StatusIter(ctx context.Context, opts StatusOptions) iter.Seq[RepoStatus] {
|
||||||
return func(yield func(RepoStatus) bool) {
|
return func(yield func(RepoStatus) bool) {
|
||||||
results := Status(ctx, opts)
|
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.
|
// Push pushes commits for a single repository.
|
||||||
// Uses interactive mode to support SSH passphrase prompts.
|
// Uses interactive mode to support SSH passphrase prompts.
|
||||||
|
//
|
||||||
func Push(ctx context.Context, path string) error {
|
func Push(ctx context.Context, path string) error {
|
||||||
return gitInteractive(ctx, path, "push")
|
return gitInteractive(ctx, path, "push")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull pulls changes for a single repository.
|
// Pull pulls changes for a single repository.
|
||||||
// Uses interactive mode to support SSH passphrase prompts.
|
// Uses interactive mode to support SSH passphrase prompts.
|
||||||
|
//
|
||||||
func Pull(ctx context.Context, path string) error {
|
func Pull(ctx context.Context, path string) error {
|
||||||
return gitInteractive(ctx, path, "pull", "--rebase")
|
return gitInteractive(ctx, path, "pull", "--rebase")
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNonFastForward checks if an error is a non-fast-forward rejection.
|
// IsNonFastForward checks if an error is a non-fast-forward rejection.
|
||||||
|
//
|
||||||
func IsNonFastForward(err error) bool {
|
func IsNonFastForward(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
|
|
@ -201,6 +210,7 @@ func gitInteractive(ctx context.Context, dir string, args ...string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushResult represents the result of a push operation.
|
// PushResult represents the result of a push operation.
|
||||||
|
//
|
||||||
type PushResult struct {
|
type PushResult struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
|
|
@ -210,11 +220,13 @@ type PushResult struct {
|
||||||
|
|
||||||
// PushMultiple pushes multiple repositories sequentially.
|
// PushMultiple pushes multiple repositories sequentially.
|
||||||
// Sequential because SSH passphrase prompts need user interaction.
|
// Sequential because SSH passphrase prompts need user interaction.
|
||||||
|
//
|
||||||
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult {
|
func PushMultiple(ctx context.Context, paths []string, names map[string]string) []PushResult {
|
||||||
return slices.Collect(PushMultipleIter(ctx, paths, names))
|
return slices.Collect(PushMultipleIter(ctx, paths, names))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushMultipleIter returns an iterator that pushes repositories sequentially and yields results.
|
// 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] {
|
func PushMultipleIter(ctx context.Context, paths []string, names map[string]string) iter.Seq[PushResult] {
|
||||||
return func(yield func(PushResult) bool) {
|
return func(yield func(PushResult) bool) {
|
||||||
for _, path := range paths {
|
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.
|
// GitError wraps a git command error with stderr output.
|
||||||
|
//
|
||||||
type GitError struct {
|
type GitError struct {
|
||||||
Err error
|
Err error
|
||||||
Stderr string
|
Stderr string
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -11,49 +13,58 @@ import (
|
||||||
// Queries for git service
|
// Queries for git service
|
||||||
|
|
||||||
// QueryStatus requests git status for paths.
|
// QueryStatus requests git status for paths.
|
||||||
|
//
|
||||||
type QueryStatus struct {
|
type QueryStatus struct {
|
||||||
Paths []string
|
Paths []string
|
||||||
Names map[string]string
|
Names map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryDirtyRepos requests repos with uncommitted changes.
|
// QueryDirtyRepos requests repos with uncommitted changes.
|
||||||
|
//
|
||||||
type QueryDirtyRepos struct{}
|
type QueryDirtyRepos struct{}
|
||||||
|
|
||||||
// QueryAheadRepos requests repos with unpushed commits.
|
// QueryAheadRepos requests repos with unpushed commits.
|
||||||
|
//
|
||||||
type QueryAheadRepos struct{}
|
type QueryAheadRepos struct{}
|
||||||
|
|
||||||
// Tasks for git service
|
// Tasks for git service
|
||||||
|
|
||||||
// TaskPush requests git push for a path.
|
// TaskPush requests git push for a path.
|
||||||
|
//
|
||||||
type TaskPush struct {
|
type TaskPush struct {
|
||||||
Path string
|
Path string
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskPull requests git pull for a path.
|
// TaskPull requests git pull for a path.
|
||||||
|
//
|
||||||
type TaskPull struct {
|
type TaskPull struct {
|
||||||
Path string
|
Path string
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskPushMultiple requests git push for multiple paths.
|
// TaskPushMultiple requests git push for multiple paths.
|
||||||
|
//
|
||||||
type TaskPushMultiple struct {
|
type TaskPushMultiple struct {
|
||||||
Paths []string
|
Paths []string
|
||||||
Names map[string]string
|
Names map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceOptions for configuring the git service.
|
// ServiceOptions for configuring the git service.
|
||||||
|
//
|
||||||
type ServiceOptions struct {
|
type ServiceOptions struct {
|
||||||
WorkDir string
|
WorkDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides git operations as a Core service.
|
// Service provides git operations as a Core service.
|
||||||
|
//
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[ServiceOptions]
|
*core.ServiceRuntime[ServiceOptions]
|
||||||
lastStatus []RepoStatus
|
lastStatus []RepoStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a git service factory.
|
// NewService creates a git service factory.
|
||||||
|
//
|
||||||
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
|
func NewService(opts ServiceOptions) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
// Package gitea provides a thin wrapper around the Gitea Go SDK
|
// Package gitea provides a thin wrapper around the Gitea Go SDK
|
||||||
// for managing repositories, issues, and pull requests on a Gitea instance.
|
// 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.
|
// Client wraps the Gitea SDK client with config-based auth.
|
||||||
|
//
|
||||||
type Client struct {
|
type Client struct {
|
||||||
api *gitea.Client
|
api *gitea.Client
|
||||||
url string
|
url string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Gitea API client for the given URL and token.
|
// New creates a new Gitea API client for the given URL and token.
|
||||||
|
//
|
||||||
func New(url, token string) (*Client, error) {
|
func New(url, token string) (*Client, error) {
|
||||||
api, err := gitea.NewClient(url, gitea.SetToken(token))
|
api, err := gitea.NewClient(url, gitea.SetToken(token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
os "dappco.re/go/core/scm/internal/ax/osx"
|
||||||
|
|
||||||
"forge.lthn.ai/core/config"
|
|
||||||
"dappco.re/go/core/log"
|
"dappco.re/go/core/log"
|
||||||
|
"forge.lthn.ai/core/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ConfigKeyURL is the config key for the Gitea instance URL.
|
// ConfigKeyURL is the config key for the Gitea instance URL.
|
||||||
|
//
|
||||||
ConfigKeyURL = "gitea.url"
|
ConfigKeyURL = "gitea.url"
|
||||||
// ConfigKeyToken is the config key for the Gitea API token.
|
// ConfigKeyToken is the config key for the Gitea API token.
|
||||||
|
//
|
||||||
ConfigKeyToken = "gitea.token"
|
ConfigKeyToken = "gitea.token"
|
||||||
|
|
||||||
// DefaultURL is the default Gitea instance URL.
|
// DefaultURL is the default Gitea instance URL.
|
||||||
|
//
|
||||||
DefaultURL = "https://gitea.snider.dev"
|
DefaultURL = "https://gitea.snider.dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +27,8 @@ const (
|
||||||
// 1. ~/.core/config.yaml keys: gitea.token, gitea.url
|
// 1. ~/.core/config.yaml keys: gitea.token, gitea.url
|
||||||
// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file)
|
// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file)
|
||||||
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
||||||
|
//
|
||||||
|
//
|
||||||
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
|
func NewFromConfig(flagURL, flagToken string) (*Client, error) {
|
||||||
url, token, err := ResolveConfig(flagURL, flagToken)
|
url, token, err := ResolveConfig(flagURL, flagToken)
|
||||||
if err != nil {
|
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.
|
// ResolveConfig resolves the Gitea URL and token from all config sources.
|
||||||
// Flag values take highest priority, then env vars, then config file.
|
// Flag values take highest priority, then env vars, then config file.
|
||||||
|
//
|
||||||
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
|
func ResolveConfig(flagURL, flagToken string) (url, token string, err error) {
|
||||||
// Start with config file values
|
// Start with config file values
|
||||||
cfg, cfgErr := config.New()
|
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.
|
// SaveConfig persists the Gitea URL and/or token to the config file.
|
||||||
|
//
|
||||||
func SaveConfig(url, token string) error {
|
func SaveConfig(url, token string) error {
|
||||||
cfg, err := config.New()
|
cfg, err := config.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func TestResolveConfig_Good_URLDefaultsWhenEmpty(t *testing.T) {
|
||||||
assert.Equal(t, "some-token", token)
|
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.url", ConfigKeyURL)
|
||||||
assert.Equal(t, "gitea.token", ConfigKeyToken)
|
assert.Equal(t, "gitea.token", ConfigKeyToken)
|
||||||
assert.Equal(t, "https://gitea.snider.dev", DefaultURL)
|
assert.Equal(t, "https://gitea.snider.dev", DefaultURL)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"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) {
|
mux.HandleFunc("/api/v1/repos/test-org/test-repo/pulls/1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonResponse(w, map[string]any{
|
jsonResponse(w, map[string]any{
|
||||||
"id": 1, "number": 1, "title": "Many Comments PR", "state": "open",
|
"id": 1, "number": 1, "title": "Many Comments PR", "state": "open",
|
||||||
"merged": false,
|
"merged": false,
|
||||||
"head": map[string]any{"ref": "feature", "label": "feature"},
|
"head": map[string]any{"ref": "feature", "label": "feature"},
|
||||||
"base": map[string]any{"ref": "main", "label": "main"},
|
"base": map[string]any{"ref": "main", "label": "main"},
|
||||||
"user": map[string]any{"login": "author"},
|
"user": map[string]any{"login": "author"},
|
||||||
"labels": []map[string]any{},
|
"labels": []map[string]any{},
|
||||||
"assignees": []map[string]any{},
|
"assignees": []map[string]any{},
|
||||||
"created_at": "2026-01-15T10:00:00Z",
|
"created_at": "2026-01-15T10:00:00Z",
|
||||||
"updated_at": "2026-01-16T12:00:00Z",
|
"updated_at": "2026-01-16T12:00:00Z",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -9,6 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListIssuesOpts configures issue listing.
|
// ListIssuesOpts configures issue listing.
|
||||||
|
//
|
||||||
type ListIssuesOpts struct {
|
type ListIssuesOpts struct {
|
||||||
State string // "open", "closed", "all"
|
State string // "open", "closed", "all"
|
||||||
Page int
|
Page int
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ func TestClient_GetPullRequest_Bad_ServerError(t *testing.T) {
|
||||||
|
|
||||||
// --- ListIssuesOpts defaulting ---
|
// --- ListIssuesOpts defaulting ---
|
||||||
|
|
||||||
func TestListIssuesOpts_Defaults(t *testing.T) {
|
func TestListIssuesOpts_Good_Defaults(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
opts ListIssuesOpts
|
opts ListIssuesOpts
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,6 +12,7 @@ import (
|
||||||
|
|
||||||
// PRMeta holds structural signals from a pull request,
|
// PRMeta holds structural signals from a pull request,
|
||||||
// used by the pipeline MetaReader for AI-driven workflows.
|
// used by the pipeline MetaReader for AI-driven workflows.
|
||||||
|
//
|
||||||
type PRMeta struct {
|
type PRMeta struct {
|
||||||
Number int64
|
Number int64
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -26,6 +29,7 @@ type PRMeta struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment represents a comment with metadata.
|
// Comment represents a comment with metadata.
|
||||||
|
//
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID int64
|
ID int64
|
||||||
Author string
|
Author string
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ func TestClient_GetIssueBody_Bad_ServerError(t *testing.T) {
|
||||||
|
|
||||||
// --- PRMeta struct tests ---
|
// --- PRMeta struct tests ---
|
||||||
|
|
||||||
func TestPRMeta_Fields(t *testing.T) {
|
func TestPRMeta_Good_Fields(t *testing.T) {
|
||||||
meta := &PRMeta{
|
meta := &PRMeta{
|
||||||
Number: 42,
|
Number: 42,
|
||||||
Title: "Test PR",
|
Title: "Test PR",
|
||||||
|
|
@ -100,7 +100,7 @@ func TestPRMeta_Fields(t *testing.T) {
|
||||||
assert.Equal(t, 5, meta.CommentCount)
|
assert.Equal(t, 5, meta.CommentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComment_Fields(t *testing.T) {
|
func TestComment_Good_Fields(t *testing.T) {
|
||||||
comment := Comment{
|
comment := Comment{
|
||||||
ID: 123,
|
ID: 123,
|
||||||
Author: "reviewer",
|
Author: "reviewer",
|
||||||
|
|
@ -112,6 +112,6 @@ func TestComment_Fields(t *testing.T) {
|
||||||
assert.Equal(t, "LGTM", comment.Body)
|
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")
|
assert.Equal(t, 50, commentPageSize, "comment page size should be 50")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ func newGiteaMux() *http.ServeMux {
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
jsonResponse(w, map[string]any{
|
jsonResponse(w, map[string]any{
|
||||||
"id": 40, "name": "mirrored-repo", "full_name": "test-org/mirrored-repo",
|
"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,
|
"mirror": true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
32
go.mod
32
go.mod
|
|
@ -5,25 +5,27 @@ go 1.26.0
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.23.2
|
code.gitea.io/sdk/gitea v0.23.2
|
||||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
|
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
|
||||||
dappco.re/go/core v0.4.7
|
dappco.re/go/core v0.5.0
|
||||||
dappco.re/go/core/api v0.1.5
|
dappco.re/go/core/api v0.2.0
|
||||||
dappco.re/go/core/i18n v0.1.7
|
dappco.re/go/core/i18n v0.2.0
|
||||||
dappco.re/go/core/io v0.1.7
|
dappco.re/go/core/io v0.2.0
|
||||||
dappco.re/go/core/log v0.0.4
|
dappco.re/go/core/log v0.1.0
|
||||||
dappco.re/go/core/ws v0.2.5
|
dappco.re/go/core/ws v0.3.0
|
||||||
forge.lthn.ai/core/cli v0.3.7
|
forge.lthn.ai/core/cli v0.3.7
|
||||||
forge.lthn.ai/core/config v0.1.8
|
forge.lthn.ai/core/config v0.1.8
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/goccy/go-json v0.10.6
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.52.0
|
||||||
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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-i18n v0.1.7 // indirect
|
||||||
forge.lthn.ai/core/go-inference 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
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
github.com/42wim/httpsig v1.2.3 // indirect
|
github.com/42wim/httpsig v1.2.3 // indirect
|
||||||
github.com/99designs/gqlgen v0.17.88 // 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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // 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/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // 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/mod v0.34.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.20.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/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
|
@ -155,15 +155,3 @@ require (
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.47.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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
55
internal/ax/filepathx/filepathx.go
Normal file
55
internal/ax/filepathx/filepathx.go
Normal file
|
|
@ -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...)
|
||||||
|
}
|
||||||
40
internal/ax/fmtx/fmtx.go
Normal file
40
internal/ax/fmtx/fmtx.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
39
internal/ax/jsonx/jsonx.go
Normal file
39
internal/ax/jsonx/jsonx.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
113
internal/ax/osx/osx.go
Normal file
113
internal/ax/osx/osx.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
40
internal/ax/stdio/stdio.go
Normal file
40
internal/ax/stdio/stdio.go
Normal file
|
|
@ -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}
|
||||||
151
internal/ax/stringsx/stringsx.go
Normal file
151
internal/ax/stringsx/stringsx.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forgejo
|
package forgejo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,32 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package forgejo
|
package forgejo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"strings"
|
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/forge"
|
||||||
"dappco.re/go/core/scm/jobrunner"
|
"dappco.re/go/core/scm/jobrunner"
|
||||||
"dappco.re/go/core/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config configures a ForgejoSource.
|
// Config configures a ForgejoSource.
|
||||||
|
//
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Repos []string // "owner/repo" format
|
Repos []string // "owner/repo" format
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForgejoSource polls a Forgejo instance for pipeline signals from epic issues.
|
// ForgejoSource polls a Forgejo instance for pipeline signals from epic issues.
|
||||||
|
//
|
||||||
type ForgejoSource struct {
|
type ForgejoSource struct {
|
||||||
repos []string
|
repos []string
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a ForgejoSource using the given forge client.
|
// New creates a ForgejoSource using the given forge client.
|
||||||
|
//
|
||||||
func New(cfg Config, client *forge.Client) *ForgejoSource {
|
func New(cfg Config, client *forge.Client) *ForgejoSource {
|
||||||
return &ForgejoSource{
|
return &ForgejoSource{
|
||||||
repos: cfg.Repos,
|
repos: cfg.Repos,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ package forgejo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -35,7 +35,7 @@ func newTestClient(t *testing.T, url string) *forge.Client {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForgejoSource_Name(t *testing.T) {
|
func TestForgejoSource_Good_Name(t *testing.T) {
|
||||||
s := New(Config{}, nil)
|
s := New(Config{}, nil)
|
||||||
assert.Equal(t, "forgejo", s.Name())
|
assert.Equal(t, "forgejo", s.Name())
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +106,7 @@ func TestForgejoSource_Poll_Good(t *testing.T) {
|
||||||
assert.Equal(t, "abc123", sig.LastCommitSHA)
|
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) {
|
srv := httptest.NewServer(withVersion(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode([]any{})
|
_ = json.NewEncoder(w).Encode([]any{})
|
||||||
|
|
@ -152,18 +152,18 @@ func TestForgejoSource_Report_Good(t *testing.T) {
|
||||||
assert.Contains(t, capturedBody, "succeeded")
|
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"
|
body := "## Tasks\n- [x] #1\n- [ ] #7\n- [ ] #8\n- [x] #3\n"
|
||||||
unchecked, checked := parseEpicChildren(body)
|
unchecked, checked := parseEpicChildren(body)
|
||||||
assert.Equal(t, []int{7, 8}, unchecked)
|
assert.Equal(t, []int{7, 8}, unchecked)
|
||||||
assert.Equal(t, []int{1, 3}, checked)
|
assert.Equal(t, []int{1, 3}, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindLinkedPR(t *testing.T) {
|
func TestFindLinkedPR_Good(t *testing.T) {
|
||||||
assert.Nil(t, findLinkedPR(nil, 7))
|
assert.Nil(t, findLinkedPR(nil, 7))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitRepo(t *testing.T) {
|
func TestSplitRepo_Good(t *testing.T) {
|
||||||
owner, repo, err := splitRepo("host-uk/core")
|
owner, repo, err := splitRepo("host-uk/core")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "host-uk", owner)
|
assert.Equal(t, "host-uk", owner)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
|
@ -11,15 +13,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
//
|
||||||
ColorAgentComplete = "#0e8a16" // Green
|
ColorAgentComplete = "#0e8a16" // Green
|
||||||
)
|
)
|
||||||
|
|
||||||
// CompletionHandler manages issue state when an agent finishes work.
|
// CompletionHandler manages issue state when an agent finishes work.
|
||||||
|
//
|
||||||
type CompletionHandler struct {
|
type CompletionHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCompletionHandler creates a handler for agent completion events.
|
// NewCompletionHandler creates a handler for agent completion events.
|
||||||
|
//
|
||||||
func NewCompletionHandler(client *forge.Client) *CompletionHandler {
|
func NewCompletionHandler(client *forge.Client) *CompletionHandler {
|
||||||
return &CompletionHandler{
|
return &CompletionHandler{
|
||||||
forge: client,
|
forge: client,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"fmt"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"path/filepath"
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
||||||
|
"path"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
|
@ -15,17 +18,24 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LabelAgentReady = "agent-ready"
|
//
|
||||||
LabelInProgress = "in-progress"
|
LabelAgentReady = "agent-ready"
|
||||||
LabelAgentFailed = "agent-failed"
|
//
|
||||||
|
LabelInProgress = "in-progress"
|
||||||
|
//
|
||||||
|
LabelAgentFailed = "agent-failed"
|
||||||
|
//
|
||||||
LabelAgentComplete = "agent-completed"
|
LabelAgentComplete = "agent-completed"
|
||||||
|
|
||||||
ColorInProgress = "#1d76db" // Blue
|
//
|
||||||
|
ColorInProgress = "#1d76db" // Blue
|
||||||
|
//
|
||||||
ColorAgentFailed = "#c0392b" // Red
|
ColorAgentFailed = "#c0392b" // Red
|
||||||
)
|
)
|
||||||
|
|
||||||
// DispatchTicket is the JSON payload written to the agent's queue.
|
// DispatchTicket is the JSON payload written to the agent's queue.
|
||||||
// The ForgeToken is transferred separately via a .env file with 0600 permissions.
|
// The ForgeToken is transferred separately via a .env file with 0600 permissions.
|
||||||
|
//
|
||||||
type DispatchTicket struct {
|
type DispatchTicket struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
RepoOwner string `json:"repo_owner"`
|
RepoOwner string `json:"repo_owner"`
|
||||||
|
|
@ -45,6 +55,7 @@ type DispatchTicket struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchHandler dispatches coding work to remote agent machines via SSH.
|
// DispatchHandler dispatches coding work to remote agent machines via SSH.
|
||||||
|
//
|
||||||
type DispatchHandler struct {
|
type DispatchHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
forgeURL string
|
forgeURL string
|
||||||
|
|
@ -53,6 +64,7 @@ type DispatchHandler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDispatchHandler creates a handler that dispatches tickets to agent machines.
|
// NewDispatchHandler creates a handler that dispatches tickets to agent machines.
|
||||||
|
//
|
||||||
func NewDispatchHandler(client *forge.Client, forgeURL, token string, spinner *agentci.Spinner) *DispatchHandler {
|
func NewDispatchHandler(client *forge.Client, forgeURL, token string, spinner *agentci.Spinner) *DispatchHandler {
|
||||||
return &DispatchHandler{
|
return &DispatchHandler{
|
||||||
forge: client,
|
forge: client,
|
||||||
|
|
@ -85,6 +97,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, coreerr.E("dispatch.Execute", "unknown agent: "+signal.Assignee, nil)
|
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.
|
// Sanitize inputs to prevent path traversal.
|
||||||
safeOwner, err := agentci.SanitizePath(signal.RepoOwner)
|
safeOwner, err := agentci.SanitizePath(signal.RepoOwner)
|
||||||
|
|
@ -184,7 +200,10 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer ticket JSON.
|
// 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 {
|
if err := h.secureTransfer(ctx, agent, remoteTicketPath, ticketJSON, 0644); err != nil {
|
||||||
h.failDispatch(signal, fmt.Sprintf("Ticket transfer failed: %v", err))
|
h.failDispatch(signal, fmt.Sprintf("Ticket transfer failed: %v", err))
|
||||||
return &jobrunner.ActionResult{
|
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.
|
// Transfer token via separate .env file with 0600 permissions.
|
||||||
envContent := fmt.Sprintf("FORGE_TOKEN=%s\n", h.token)
|
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 {
|
if err := h.secureTransfer(ctx, agent, remoteEnvPath, []byte(envContent), 0600); err != nil {
|
||||||
// Clean up the ticket if env transfer fails.
|
// 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))
|
h.failDispatch(signal, fmt.Sprintf("Token transfer failed: %v", err))
|
||||||
return &jobrunner.ActionResult{
|
return &jobrunner.ActionResult{
|
||||||
Action: "dispatch",
|
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.
|
// 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 {
|
func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.AgentConfig, remotePath string, data []byte, mode int) error {
|
||||||
safeRemotePath := agentci.EscapeShellArg(remotePath)
|
safePath := agentci.EscapeShellArg(remotePath)
|
||||||
remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safeRemotePath, mode, safeRemotePath)
|
remoteCmd := fmt.Sprintf("cat > %s && chmod %o %s", safePath, mode, safePath)
|
||||||
|
|
||||||
cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd)
|
cmd := agentci.SecureSSHCommand(agent.Host, remoteCmd)
|
||||||
cmd.Stdin = bytes.NewReader(data)
|
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.
|
// runRemote executes a command on the agent via SSH.
|
||||||
func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, cmdStr string) error {
|
func (h *DispatchHandler) runRemote(ctx context.Context, agent agentci.AgentConfig, command string, args ...string) error {
|
||||||
cmd := agentci.SecureSSHCommand(agent.Host, cmdStr)
|
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()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ticketExists checks if a ticket file already exists in queue, active, or done.
|
// 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 {
|
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 {
|
if err != nil {
|
||||||
return false
|
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(
|
checkCmd := fmt.Sprintf(
|
||||||
"test -f %s/%s || test -f %s/../active/%s || test -f %s/../done/%s",
|
"test -f %s || test -f %s || test -f %s",
|
||||||
qDir, safeTicket, qDir, safeTicket, qDir, safeTicket,
|
queuePath, activePath, donePath,
|
||||||
)
|
)
|
||||||
cmd := agentci.SecureSSHCommand(agent.Host, checkCmd)
|
cmd := agentci.SecureSSHCommand(agent.Host, checkCmd)
|
||||||
return cmd.Run() == nil
|
return cmd.Run() == nil
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core/scm/agentci"
|
"dappco.re/go/core/scm/agentci"
|
||||||
|
|
@ -13,6 +16,18 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"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.
|
// newTestSpinner creates a Spinner with the given agents for testing.
|
||||||
func newTestSpinner(agents map[string]agentci.AgentConfig) *agentci.Spinner {
|
func newTestSpinner(agents map[string]agentci.AgentConfig) *agentci.Spinner {
|
||||||
return agentci.NewSpinner(agentci.ClothoConfig{Strategy: "direct"}, agents)
|
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")
|
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) {
|
func TestDispatch_TicketJSON_Good(t *testing.T) {
|
||||||
ticket := DispatchTicket{
|
ticket := DispatchTicket{
|
||||||
ID: "host-uk-core-5-1234567890",
|
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")
|
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) {
|
func TestDispatch_TicketJSON_Good_ModelRunnerVariants(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core/scm/forge"
|
"dappco.re/go/core/scm/forge"
|
||||||
|
|
@ -10,11 +12,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnableAutoMergeHandler merges a PR that is ready using squash strategy.
|
// EnableAutoMergeHandler merges a PR that is ready using squash strategy.
|
||||||
|
//
|
||||||
type EnableAutoMergeHandler struct {
|
type EnableAutoMergeHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEnableAutoMergeHandler creates a handler that merges ready PRs.
|
// NewEnableAutoMergeHandler creates a handler that merges ready PRs.
|
||||||
|
//
|
||||||
func NewEnableAutoMergeHandler(f *forge.Client) *EnableAutoMergeHandler {
|
func NewEnableAutoMergeHandler(f *forge.Client) *EnableAutoMergeHandler {
|
||||||
return &EnableAutoMergeHandler{forge: f}
|
return &EnableAutoMergeHandler{forge: f}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core/scm/forge"
|
"dappco.re/go/core/scm/forge"
|
||||||
|
|
@ -10,11 +12,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublishDraftHandler marks a draft PR as ready for review once its checks pass.
|
// PublishDraftHandler marks a draft PR as ready for review once its checks pass.
|
||||||
|
//
|
||||||
type PublishDraftHandler struct {
|
type PublishDraftHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPublishDraftHandler creates a handler that publishes draft PRs.
|
// NewPublishDraftHandler creates a handler that publishes draft PRs.
|
||||||
|
//
|
||||||
func NewPublishDraftHandler(f *forge.Client) *PublishDraftHandler {
|
func NewPublishDraftHandler(f *forge.Client) *PublishDraftHandler {
|
||||||
return &PublishDraftHandler{forge: f}
|
return &PublishDraftHandler{forge: f}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||||
|
|
@ -15,11 +17,13 @@ import (
|
||||||
// DismissReviewsHandler dismisses stale "request changes" reviews on a PR.
|
// DismissReviewsHandler dismisses stale "request changes" reviews on a PR.
|
||||||
// This replaces the GitHub-only ResolveThreadsHandler because Forgejo does
|
// This replaces the GitHub-only ResolveThreadsHandler because Forgejo does
|
||||||
// not have a thread resolution API.
|
// not have a thread resolution API.
|
||||||
|
//
|
||||||
type DismissReviewsHandler struct {
|
type DismissReviewsHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDismissReviewsHandler creates a handler that dismisses stale reviews.
|
// NewDismissReviewsHandler creates a handler that dismisses stale reviews.
|
||||||
|
//
|
||||||
func NewDismissReviewsHandler(f *forge.Client) *DismissReviewsHandler {
|
func NewDismissReviewsHandler(f *forge.Client) *DismissReviewsHandler {
|
||||||
return &DismissReviewsHandler{forge: f}
|
return &DismissReviewsHandler{forge: f}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
json "dappco.re/go/core/scm/internal/ax/jsonx"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core/scm/forge"
|
"dappco.re/go/core/scm/forge"
|
||||||
|
|
@ -11,11 +13,13 @@ import (
|
||||||
|
|
||||||
// SendFixCommandHandler posts a comment on a PR asking for conflict or
|
// SendFixCommandHandler posts a comment on a PR asking for conflict or
|
||||||
// review fixes.
|
// review fixes.
|
||||||
|
//
|
||||||
type SendFixCommandHandler struct {
|
type SendFixCommandHandler struct {
|
||||||
forge *forge.Client
|
forge *forge.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSendFixCommandHandler creates a handler that posts fix commands.
|
// NewSendFixCommandHandler creates a handler that posts fix commands.
|
||||||
|
//
|
||||||
func NewSendFixCommandHandler(f *forge.Client) *SendFixCommandHandler {
|
func NewSendFixCommandHandler(f *forge.Client) *SendFixCommandHandler {
|
||||||
return &SendFixCommandHandler{forge: f}
|
return &SendFixCommandHandler{forge: f}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue