Skip to content

Commit 11ca26b

Browse files
Agentic Setup - PR Proposal
1 parent 9beb2a0 commit 11ca26b

4 files changed

Lines changed: 273 additions & 8 deletions

File tree

cmd/setup.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@ func runAgentOnce(
228228

229229
retryErr := retrySetupIfRequested(runner, setupConfig, decision)
230230

231-
_ = writeAgentSummary(ctx, llm, runtimeCfg.Model, wd, execErr, decision, retryErr)
231+
buckets, _, _ := writeAgentSummary(ctx, llm, runtimeCfg.Model, wd, execErr, decision, retryErr)
232+
_ = maybeOfferSetupFixPR(ctx, wd, buckets)
232233
return retryErr
233234
}
234235

@@ -305,32 +306,32 @@ func writeAgentSummary(
305306
execErr *setup.SetupExecutionError,
306307
decision *agent.Decision,
307308
retryErr error,
308-
) error {
309+
) (*agent.IssueBuckets, string, error) {
309310
if strings.TrimSpace(baseDir) == "" {
310-
return nil
311+
return nil, "", nil
311312
}
312313
if execErr == nil || execErr.Report == nil || decision == nil {
313-
return nil
314+
return nil, "", nil
314315
}
315316

316317
buckets, err := agent.AssessIssues(ctx, llm, model, execErr.Report, decision)
317318
if err != nil {
318-
return err
319+
return nil, "", err
319320
}
320321

321322
summaryPath := filepath.Join(baseDir, ".initiat", "agent-summary.md")
322323
if err := os.MkdirAll(filepath.Dir(summaryPath), agentSummaryDirPerm); err != nil {
323-
return err
324+
return nil, "", err
324325
}
325326

326327
content := buildAgentSummaryMarkdown(execErr, decision, buckets, retryErr)
327328
if err := os.WriteFile(summaryPath, []byte(content), agentSummaryFilePerm); err != nil {
328-
return err
329+
return nil, "", err
329330
}
330331

331332
fmt.Println()
332333
fmt.Println("Wrote agent summary to", summaryPath)
333-
return nil
334+
return buckets, summaryPath, nil
334335
}
335336

