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.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. 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
+ }