2026-01-30 10:18:54 +00:00
|
|
|
package dev
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/host-uk/core/pkg/agentic"
|
2026-01-31 11:39:19 +00:00
|
|
|
"github.com/host-uk/core/pkg/cli"
|
2026-01-30 10:18:54 +00:00
|
|
|
"github.com/host-uk/core/pkg/framework"
|
|
|
|
|
"github.com/host-uk/core/pkg/git"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tasks for dev service
|
|
|
|
|
|
|
|
|
|
// TaskWork runs the full dev workflow: status, commit, push.
|
|
|
|
|
type TaskWork struct {
|
|
|
|
|
RegistryPath string
|
|
|
|
|
StatusOnly bool
|
|
|
|
|
AutoCommit bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TaskStatus displays git status for all repos.
|
|
|
|
|
type TaskStatus struct {
|
|
|
|
|
RegistryPath string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServiceOptions for configuring the dev service.
|
|
|
|
|
type ServiceOptions struct {
|
|
|
|
|
RegistryPath string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Service provides dev workflow orchestration as a Core service.
|
|
|
|
|
type Service struct {
|
|
|
|
|
*framework.ServiceRuntime[ServiceOptions]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewService creates a dev service factory.
|
|
|
|
|
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
|
|
|
|
return func(c *framework.Core) (any, error) {
|
|
|
|
|
return &Service{
|
|
|
|
|
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// OnStartup registers task handlers.
|
|
|
|
|
func (s *Service) OnStartup(ctx context.Context) error {
|
|
|
|
|
s.Core().RegisterTask(s.handleTask)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
|
|
|
|
switch m := t.(type) {
|
|
|
|
|
case TaskWork:
|
|
|
|
|
err := s.runWork(m)
|
|
|
|
|
return nil, true, err
|
|
|
|
|
|
|
|
|
|
case TaskStatus:
|
|
|
|
|
err := s.runStatus(m)
|
|
|
|
|
return nil, true, err
|
|
|
|
|
}
|
|
|
|
|
return nil, false, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) runWork(task TaskWork) error {
|
|
|
|
|
// Load registry
|
|
|
|
|
paths, names, err := s.loadRegistry(task.RegistryPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(paths) == 0 {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Println("No git repositories found")
|
2026-01-30 10:18:54 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// QUERY git status
|
|
|
|
|
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
|
|
|
|
Paths: paths,
|
|
|
|
|
Names: names,
|
|
|
|
|
})
|
|
|
|
|
if !handled {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("git service not available")
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
statuses := result.([]git.RepoStatus)
|
|
|
|
|
|
|
|
|
|
// Sort by name
|
|
|
|
|
sort.Slice(statuses, func(i, j int) bool {
|
|
|
|
|
return statuses[i].Name < statuses[j].Name
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Display status table
|
|
|
|
|
s.printStatusTable(statuses)
|
|
|
|
|
|
|
|
|
|
// Collect dirty and ahead repos
|
|
|
|
|
var dirtyRepos []git.RepoStatus
|
|
|
|
|
var aheadRepos []git.RepoStatus
|
|
|
|
|
|
|
|
|
|
for _, st := range statuses {
|
|
|
|
|
if st.Error != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if st.IsDirty() {
|
|
|
|
|
dirtyRepos = append(dirtyRepos, st)
|
|
|
|
|
}
|
|
|
|
|
if st.HasUnpushed() {
|
|
|
|
|
aheadRepos = append(aheadRepos, st)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-commit dirty repos if requested
|
|
|
|
|
if task.AutoCommit && len(dirtyRepos) > 0 {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
|
|
|
|
cli.Println("Committing changes...")
|
|
|
|
|
cli.Blank()
|
2026-01-30 10:18:54 +00:00
|
|
|
|
|
|
|
|
for _, repo := range dirtyRepos {
|
|
|
|
|
_, handled, err := s.Core().PERFORM(agentic.TaskCommit{
|
|
|
|
|
Path: repo.Path,
|
|
|
|
|
Name: repo.Name,
|
|
|
|
|
})
|
|
|
|
|
if !handled {
|
|
|
|
|
// Agentic service not available - skip silently
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" - %s: agentic service not available\n", repo.Name)
|
2026-01-30 10:18:54 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" x %s: %s\n", repo.Name, err)
|
2026-01-30 10:18:54 +00:00
|
|
|
} else {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" v %s\n", repo.Name)
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-query status after commits
|
|
|
|
|
result, _, _ = s.Core().QUERY(git.QueryStatus{
|
|
|
|
|
Paths: paths,
|
|
|
|
|
Names: names,
|
|
|
|
|
})
|
|
|
|
|
statuses = result.([]git.RepoStatus)
|
|
|
|
|
|
|
|
|
|
// Rebuild ahead repos list
|
|
|
|
|
aheadRepos = nil
|
|
|
|
|
for _, st := range statuses {
|
|
|
|
|
if st.Error == nil && st.HasUnpushed() {
|
|
|
|
|
aheadRepos = append(aheadRepos, st)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If status only, we're done
|
|
|
|
|
if task.StatusOnly {
|
|
|
|
|
if len(dirtyRepos) > 0 && !task.AutoCommit {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
|
|
|
|
cli.Println("Use --commit flag to auto-commit dirty repos")
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Push repos with unpushed commits
|
|
|
|
|
if len(aheadRepos) == 0 {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
|
|
|
|
cli.Println("All repositories are up to date")
|
2026-01-30 10:18:54 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print("%d repos with unpushed commits:\n", len(aheadRepos))
|
2026-01-30 10:18:54 +00:00
|
|
|
for _, st := range aheadRepos {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" %s: %d commits\n", st.Name, st.Ahead)
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print("Push all? [y/N] ")
|
2026-01-30 10:18:54 +00:00
|
|
|
var answer string
|
feat: infrastructure packages and lint cleanup (#281)
* 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>
2026-02-04 11:34:43 +00:00
|
|
|
_, _ = cli.Scanln(&answer)
|
2026-01-30 10:18:54 +00:00
|
|
|
if strings.ToLower(answer) != "y" {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Println("Aborted")
|
2026-01-30 10:18:54 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Blank()
|
2026-01-30 10:18:54 +00:00
|
|
|
|
|
|
|
|
// Push each repo
|
|
|
|
|
for _, st := range aheadRepos {
|
|
|
|
|
_, handled, err := s.Core().PERFORM(git.TaskPush{
|
|
|
|
|
Path: st.Path,
|
|
|
|
|
Name: st.Name,
|
|
|
|
|
})
|
|
|
|
|
if !handled {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" x %s: git service not available\n", st.Name)
|
2026-01-30 10:18:54 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if git.IsNonFastForward(err) {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" ! %s: branch has diverged\n", st.Name)
|
2026-01-30 10:18:54 +00:00
|
|
|
} else {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" x %s: %s\n", st.Name, err)
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print(" v %s\n", st.Name)
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) runStatus(task TaskStatus) error {
|
|
|
|
|
paths, names, err := s.loadRegistry(task.RegistryPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(paths) == 0 {
|
2026-01-31 23:36:43 +00:00
|
|
|
cli.Println("No git repositories found")
|
2026-01-30 10:18:54 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, handled, err := s.Core().QUERY(git.QueryStatus{
|
|
|
|
|
Paths: paths,
|
|
|
|
|
Names: names,
|
|
|
|
|
})
|
|
|
|
|
if !handled {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("git service not available")
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
statuses := result.([]git.RepoStatus)
|
|
|
|
|
sort.Slice(statuses, func(i, j int) bool {
|
|
|
|
|
return statuses[i].Name < statuses[j].Name
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
s.printStatusTable(statuses)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) loadRegistry(registryPath string) ([]string, map[string]string, error) {
|
2026-02-01 02:07:26 +00:00
|
|
|
reg, _, err := loadRegistryWithConfig(registryPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
2026-01-30 10:18:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Service) printStatusTable(statuses []git.RepoStatus) {
|
|
|
|
|
// Calculate column widths
|
|
|
|
|
nameWidth := 4 // "Repo"
|
|
|
|
|
for _, st := range statuses {
|
|
|
|
|
if len(st.Name) > nameWidth {
|
|
|
|
|
nameWidth = len(st.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Print header
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print("%-*s %8s %9s %6s %5s\n",
|
2026-01-30 10:18:54 +00:00
|
|
|
nameWidth, "Repo", "Modified", "Untracked", "Staged", "Ahead")
|
|
|
|
|
|
|
|
|
|
// Print separator
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Text(strings.Repeat("-", nameWidth+2+10+11+8+7))
|
2026-01-30 10:18:54 +00:00
|
|
|
|
|
|
|
|
// Print rows
|
|
|
|
|
for _, st := range statuses {
|
|
|
|
|
if st.Error != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print("%-*s error: %s\n", nameWidth, st.Name, st.Error)
|
2026-01-30 10:18:54 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 11:39:19 +00:00
|
|
|
cli.Print("%-*s %8d %9d %6d %5d\n",
|
2026-01-30 10:18:54 +00:00
|
|
|
nameWidth, st.Name,
|
|
|
|
|
st.Modified, st.Untracked, st.Staged, st.Ahead)
|
|
|
|
|
}
|
|
|
|
|
}
|