336337
func buildAgentSummaryMarkdown(

cmd/setup_pr.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/InitiatDev/initiat-cli/internal/agent"
14+
"github.com/InitiatDev/initiat-cli/internal/git"
15+
"github.com/InitiatDev/initiat-cli/internal/prompt"
16+
"github.com/InitiatDev/initiat-cli/internal/setupfixpr"
17+
)
18+
19+
const (
20+
prBranchTimeFormat = "20060102-150405"
21+
)
22+
23+
func maybeOfferSetupFixPR(ctx context.Context, baseDir string, buckets *agent.IssueBuckets) error {
24+
gitRoot, eligible, ok, err := detectSetupFixPREligibility(ctx, baseDir, buckets)
25+
if err != nil {
26+
return err
27+
}
28+
if !ok {
29+
return nil
30+
}
31+
32+
fmt.Println()
33+
fmt.Println("Agent made setup-related changes that look PR-eligible:")
34+
for _, p := range eligible {
35+
fmt.Println(" -", p)
36+
}
37+
38+
shouldCreatePR, err := prompt.PromptYesNo("Create a PR with these setup fixes?")
39+
if err != nil {
40+
return err
41+
}
42+
if !shouldCreatePR {
43+
return nil
44+
}
45+
46+
okToProceed, err := prompt.PromptYesNo("Proceed with git operations (branch, commit, push, PR)?")
47+
if err != nil {
48+
return err
49+
}
50+
if !okToProceed {
51+
return nil
52+
}
53+
54+
return createSetupFixPR(ctx, gitRoot, eligible, buckets)
55+
}
56+
57+
func detectSetupFixPREligibility(
58+
ctx context.Context,
59+
baseDir string,
60+
buckets *agent.IssueBuckets,
61+
) (string, []string, bool, error) {
62+
if strings.TrimSpace(baseDir) == "" {
63+
return "", nil, false, nil
64+
}
65+
66+
handler := git.NewHandler()
67+
gitRoot, ok := handler.FindGitRoot(filepath.Join(baseDir, "."))
68+
if !ok || strings.TrimSpace(gitRoot) == "" {
69+
return "", nil, false, nil
70+
}
71+
72+
statusOut, err := runGit(ctx, gitRoot, "status", "--porcelain")
73+
if err != nil {
74+
return "", nil, false, err
75+
}
76+
77+
changed := setupfixpr.ParseGitPorcelainPaths(statusOut)
78+
eligible := setupfixpr.EligibleSetupFixPaths(changed)
79+
if len(eligible) == 0 {
80+
return "", nil, false, nil
81+
}
82+
83+
if buckets != nil && len(buckets.SetupOrApp) == 0 {
84+
return "", nil, false, nil
85+
}
86+
87+
return gitRoot, eligible, true, nil
88+
}
89+
90+
func createSetupFixPR(ctx context.Context, gitRoot string, eligible []string, buckets *agent.IssueBuckets) error {
91+
baseBranch := detectBaseBranch(ctx, gitRoot)
92+
93+
branch := fmt.Sprintf("initiat-agent-setup-fix-%s", time.Now().Format(prBranchTimeFormat))
94+
95+
if _, err := runGit(ctx, gitRoot, "checkout", "-b", branch); err != nil {
96+
return err
97+
}
98+
if _, err := runGit(ctx, gitRoot, append([]string{"add", "--"}, eligible...)...); err != nil {
99+
return err
100+
}
101+
if _, err := runGit(ctx, gitRoot, "commit", "-m", "chore(setup): agent-guided setup fixes"); err != nil {
102+
return err
103+
}
104+
if _, err := runGit(ctx, gitRoot, "push", "-u", "origin", "HEAD"); err != nil {
105+
return err
106+
}
107+
108+
if _, err := exec.LookPath("gh"); err != nil {
109+
fmt.Println()
110+
fmt.Println("GitHub CLI (gh) not found. You can open a PR manually from your pushed branch.")
111+
return nil
112+
}
113+
114+
title := "Setup fixes (agent-guided)"
115+
body := buildSetupFixPRBody(buckets)
116+
out, err := runGH(ctx, gitRoot, "pr", "create", "--base", baseBranch, "--title", title, "--body", body)
117+
if err != nil {
118+
return err
119+
}
120+
121+
fmt.Println()
122+
fmt.Println(strings.TrimSpace(out))
123+
return nil
124+
}
125+
126+
func buildSetupFixPRBody(buckets *agent.IssueBuckets) string {
127+
var b strings.Builder
128+
b.WriteString("## Summary\n")
129+
b.WriteString("- Agent-guided setup changes to improve onboarding reliability.\n\n")
130+
131+
if buckets == nil {
132+
return b.String()
133+
}
134+
135+
if len(buckets.SetupOrApp) > 0 {
136+
b.WriteString("## Setup/App issues addressed\n")
137+
for _, it := range buckets.SetupOrApp {
138+
b.WriteString("- " + it + "\n")
139+
}
140+
b.WriteString("\n")
141+
}
142+
143+
if strings.TrimSpace(buckets.Notes) != "" {
144+
b.WriteString("## Notes\n")
145+
b.WriteString(buckets.Notes)
146+
b.WriteString("\n")
147+
}
148+
149+
return b.String()
150+
}
151+
152+
func detectBaseBranch(ctx context.Context, gitRoot string) string {
153+
out, err := runGit(ctx, gitRoot, "symbolic-ref", "refs/remotes/origin/HEAD")
154+
if err == nil {
155+
return setupfixpr.BaseBranchFromOriginHeadRef(out)
156+
}
157+
return "main"
158+
}
159+
160+
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
161+
return runBin(ctx, dir, "git", args...)
162+
}
163+
164+
func runGH(ctx context.Context, dir string, args ...string) (string, error) {
165+
return runBin(ctx, dir, "gh", args...)
166+
}
167+
168+
func runBin(ctx context.Context, dir string, bin string, args ...string) (string, error) {
169+
cmd := exec.CommandContext(ctx, bin, args...)
170+
cmd.Dir = dir
171+
cmd.Env = os.Environ()
172+
var buf bytes.Buffer
173+
cmd.Stdout = &buf
174+
cmd.Stderr = &buf
175+
if err := cmd.Run(); err != nil {
176+
return "", fmt.Errorf("%s %s: %w\n%s", bin, strings.Join(args, " "), err, strings.TrimSpace(buf.String()))
177+
}
178+
return buf.String(), nil
179+
}

