Skip to content

Commit 163972f

Browse files
authored
Refactor Holon solve/workflow to explicit goal routing (#745)
* refactor solve/workflow to explicit goal routing * fix review feedback for goal routing
1 parent 1aa8631 commit 163972f

File tree

7 files changed

+233
-469
lines changed

7 files changed

+233
-469
lines changed

.github/workflows/holon-solve.yml

Lines changed: 70 additions & 384 deletions
Large diffs are not rendered by default.

.github/workflows/holon-trigger.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
comment_id: ${{ github.event.comment.id || 0 }}
2727
review_id: ${{ github.event.review.id || 0 }}
2828
auto_review: ${{ fromJSON(vars.HOLON_AUTO_REVIEW || 'false') }}
29+
# Optional: override goal for all runs.
30+
# goal: 'Review this PR and publish findings.'
2931

3032
# Repo-local workflow uses the current branch Holon implementation.
3133
log_level: 'debug'

action.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,10 @@ inputs:
5050
description: 'Assistant output mode for CI visibility (none, stream)'
5151
required: false
5252
default: 'none'
53-
skill:
54-
description: 'Deprecated. Skill selection is now driven by workflow goal hints; this input is ignored.'
53+
goal:
54+
description: 'Explicit execution goal passed to holon solve --goal. Empty = solve defaults by target type and PR review signals.'
5555
required: false
5656
default: ''
57-
deprecationMessage: 'Deprecated: skill is no longer passed to holon solve. Use workflow goal hints (workflow.json trigger.goal_hint) to influence behavior.'
5857
role:
5958
description: 'Role to assume (e.g. developer, reviewer)'
6059
required: false
@@ -337,6 +336,9 @@ runs:
337336
if [ -n "${{ inputs.state_dir }}" ]; then
338337
SOLVE_ARGS+=(--state-dir "${{ inputs.state_dir }}")
339338
fi
339+
if [ -n "${{ inputs.goal }}" ]; then
340+
SOLVE_ARGS+=(--goal "${{ inputs.goal }}")
341+
fi
340342
341343
# Always add output directory (either user-specified or temp)
342344
SOLVE_ARGS+=(--output "$OUT_DIR")

cmd/holon/solve.go

Lines changed: 97 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"strings"
1111

12+
"github.com/google/go-github/v68/github"
1213
"github.com/holon-run/holon/pkg/config"
1314
"github.com/holon-run/holon/pkg/git"
1415
pkggithub "github.com/holon-run/holon/pkg/github"
@@ -34,6 +35,7 @@ var (
3435
solveImageAutoDetect bool
3536
solveSkillPaths []string // Skill paths (--skill flag, repeatable)
3637
solveSkillsList string // Comma-separated skills (--skills flag)
38+
solveGoal string
3739

3840
solveLogLevel string
3941
solveAssistantOutput string
@@ -58,7 +60,7 @@ This is a high-level command that orchestrates the full workflow:
5860
1. Prepare workspace using smart workspace preparation
5961
2. Collect context from the GitHub Issue or PR
6062
3. Run Holon with the collected context
61-
4. Publish results (create PR for issues, or push/fix for PRs)
63+
4. Publish results (create PR for issues, or review/fix for PRs)
6264
6365
Skill-First Mode:
6466
Holon solve uses skill-first IO mode, which delegates context
@@ -90,7 +92,7 @@ Examples:
9092
# Solve an issue (creates/updates a PR) - uses skill-first mode
9193
holon solve https://github.com/holon-run/holon/issues/123
9294
93-
# Solve a PR (fixes review comments) - uses skill-first mode
95+
# Solve a PR (defaults to review; switches to fix when review signals exist)
9496
holon solve https://github.com/holon-run/holon/pull/456
9597
9698
# Short form
@@ -140,14 +142,17 @@ Examples:
140142
// solvePRCmd is the explicit "solve pr" subcommand
141143
var solvePRCmd = &cobra.Command{
142144
Use: "pr <ref>",
143-
Short: "Solve a GitHub PR (fixes review comments)",
144-
Long: `Solve a GitHub PR by collecting context, running Holon, and fixing review comments.
145+
Short: "Solve a GitHub PR (review or fix)",
146+
Long: `Solve a GitHub PR by collecting context, running Holon, and publishing review/fix results.
145147
146148
The workflow:
147149
1. Collect PR context (diff, review threads, checks)
148-
2. Run Holon in "pr-fix" mode
150+
2. Resolve goal:
151+
- default review when no review signals exist
152+
- default fix when review feedback exists
153+
- explicit --goal overrides defaults
149154
3. If diff.patch exists: apply/push to PR branch
150-
4. Publish replies based on pr-fix.json
155+
4. Publish review findings or fix replies to GitHub
151156
152157
Examples:
153158
holon solve pr https://github.com/holon-run/holon/pull/123
@@ -515,31 +520,6 @@ func runSolve(ctx context.Context, refStr, explicitType string) error {
515520
return fmt.Errorf("failed to create context directory: %w", err)
516521
}
517522

518-
// Read workflow.json for trigger metadata (if exists)
519-
// This is populated by the holon-solve workflow when triggered by comments
520-
// We read it once and pass the data to buildGoal to avoid duplicate reads
521-
type workflowMetadata struct {
522-
TriggerCommentID int64
523-
TriggerGoalHint string
524-
}
525-
var workflowMeta workflowMetadata
526-
workflowPath := filepath.Join(inputDir, "workflow.json")
527-
if workflowData, err := os.ReadFile(workflowPath); err == nil {
528-
var workflow struct {
529-
Trigger struct {
530-
CommentID int64 `json:"comment_id"`
531-
GoalHint string `json:"goal_hint"`
532-
} `json:"trigger"`
533-
}
534-
if err := json.Unmarshal(workflowData, &workflow); err == nil {
535-
workflowMeta.TriggerCommentID = workflow.Trigger.CommentID
536-
workflowMeta.TriggerGoalHint = workflow.Trigger.GoalHint
537-
if workflowMeta.TriggerCommentID > 0 {
538-
fmt.Printf("Found trigger comment ID: %d\n", workflowMeta.TriggerCommentID)
539-
}
540-
}
541-
}
542-
543523
// Resolve skills early to determine skill configuration
544524
// In skill mode, the skill (agent) is responsible for context collection and publishing
545525
// Parse CLI skills
@@ -571,15 +551,14 @@ func runSolve(ctx context.Context, refStr, explicitType string) error {
571551
allSkills = configSkills
572552
}
573553

574-
// Skill-first is the default: always use skill mode
575-
// If no skills are configured, add a default skill bundle for issues only.
576-
// For PRs, leave skills empty so PR-specific defaults can be applied later.
554+
// Skill-first is the default: always use skill mode.
555+
// If no skills are configured, add a default skill bundle for issues.
556+
// For PRs, leave skills empty and rely on the goal for review/fix behavior.
577557
useSkillMode := true // Always skill-first now
578558
if len(allSkills) == 0 {
579559
if refType == "pr" {
580560
// In PR solves, do not force an issue-solve default skill.
581-
// Leaving skills empty allows buildGoal's PR defaults to select appropriate skills.
582-
fmt.Printf("Config: skills = [] (source: skill-first mode for PR; deferring to PR-specific defaults)\n")
561+
fmt.Printf("Config: skills = [] (source: skill-first mode for PR)\n")
583562
} else {
584563
// Add default skill bundle for skill-first mode on issues
585564
// This ensures skill-first IO is used by default
@@ -650,13 +629,16 @@ func runSolve(ctx context.Context, refStr, explicitType string) error {
650629
return fmt.Errorf("failed to resolve assistant output: %w", err)
651630
}
652631

653-
// Determine goal from the reference (skill mode already determined earlier)
654-
// Pass useSkillMode to generate appropriate goal
655-
selectedSkill := ""
656-
if len(allSkills) > 0 {
657-
selectedSkill = strings.TrimSpace(allSkills[0])
632+
goal := strings.TrimSpace(solveGoal)
633+
if goal == "" {
634+
defaultGoal, err := buildDefaultSolveGoal(ctx, solveRef, refType, token)
635+
if err != nil {
636+
return fmt.Errorf("failed to build default goal: %w", err)
637+
}
638+
goal = defaultGoal
639+
} else {
640+
fmt.Println("Using explicit solve goal from --goal")
658641
}
659-
goal := buildGoal(inputDir, solveRef, refType, workflowMeta.TriggerGoalHint, selectedSkill)
660642

661643
// Resolve output directory with precedence: CLI flag > temp dir
662644
// For solve command, we use a temp directory by default to avoid polluting the workspace
@@ -862,34 +844,84 @@ func getGitHubToken() (string, error) {
862844
return token, nil
863845
}
864846

865-
// buildGoal builds a goal description from the reference
866-
// triggerGoalHint is the optional goal hint from free-form triggers (e.g., "@holonbot fix this bug")
867-
func buildGoal(inputDir string, ref *pkggithub.SolveRef, refType string, triggerGoalHint string, selectedSkill string) string {
868-
baseGoal := ""
847+
func buildDefaultSolveGoal(ctx context.Context, ref *pkggithub.SolveRef, refType, token string) (string, error) {
869848
if refType == "pr" {
870-
switch selectedSkill {
871-
case "github-review":
872-
baseGoal = fmt.Sprintf("Use the github-review skill to review the PR %s. The skill will guide you through: (1) Collecting PR context, (2) Analyzing code changes for issues, and (3) Publishing structured review findings to GitHub.", ref.URL())
873-
case "github-pr-fix", "":
874-
baseGoal = fmt.Sprintf("Use the github-pr-fix skill to fix the PR %s. The skill will guide you through: (1) Analyzing review comments, (2) Implementing fixes, (3) Publishing replies to GitHub.", ref.URL())
875-
default:
876-
baseGoal = fmt.Sprintf("Use the %s skill to process the PR %s according to the skill instructions, and publish the expected results back to GitHub.", selectedSkill, ref.URL())
877-
}
878-
} else {
879-
switch selectedSkill {
880-
case "github-issue-solve", "":
881-
baseGoal = fmt.Sprintf("Use the github-issue-solve skill to solve the GitHub issue %s end-to-end. Success requires all of the following: (1) Collect GitHub context, (2) Implement the solution, (3) Ensure ${GITHUB_OUTPUT_DIR}/manifest.json has status='completed' and outcome='success', and (4) After publish completes, ensure ${GITHUB_OUTPUT_DIR}/manifest.json contains pr_number and pr_url for the created PR. The runtime validates success based on the manifest contract (status/outcome), with summary.md treated as optional human-readable output.", ref.URL())
882-
default:
883-
baseGoal = fmt.Sprintf("Use the %s skill to solve the GitHub issue %s end-to-end following the skill instructions and publish the expected results to GitHub. Ensure ${GITHUB_OUTPUT_DIR}/manifest.json has status='completed' and outcome='success' for successful execution.", selectedSkill, ref.URL())
849+
intent, err := determinePRDefaultIntent(ctx, ref, token)
850+
if err != nil {
851+
fmt.Fprintf(os.Stderr, "Warning: failed to inspect PR review signals, defaulting to review goal: %v\n", err)
852+
intent = "review"
884853
}
854+
855+
return buildPRGoal(ref.URL(), intent), nil
856+
}
857+
858+
return buildIssueGoal(ref.URL()), nil
859+
}
860+
861+
func buildPRGoal(prURL, intent string) string {
862+
if intent == "fix" {
863+
return fmt.Sprintf("Fix the PR %s by addressing outstanding review feedback and requested changes. Implement necessary code changes and publish replies to GitHub.", prURL)
864+
}
865+
return fmt.Sprintf("Review the PR %s. Analyze code changes for correctness, security, and maintainability, then publish structured review findings to GitHub.", prURL)
866+
}
867+
868+
func buildIssueGoal(issueURL string) string {
869+
return fmt.Sprintf("Solve the GitHub issue %s end-to-end. Success requires all of the following: (1) Collect GitHub context, (2) Implement the solution, (3) Ensure ${GITHUB_OUTPUT_DIR}/manifest.json has status='completed' and outcome='success', and (4) After publish completes, ensure ${GITHUB_OUTPUT_DIR}/manifest.json contains pr_number and pr_url for the created PR. The runtime validates success based on the manifest contract (status/outcome), with summary.md treated as optional human-readable output.", issueURL)
870+
}
871+
872+
func determinePRDefaultIntent(ctx context.Context, ref *pkggithub.SolveRef, token string) (string, error) {
873+
client := pkggithub.NewClient(token)
874+
875+
threads, err := client.FetchReviewThreads(ctx, ref.Owner, ref.Repo, ref.Number, true)
876+
if err != nil {
877+
return "review", fmt.Errorf("failed to fetch PR review threads: %w", err)
878+
}
879+
880+
latestState, err := fetchLatestPRReviewState(ctx, client, ref.Owner, ref.Repo, ref.Number)
881+
if err != nil {
882+
return "review", err
885883
}
886884

887-
// Append goal hint from free-form triggers if provided
888-
if triggerGoalHint != "" {
889-
return fmt.Sprintf("%s\n\nUser intent: %s", baseGoal, triggerGoalHint)
885+
return inferPRIntentFromSignals(len(threads), latestState), nil
886+
}
887+
888+
func inferPRIntentFromSignals(unresolvedThreadCount int, latestReviewState string) string {
889+
if unresolvedThreadCount > 0 {
890+
return "fix"
891+
}
892+
if strings.EqualFold(strings.TrimSpace(latestReviewState), "CHANGES_REQUESTED") {
893+
return "fix"
894+
}
895+
return "review"
896+
}
897+
898+
func fetchLatestPRReviewState(ctx context.Context, client *pkggithub.Client, owner, repo string, prNumber int) (string, error) {
899+
opts := &github.ListOptions{PerPage: 100}
900+
latestState := ""
901+
902+
for {
903+
reviews, resp, err := client.GitHubClient().PullRequests.ListReviews(ctx, owner, repo, prNumber, opts)
904+
if err != nil {
905+
return "", fmt.Errorf("failed to fetch PR reviews: %w", err)
906+
}
907+
908+
for _, review := range reviews {
909+
if review == nil {
910+
continue
911+
}
912+
state := strings.ToUpper(strings.TrimSpace(review.GetState()))
913+
if state != "" {
914+
latestState = state
915+
}
916+
}
917+
918+
if resp.NextPage == 0 {
919+
break
920+
}
921+
opts.Page = resp.NextPage
890922
}
891923

892-
return baseGoal
924+
return latestState, nil
893925
}
894926

895927
// publishResults publishes the holon execution results
@@ -1050,6 +1082,7 @@ func init() {
10501082
solveCmd.Flags().StringVar(&solveRepo, "repo", "", "Default repository in owner/repo format (for numeric references)")
10511083
solveCmd.Flags().StringVar(&solveBase, "base", "main", "Base branch for PR creation (issue mode only)")
10521084
solveCmd.Flags().StringVarP(&solveOutDir, "output", "O", "", "Output directory (default: creates temp dir to avoid polluting workspace)")
1085+
solveCmd.Flags().StringVarP(&solveGoal, "goal", "g", "", "Explicit goal description (when set, skips default issue/PR goal generation)")
10531086
_ = solveCmd.Flags().MarkDeprecated("out", "use --output instead")
10541087
solveCmd.Flags().StringVarP(&solveOutDir, "out", "o", "", "Deprecated: use --output")
10551088
solveCmd.Flags().StringVarP(&solveContext, "context", "c", "", "Additional context directory (deprecated)")

cmd/holon/solve_test.go

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@ import (
1010
pkggithub "github.com/holon-run/holon/pkg/github"
1111
)
1212

13-
func TestBuildGoal_SkillModePRReviewUsesGithubReviewSkill(t *testing.T) {
14-
ref := &pkggithub.SolveRef{Owner: "holon-run", Repo: "holon", Number: 564}
15-
goal := buildGoal("", ref, "pr", "", "github-review")
13+
func TestBuildPRGoal_ReviewIntent(t *testing.T) {
14+
goal := buildPRGoal("https://github.com/holon-run/holon/pull/564", "review")
1615

17-
if !strings.Contains(goal, "Use the github-review skill") {
18-
t.Fatalf("expected github-review goal, got: %s", goal)
16+
if !strings.Contains(goal, "Review the PR") {
17+
t.Fatalf("expected review goal, got: %s", goal)
1918
}
20-
if strings.Contains(goal, "github-pr-fix") {
21-
t.Fatalf("expected goal to avoid github-pr-fix for review skill, got: %s", goal)
19+
if strings.Contains(strings.ToLower(goal), "fix the pr") {
20+
t.Fatalf("expected review goal to avoid fix wording, got: %s", goal)
2221
}
2322
}
2423

@@ -93,22 +92,17 @@ func TestInferRefTypeFromURL(t *testing.T) {
9392
}
9493
}
9594

96-
func TestBuildGoal_SkillModePRFixUsesGithubPrFixSkill(t *testing.T) {
97-
ref := &pkggithub.SolveRef{Owner: "holon-run", Repo: "holon", Number: 564}
98-
goal := buildGoal("", ref, "pr", "", "github-pr-fix")
95+
func TestBuildPRGoal_FixIntent(t *testing.T) {
96+
goal := buildPRGoal("https://github.com/holon-run/holon/pull/564", "fix")
9997

100-
if !strings.Contains(goal, "Use the github-pr-fix skill") {
101-
t.Fatalf("expected github-pr-fix goal, got: %s", goal)
98+
if !strings.Contains(strings.ToLower(goal), "fix the pr") {
99+
t.Fatalf("expected fix goal, got: %s", goal)
102100
}
103101
}
104102

105-
func TestBuildGoal_SkillModeIssueUsesGithubIssueSolveSkill(t *testing.T) {
106-
ref := &pkggithub.SolveRef{Owner: "holon-run", Repo: "holon", Number: 527}
107-
goal := buildGoal("", ref, "issue", "", "github-issue-solve")
103+
func TestBuildIssueGoal(t *testing.T) {
104+
goal := buildIssueGoal("https://github.com/holon-run/holon/issues/527")
108105

109-
if !strings.Contains(goal, "Use the github-issue-solve skill") {
110-
t.Fatalf("expected github-issue-solve goal, got: %s", goal)
111-
}
112106
// Verify it uses generic manifest contract, not publish-intent.json
113107
if strings.Contains(goal, "publish-intent.json") {
114108
t.Fatalf("goal should not mention publish-intent.json, got: %s", goal)
@@ -119,6 +113,49 @@ func TestBuildGoal_SkillModeIssueUsesGithubIssueSolveSkill(t *testing.T) {
119113
}
120114
}
121115

116+
func TestInferPRIntentFromSignals(t *testing.T) {
117+
tests := []struct {
118+
name string
119+
unresolvedThreadCount int
120+
latestReviewState string
121+
want string
122+
}{
123+
{
124+
name: "unresolved threads force fix",
125+
unresolvedThreadCount: 2,
126+
latestReviewState: "",
127+
want: "fix",
128+
},
129+
{
130+
name: "changes requested forces fix",
131+
unresolvedThreadCount: 0,
132+
latestReviewState: "CHANGES_REQUESTED",
133+
want: "fix",
134+
},
135+
{
136+
name: "changes requested is case insensitive",
137+
unresolvedThreadCount: 0,
138+
latestReviewState: "changes_requested",
139+
want: "fix",
140+
},
141+
{
142+
name: "no review signals defaults to review",
143+
unresolvedThreadCount: 0,
144+
latestReviewState: "COMMENTED",
145+
want: "review",
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
got := inferPRIntentFromSignals(tt.unresolvedThreadCount, tt.latestReviewState)
152+
if got != tt.want {
153+
t.Fatalf("inferPRIntentFromSignals() = %q, want %q", got, tt.want)
154+
}
155+
})
156+
}
157+
}
158+
122159
func TestPrepareWorkspaceForSolve_WithWorkspace_UsesDirectWorkspace(t *testing.T) {
123160
userWorkspace := t.TempDir()
124161
ref := &pkggithub.SolveRef{Owner: "holon-run", Repo: "holon", Number: 627}

cmd/holon/templates/workflow.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ jobs:
2828
assignee_login: ${{ github.event.assignee.login || '' }}
2929
comment_id: ${{ github.event.comment.id || 0 }}
3030
review_id: ${{ github.event.review.id || 0 }}
31+
# Optional: override goal for all runs.
32+
# goal: 'Review this PR and publish findings.'
3133
# Optional: Specify custom runner labels.
3234
# Plain string for single label (e.g., 'self-hosted' or 'ubuntu-24.04-large'):
3335
# runs_on: 'self-hosted'

examples/workflows/holon-trigger.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
# Optional: enable auto-review for every PR when true (default false)
2828
# Configure repo variable HOLON_AUTO_REVIEW=true to enable.
2929
auto_review: ${{ fromJSON(vars.HOLON_AUTO_REVIEW || 'false') }}
30+
# Optional: override goal for all runs. Empty = Holon defaults by target type.
31+
# goal: 'Review this PR and publish findings.'
3032
# Optional: Specify custom runner labels.
3133
# Plain string for single label (e.g., 'self-hosted' or 'ubuntu-24.04-large'):
3234
# runs_on: 'self-hosted'

0 commit comments

Comments
 (0)