feat: Forge CLI commands — issue, PR, and repo operations

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-22 14:07:25 +00:00
parent 6e03287178
commit bfe70871a2
4 changed files with 420 additions and 0 deletions

257
cmd/core-agent/forge.go Normal file
View file

@ -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 <repo> --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 <repo> [--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 <repo> --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 <repo> --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 <repo> [--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 <repo> --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 <repo> [--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}
},
})
}

View file

@ -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 <repo> --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 <repo> --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{})

2
go.mod
View file

@ -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

2
go.sum
View file

@ -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=