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:
parent
6e03287178
commit
bfe70871a2
4 changed files with 420 additions and 0 deletions
257
cmd/core-agent/forge.go
Normal file
257
cmd/core-agent/forge.go
Normal 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}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
2
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue