* ci: consolidate duplicate workflows and merge CodeQL configs Remove 17 duplicate workflow files that were split copies of the combined originals. Each family (CI, CodeQL, Coverage, PR Build, Alpha Release) had the same job duplicated across separate push/pull_request/schedule/manual trigger files. Merge codeql.yml and codescan.yml into a single codeql.yml with a language matrix covering go, javascript-typescript, python, and actions — matching the previous default setup coverage. Remaining workflows (one per family): - ci.yml (push + PR + manual) - codeql.yml (push + PR + schedule, all languages) - coverage.yml (push + PR + manual) - alpha-release.yml (push + manual) - pr-build.yml (PR + manual) - release.yml (tag push) - agent-verify.yml, auto-label.yml, auto-project.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add collect, config, crypt, plugin packages and fix all lint issues Add four new infrastructure packages with CLI commands: - pkg/config: layered configuration (defaults → file → env → flags) - pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums) - pkg/plugin: plugin system with GitHub-based install/update/remove - pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate) Fix all golangci-lint issues across the entire codebase (~100 errcheck, staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that `core go qa` passes with 0 issues. Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
344 lines
8.6 KiB
Go
344 lines
8.6 KiB
Go
package dev
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/host-uk/core/pkg/agentic"
|
|
"github.com/host-uk/core/pkg/cli"
|
|
"github.com/host-uk/core/pkg/git"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
)
|
|
|
|
// Work command flags
|
|
var (
|
|
workStatusOnly bool
|
|
workAutoCommit bool
|
|
workRegistryPath string
|
|
)
|
|
|
|
// AddWorkCommand adds the 'work' command to the given parent command.
|
|
func AddWorkCommand(parent *cli.Command) {
|
|
workCmd := &cli.Command{
|
|
Use: "work",
|
|
Short: i18n.T("cmd.dev.work.short"),
|
|
Long: i18n.T("cmd.dev.work.long"),
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
return runWork(workRegistryPath, workStatusOnly, workAutoCommit)
|
|
},
|
|
}
|
|
|
|
workCmd.Flags().BoolVar(&workStatusOnly, "status", false, i18n.T("cmd.dev.work.flag.status"))
|
|
workCmd.Flags().BoolVar(&workAutoCommit, "commit", false, i18n.T("cmd.dev.work.flag.commit"))
|
|
workCmd.Flags().StringVar(&workRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
|
|
|
parent.AddCommand(workCmd)
|
|
}
|
|
|
|
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
|
ctx := context.Background()
|
|
|
|
// Build worker bundle with required services
|
|
bundle, err := NewWorkBundle(WorkBundleOptions{
|
|
RegistryPath: registryPath,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start services (registers handlers)
|
|
if err := bundle.Start(ctx); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = bundle.Stop(ctx) }()
|
|
|
|
// Load registry and get paths
|
|
paths, names, err := func() ([]string, map[string]string, error) {
|
|
reg, _, err := loadRegistryWithConfig(registryPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var paths []string
|
|
names := make(map[string]string)
|
|
for _, repo := range reg.List() {
|
|
if repo.IsGitRepo() {
|
|
paths = append(paths, repo.Path)
|
|
names[repo.Path] = repo.Name
|
|
}
|
|
}
|
|
return paths, names, nil
|
|
}()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(paths) == 0 {
|
|
cli.Text(i18n.T("cmd.dev.no_git_repos"))
|
|
return nil
|
|
}
|
|
|
|
// QUERY git status
|
|
result, handled, err := bundle.Core.QUERY(git.QueryStatus{
|
|
Paths: paths,
|
|
Names: names,
|
|
})
|
|
if !handled {
|
|
return cli.Err("git service not available")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
statuses := result.([]git.RepoStatus)
|
|
|
|
// Sort by repo name for consistent output
|
|
sort.Slice(statuses, func(i, j int) bool {
|
|
return statuses[i].Name < statuses[j].Name
|
|
})
|
|
|
|
// Display status table
|
|
printStatusTable(statuses)
|
|
|
|
// Collect dirty and ahead repos
|
|
var dirtyRepos []git.RepoStatus
|
|
var aheadRepos []git.RepoStatus
|
|
|
|
for _, s := range statuses {
|
|
if s.Error != nil {
|
|
continue
|
|
}
|
|
if s.IsDirty() {
|
|
dirtyRepos = append(dirtyRepos, s)
|
|
}
|
|
if s.HasUnpushed() {
|
|
aheadRepos = append(aheadRepos, s)
|
|
}
|
|
}
|
|
|
|
// Auto-commit dirty repos if requested
|
|
if autoCommit && len(dirtyRepos) > 0 {
|
|
cli.Blank()
|
|
cli.Print("%s\n", cli.TitleStyle.Render(i18n.T("cmd.dev.commit.committing")))
|
|
cli.Blank()
|
|
|
|
for _, s := range dirtyRepos {
|
|
// PERFORM commit via agentic service
|
|
_, handled, err := bundle.Core.PERFORM(agentic.TaskCommit{
|
|
Path: s.Path,
|
|
Name: s.Name,
|
|
})
|
|
if !handled {
|
|
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, "agentic service not available")
|
|
continue
|
|
}
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
} else {
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
|
|
// Re-QUERY status after commits
|
|
result, _, _ = bundle.Core.QUERY(git.QueryStatus{
|
|
Paths: paths,
|
|
Names: names,
|
|
})
|
|
statuses = result.([]git.RepoStatus)
|
|
|
|
// Rebuild ahead repos list
|
|
aheadRepos = nil
|
|
for _, s := range statuses {
|
|
if s.Error == nil && s.HasUnpushed() {
|
|
aheadRepos = append(aheadRepos, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If status only, we're done
|
|
if statusOnly {
|
|
if len(dirtyRepos) > 0 && !autoCommit {
|
|
cli.Blank()
|
|
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.work.use_commit_flag")))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Push repos with unpushed commits
|
|
if len(aheadRepos) == 0 {
|
|
cli.Blank()
|
|
cli.Text(i18n.T("cmd.dev.work.all_up_to_date"))
|
|
return nil
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", i18n.T("common.count.repos_unpushed", map[string]interface{}{"Count": len(aheadRepos)}))
|
|
for _, s := range aheadRepos {
|
|
cli.Print(" %s: %s\n", s.Name, i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead}))
|
|
}
|
|
|
|
cli.Blank()
|
|
if !cli.Confirm(i18n.T("cmd.dev.push.confirm")) {
|
|
cli.Text(i18n.T("cli.aborted"))
|
|
return nil
|
|
}
|
|
|
|
cli.Blank()
|
|
|
|
// PERFORM push for each repo
|
|
var divergedRepos []git.RepoStatus
|
|
|
|
for _, s := range aheadRepos {
|
|
_, handled, err := bundle.Core.PERFORM(git.TaskPush{
|
|
Path: s.Path,
|
|
Name: s.Name,
|
|
})
|
|
if !handled {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, "git service not available")
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if git.IsNonFastForward(err) {
|
|
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), s.Name, i18n.T("cmd.dev.push.diverged"))
|
|
divergedRepos = append(divergedRepos, s)
|
|
} else {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
}
|
|
} else {
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
|
|
// Handle diverged repos - offer to pull and retry
|
|
if len(divergedRepos) > 0 {
|
|
cli.Blank()
|
|
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
|
|
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
|
|
cli.Blank()
|
|
for _, s := range divergedRepos {
|
|
cli.Print(" %s %s...\n", dimStyle.Render("↓"), s.Name)
|
|
|
|
// PERFORM pull
|
|
_, _, err := bundle.Core.PERFORM(git.TaskPull{Path: s.Path, Name: s.Name})
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
continue
|
|
}
|
|
|
|
cli.Print(" %s %s...\n", dimStyle.Render("↑"), s.Name)
|
|
|
|
// PERFORM push
|
|
_, _, err = bundle.Core.PERFORM(git.TaskPush{Path: s.Path, Name: s.Name})
|
|
if err != nil {
|
|
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), s.Name, err)
|
|
continue
|
|
}
|
|
|
|
cli.Print(" %s %s\n", successStyle.Render("v"), s.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printStatusTable(statuses []git.RepoStatus) {
|
|
// Calculate column widths
|
|
nameWidth := 4 // "Repo"
|
|
for _, s := range statuses {
|
|
if len(s.Name) > nameWidth {
|
|
nameWidth = len(s.Name)
|
|
}
|
|
}
|
|
|
|
// Print header with fixed-width formatting
|
|
cli.Print("%-*s %8s %9s %6s %5s\n",
|
|
nameWidth,
|
|
cli.TitleStyle.Render(i18n.Label("repo")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_modified")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_untracked")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_staged")),
|
|
cli.TitleStyle.Render(i18n.T("cmd.dev.work.table_ahead")),
|
|
)
|
|
|
|
// Print separator
|
|
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
|
|
|
// Print rows
|
|
for _, s := range statuses {
|
|
if s.Error != nil {
|
|
paddedName := cli.Sprintf("%-*s", nameWidth, s.Name)
|
|
cli.Print("%s %s\n",
|
|
repoNameStyle.Render(paddedName),
|
|
errorStyle.Render(i18n.T("cmd.dev.work.error_prefix")+" "+s.Error.Error()),
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Style numbers based on values
|
|
modStr := cli.Sprintf("%d", s.Modified)
|
|
if s.Modified > 0 {
|
|
modStr = dirtyStyle.Render(modStr)
|
|
} else {
|
|
modStr = cleanStyle.Render(modStr)
|
|
}
|
|
|
|
untrackedStr := cli.Sprintf("%d", s.Untracked)
|
|
if s.Untracked > 0 {
|
|
untrackedStr = dirtyStyle.Render(untrackedStr)
|
|
} else {
|
|
untrackedStr = cleanStyle.Render(untrackedStr)
|
|
}
|
|
|
|
stagedStr := cli.Sprintf("%d", s.Staged)
|
|
if s.Staged > 0 {
|
|
stagedStr = aheadStyle.Render(stagedStr)
|
|
} else {
|
|
stagedStr = cleanStyle.Render(stagedStr)
|
|
}
|
|
|
|
aheadStr := cli.Sprintf("%d", s.Ahead)
|
|
if s.Ahead > 0 {
|
|
aheadStr = aheadStyle.Render(aheadStr)
|
|
} else {
|
|
aheadStr = cleanStyle.Render(aheadStr)
|
|
}
|
|
|
|
// Pad name before styling to avoid ANSI code length issues
|
|
paddedName := cli.Sprintf("%-*s", nameWidth, s.Name)
|
|
cli.Print("%s %8s %9s %6s %5s\n",
|
|
repoNameStyle.Render(paddedName),
|
|
modStr,
|
|
untrackedStr,
|
|
stagedStr,
|
|
aheadStr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// claudeCommit shells out to claude for committing (legacy helper for other commands)
|
|
func claudeCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
prompt := agentic.Prompt("commit")
|
|
|
|
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep")
|
|
cmd.Dir = repoPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
// claudeEditCommit shells out to claude with edit permissions (legacy helper)
|
|
func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath string) error {
|
|
prompt := agentic.Prompt("commit")
|
|
|
|
cmd := exec.CommandContext(ctx, "claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep")
|
|
cmd.Dir = repoPath
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
return cmd.Run()
|
|
}
|