From bfe70871a2f2eec9d48df1a2caab171cd780209d Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:07:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Forge=20CLI=20commands=20=E2=80=94=20is?= =?UTF-8?q?sue,=20PR,=20and=20repo=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New commands via go-forge library: issue/get, issue/list, issue/comment pr/get, pr/list, pr/merge repo/get, repo/list Enables CLI-driven Forge interaction for dispatch automation. Agent can now read issues, create PRs, merge, and list repos without MCP. Co-Authored-By: Virgil --- cmd/core-agent/forge.go | 257 ++++++++++++++++++++++++++++++++++++++++ cmd/core-agent/main.go | 159 +++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 + 4 files changed, 420 insertions(+) create mode 100644 cmd/core-agent/forge.go diff --git a/cmd/core-agent/forge.go b/cmd/core-agent/forge.go new file mode 100644 index 0000000..ad83b6a --- /dev/null +++ b/cmd/core-agent/forge.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "strconv" + + "dappco.re/go/core" + "dappco.re/go/core/forge" +) + +// newForgeClient creates a Forge client from env config. +func newForgeClient() *forge.Forge { + url := core.Env("FORGE_URL") + if url == "" { + url = "https://forge.lthn.ai" + } + token := core.Env("FORGE_TOKEN") + if token == "" { + token = core.Env("GITEA_TOKEN") + } + return forge.NewForge(url, token) +} + +// parseArgs extracts org and repo from opts. First positional arg is repo, --org flag defaults to "core". +func parseArgs(opts core.Options) (org, repo string, num int64) { + org = opts.String("org") + if org == "" { + org = "core" + } + repo = opts.String("_arg") + if v := opts.String("number"); v != "" { + num, _ = strconv.ParseInt(v, 10, 64) + } + return +} + +func fmtIndex(n int64) string { return strconv.FormatInt(n, 10) } + +func registerForgeCommands(c *core.Core) { + ctx := context.Background() + + // --- Issues --- + + c.Command("issue/get", core.Command{ + Description: "Get a Forge issue", + Action: func(opts core.Options) core.Result { + org, repo, num := parseArgs(opts) + if repo == "" || num == 0 { + core.Print(nil, "usage: core-agent issue/get --number=N [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + issue, err := f.Issues.Get(ctx, forge.Params{"owner": org, "repo": repo, "index": fmtIndex(num)}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "#%d %s", issue.Index, issue.Title) + core.Print(nil, " state: %s", issue.State) + core.Print(nil, " url: %s", issue.HTMLURL) + if issue.Body != "" { + core.Print(nil, "") + core.Print(nil, "%s", issue.Body) + } + return core.Result{OK: true} + }, + }) + + c.Command("issue/list", core.Command{ + Description: "List Forge issues for a repo", + Action: func(opts core.Options) core.Result { + org, repo, _ := parseArgs(opts) + if repo == "" { + core.Print(nil, "usage: core-agent issue/list [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + issues, err := f.Issues.ListAll(ctx, forge.Params{"owner": org, "repo": repo}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + for _, issue := range issues { + core.Print(nil, " #%-4d %-6s %s", issue.Index, issue.State, issue.Title) + } + if len(issues) == 0 { + core.Print(nil, " no issues") + } + return core.Result{OK: true} + }, + }) + + c.Command("issue/comment", core.Command{ + Description: "Comment on a Forge issue", + Action: func(opts core.Options) core.Result { + org, repo, num := parseArgs(opts) + body := opts.String("body") + if repo == "" || num == 0 || body == "" { + core.Print(nil, "usage: core-agent issue/comment --number=N --body=\"text\" [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + comment, err := f.Issues.CreateComment(ctx, org, repo, num, body) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "comment #%d created on %s/%s#%d", comment.ID, org, repo, num) + return core.Result{OK: true} + }, + }) + + // --- Pull Requests --- + + c.Command("pr/get", core.Command{ + Description: "Get a Forge PR", + Action: func(opts core.Options) core.Result { + org, repo, num := parseArgs(opts) + if repo == "" || num == 0 { + core.Print(nil, "usage: core-agent pr/get --number=N [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + pr, err := f.Pulls.Get(ctx, forge.Params{"owner": org, "repo": repo, "index": fmtIndex(num)}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "#%d %s", pr.Index, pr.Title) + core.Print(nil, " state: %s", pr.State) + core.Print(nil, " head: %s", pr.Head.Ref) + core.Print(nil, " base: %s", pr.Base.Ref) + core.Print(nil, " mergeable: %v", pr.Mergeable) + core.Print(nil, " url: %s", pr.HTMLURL) + if pr.Body != "" { + core.Print(nil, "") + core.Print(nil, "%s", pr.Body) + } + return core.Result{OK: true} + }, + }) + + c.Command("pr/list", core.Command{ + Description: "List Forge PRs for a repo", + Action: func(opts core.Options) core.Result { + org, repo, _ := parseArgs(opts) + if repo == "" { + core.Print(nil, "usage: core-agent pr/list [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + prs, err := f.Pulls.ListAll(ctx, forge.Params{"owner": org, "repo": repo}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + for _, pr := range prs { + core.Print(nil, " #%-4d %-6s %s → %s %s", pr.Index, pr.State, pr.Head.Ref, pr.Base.Ref, pr.Title) + } + if len(prs) == 0 { + core.Print(nil, " no PRs") + } + return core.Result{OK: true} + }, + }) + + c.Command("pr/merge", core.Command{ + Description: "Merge a Forge PR", + Action: func(opts core.Options) core.Result { + org, repo, num := parseArgs(opts) + method := opts.String("method") + if method == "" { + method = "merge" + } + if repo == "" || num == 0 { + core.Print(nil, "usage: core-agent pr/merge --number=N [--method=merge|rebase|squash] [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + if err := f.Pulls.Merge(ctx, org, repo, num, method); err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "merged %s/%s#%d via %s", org, repo, num, method) + return core.Result{OK: true} + }, + }) + + // --- Repositories --- + + c.Command("repo/get", core.Command{ + Description: "Get Forge repo info", + Action: func(opts core.Options) core.Result { + org, repo, _ := parseArgs(opts) + if repo == "" { + core.Print(nil, "usage: core-agent repo/get [--org=core]") + return core.Result{OK: false} + } + + f := newForgeClient() + r, err := f.Repos.Get(ctx, forge.Params{"owner": org, "repo": repo}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "%s/%s", r.Owner.UserName, r.Name) + core.Print(nil, " description: %s", r.Description) + core.Print(nil, " default: %s", r.DefaultBranch) + core.Print(nil, " private: %v", r.Private) + core.Print(nil, " archived: %v", r.Archived) + core.Print(nil, " url: %s", r.HTMLURL) + return core.Result{OK: true} + }, + }) + + c.Command("repo/list", core.Command{ + Description: "List Forge repos for an org", + Action: func(opts core.Options) core.Result { + org := opts.String("org") + if org == "" { + org = "core" + } + + f := newForgeClient() + repos, err := f.Repos.ListOrgRepos(ctx, org) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + for _, r := range repos { + archived := "" + if r.Archived { + archived = " (archived)" + } + core.Print(nil, " %-30s %s%s", r.Name, r.Description, archived) + } + core.Print(nil, "\n %d repos", len(repos)) + return core.Result{OK: true} + }, + }) +} diff --git a/cmd/core-agent/main.go b/cmd/core-agent/main.go index 261d6e0..181ab4c 100644 --- a/cmd/core-agent/main.go +++ b/cmd/core-agent/main.go @@ -124,6 +124,165 @@ func main() { }, }) + // --- Forge CLI commands --- + registerForgeCommands(c) + + // --- CLI commands for feature testing --- + + prep := agentic.NewPrep() + + // prep — test workspace preparation (clone + prompt) + c.Command("prep", core.Command{ + Description: "Prepare a workspace: clone repo, build prompt", + Action: func(opts core.Options) core.Result { + repo := opts.String("_arg") + if repo == "" { + core.Print(nil, "usage: core-agent prep --issue=N|--pr=N|--branch=X --task=\"...\"") + return core.Result{OK: false} + } + + input := agentic.PrepInput{ + Repo: repo, + Org: opts.String("org"), + Task: opts.String("task"), + Template: opts.String("template"), + Persona: opts.String("persona"), + DryRun: opts.Bool("dry-run"), + } + + // Parse identifier from flags + if v := opts.String("issue"); v != "" { + n := 0 + for _, ch := range v { + if ch >= '0' && ch <= '9' { + n = n*10 + int(ch-'0') + } + } + input.Issue = n + } + if v := opts.String("pr"); v != "" { + n := 0 + for _, ch := range v { + if ch >= '0' && ch <= '9' { + n = n*10 + int(ch-'0') + } + } + input.PR = n + } + if v := opts.String("branch"); v != "" { + input.Branch = v + } + if v := opts.String("tag"); v != "" { + input.Tag = v + } + + // Default to branch "dev" if no identifier + if input.Issue == 0 && input.PR == 0 && input.Branch == "" && input.Tag == "" { + input.Branch = "dev" + } + + _, out, err := prep.TestPrepWorkspace(context.Background(), input) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "workspace: %s", out.WorkspaceDir) + core.Print(nil, "repo: %s", out.RepoDir) + core.Print(nil, "branch: %s", out.Branch) + core.Print(nil, "resumed: %v", out.Resumed) + core.Print(nil, "memories: %d", out.Memories) + core.Print(nil, "consumers: %d", out.Consumers) + if out.Prompt != "" { + core.Print(nil, "") + core.Print(nil, "--- prompt (%d chars) ---", len(out.Prompt)) + core.Print(nil, "%s", out.Prompt) + } + return core.Result{OK: true} + }, + }) + + // status — list workspace statuses + c.Command("status", core.Command{ + Description: "List agent workspace statuses", + Action: func(opts core.Options) core.Result { + wsRoot := agentic.WorkspaceRoot() + fsys := c.Fs() + r := fsys.List(wsRoot) + if !r.OK { + core.Print(nil, "no workspaces found at %s", wsRoot) + return core.Result{OK: true} + } + + entries := r.Value.([]os.DirEntry) + if len(entries) == 0 { + core.Print(nil, "no workspaces") + return core.Result{OK: true} + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + statusFile := core.JoinPath(wsRoot, e.Name(), "status.json") + if sr := fsys.Read(statusFile); sr.OK { + core.Print(nil, " %s", e.Name()) + } + } + return core.Result{OK: true} + }, + }) + + // prompt — build and show an agent prompt without cloning + c.Command("prompt", core.Command{ + Description: "Build and display an agent prompt for a repo", + Action: func(opts core.Options) core.Result { + repo := opts.String("_arg") + if repo == "" { + core.Print(nil, "usage: core-agent prompt --task=\"...\"") + return core.Result{OK: false} + } + + org := opts.String("org") + if org == "" { + org = "core" + } + task := opts.String("task") + if task == "" { + task = "Review and report findings" + } + + repoPath := core.JoinPath(core.Env("DIR_HOME"), "Code", org, repo) + + input := agentic.PrepInput{ + Repo: repo, + Org: org, + Task: task, + Template: opts.String("template"), + Persona: opts.String("persona"), + } + + prompt, memories, consumers := prep.TestBuildPrompt(context.Background(), input, "dev", repoPath) + core.Print(nil, "memories: %d", memories) + core.Print(nil, "consumers: %d", consumers) + core.Print(nil, "") + core.Print(nil, "%s", prompt) + return core.Result{OK: true} + }, + }) + + // env — dump all Env keys + c.Command("env", core.Command{ + Description: "Show all core.Env() keys and values", + Action: func(opts core.Options) core.Result { + keys := core.EnvKeys() + for _, k := range keys { + core.Print(nil, " %-15s %s", k, core.Env(k)) + } + return core.Result{OK: true} + }, + }) + // Shared setup — creates MCP service with all subsystems wired initServices := func() (*mcp.Service, *monitor.Subsystem, error) { procFactory := process.NewService(process.Options{}) diff --git a/go.mod b/go.mod index ed716de..d3c7998 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require dappco.re/go/core/forge v0.2.0 // indirect + require ( dappco.re/go/core/i18n v0.2.0 dappco.re/go/core/io v0.2.0 // indirect diff --git a/go.sum b/go.sum index 337ec30..968bb42 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0= dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo= +dappco.re/go/core/forge v0.2.0 h1:EBCHaUdzEAbYpDwRTXMmJoSfSrK30IJTOVBPRxxkJTg= +dappco.re/go/core/forge v0.2.0/go.mod h1:XMz9ZNVl9xane9Rg3AEBuVV5UNNBGWbPY9rSKbqYgnM= dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=