internal/setupfixpr/setupfixpr.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package setupfixpr
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
)
7+
8+
const (
9+
porcelainMinLen = 4
10+
renameArrow = " -> "
11+
defaultBaseBranch = "main"
12+
)
13+
14+
func EligibleSetupFixPaths(paths []string) []string {
15+
var out []string
16+
for _, p := range paths {
17+
p = filepath.Clean(p)
18+
switch p {
19+
case filepath.Join(".initiat", "setup.yml"),
20+
filepath.Join(".initiat", "setup.yaml"):
21+
out = append(out, p)
22+
}
23+
}
24+
return out
25+
}
26+
27+
func ParseGitPorcelainPaths(status string) []string {
28+
var out []string
29+
for _, line := range strings.Split(status, "\n") {
30+
line = strings.TrimRight(line, "\r")
31+
if len(line) < porcelainMinLen {
32+
continue
33+
}
34+
path := strings.TrimSpace(line[3:])
35+
if path == "" {
36+
continue
37+
}
38+
if strings.Contains(path, renameArrow) {
39+
parts := strings.Split(path, renameArrow)
40+
path = strings.TrimSpace(parts[len(parts)-1])
41+
}
42+
out = append(out, path)
43+
}
44+
return out
45+
}
46+
47+
func BaseBranchFromOriginHeadRef(originHeadRef string) string {
48+
s := strings.TrimSpace(originHeadRef)
49+
const prefix = "refs/remotes/origin/"
50+
if strings.HasPrefix(s, prefix) {
51+
return strings.TrimPrefix(s, prefix)
52+
}
53+
return defaultBaseBranch
54+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package setupfixpr
2+
3+
import "testing"
4+
5+
func TestParseGitPorcelainPaths(t *testing.T) {
6+
in := " M .initiat/setup.yml\nR old.yml -> .initiat/setup.yaml\n?? foo.txt\n"
7+
paths := ParseGitPorcelainPaths(in)
8+
if len(paths) != 3 {
9+
t.Fatalf("expected 3 paths, got %d", len(paths))
10+
}
11+
if paths[0] != ".initiat/setup.yml" {
12+
t.Fatalf("unexpected first: %q", paths[0])
13+
}
14+
if paths[1] != ".initiat/setup.yaml" {
15+
t.Fatalf("unexpected rename target: %q", paths[1])
16+
}
17+
if paths[2] != "foo.txt" {
18+
t.Fatalf("unexpected third: %q", paths[2])
19+
}
20+
}
21+
22+
func TestEligibleSetupFixPaths(t *testing.T) {
23+
in := []string{".initiat/setup.yml", ".initiat/agent-summary.md", "README.md", ".initiat/setup.yaml"}
24+
got := EligibleSetupFixPaths(in)
25+
if len(got) != 2 {
26+
t.Fatalf("expected 2, got %d", len(got))
27+
}
28+
if got[0] != ".initiat/setup.yml" || got[1] != ".initiat/setup.yaml" {
29+
t.Fatalf("unexpected: %#v", got)
30+
}
31+
}

0 commit comments

Comments
 (0)