orch-code 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- package/scripts/release.js +52 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
package orchestrator
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/furkanbeydemir/orch/internal/agents"
|
|
9
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
10
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type agentStub struct {
|
|
14
|
+
name string
|
|
15
|
+
execute func(input *agents.Input) (*agents.Output, error)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func (a agentStub) Name() string { return a.name }
|
|
19
|
+
|
|
20
|
+
func (a agentStub) Execute(input *agents.Input) (*agents.Output, error) {
|
|
21
|
+
return a.execute(input)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func TestRunEnforcesTestRetryLimit(t *testing.T) {
|
|
25
|
+
repoRoot := t.TempDir()
|
|
26
|
+
|
|
27
|
+
cfg := config.DefaultConfig()
|
|
28
|
+
cfg.Commands.Test = "false"
|
|
29
|
+
cfg.Safety.FeatureFlags.RetryLimits = true
|
|
30
|
+
cfg.Safety.Retry.TestMax = 2
|
|
31
|
+
|
|
32
|
+
orch := New(cfg, repoRoot, false)
|
|
33
|
+
orch.planner = agentStub{name: "planner", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
34
|
+
return &agents.Output{Plan: &models.Plan{
|
|
35
|
+
TaskID: input.Task.ID,
|
|
36
|
+
Summary: "retry test plan",
|
|
37
|
+
TaskType: models.TaskTypeBugfix,
|
|
38
|
+
RiskLevel: models.RiskMedium,
|
|
39
|
+
FilesToModify: []string{"demo.go"},
|
|
40
|
+
FilesToInspect: []string{"demo.go"},
|
|
41
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Patch updates demo.go."}},
|
|
42
|
+
TestRequirements: []string{"Run configured test command."},
|
|
43
|
+
Steps: []models.PlanStep{{Order: 1, Description: "Modify demo.go."}},
|
|
44
|
+
}}, nil
|
|
45
|
+
}}
|
|
46
|
+
orch.coder = agentStub{name: "coder", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
47
|
+
return &agents.Output{Patch: &models.Patch{TaskID: input.Task.ID, RawDiff: "diff --git a/demo.go b/demo.go\n--- a/demo.go\n+++ b/demo.go\n@@ -1 +1 @@\n-old\n+new\n"}}, nil
|
|
48
|
+
}}
|
|
49
|
+
task := &models.Task{ID: "task-1", Description: "retry test", CreatedAt: time.Now()}
|
|
50
|
+
|
|
51
|
+
state, err := orch.Run(task)
|
|
52
|
+
if err == nil {
|
|
53
|
+
t.Fatalf("expected run to fail when test command always fails")
|
|
54
|
+
}
|
|
55
|
+
if state == nil {
|
|
56
|
+
t.Fatalf("expected run state")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if state.Retries.Testing != 2 {
|
|
60
|
+
t.Fatalf("unexpected testing retries. got=%d want=%d", state.Retries.Testing, 2)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if len(state.UnresolvedFailures) == 0 {
|
|
64
|
+
t.Fatalf("expected unresolved failure summary to be recorded")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestRunLogsToolPolicyDecisions(t *testing.T) {
|
|
69
|
+
repoRoot := t.TempDir()
|
|
70
|
+
|
|
71
|
+
cfg := config.DefaultConfig()
|
|
72
|
+
cfg.Commands.Test = "printf ok"
|
|
73
|
+
|
|
74
|
+
orch := New(cfg, repoRoot, false)
|
|
75
|
+
orch.planner = agentStub{name: "planner", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
76
|
+
return &agents.Output{Plan: &models.Plan{
|
|
77
|
+
TaskID: input.Task.ID,
|
|
78
|
+
Summary: "policy log test plan",
|
|
79
|
+
TaskType: models.TaskTypeBugfix,
|
|
80
|
+
RiskLevel: models.RiskMedium,
|
|
81
|
+
FilesToModify: []string{"demo.go"},
|
|
82
|
+
FilesToInspect: []string{"demo.go"},
|
|
83
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Patch updates demo.go."}},
|
|
84
|
+
TestRequirements: []string{"Run configured test command."},
|
|
85
|
+
Steps: []models.PlanStep{{Order: 1, Description: "Modify demo.go."}},
|
|
86
|
+
}}, nil
|
|
87
|
+
}}
|
|
88
|
+
orch.coder = agentStub{name: "coder", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
89
|
+
return &agents.Output{Patch: &models.Patch{TaskID: input.Task.ID, RawDiff: "diff --git a/demo.go b/demo.go\n--- a/demo.go\n+++ b/demo.go\n@@ -1 +1 @@\n-old\n+new\n"}}, nil
|
|
90
|
+
}}
|
|
91
|
+
task := &models.Task{ID: "task-2", Description: "policy log test", CreatedAt: time.Now()}
|
|
92
|
+
|
|
93
|
+
state, err := orch.Run(task)
|
|
94
|
+
if err != nil {
|
|
95
|
+
t.Fatalf("expected run to complete: %v", err)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
found := false
|
|
99
|
+
for _, entry := range state.Logs {
|
|
100
|
+
if entry.Actor == "policy" && entry.Step == "decision" && strings.Contains(entry.Message, "tool=run_tests") {
|
|
101
|
+
found = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if !found {
|
|
107
|
+
t.Fatalf("expected policy decision log for run_tests tool")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if state.Context == nil {
|
|
111
|
+
t.Fatalf("expected context to be built and attached to run state")
|
|
112
|
+
}
|
|
113
|
+
if state.TaskBrief == nil {
|
|
114
|
+
t.Fatalf("expected task brief to be attached to run state")
|
|
115
|
+
}
|
|
116
|
+
if state.Plan == nil || len(state.Plan.AcceptanceCriteria) == 0 {
|
|
117
|
+
t.Fatalf("expected structured plan acceptance criteria to be attached to run state")
|
|
118
|
+
}
|
|
119
|
+
if state.ExecutionContract == nil || len(state.ExecutionContract.AllowedFiles) == 0 {
|
|
120
|
+
t.Fatalf("expected execution contract to be attached to run state")
|
|
121
|
+
}
|
|
122
|
+
if len(state.ValidationResults) == 0 {
|
|
123
|
+
t.Fatalf("expected validation results to be attached to run state")
|
|
124
|
+
}
|
|
125
|
+
foundPlanCompliance := false
|
|
126
|
+
for _, result := range state.ValidationResults {
|
|
127
|
+
if result.Name == "plan_compliance" {
|
|
128
|
+
foundPlanCompliance = true
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if !foundPlanCompliance {
|
|
133
|
+
t.Fatalf("expected plan_compliance validation result")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package orchestrator
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/agents"
|
|
8
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
9
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestRunAttachesReviewScorecard(t *testing.T) {
|
|
13
|
+
repoRoot := t.TempDir()
|
|
14
|
+
|
|
15
|
+
cfg := config.DefaultConfig()
|
|
16
|
+
cfg.Commands.Test = "printf ok"
|
|
17
|
+
|
|
18
|
+
orch := New(cfg, repoRoot, false)
|
|
19
|
+
orch.planner = agentStub{name: "planner", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
20
|
+
return &agents.Output{Plan: &models.Plan{
|
|
21
|
+
TaskID: input.Task.ID,
|
|
22
|
+
Summary: "health endpoint plan",
|
|
23
|
+
TaskType: models.TaskTypeFeature,
|
|
24
|
+
RiskLevel: models.RiskMedium,
|
|
25
|
+
FilesToModify: []string{"health.go"},
|
|
26
|
+
FilesToInspect: []string{"health.go"},
|
|
27
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Health endpoint is implemented."}},
|
|
28
|
+
TestRequirements: []string{"Run configured test command."},
|
|
29
|
+
Steps: []models.PlanStep{{Order: 1, Description: "Modify health.go."}},
|
|
30
|
+
}}, nil
|
|
31
|
+
}}
|
|
32
|
+
orch.coder = agentStub{name: "coder", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
33
|
+
return &agents.Output{Patch: &models.Patch{TaskID: input.Task.ID, RawDiff: "diff --git a/health.go b/health.go\n--- a/health.go\n+++ b/health.go\n@@ -1 +1 @@\n-old\n+new\n"}}, nil
|
|
34
|
+
}}
|
|
35
|
+
task := &models.Task{ID: "task-review-1", Description: "add health endpoint", CreatedAt: time.Now()}
|
|
36
|
+
|
|
37
|
+
state, err := orch.Run(task)
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("expected run to complete: %v", err)
|
|
40
|
+
}
|
|
41
|
+
if state.ReviewScorecard == nil {
|
|
42
|
+
t.Fatalf("expected review scorecard to be attached to run state")
|
|
43
|
+
}
|
|
44
|
+
if state.Review == nil {
|
|
45
|
+
t.Fatalf("expected final review result")
|
|
46
|
+
}
|
|
47
|
+
if state.Confidence == nil {
|
|
48
|
+
t.Fatalf("expected confidence report to be attached to run state")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Run state machine:
|
|
2
|
+
//
|
|
3
|
+
// Created → Analyzing → Planning → Coding → Validating → Testing → Reviewing → Completed
|
|
4
|
+
package orchestrator
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"fmt"
|
|
8
|
+
|
|
9
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var validTransitions = map[models.RunStatus][]models.RunStatus{
|
|
13
|
+
models.StatusCreated: {models.StatusAnalyzing, models.StatusFailed},
|
|
14
|
+
models.StatusAnalyzing: {models.StatusPlanning, models.StatusFailed},
|
|
15
|
+
models.StatusPlanning: {models.StatusCoding, models.StatusFailed},
|
|
16
|
+
models.StatusCoding: {models.StatusValidating, models.StatusFailed},
|
|
17
|
+
models.StatusValidating: {models.StatusTesting, models.StatusCoding, models.StatusFailed},
|
|
18
|
+
models.StatusTesting: {models.StatusReviewing, models.StatusCoding, models.StatusFailed},
|
|
19
|
+
models.StatusReviewing: {models.StatusCompleted, models.StatusCoding, models.StatusFailed},
|
|
20
|
+
models.StatusCompleted: {},
|
|
21
|
+
models.StatusFailed: {},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func Transition(state *models.RunState, target models.RunStatus) error {
|
|
25
|
+
allowed, ok := validTransitions[state.Status]
|
|
26
|
+
if !ok {
|
|
27
|
+
return fmt.Errorf("unknown state: %s", state.Status)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for _, valid := range allowed {
|
|
31
|
+
if valid == target {
|
|
32
|
+
state.Status = target
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return fmt.Errorf("invalid state transition: %s → %s", state.Status, target)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func IsTerminal(status models.RunStatus) bool {
|
|
41
|
+
return status == models.StatusCompleted || status == models.StatusFailed
|
|
42
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package orchestrator
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/furkanbeydemir/orch/internal/agents"
|
|
11
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
12
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestRunClassifiesTestFailure(t *testing.T) {
|
|
16
|
+
repoRoot := t.TempDir()
|
|
17
|
+
|
|
18
|
+
scriptPath := filepath.Join(repoRoot, "testfail.sh")
|
|
19
|
+
if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho '--- FAIL: TestAuth' >&2\necho 'expected 200 got 500' >&2\nexit 1\n"), 0o755); err != nil {
|
|
20
|
+
t.Fatalf("write test script: %v", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
cfg := config.DefaultConfig()
|
|
24
|
+
cfg.Commands.Test = "sh testfail.sh"
|
|
25
|
+
cfg.Safety.FeatureFlags.RetryLimits = false
|
|
26
|
+
|
|
27
|
+
orch := New(cfg, repoRoot, false)
|
|
28
|
+
orch.planner = agentStub{name: "planner", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
29
|
+
return &agents.Output{Plan: &models.Plan{
|
|
30
|
+
TaskID: input.Task.ID,
|
|
31
|
+
Summary: "classify test failure plan",
|
|
32
|
+
TaskType: models.TaskTypeBugfix,
|
|
33
|
+
RiskLevel: models.RiskMedium,
|
|
34
|
+
FilesToModify: []string{"demo.go"},
|
|
35
|
+
FilesToInspect: []string{"demo.go"},
|
|
36
|
+
AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Patch updates demo.go."}},
|
|
37
|
+
TestRequirements: []string{"Run configured test command."},
|
|
38
|
+
Steps: []models.PlanStep{{Order: 1, Description: "Modify demo.go."}},
|
|
39
|
+
}}, nil
|
|
40
|
+
}}
|
|
41
|
+
orch.coder = agentStub{name: "coder", execute: func(input *agents.Input) (*agents.Output, error) {
|
|
42
|
+
return &agents.Output{Patch: &models.Patch{TaskID: input.Task.ID, RawDiff: "diff --git a/demo.go b/demo.go\n--- a/demo.go\n+++ b/demo.go\n@@ -1 +1 @@\n-old\n+new\n"}}, nil
|
|
43
|
+
}}
|
|
44
|
+
|
|
45
|
+
state, err := orch.Run(&models.Task{ID: "task-test-classifier", Description: "classify failing tests", CreatedAt: time.Now()})
|
|
46
|
+
if err == nil {
|
|
47
|
+
t.Fatalf("expected run to fail on test command")
|
|
48
|
+
}
|
|
49
|
+
if state == nil {
|
|
50
|
+
t.Fatalf("expected run state")
|
|
51
|
+
}
|
|
52
|
+
if len(state.TestFailures) == 0 {
|
|
53
|
+
t.Fatalf("expected classified test failures")
|
|
54
|
+
}
|
|
55
|
+
if state.TestFailures[0].Code != "test_assertion_failure" {
|
|
56
|
+
t.Fatalf("unexpected test failure code: %s", state.TestFailures[0].Code)
|
|
57
|
+
}
|
|
58
|
+
found := false
|
|
59
|
+
for _, result := range state.ValidationResults {
|
|
60
|
+
if result.Name == "required_tests_passed" && result.Status == models.ValidationFail && strings.Contains(result.Summary, "test_assertion_failure") {
|
|
61
|
+
found = true
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if !found {
|
|
66
|
+
t.Fatalf("expected test gate failure to be recorded")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Package patch contains patch application implementation.
|
|
2
|
+
package patch
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"fmt"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"regexp"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Applier struct{}
|
|
15
|
+
|
|
16
|
+
type ConflictError struct {
|
|
17
|
+
Files []string
|
|
18
|
+
Reason string
|
|
19
|
+
BestPatchSummary string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (e *ConflictError) Error() string {
|
|
23
|
+
if len(e.Files) == 0 {
|
|
24
|
+
return fmt.Sprintf("patch conflict: %s", e.Reason)
|
|
25
|
+
}
|
|
26
|
+
return fmt.Sprintf("patch conflict in %s: %s", strings.Join(e.Files, ", "), e.Reason)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type InvalidPatchError struct {
|
|
30
|
+
Reason string
|
|
31
|
+
BestPatchSummary string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (e *InvalidPatchError) Error() string {
|
|
35
|
+
return fmt.Sprintf("invalid patch: %s", e.Reason)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func NewApplier() *Applier {
|
|
39
|
+
return &Applier{}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Apply applies patch content to the target repository.
|
|
43
|
+
func (a *Applier) Apply(p *models.Patch, repoRoot string, dryRun bool) error {
|
|
44
|
+
if p == nil || strings.TrimSpace(p.RawDiff) == "" {
|
|
45
|
+
return fmt.Errorf("no patch to apply")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
args := []string{"apply"}
|
|
49
|
+
|
|
50
|
+
if dryRun {
|
|
51
|
+
args = append(args, "--check")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stdin'den patch oku
|
|
55
|
+
args = append(args, "-")
|
|
56
|
+
|
|
57
|
+
cmd := exec.Command("git", args...)
|
|
58
|
+
cmd.Dir = repoRoot
|
|
59
|
+
cmd.Stdin = strings.NewReader(p.RawDiff)
|
|
60
|
+
|
|
61
|
+
output, err := cmd.CombinedOutput()
|
|
62
|
+
if err != nil {
|
|
63
|
+
summary := Summarize(p)
|
|
64
|
+
files := parseConflictFiles(string(output))
|
|
65
|
+
if len(files) > 0 {
|
|
66
|
+
return &ConflictError{
|
|
67
|
+
Files: files,
|
|
68
|
+
Reason: strings.TrimSpace(string(output)),
|
|
69
|
+
BestPatchSummary: summary,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if isInvalidPatchOutput(string(output)) {
|
|
74
|
+
return &InvalidPatchError{
|
|
75
|
+
Reason: strings.TrimSpace(string(output)),
|
|
76
|
+
BestPatchSummary: summary,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
mode := "apply"
|
|
81
|
+
if dryRun {
|
|
82
|
+
mode = "dry-run check"
|
|
83
|
+
}
|
|
84
|
+
return fmt.Errorf("patch %s failed: %s\n%s", mode, err.Error(), string(output))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (a *Applier) DryRun(p *models.Patch, repoRoot string) error {
|
|
91
|
+
return a.Apply(p, repoRoot, true)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var (
|
|
95
|
+
patchFailedPattern = regexp.MustCompile(`patch failed:\s+([^:]+):`) //nolint:gochecknoglobals
|
|
96
|
+
patchDoesNotApplyExpr = regexp.MustCompile(`error:\s+([^:]+):\s+patch does not apply`) //nolint:gochecknoglobals
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
func parseConflictFiles(output string) []string {
|
|
100
|
+
unique := map[string]struct{}{}
|
|
101
|
+
|
|
102
|
+
for _, match := range patchFailedPattern.FindAllStringSubmatch(output, -1) {
|
|
103
|
+
if len(match) > 1 {
|
|
104
|
+
unique[strings.TrimSpace(match[1])] = struct{}{}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for _, match := range patchDoesNotApplyExpr.FindAllStringSubmatch(output, -1) {
|
|
109
|
+
if len(match) > 1 {
|
|
110
|
+
unique[strings.TrimSpace(match[1])] = struct{}{}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if len(unique) == 0 {
|
|
115
|
+
return nil
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
files := make([]string, 0, len(unique))
|
|
119
|
+
for file := range unique {
|
|
120
|
+
files = append(files, file)
|
|
121
|
+
}
|
|
122
|
+
sort.Strings(files)
|
|
123
|
+
return files
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func isInvalidPatchOutput(output string) bool {
|
|
127
|
+
lower := strings.ToLower(output)
|
|
128
|
+
return strings.Contains(lower, "no valid patches in input") ||
|
|
129
|
+
strings.Contains(lower, "corrupt patch") ||
|
|
130
|
+
strings.Contains(lower, "malformed patch")
|
|
131
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package patch
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"reflect"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestParseConflictFiles(t *testing.T) {
|
|
9
|
+
output := "error: patch failed: internal/orchestrator/orchestrator.go:10\nerror: internal/patch/patch.go: patch does not apply\n"
|
|
10
|
+
|
|
11
|
+
files := parseConflictFiles(output)
|
|
12
|
+
expected := []string{"internal/orchestrator/orchestrator.go", "internal/patch/patch.go"}
|
|
13
|
+
if !reflect.DeepEqual(files, expected) {
|
|
14
|
+
t.Fatalf("unexpected files. got=%v want=%v", files, expected)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func TestParserRejectsInvalidUnifiedDiff(t *testing.T) {
|
|
19
|
+
parser := NewParser()
|
|
20
|
+
|
|
21
|
+
_, err := parser.Parse("this is not a diff")
|
|
22
|
+
if err == nil {
|
|
23
|
+
t.Fatalf("expected parser to reject invalid unified diff")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Package patch contains unified diff parser implementation.
|
|
2
|
+
//
|
|
3
|
+
// Supported format:
|
|
4
|
+
//
|
|
5
|
+
// diff --git a/file b/file
|
|
6
|
+
// --- a/file
|
|
7
|
+
// +++ b/file
|
|
8
|
+
// @@ -start,count +start,count @@
|
|
9
|
+
// ...
|
|
10
|
+
package patch
|
|
11
|
+
|
|
12
|
+
import (
|
|
13
|
+
"fmt"
|
|
14
|
+
"strings"
|
|
15
|
+
|
|
16
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type Parser struct{}
|
|
20
|
+
|
|
21
|
+
func NewParser() *Parser {
|
|
22
|
+
return &Parser{}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (p *Parser) Parse(rawDiff string) (*models.Patch, error) {
|
|
26
|
+
patch := &models.Patch{
|
|
27
|
+
RawDiff: rawDiff,
|
|
28
|
+
Files: make([]models.PatchFile, 0),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if strings.TrimSpace(rawDiff) == "" {
|
|
32
|
+
return patch, nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines := strings.Split(rawDiff, "\n")
|
|
36
|
+
hasDiffHeader := false
|
|
37
|
+
var currentFile *models.PatchFile
|
|
38
|
+
var currentDiff strings.Builder
|
|
39
|
+
|
|
40
|
+
for _, line := range lines {
|
|
41
|
+
if strings.HasPrefix(line, "diff --git") {
|
|
42
|
+
hasDiffHeader = true
|
|
43
|
+
if currentFile != nil {
|
|
44
|
+
currentFile.Diff = currentDiff.String()
|
|
45
|
+
patch.Files = append(patch.Files, *currentFile)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Parse new file metadata
|
|
49
|
+
path := parseDiffHeader(line)
|
|
50
|
+
currentFile = &models.PatchFile{
|
|
51
|
+
Path: path,
|
|
52
|
+
Status: "modified",
|
|
53
|
+
}
|
|
54
|
+
currentDiff.Reset()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if currentFile != nil {
|
|
58
|
+
currentDiff.WriteString(line)
|
|
59
|
+
currentDiff.WriteString("\n")
|
|
60
|
+
|
|
61
|
+
// Determine file status
|
|
62
|
+
if strings.HasPrefix(line, "new file") {
|
|
63
|
+
currentFile.Status = "added"
|
|
64
|
+
} else if strings.HasPrefix(line, "deleted file") {
|
|
65
|
+
currentFile.Status = "deleted"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if currentFile != nil {
|
|
71
|
+
currentFile.Diff = currentDiff.String()
|
|
72
|
+
patch.Files = append(patch.Files, *currentFile)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if !hasDiffHeader {
|
|
76
|
+
return nil, fmt.Errorf("invalid unified diff: missing 'diff --git' header")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return patch, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func parseDiffHeader(line string) string {
|
|
83
|
+
// Format: diff --git a/path b/path
|
|
84
|
+
parts := strings.Split(line, " ")
|
|
85
|
+
if len(parts) >= 4 {
|
|
86
|
+
return strings.TrimPrefix(parts[3], "b/")
|
|
87
|
+
}
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 2. Parse - unified diff format parse
|
|
2
|
+
// 5. Apply - apply to working tree
|
|
3
|
+
//
|
|
4
|
+
// - Patch size limits are enforced
|
|
5
|
+
package patch
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"fmt"
|
|
9
|
+
|
|
10
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type Pipeline struct {
|
|
14
|
+
maxFiles int
|
|
15
|
+
maxLines int
|
|
16
|
+
validator *Validator
|
|
17
|
+
parser *Parser
|
|
18
|
+
applier *Applier
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func NewPipeline(maxFiles, maxLines int) *Pipeline {
|
|
22
|
+
return &Pipeline{
|
|
23
|
+
maxFiles: maxFiles,
|
|
24
|
+
maxLines: maxLines,
|
|
25
|
+
validator: NewValidator(maxFiles, maxLines),
|
|
26
|
+
parser: NewParser(),
|
|
27
|
+
applier: NewApplier(),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (p *Pipeline) Process(rawDiff string) (*models.Patch, error) {
|
|
32
|
+
// 1. Parse
|
|
33
|
+
patchResult, err := p.parser.Parse(rawDiff)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return nil, fmt.Errorf("patch parse error: %w", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. Validate
|
|
39
|
+
if err := p.validator.Validate(patchResult); err != nil {
|
|
40
|
+
return nil, fmt.Errorf("patch validation error: %w", err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return patchResult, nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (p *Pipeline) Validate(patch *models.Patch) error {
|
|
47
|
+
return p.validator.Validate(patch)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func (p *Pipeline) Preview(patch *models.Patch) string {
|
|
51
|
+
if patch == nil || patch.RawDiff == "" {
|
|
52
|
+
return "No changes to display."
|
|
53
|
+
}
|
|
54
|
+
return patch.RawDiff
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Apply applies patch content to the working tree.
|
|
58
|
+
func (p *Pipeline) Apply(patch *models.Patch, repoRoot string, dryRun bool) error {
|
|
59
|
+
return p.applier.Apply(patch, repoRoot, dryRun)
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package patch
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/furkanbeydemir/orch/internal/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func Summarize(p *models.Patch) string {
|
|
11
|
+
if p == nil || strings.TrimSpace(p.RawDiff) == "" {
|
|
12
|
+
return "no patch available"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
added := 0
|
|
16
|
+
removed := 0
|
|
17
|
+
for _, line := range strings.Split(p.RawDiff, "\n") {
|
|
18
|
+
if strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---") {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
if strings.HasPrefix(line, "+") {
|
|
22
|
+
added++
|
|
23
|
+
}
|
|
24
|
+
if strings.HasPrefix(line, "-") {
|
|
25
|
+
removed++
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return fmt.Sprintf("files=%d added=%d removed=%d", len(p.Files), added, removed)
|
|
30
|
+
}
|