diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index e6e2614..5938be5 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -53,10 +53,10 @@ jobs: if: github.event_name != 'pull_request' run: make controller-agent-push - agent-java-goose: + agent-images: permissions: contents: read - name: Build Java migration agent image + name: Build stage agent images runs-on: ubuntu-latest steps: - name: Clone the code @@ -64,8 +64,8 @@ jobs: with: persist-credentials: false - - name: Build image - run: make agent-java-goose-build + - name: Build images + run: make agent-images-build - name: Log in to Quay.io if: github.event_name != 'pull_request' @@ -75,9 +75,9 @@ jobs: username: ${{ vars.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Push image + - name: Push images if: github.event_name != 'pull_request' - run: make agent-java-goose-push + run: make agent-images-push controller: permissions: diff --git a/Makefile b/Makefile index 8da8633..0bbce6b 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration ##@ Build CONTROLLER_AGENT_IMG ?= quay.io/konveyor/agentic-controller-agent:latest -AGENT_JAVA_GOOSE_IMG ?= quay.io/konveyor/agent-base-goose-java:latest +AGENT_BASE_IMG ?= quay.io/konveyor/agent-base:latest +AGENT_JAVA_IMG ?= quay.io/konveyor/agent-java:latest +AGENT_GO_IMG ?= quay.io/konveyor/agent-go:latest +AGENT_CSHARP_IMG ?= quay.io/konveyor/agent-csharp:latest +AGENT_NODEJS_IMG ?= quay.io/konveyor/agent-nodejs:latest .PHONY: build build: manifests generate fmt vet ## Build manager binary. @@ -114,13 +118,36 @@ controller-agent-build: ## Build the controller's test/verification agent image. controller-agent-push: controller-agent-build ## Build and push the controller's test/verification agent image. $(CONTAINER_TOOL) push $(CONTROLLER_AGENT_IMG) -.PHONY: agent-java-goose-build -agent-java-goose-build: ## Build the Java migration agent image (Goose + JDK 21 + harness). - $(CONTAINER_TOOL) build -t $(AGENT_JAVA_GOOSE_IMG) -f images/agent-base-goose-java/Containerfile . +.PHONY: agent-base-build +agent-base-build: ## Build the base agent image (goose + git + Python + graphify + harness binary). + $(CONTAINER_TOOL) build -t $(AGENT_BASE_IMG) -f images/agent-base/Containerfile . -.PHONY: agent-java-goose-push -agent-java-goose-push: agent-java-goose-build ## Build and push the Java migration agent image. - $(CONTAINER_TOOL) push $(AGENT_JAVA_GOOSE_IMG) +.PHONY: agent-java-build +agent-java-build: agent-base-build ## Build the Java agent image (JDK 21 + Maven). + $(CONTAINER_TOOL) build -t $(AGENT_JAVA_IMG) -f images/agent-java/Containerfile . + +.PHONY: agent-go-build +agent-go-build: agent-base-build ## Build the Go agent image. + $(CONTAINER_TOOL) build -t $(AGENT_GO_IMG) -f images/agent-go/Containerfile . + +.PHONY: agent-csharp-build +agent-csharp-build: agent-base-build ## Build the C# agent image (.NET SDK). + $(CONTAINER_TOOL) build -t $(AGENT_CSHARP_IMG) -f images/agent-csharp/Containerfile . + +.PHONY: agent-nodejs-build +agent-nodejs-build: agent-base-build ## Build the Node.js agent image. + $(CONTAINER_TOOL) build -t $(AGENT_NODEJS_IMG) -f images/agent-nodejs/Containerfile . + +.PHONY: agent-images-build +agent-images-build: agent-java-build agent-go-build agent-csharp-build agent-nodejs-build ## Build all agent images. + +.PHONY: agent-images-push +agent-images-push: agent-images-build ## Build and push all agent images. + $(CONTAINER_TOOL) push $(AGENT_BASE_IMG) + $(CONTAINER_TOOL) push $(AGENT_JAVA_IMG) + $(CONTAINER_TOOL) push $(AGENT_GO_IMG) + $(CONTAINER_TOOL) push $(AGENT_CSHARP_IMG) + $(CONTAINER_TOOL) push $(AGENT_NODEJS_IMG) .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/changes/unreleased/53-harness-thin-runner.yaml b/changes/unreleased/53-harness-thin-runner.yaml new file mode 100644 index 0000000..f18e331 --- /dev/null +++ b/changes/unreleased/53-harness-thin-runner.yaml @@ -0,0 +1,5 @@ +kind: enhancement + +description: > + Rewrite harness as thin single-stage runner with SkillCard-based skills, + filesystem watcher for incremental commits, and per-stage container images. diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index d462498..394b941 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,8 +2,10 @@ # These provide out-of-the-box migration skills backed by # OCI images pushed to quay.io/konveyor/skills/. resources: + - skillcard_javax_to_jakarta_ee.yaml - skillcard_maven_migration.yaml - skillcard_no_javax_imports.yaml - skillcard_ejb_to_cdi.yaml + - skillcard_javaee_to_quarkus.yaml - skillcollection_java_migration.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/skillcard_javaee_to_quarkus.yaml b/config/samples/skillcard_javaee_to_quarkus.yaml new file mode 100644 index 0000000..fabc30f --- /dev/null +++ b/config/samples/skillcard_javaee_to_quarkus.yaml @@ -0,0 +1,18 @@ +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javaee-to-quarkus +spec: + image: quay.io/konveyor/skills:javaee-to-quarkus + displayName: Java EE to Quarkus Migration + version: "1.0.0" + description: > + Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. + Covers EJB-to-CDI, JMS/MDB-to-SmallRye Reactive Messaging, WAR-to-JAR packaging, + persistence.xml-to-application.properties, JNDI removal, and server lifecycle replacement. + type: skill + tags: + - java + - javaee + - quarkus + - migration diff --git a/config/samples/skillcard_javax_to_jakarta_ee.yaml b/config/samples/skillcard_javax_to_jakarta_ee.yaml new file mode 100644 index 0000000..9c52d42 --- /dev/null +++ b/config/samples/skillcard_javax_to_jakarta_ee.yaml @@ -0,0 +1,18 @@ +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javax-to-jakarta-ee +spec: + image: quay.io/konveyor/skills:javax-to-jakarta-ee + displayName: javax to Jakarta EE Migration + version: "1.0.0" + description: > + Migrates Java EE 8 (javax) applications to Jakarta EE 9/10 (jakarta). + Covers dependency changes, package-level renames, XML namespace updates, + ServiceLoader file renames, and removed specifications. + type: skill + tags: + - java + - javaee + - jakarta + - migration diff --git a/config/samples/skillcollection_java_migration.yaml b/config/samples/skillcollection_java_migration.yaml index 507ce2e..ea0e720 100644 --- a/config/samples/skillcollection_java_migration.yaml +++ b/config/samples/skillcollection_java_migration.yaml @@ -3,11 +3,15 @@ kind: SkillCollection metadata: name: java-migration-skills spec: - version: "1.0.0" + version: "2.0.0" skills: + - name: javax-to-jakarta-ee + skillCardRef: javax-to-jakarta-ee - name: maven-migration skillCardRef: maven-migration - name: no-javax-imports skillCardRef: no-javax-imports - name: ejb-to-cdi skillCardRef: ejb-to-cdi + - name: javaee-to-quarkus + skillCardRef: javaee-to-quarkus diff --git a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md new file mode 100644 index 0000000..0b1f800 --- /dev/null +++ b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md @@ -0,0 +1,319 @@ +# ADR 0006: Harness as Thin Single-Stage Runner with SkillCard-Based Skills + +**Status:** Accepted +**Date:** 2026-07-21 +**Authors:** Savitha Raghunathan + +## Context + +The migration harness was a monolithic Go binary that orchestrated five +sequential stages (detect, plan, execute, verify, fix-loop) internally. +Each stage was a Go package that constructed multi-turn ACP prompts from +YAML recipe files and an embedded skill bundle. The harness owned both +stage sequencing and migration intelligence. + +With the AgentPlaybookRun controller (ADR 0001) handling stage +sequencing, the harness no longer needs orchestration logic. Meanwhile, +the SkillCard CRD and OCI-based skill packaging provide a clean +mechanism for delivering migration knowledge to agent pods at runtime. + +The harness needed to become a thin, uniform wrapper — identical in +every stage image — that handles only git plumbing and goose lifecycle. +Migration intelligence needed to move from Go code and YAML recipes into +standalone skills that can be versioned, shared, and composed +independently. + +## Decision + +### Thin single-stage runner + +The harness binary exposes a single `run` command. It does not know what +stage it is running, what language is being migrated, or what migration +patterns to apply. Its responsibilities are: + +1. Load configuration from environment variables (`config.LoadFromEnv`) +2. Resolve application metadata, git credentials, and analysis results + from the Konveyor Hub API (`HUB_BASE_URL`, `HUB_TOKEN`, `APP_ID`) +3. Clone the git repo using Hub-provided credentials, strip credentials + from the remote, checkout the controller-provided target branch + (`TARGET_BRANCH`) +4. Write analysis insights to `.konveyor/analysis.json` (if available) +5. Clear Hub credentials from the environment +6. Start `goose serve` with LLM provider credentials +7. Connect via ACP WebSocket, create a session +8. Discover skills from `/opt/skills/*/SKILL.md` (glob) +9. Build a single prompt from four context layers: + - `KONVEYOR_PROMPT` — agent-level standing instructions + - `KONVEYOR_PLAYBOOK_INSTRUCTIONS` — playbook guide context + - Skill content (concatenated from all discovered skills) + - `KONVEYOR_INSTRUCTIONS` — stage-specific task +10. Start a filesystem watcher for incremental commit+push +11. Send one ACP prompt and block until completion +12. Stop watcher, determine exit status from ACP completion (error = + failure, clean return = success) +13. Final commit+push — push failure is fatal (exit 1) + +No interactive commands, no file-based config, no multi-turn recipe +execution. + +#### Credential isolation + +Hub credentials (`HUB_BASE_URL`, `HUB_TOKEN`, `APP_ID`) are cleared +from the environment after resolution via `hub.ClearEnv()`. Git +credentials are stripped from the cloned repo's remote. Both happen +before goose starts. This is a deliberate security boundary — goose +and any skill content it executes cannot access Hub or push +credentials. Only the harness binary pushes to git. + +### Two kinds of skills: stage and domain + +Skills are classified into two types with distinct responsibilities: + +- **Stage skills** define *process* — what to do. They encode the + workflow for a stage (plan, execute, verify) without + language-specific knowledge. Stage skills reference domain skills + with directives like "follow the migration phases from any loaded + domain skill." + +- **Domain skills** define *knowledge* — how to do it. They provide + language-specific migration intelligence: annotation maps, + dependency maps, pattern catalogs, phased migration guides. + Example: `javaee-to-quarkus`. + +An agent loads one stage skill and one or more domain skills. The +harness concatenates all discovered skills into the prompt. The stage +skill's process instructions frame the work; the domain skill's +knowledge fills in the specifics. + +Three stage skills encode what was previously spread across Go packages: + +| Skill | Replaces | Purpose | +|-------|----------|---------| +| `plan` | `detect/`, `plan/`, `recipes/plan.yaml` | Run graphify, analyze project, produce PLAN.md | +| `execute` | `execute/`, `recipes/execute.yaml` | Read PLAN.md, apply transformations file by file | +| `verify` | `verify/`, `fixloop/`, `recipes/verify.yaml` | Build, fix errors iteratively, report result | + +Skills are packaged as scratch-based OCI images (`FROM scratch; COPY . /`) +and referenced by SkillCard CRs. The controller mounts them as init +container volumes at `/opt/skills//` in the agent pod. + +### Skill discovery at runtime (not baked into images) + +The original design specified baking exactly one skill into each stage +image via `COPY skills/plan/ /opt/skills/plan/`. We chose instead to +keep stage images skill-free and mount skills at runtime via SkillCards. + +This means: +- Stage images contain only toolchain (graphify, JDK, Maven) +- Skills are versioned and released independently of images +- The same stage image works with different skill combinations +- The harness discovers all mounted skills via glob, not exactly one + +### Image hierarchy + +Five images, two intermediate bases. Image names include the language +suffix for multi-language support: + +``` +agent-base (UBI 10 + goose + git + harness binary) +├── agent-plan (+ Python 3, graphify — language-agnostic) +├── agent-java-base (+ JDK 21, Maven) +│ ├── agent-execute-java +│ └── agent-verify-java +``` + +Graphify is language-agnostic and supports multiple languages, so +`agent-plan` is shared across all migration types. Execute and verify +images are language-specific — adding a new language (e.g., Go) means +new `agent-go-base`, `agent-execute-go`, and `agent-verify-go` images. +No harness or controller changes required. + +Execute and verify share the same language base since both need the +build toolchain. They are separate images (currently identical) to +allow future divergence (e.g., verify may add test frameworks or +coverage tools). + +### Filesystem watcher + +A background goroutine watches the working directory using fsnotify and +commits+pushes after a 30-second quiet period (no new file writes). +This provides the "constant pushing to git" guarantee without goose +needing git credentials. + +The watcher uses targeted staging: +- `git add -u` for tracked files +- Selective staging of new files matching known source patterns +- Respects `.gitignore` for binary/build artifacts +- Excludes `.goose/`, `__pycache__/`, `graphify-out/`, `node_modules/` + +A final commit+push after goose exits catches anything the watcher +missed, including `.konveyor/` state files. + +The 30-second quiet period is a reasonable default for all stages. The +`WithQuietPeriod()` Go API allows overriding it. If noisy intermediate +commits become a problem (e.g., during execute where goose may pause +30+ seconds between file migrations), a `KONVEYOR_WATCHER_QUIET_PERIOD` +env var can be added to tune it per stage — deferred until there is a +real complaint. + +### Cross-stage state via git + +Git is the shared state boundary. Each stage clones the repo and reads +artifacts left by prior stages: + +- Plan writes: `PLAN.md`, `graph.json` +- Execute reads: `PLAN.md`. Writes: migrated source files +- Verify reads: migrated source. Writes: fix patches + +#### Exit status + +The harness determines success or failure from ACP/goose signals: +`SendPrompt` returning without error and goose still alive = success +(exit 0). Any error, goose crash, or final push failure = failure +(exit 1). Push failure is fatal because the next stage clones the +branch — if the push didn't land, the next stage has no artifacts to +work with. The harness does not read any skill-written status file — +git commits are the primary cross-stage context. Quality assessment +is the eval stage's responsibility, not the harness's. + +#### handoff.md + +The harness writes `.konveyor/handoff.md` after each stage with +status, skills loaded, and file counts. Git commits are the primary +cross-stage context — handoff.md supplements them with metadata that +isn't in the diff. If git commits prove insufficient for agent +consumption, handoff.md can be redesigned for machine readability. + +### Stage timeout + +The harness will support a `KONVEYOR_STAGE_TIMEOUT` env var (default: +60 minutes). When the timeout fires, `SendPrompt` returns via context +cancellation, the harness performs the final commit+push to preserve +partial work, and exits 1. This provides graceful timeout with artifact +preservation, versus the Sandbox's `activeDeadlineSeconds` which kills +the pod hard and loses uncommitted progress. + +### Context window scaling + +A single prompt per stage means the LLM's context window is the primary +scaling constraint. On large projects (hundreds of files, 50+ plan +steps), the combined prompt + skill content + tool call history can +exceed the context window mid-stage. + +The mitigation is skill-level chunking: the execute skill processes N +steps at a time, committing between chunks, so each chunk fits in +context. This keeps the complexity in the skill (where domain knowledge +lives) rather than in the harness. The harness remains a single-prompt +sender regardless of project size. + +## Deleted code + +| Deleted | Reason | +|---------|--------| +| `internal/detect/`, `internal/plan/`, `internal/execute/`, `internal/verify/`, `internal/fixloop/` | Logic moved to skills | +| `internal/handoff/`, `internal/metrics/`, `internal/rundir/` | Controller handles lifecycle | +| `internal/goose/recipe.go`, `internal/goose/acp_runner.go` | Multi-turn recipe execution replaced by single prompt | +| `harness/recipes/` | Content folded into skills | +| `harness/skill-bundle/` | Replaced by OCI-packaged SkillCards | +| `images/agent-base-goose/`, `images/agent-java-goose/` | Superseded by new image hierarchy | +| `docs/superpowers/` | Replaced by this ADR | + +## Alternatives Considered + +### Keep skills baked into images + +Each stage image copies its skill at build time +(`COPY skills/plan/ /opt/skills/plan/`). Simpler build, no SkillCard +dependency. + +Rejected because: couples skill content to image releases. Updating a +skill prompt requires rebuilding and redeploying the image. SkillCard +mounting allows skill iteration without image changes and enables +composing multiple skills per stage. + +### Keep multi-turn recipe execution + +The harness constructs multiple ACP prompts per stage using recipe YAML +files, sending them sequentially with context management. + +Rejected because: the recipe system duplicated orchestration that the +LLM handles naturally. A single well-composed prompt with skill +instructions produces equivalent or better results, and the LLM +manages its own context within the session. The multi-turn approach +also required complex Go code for recipe parsing, context windowing, +and turn management. + +### Harness as a library, not a binary + +Ship the harness as a Go library that custom agent images import and +call. Each image would have its own `main.go`. + +Rejected because: the harness behavior is identical across all stages. +A single binary with env-var configuration is simpler to maintain, +test, and debug. Custom behavior belongs in skills, not in the +harness binary. + +### Harness-level session splitting for large projects + +The harness sends multiple prompts per stage (e.g., "execute steps +1-10", then "execute steps 11-20"), re-reading PLAN.md each time. + +Rejected because: adds orchestration complexity back into the harness. +Skill-level chunking (the skill itself decides how to batch work) +keeps the harness thin and puts the complexity where domain knowledge +lives. + +### Controller reads status files from git + +The controller clones the git branch or reads status files from the +pod filesystem to get structured stage results. + +Rejected because: adds git or filesystem dependencies to the +controller. The harness exit code (derived from ACP completion +status) is sufficient. The controller stays a standard stateless +reconciler that watches pod exit codes. + +## Consequences + +- **Skill quality is critical.** Migration intelligence now lives + entirely in markdown skill files, not in deterministic Go code. + The LLM interprets skill instructions, which means skill authoring + quality directly impacts migration quality. Poor skill instructions + produce poor migrations. + +- **Single prompt per stage.** The harness sends one prompt. For large + projects, skills must implement their own chunking strategy to stay + within the context window. The harness has no automatic recovery if + the LLM loses context mid-stage. + +- **Exit status from ACP, not files.** The harness determines + success/failure from the ACP completion signal, not from any + skill-written file. Git commits are the cross-stage context. + Quality assessment is deferred to the eval stage. + +- **SkillCard dependency.** Stage images are non-functional without + mounted SkillCards. A misconfigured Agent CR (missing skillCards + ref) produces a pod that starts, finds no skills, and exits + immediately with an error. + +- **Image builds require dependency ordering.** CI must build + `agent-base` before `agent-plan`, and `agent-java-base` before + `agent-execute-java`/`agent-verify-java`. The Makefile encodes + these dependencies. + +- **Multi-language via image naming.** Image names include the + language suffix (`agent-execute-java`, `agent-execute-go`). The + plan image is shared across languages since graphify is + language-agnostic. + +- **Test harness scaffolding.** `hack/harness-test/setup.sh` builds + skill OCI images locally, loads them into Kind, and applies + SkillCard + Agent + AgentPlaybook + AgentPlaybookRun CRs for + end-to-end testing. + +## Planned work + +| Item | Description | +|------|-------------| +| handoff.md redesign | Redesign for machine readability if git commits prove insufficient for cross-stage context | +| Stage timeout | Implement `KONVEYOR_STAGE_TIMEOUT` env var | diff --git a/hack/harness-test/playbook-resources.yaml b/hack/harness-test/playbook-resources.yaml new file mode 100644 index 0000000..0859412 --- /dev/null +++ b/hack/harness-test/playbook-resources.yaml @@ -0,0 +1,172 @@ +# Example resources for testing the AgentPlaybook + AgentPlaybookRun flow. +# Migrates the coolstore Java EE app to Quarkus using 3 stages. +# +# Prerequisites: +# - LLMProvider "gcp-vertex-ai" from resources.yaml +# - Hub running with app + identity configured +# +# Usage: +# kubectl apply -f hack/harness-test/resources.yaml +# kubectl apply -f hack/harness-test/playbook-resources.yaml + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: plan +spec: + image: quay.io/konveyor/skills:plan + displayName: Plan Stage + version: "1.0.0" + description: Reads a project and produces PLAN.md with migration steps. + type: skill + tags: + - migration + - planning + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: execute +spec: + image: quay.io/konveyor/skills:execute + displayName: Execute Stage + version: "1.0.0" + description: Reads PLAN.md and executes each migration step sequentially. + type: skill + tags: + - migration + - execution + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: verify +spec: + image: quay.io/konveyor/skills:verify + displayName: Verify Stage + version: "1.0.0" + description: Runs the build command, fixes errors, and iterates until the build passes. + type: skill + tags: + - migration + - verification + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javaee-to-quarkus +spec: + image: quay.io/konveyor/skills:javaee-to-quarkus + displayName: Java EE to Quarkus Migration + version: "1.0.0" + description: > + Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. + Covers EJB-to-CDI, JMS/MDB-to-SmallRye, WAR-to-JAR, persistence.xml replacement, + JNDI removal, and server lifecycle replacement. + type: skill + tags: + - java + - javaee + - quarkus + - migration + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-plan-agent +spec: + image: quay.io/konveyor/agent-java:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: plan + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-execute-agent +spec: + image: quay.io/konveyor/agent-java:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: execute + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-verify-agent +spec: + image: quay.io/konveyor/agent-java:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: verify + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + - name: max_fix_iterations + type: number + default: "3" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybook +metadata: + name: java-ee-to-quarkus +spec: + guide: "Migrate a Java EE application to Quarkus 3" + stages: + - name: plan + agentRef: migration-plan-agent + instructions: "Analyze the project structure using graphify and produce PLAN.md with migration steps" + - name: execute + agentRef: migration-execute-agent + instructions: "Execute each step in PLAN.md to migrate the code from Java EE to Quarkus" + - name: verify + agentRef: migration-verify-agent + instructions: "Run mvn clean compile, fix any compilation errors, then run tests" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybookRun +metadata: + name: coolstore-migration-__TIMESTAMP__ +spec: + playbookRef: java-ee-to-quarkus + models: + - role: primary + provider: gcp-vertex-ai + model: claude-sonnet-4-5 + env: + - name: HUB_BASE_URL + value: "http://host.containers.internal:8080" + - name: HUB_TOKEN + value: "__HUB_TOKEN__" + - name: APP_ID + value: "1" + - name: TARGET_BRANCH + value: "konveyor/migration-__TIMESTAMP__" + - name: GCP_PROJECT_ID + value: "__GCP_PROJECT_ID__" + - name: GCP_LOCATION + value: "global" diff --git a/hack/harness-test/resources.yaml b/hack/harness-test/resources.yaml index 1049c19..2fd3dbb 100644 --- a/hack/harness-test/resources.yaml +++ b/hack/harness-test/resources.yaml @@ -1,5 +1,5 @@ -# Harness integration test resources for Kind. -# Creates the full CR chain: Secret → LLMProvider → Agent → AgentRun +# Shared resources for harness integration tests in Kind. +# Creates: LLMProvider (used by playbook-resources.yaml) # # Usage: # hack/harness-test/setup.sh @@ -17,45 +17,3 @@ spec: - name: claude-sonnet-4-5 contextWindow: 200000 tier: premium - ---- -apiVersion: konveyor.io/v1alpha1 -kind: Agent -metadata: - name: migration-harness -spec: - image: quay.io/konveyor/agent-base-goose-java:dev - providers: - - ref: gcp-vertex-ai - params: - - name: max_turns - type: number - default: "200" - - name: max_fix_iterations - type: number - default: "3" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: AgentRun -metadata: - name: coolstore-migration -spec: - agentRef: migration-harness - models: - - role: primary - provider: gcp-vertex-ai - model: claude-sonnet-4-5 - instructions: "Migrate this Java EE application to Quarkus" - env: - - name: GIT_REPO_URL - value: "https://github.com/savitharaghunathan/coolstore.git" - - name: GIT_TOKEN - valueFrom: - secretKeyRef: - name: git-credentials - key: token - - name: GCP_PROJECT_ID - value: "__GCP_PROJECT_ID__" - - name: GCP_LOCATION - value: "global" diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index cb33d24..60221b7 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -3,7 +3,6 @@ # # Prerequisites: # - Kind cluster running (make e2e-setup) -# - agent-base-goose-java image loaded into Kind # # Usage: # hack/harness-test/setup.sh @@ -13,6 +12,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CONTAINER_TOOL="${CONTAINER_TOOL:-podman}" +KIND_CLUSTER="${KIND_CLUSTER:-agentic-controller-e2e}" + echo "=== Creating secrets ===" # Vertex AI credentials from local ADC @@ -27,34 +29,68 @@ kubectl create secret generic vertex-credentials \ --dry-run=client -o yaml | kubectl apply -f - echo " vertex-credentials created" -# Git token from gh CLI -GIT_TOKEN=$(gh auth token 2>/dev/null || echo "") -if [ -z "$GIT_TOKEN" ]; then - echo "ERROR: Could not get GitHub token. Run: gh auth login" - exit 1 -fi -kubectl create secret generic git-credentials \ - --from-literal=token="$GIT_TOKEN" \ - --dry-run=client -o yaml | kubectl apply -f - -echo " git-credentials created" +# Hub token (JWT signed with default key "tackle") +HUB_KEY="${HUB_KEY:-tackle}" +EXP=$(( $(date +%s) + 86400 )) +HEADER_B64=$(printf '{"typ":"JWT","alg":"HS512"}' | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +PAYLOAD_B64=$(printf '{"sub":"admin","scope":"*:*","exp":%d}' "$EXP" | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +SIGNATURE=$(printf '%s.%s' "$HEADER_B64" "$PAYLOAD_B64" | openssl dgst -sha512 -hmac "$HUB_KEY" -binary | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +HUB_TOKEN="${HEADER_B64}.${PAYLOAD_B64}.${SIGNATURE}" +echo " hub token generated (expires in 24h)" echo "" -echo "=== Loading harness image into Kind ===" -CONTAINER_TOOL="${CONTAINER_TOOL:-podman}" -KIND_CLUSTER="${KIND_CLUSTER:-agentic-controller-e2e}" +echo "=== Building agent images ===" +make -C "$REPO_ROOT" agent-images-build CONTAINER_TOOL="$CONTAINER_TOOL" -HARNESS_IMG="quay.io/konveyor/agent-base-goose-java" -make -C "$REPO_ROOT" agent-java-goose-build CONTAINER_TOOL="$CONTAINER_TOOL" -$CONTAINER_TOOL tag "${HARNESS_IMG}:latest" "${HARNESS_IMG}:dev" +echo "" +echo "=== Building skill images ===" -if [ "$CONTAINER_TOOL" = "podman" ]; then - $CONTAINER_TOOL save "${HARNESS_IMG}:dev" -o /tmp/harness-image.tar - KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/harness-image.tar --name "$KIND_CLUSTER" - rm -f /tmp/harness-image.tar -else - kind load docker-image "${HARNESS_IMG}:dev" --name "$KIND_CLUSTER" -fi -echo " image loaded" +SKILL_IMAGE="quay.io/konveyor/skills" +SKILL_DIRS=(plan execute verify javaee-to-quarkus) + +for SKILL in "${SKILL_DIRS[@]}"; do + SKILL_PATH="$REPO_ROOT/skills/$SKILL" + if [ ! -d "$SKILL_PATH" ]; then + echo " WARN: skill dir $SKILL_PATH not found, skipping" + continue + fi + echo "FROM scratch +COPY . /" | $CONTAINER_TOOL build -t "${SKILL_IMAGE}:${SKILL}" -f - "$SKILL_PATH" + echo " built ${SKILL_IMAGE}:${SKILL}" +done + +echo "" +echo "=== Loading images into Kind ===" + +IMAGES=( + "quay.io/konveyor/agent-base" + "quay.io/konveyor/agent-java" +) + +for IMG in "${IMAGES[@]}"; do + $CONTAINER_TOOL tag "${IMG}:latest" "${IMG}:dev" + if [ "$CONTAINER_TOOL" = "podman" ]; then + $CONTAINER_TOOL save "${IMG}:dev" -o /tmp/agent-image.tar + KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/agent-image.tar --name "$KIND_CLUSTER" + rm -f /tmp/agent-image.tar + else + kind load docker-image "${IMG}:dev" --name "$KIND_CLUSTER" + fi + echo " loaded ${IMG}:dev" +done + +for SKILL in "${SKILL_DIRS[@]}"; do + if $CONTAINER_TOOL image inspect "${SKILL_IMAGE}:${SKILL}" >/dev/null 2>&1; then + if [ "$CONTAINER_TOOL" = "podman" ]; then + $CONTAINER_TOOL save "${SKILL_IMAGE}:${SKILL}" -o /tmp/skill-image.tar + KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/skill-image.tar --name "$KIND_CLUSTER" + rm -f /tmp/skill-image.tar + else + kind load docker-image "${SKILL_IMAGE}:${SKILL}" --name "$KIND_CLUSTER" + fi + echo " loaded ${SKILL_IMAGE}:${SKILL}" + fi +done echo "" echo "=== Applying resources ===" @@ -63,11 +99,17 @@ if [ -z "$GCP_PROJECT_ID" ]; then echo "ERROR: No GCP project set. Run: gcloud config set project " exit 1 fi -echo " GCP project: $GCP_PROJECT_ID" +echo " GCP project: (set)" sed "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/" "$SCRIPT_DIR/resources.yaml" | kubectl apply -f - +TIMESTAMP=$(date +%s) +sed -e "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/g" \ + -e "s/__TIMESTAMP__/$TIMESTAMP/g" \ + -e "s|__HUB_TOKEN__|$HUB_TOKEN|g" \ + "$SCRIPT_DIR/playbook-resources.yaml" | kubectl apply -f - +echo " AgentPlaybookRun: coolstore-migration-$TIMESTAMP" echo "" echo "=== Done ===" -echo "Watch the run: kubectl get agentrun coolstore-migration -w" +echo "Watch the run: kubectl get agentplaybookrun coolstore-migration-$TIMESTAMP -w" echo "Check pods: kubectl get pods" -echo "View logs: kubectl logs -f coolstore-migration -c agent" +echo "View logs: kubectl logs -f coolstore-migration-${TIMESTAMP}-plan -c agent" diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index a748764..25e0278 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -1,78 +1,41 @@ package main import ( - "bufio" "context" - "crypto/rand" + "encoding/json" "fmt" "os" - "os/exec" "os/signal" "path/filepath" - "strconv" "strings" "time" - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" "github.com/spf13/cobra" + gogit "github.com/go-git/go-git/v5" + "github.com/konveyor/migration-harness/internal/acp" "github.com/konveyor/migration-harness/internal/config" - "github.com/konveyor/migration-harness/internal/detect" - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/fixloop" "github.com/konveyor/migration-harness/internal/git" "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/handoff" + "github.com/konveyor/migration-harness/internal/hub" "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/metrics" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/rundir" - "github.com/konveyor/migration-harness/internal/verify" + "github.com/konveyor/migration-harness/internal/watcher" ) var rootCmd = &cobra.Command{ Use: "migration-harness", - Short: "AI-powered code migration CLI", - Long: "migration-harness orchestrates LLM agents through a 5-step pipeline (detect, plan, execute, verify, fix-loop) to automate enterprise code migrations.", -} - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Configure migration-harness", - RunE: runInit, + Short: "Thin git plumbing wrapper for goose-based migration stages", } var runCmd = &cobra.Command{ - Use: "run [repo-or-url] [request]", - Short: "Run a full migration pipeline", - Args: cobra.MaximumNArgs(2), - RunE: runMigration, -} - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of the latest migration run", - RunE: runStatus, -} - -var resumeCmd = &cobra.Command{ - Use: "resume", - Short: "Resume an incomplete migration", - RunE: runResume, -} - -var stepCmd = &cobra.Command{ - Use: "step ", - Short: "Run a single pipeline step", - Args: cobra.MinimumNArgs(2), - RunE: runStep, + Use: "run", + Short: "Run a single migration stage (plan, execute, or verify)", + RunE: runStage, } func init() { - runCmd.Flags().BoolVar(&plan.AutoApprove, "auto-approve", false, "Skip interactive plan approval") - rootCmd.AddCommand(initCmd, runCmd, statusCmd, resumeCmd, stepCmd) + rootCmd.AddCommand(runCmd) } func main() { @@ -81,409 +44,345 @@ func main() { } } -func checkDeps() error { - for _, dep := range []string{"goose", "graphify"} { - if _, err := exec.LookPath(dep); err != nil { - return fmt.Errorf("required command not found: %s", dep) - } +func runStage(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + // 1. Load config from env + cfg := config.LoadFromEnv() + if cfg == nil { + return fmt.Errorf("required env vars: KONVEYOR_MODEL_PRIMARY_MODEL, KONVEYOR_MODEL_PRIMARY_PROVIDER, HUB_BASE_URL, APP_ID") } - return nil -} -func runInit(cmd *cobra.Command, args []string) error { - logging.Header("Migration Harness Setup") + // 2. Resolve app info + git creds from Hub + cloneDir := os.Getenv("HARNESS_WORK_DIR") + if cloneDir == "" { + cloneDir = "/workspace/repo" + } - if err := checkDeps(); err != nil { - return err + creds, hubClient, err := resolveFromHub(cfg) + if err != nil { + return fmt.Errorf("hub resolution: %w", err) } - reader := bufio.NewReader(os.Stdin) + // Controller must set the target branch — Hub branch is the source, not the push target. + targetBranch := os.Getenv("TARGET_BRANCH") + if targetBranch == "" { + return fmt.Errorf("TARGET_BRANCH is required") + } + creds.Branch = targetBranch - provider := promptWithDefault(reader, "LLM provider (e.g. anthropic, gcp_vertex_ai, openai)", "anthropic") - model := promptWithDefault(reader, "Model name", "claude-sonnet-4-5") - maxTurnsStr := promptWithDefault(reader, "Max turns per step", strconv.Itoa(config.DefaultMaxTurns)) - maxFixStr := promptWithDefault(reader, "Max fix iterations", strconv.Itoa(config.DefaultMaxFixIterations)) + // 3. Clone, strip creds, checkout branch + logging.Header("Git Setup") + logging.Info("cloning %s...", creds.RepoURL) - maxTurns, err := strconv.Atoi(maxTurnsStr) - if err != nil { - maxTurns = config.DefaultMaxTurns - } - maxFix, err := strconv.Atoi(maxFixStr) + repo, err := git.Clone(ctx, creds, cloneDir) if err != nil { - maxFix = config.DefaultMaxFixIterations + return fmt.Errorf("clone: %w", err) } - cfg := &config.Config{ - Model: model, - Provider: provider, - MaxTurns: maxTurns, - MaxFixIterations: maxFix, + if err := git.StripCredentials(repo); err != nil { + return fmt.Errorf("strip credentials: %w", err) } + hub.ClearEnv() - path := config.DefaultConfigPath() - if err := config.Save(path, cfg); err != nil { - return fmt.Errorf("save config: %w", err) + if err := git.CheckoutBranch(repo, creds.Branch); err != nil { + return fmt.Errorf("checkout branch %s: %w", creds.Branch, err) } + logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) - logging.Ok("Config saved to %s", path) - return nil -} - -func promptWithDefault(reader *bufio.Reader, prompt, defaultVal string) string { - fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, defaultVal) - line, _ := reader.ReadString('\n') - line = strings.TrimSpace(line) - if line == "" { - return defaultVal + // 3b. Write analysis to workspace (if resolved from Hub) + if hubClient != nil { + if err := fetchAndWriteAnalysis(hubClient, cfg.AppID, cloneDir); err != nil { + logging.Warn("analysis fetch: %v", err) + } } - return line -} -func runMigration(cmd *cobra.Command, args []string) error { - var repoArg, request string - switch len(args) { - case 2: - repoArg = args[0] - request = args[1] - case 1: - repoArg = args[0] - } - if request == "" { - request = os.Getenv("KONVEYOR_INSTRUCTIONS") + // 4. Start goose serve + logging.Header("Goose Setup") + srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) + if err != nil { + return fmt.Errorf("start goose serve: %w", err) } - if request == "" { - return fmt.Errorf("request is required: pass as argument or set KONVEYOR_INSTRUCTIONS") + defer srv.Stop() + + // 5. Connect ACP, create session + wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) + if err != nil { + return fmt.Errorf("connect to goose: %w", err) } - if repoArg == "" { - repoArg = os.Getenv("GIT_REPO_URL") + defer wsClient.Close() + + session := acp.NewSessionClient(wsClient) + sessionID, err := session.CreateSession(ctx, cloneDir, nil) + if err != nil { + return fmt.Errorf("create session: %w", err) } - if repoArg == "" { - return fmt.Errorf("repo is required: pass as argument or set GIT_REPO_URL") + + // 6. Discover skills + skillContent, skillPaths, err := discoverSkills() + if err != nil { + return fmt.Errorf("discover skills: %w", err) } - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() + // 7. Build prompt from context layers + prompt := buildPrompt(skillContent) - cfgPath := config.DefaultConfigPath() - cfg, err := config.Load(cfgPath) + // 8. Start filesystem watcher BEFORE blocking prompt + commitPush := func() error { + if _, err := git.CommitAll(repo, "konveyor: auto-commit progress"); err != nil { + return err + } + return git.Push(ctx, creds, repo, creds.Branch) + } + w, err := watcher.New(cloneDir, commitPush) if err != nil { - return fmt.Errorf("load config (run 'migration-harness init' first): %w", err) + return fmt.Errorf("create watcher: %w", err) + } + if err := w.Start(ctx); err != nil { + return fmt.Errorf("start watcher: %w", err) } + defer w.Stop() - tracker := metrics.NewTracker() + // 9. Send single ACP prompt (blocks until goose finishes or MaxTurns is hit) + logging.Header("Running Stage") + logging.Info("max turns: %d", cfg.MaxTurns) + _, err = session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ + {Type: "text", Text: prompt}, + }, cfg.MaxTurns) - creds, err := git.ReadFromEnv() if err != nil { - return fmt.Errorf("git credentials: %w", err) + logging.Err("prompt failed: %v", err) } - var workDir string - var repo *gogit.Repository + if !srv.Alive() { + logging.Err("goose serve crashed") + } - if creds != nil { - tracker.StartStep("git-clone") - logging.Header("Git Setup") - logging.Info("cloning %s...", creds.RepoURL) + // 10. Stop watcher + w.Stop() - tmpBase := os.Getenv("HARNESS_WORK_DIR") - if tmpBase == "" { - tmpBase = os.TempDir() - } - workDir, err = os.MkdirTemp(tmpBase, "migration-harness-"+filepath.Base(creds.RepoURL)+"-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - repo, err = git.Clone(ctx, creds, workDir) - if err != nil { - return fmt.Errorf("clone: %w", err) - } + // 11. Determine exit status from ACP/goose signals + stageFailed := err != nil || !srv.Alive() - if err := git.StripCredentials(repo); err != nil { - return fmt.Errorf("strip credentials: %w", err) - } - - git.ClearEnvCredentials() + status := "succeeded" + if stageFailed { + status = "failed" + } - if err := git.CheckoutBranch(repo, creds.Branch); err != nil { - return fmt.Errorf("checkout branch: %w", err) - } + // 11b. Write handoff.md for next stage + if err := writeHandoff(cloneDir, skillPaths, status, repo); err != nil { + logging.Warn("handoff: %v", err) + } - logging.Ok("cloned to %s, branch %s", workDir, creds.Branch) - tracker.EndStep() - } else { - workDir = repoArg - if _, err := os.Stat(workDir); err != nil { - return fmt.Errorf("repo path does not exist: %s", workDir) - } + // 12. Final commit + push + logging.Header("Final Push") + if _, err := git.CommitAll(repo, "konveyor: stage complete"); err != nil { + logging.Warn("final commit: %v", err) + } + if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { + return fmt.Errorf("final push: %w", err) } - runsDir := rundir.DefaultRunsDir() - runDir, err := rundir.New(runsDir, filepath.Base(workDir)) - if err != nil { - return fmt.Errorf("create run dir: %w", err) + // 13. Exit + if stageFailed { + logging.Err("stage failed") + return fmt.Errorf("stage failed") } - logging.Info("run directory: %s", runDir) + logging.Ok("stage succeeded") + return nil +} - installDir := findInstallDir() - skillDir := filepath.Join(installDir, "skill-bundle", "goose-migration") - recipesDir := filepath.Join(installDir, "recipes") - logDir := filepath.Join(runDir, "logs") +const defaultSkillsDir = "/opt/skills" - srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) - if err != nil { - return fmt.Errorf("start goose serve: %w", err) +func skillsDir() string { + if v := os.Getenv("HARNESS_SKILLS_DIR"); v != "" { + return v } - defer srv.Stop() + return defaultSkillsDir +} - wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) +func discoverSkills() (string, []string, error) { + pattern := filepath.Join(skillsDir(), "*/SKILL.md") + matches, err := filepath.Glob(pattern) if err != nil { - return fmt.Errorf("connect to goose serve: %w", err) + return "", nil, err + } + if len(matches) == 0 { + return "", nil, fmt.Errorf("no skills found at %s", pattern) } - defer wsClient.Close() - - acpSession := acp.NewSessionClient(wsClient) - runner := goose.NewACPRunner(acpSession, srv, cfg.Provider, cfg.Model, logDir, workDir) - var pushFn func() error - if creds != nil && repo != nil { - pushFn = func() error { - return git.Push(ctx, creds, repo, creds.Branch) + var combined strings.Builder + for i, m := range matches { + content, err := os.ReadFile(m) + if err != nil { + return "", nil, fmt.Errorf("read skill %s: %w", m, err) + } + logging.Info("discovered skill: %s", m) + if i > 0 { + combined.WriteString("\n\n---\n\n") } + combined.Write(content) } + return combined.String(), matches, nil +} - session := handoff.NewSession( - generateSessionID(), - request, - repoArg, - branchName(creds), - cfg.Model, - cfg.Provider, - ) +func buildPrompt(skillContent string) string { + var b strings.Builder - pipelineStatus := "completed" + if v := os.Getenv("KONVEYOR_PROMPT"); v != "" { + b.WriteString(v) + b.WriteString("\n\n") + } - syncSession := func(stepMsg string) { - session.Status = "in_progress" - if err := handoff.WriteSession(workDir, session); err != nil { - logging.Warn("write session.json: %v", err) - } - commitAndPush(repo, pushFn, stepMsg) + if v := os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS"); v != "" { + b.WriteString("## Migration Context\n\n") + b.WriteString(v) + b.WriteString("\n\n") } - defer func() { - session.Status = pipelineStatus + b.WriteString("## Skill Instructions\n\n") + b.WriteString(skillContent) + b.WriteString("\n\n") - if err := handoff.WriteSession(workDir, session); err != nil { - logging.Warn("write session.json: %v", err) - } + if v := os.Getenv("KONVEYOR_INSTRUCTIONS"); v != "" { + b.WriteString("## Stage Task\n\n") + b.WriteString(v) + } - m := tracker.Generate(pipelineStatus, cfg.Model, cfg.Provider) - if err := metrics.WriteMetrics(runDir, m); err != nil { - logging.Warn("write metrics: %v", err) - } + return b.String() +} - if creds != nil && repo != nil { - if _, err := git.CommitAll(repo, "konveyor: session handoff"); err != nil { - logging.Warn("commit handoff: %v", err) - } - if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { - logging.Warn("push: %v", err) - } - } - }() +func skillName(path string) string { + return filepath.Base(filepath.Dir(path)) +} - // Step 1: Detect - tracker.StartStep("detect") - detectResult, err := detect.Run(ctx, workDir, runDir) - if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("detect: %w", err) - } - session.Pipeline.Detect = &handoff.DetectStatus{ - StepStatus: handoff.StepStatus{ - Status: "completed", - DurationSeconds: tracker.StepDuration("detect"), - }, - Nodes: detectResult.Graph.Nodes, - Edges: detectResult.Graph.Edges, - Communities: detectResult.Graph.Communities, - } - tracker.EndStep() - syncSession("konveyor: detect complete") - - // Step 2: Plan - tracker.StartStep("plan") - p, err := plan.Run(ctx, workDir, runDir, request, skillDir, runner) - if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("plan: %w", err) - } - session.Pipeline.Plan = &handoff.PlanStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("plan")}, - ItemsPlanned: len(p.Items), +func writeHandoff(workDir string, skills []string, status string, repo *gogit.Repository) error { + handoffPath := filepath.Join(workDir, ".konveyor", "handoff.md") + if err := os.MkdirAll(filepath.Dir(handoffPath), 0o755); err != nil { + return fmt.Errorf("create .konveyor dir: %w", err) } - tracker.EndStep() - syncSession("konveyor: plan complete") - // Step 3: Execute - tracker.StartStep("execute") - items, summary, execCommits, err := execute.Run(ctx, workDir, runDir, recipesDir, p, runner, repo, pushFn) - if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("execute: %w", err) - } - for _, c := range execCommits { - session.Commits = append(session.Commits, handoff.CommitRecord{ - SHA: c.SHA, Message: c.Message, Step: c.Step, - }) - } - session.Pipeline.Execute = &handoff.ExecuteStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("execute")}, - ItemsSucceeded: summary.Ok, - ItemsFailed: summary.Failed, - ItemsSkipped: summary.Skipped, - } - tracker.EndStep() - syncSession("konveyor: execute complete") - - // Step 4: Verify - tracker.StartStep("verify") - vr, err := verify.Run(ctx, workDir, runDir, recipesDir, p.MigrationType, runner) - if err != nil { - logging.Warn("verify: %v", err) - } - verifyOk := vr != nil && vr.BuildOk - session.Pipeline.Verify = &handoff.VerifyStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("verify")}, - BuildOk: verifyOk, - TestsPassed: safeTestsPassed(vr), - } - tracker.EndStep() - syncSession("konveyor: verify complete") + existing, _ := os.ReadFile(handoffPath) - // Step 5: Fix Loop - tracker.StartStep("fix-loop") - flReport, flCommits, err := fixloop.Run(ctx, workDir, runDir, recipesDir, p.MigrationType, cfg.MaxFixIterations, runner, repo, pushFn) - if err != nil { - logging.Warn("fix-loop: %v", err) - } - for _, c := range flCommits { - session.Commits = append(session.Commits, handoff.CommitRecord{ - SHA: c.SHA, Message: c.Message, Step: c.Iter, - }) - } - session.Pipeline.FixLoop = &handoff.FixLoopStatus{ - StepStatus: handoff.StepStatus{Status: fixLoopStatus(flReport)}, - Iterations: fixLoopIterations(flReport), + var b strings.Builder + + if len(existing) > 0 { + b.Write(existing) + b.WriteString("\n---\n\n") } - tracker.EndStep() - // Write handoff - if err := handoff.WriteHandoff(workDir, session, p, items, vr); err != nil { - logging.Warn("write handoff: %v", err) + fmt.Fprintf(&b, "**Status:** %s \n", status) + fmt.Fprintf(&b, "**Completed:** %s\n", time.Now().UTC().Format(time.RFC3339)) + + b.WriteString("\n### Skills\n\n") + for _, s := range skills { + fmt.Fprintf(&b, "- %s\n", skillName(s)) } - if !verifyOk && (flReport == nil || flReport.Status != "success") { - pipelineStatus = "partial" + if n := changedFileCount(repo); n > 0 { + fmt.Fprintf(&b, "\n**Files changed:** %d\n", n) } - logging.Header("Migration Complete") - logging.Ok("status: %s", pipelineStatus) - logging.Info("run dir: %s", runDir) + if err := os.WriteFile(handoffPath, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("write handoff.md: %w", err) + } + logging.Ok("wrote %s", handoffPath) return nil } -func runStatus(cmd *cobra.Command, args []string) error { - runsDir := rundir.DefaultRunsDir() - latest, err := rundir.Latest(runsDir) +func resolveFromHub(cfg *config.Config) (*git.Credentials, *hub.Client, error) { + logging.Header("Hub Resolution") + + appID, err := hub.ParseAppID(cfg.AppID) if err != nil { - return fmt.Errorf("no runs found: %w", err) + return nil, nil, fmt.Errorf("invalid APP_ID %q: %w", cfg.AppID, err) } - logging.Info("latest run: %s", latest) - metricsPath := filepath.Join(latest, "metrics.json") - if data, err := os.ReadFile(metricsPath); err == nil { - fmt.Fprintln(os.Stderr, string(data)) - } else { - logging.Warn("no metrics.json found") - } - return nil -} + hubClient := hub.NewClient(cfg.HubBaseURL, cfg.HubToken) -func runResume(cmd *cobra.Command, args []string) error { - return fmt.Errorf("resume is not yet implemented") -} + app, err := hubClient.FetchApp(appID) + if err != nil { + return nil, nil, fmt.Errorf("fetch app: %w", err) + } + logging.Ok("app: %s (id=%d), repo: %s", app.Name, app.ID, app.Repository.URL) -func runStep(cmd *cobra.Command, args []string) error { - return fmt.Errorf("step execution is not yet implemented") -} + identity, err := hubClient.FetchGitCreds(appID) + if err != nil { + return nil, nil, fmt.Errorf("fetch git creds: %w", err) + } -func findInstallDir() string { - // Environment override takes priority (set by Dockerfile or operator) - if dir := os.Getenv("KONVEYOR_INSTALL_DIR"); dir != "" { - return dir + creds := &git.Credentials{ + RepoURL: app.Repository.URL, + Branch: app.Repository.Branch, } - // Default: go up one level from the binary location. - // Binary at /opt/migration-harness/bin/migration-harness - // → installDir = /opt/migration-harness/ - // This matches the bash version's behavior. - exe, err := os.Executable() - if err != nil { - return "." + if identity != nil { + creds.Username = identity.User + creds.Token = identity.Password + if creds.Username == "" { + creds.Username = "x-access-token" + } + logging.Ok("git identity: %s", identity.Name) } - return filepath.Dir(filepath.Dir(exe)) + + return creds, hubClient, nil } -func branchName(creds *git.Credentials) string { - if creds != nil { - return creds.Branch +func fetchAndWriteAnalysis(hubClient *hub.Client, appIDStr string, workDir string) error { + appID, _ := hub.ParseAppID(appIDStr) + insights, err := hubClient.FetchAnalysis(appID) + if err != nil { + return err + } + if len(insights) == 0 { + logging.Info("no analysis results for app %s", appIDStr) + return nil } - return "" -} -func safeTestsPassed(vr *verify.VerifyResult) int { - if vr != nil { - return vr.TestsPassed + analysisDir := filepath.Join(workDir, ".konveyor") + if err := os.MkdirAll(analysisDir, 0o755); err != nil { + return fmt.Errorf("create .konveyor dir: %w", err) } - return 0 -} -func fixLoopStatus(r *fixloop.FixLoopReport) string { - if r != nil { - return r.Status + data, err := json.MarshalIndent(insights, "", " ") + if err != nil { + return fmt.Errorf("marshal analysis: %w", err) } - return "skipped" -} -func fixLoopIterations(r *fixloop.FixLoopReport) int { - if r != nil { - return r.Iterations + analysisPath := filepath.Join(analysisDir, "analysis.json") + if err := os.WriteFile(analysisPath, data, 0o644); err != nil { + return fmt.Errorf("write analysis: %w", err) } - return 0 + + logging.Ok("wrote %d analysis insights to %s", len(insights), analysisPath) + return nil } -func commitAndPush(repo *gogit.Repository, pushFn func() error, msg string) { - if repo == nil { - return - } - hash, err := git.CommitAll(repo, msg) +var excludeDirs = []string{"graphify-out/", ".konveyor/", "target/", ".git/"} + +func changedFileCount(repo *gogit.Repository) int { + wt, err := repo.Worktree() if err != nil { - logging.Warn("commit (%s): %v", msg, err) - return + return 0 } - if hash == (plumbing.Hash{}) { - return - } - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("push (%s): %v", msg, err) + status, err := wt.Status() + if err != nil { + return 0 + } + n := 0 + for path := range status { + excluded := false + for _, prefix := range excludeDirs { + if strings.HasPrefix(path, prefix) { + excluded = true + break + } + } + if !excluded { + n++ } } + return n } -func generateSessionID() string { - b := make([]byte, 16) - rand.Read(b) - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) -} diff --git a/harness/go.mod b/harness/go.mod index f654fc2..e9f703a 100644 --- a/harness/go.mod +++ b/harness/go.mod @@ -3,7 +3,10 @@ module github.com/konveyor/migration-harness go 1.26.2 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/go-git/go-git/v5 v5.19.1 + github.com/gorilla/websocket v1.5.3 + github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 ) @@ -17,22 +20,23 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jortel/go-utils v0.1.5 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/harness/go.sum b/harness/go.sum index 3d7369e..13125e2 100644 --- a/harness/go.sum +++ b/harness/go.sum @@ -21,6 +21,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -31,6 +33,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -41,10 +45,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jortel/go-utils v0.1.5 h1:fBkvlojnbrx5rqJt+1fx9dlGV5NjjnCyGgWyXsQ12Ws= +github.com/jortel/go-utils v0.1.5/go.mod h1:R9W67T6eTYPcofmSuvvv3lOcNuF4zh7ZoBfaoTA9zss= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703 h1:Gzjt6DhN/qj276MkBLTc88vMV5q4Nvj6tWWu0OG/gHU= +github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703/go.mod h1:DBpy7S/qWyOmxK9QaqiHUHKkQ3gMRXXnEY8/FsA6fgA= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -66,6 +74,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -87,13 +97,13 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -106,8 +116,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/harness/internal/acp/session.go b/harness/internal/acp/session.go index 4261e5a..b32c6bf 100644 --- a/harness/internal/acp/session.go +++ b/harness/internal/acp/session.go @@ -153,8 +153,9 @@ type PromptUsage struct { } // SendPrompt sends a prompt to a session and collects the streaming -// response. Returns the final result with all collected message chunks. -func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, content []ContentBlock) (*PromptResult, error) { +// response. maxTurns limits the number of tool calls before the prompt +// is terminated. If maxTurns is 0, no limit is enforced. +func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, content []ContentBlock, maxTurns int) (*PromptResult, error) { req := newRequest("session/prompt", &PromptParams{ SessionID: sessionID, Prompt: content, @@ -165,6 +166,7 @@ func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, conten } result := &PromptResult{} + turnCount := 0 for { select { @@ -174,6 +176,13 @@ func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, conten return nil, fmt.Errorf("websocket connection closed during prompt") case msg := <-c.ws.Recv(): if msg.IsNotification() { + if isToolCall(msg) { + turnCount++ + if maxTurns > 0 && turnCount >= maxTurns { + logging.Warn("max turns reached (%d), terminating", maxTurns) + return result, fmt.Errorf("max turns reached (%d)", maxTurns) + } + } handlePromptNotification(msg, result) continue } @@ -205,6 +214,21 @@ func extractSessionIDFromNotifications(notifications []*RPCResponse) string { return "" } +func isToolCall(msg *RPCResponse) bool { + if msg.Method != "session/update" { + return false + } + var params struct { + Update struct { + SessionUpdate string `json:"sessionUpdate"` + } `json:"update"` + } + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + return false + } + return params.Update.SessionUpdate == "tool_call" +} + func handlePromptNotification(msg *RPCResponse, result *PromptResult) { if msg.Method != "session/update" { return diff --git a/harness/internal/config/config.go b/harness/internal/config/config.go index fa44baa..9eb03d6 100644 --- a/harness/internal/config/config.go +++ b/harness/internal/config/config.go @@ -1,12 +1,8 @@ package config import ( - "bufio" - "fmt" "os" - "path/filepath" "strconv" - "strings" ) const ( @@ -21,19 +17,12 @@ type Config struct { APIKey string MaxTurns int MaxFixIterations int -} - -func DefaultHome() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".migration-harness") -} -func DefaultConfigPath() string { - return filepath.Join(DefaultHome(), "config") + HubBaseURL string + HubToken string + AppID string } -// LoadFromEnv builds a Config from the KONVEYOR_MODEL_* env vars injected -// by the agentic controller. Returns nil if the env vars are not set. func LoadFromEnv() *Config { model := os.Getenv("KONVEYOR_MODEL_PRIMARY_MODEL") provider := os.Getenv("KONVEYOR_MODEL_PRIMARY_PROVIDER") @@ -57,89 +46,15 @@ func LoadFromEnv() *Config { cfg.MaxFixIterations = n } - return cfg -} - -func Load(path string) (*Config, error) { - if cfg := LoadFromEnv(); cfg != nil { - return cfg, nil - } - - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open config: %w", err) - } - defer f.Close() - - cfg := &Config{ - MaxTurns: DefaultMaxTurns, - MaxFixIterations: DefaultMaxFixIterations, - } - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - key, value, ok := parseConfigLine(line) - if !ok { - continue - } - - switch key { - case "MH_MODEL": - cfg.Model = value - case "MH_PROVIDER": - cfg.Provider = value - case "MH_MAX_TURNS": - if n, err := strconv.Atoi(value); err == nil { - cfg.MaxTurns = n - } - case "MH_MAX_FIX_ITERATIONS": - if n, err := strconv.Atoi(value); err == nil { - cfg.MaxFixIterations = n - } - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - - if cfg.Model == "" { - return nil, fmt.Errorf("MH_MODEL is required in %s", path) - } - if cfg.Provider == "" { - return nil, fmt.Errorf("MH_PROVIDER is required in %s", path) - } - - return cfg, nil -} - -func Save(path string, cfg *Config) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("create config dir: %w", err) + cfg.HubBaseURL = os.Getenv("HUB_BASE_URL") + if cfg.HubBaseURL == "" { + return nil } - - content := fmt.Sprintf( - "MH_MODEL=\"%s\"\nMH_PROVIDER=\"%s\"\nMH_MAX_TURNS=\"%d\"\nMH_MAX_FIX_ITERATIONS=\"%d\"\n", - cfg.Model, cfg.Provider, cfg.MaxTurns, cfg.MaxFixIterations, - ) - - if err := os.WriteFile(path, []byte(content), 0600); err != nil { - return fmt.Errorf("write config: %w", err) + cfg.HubToken = os.Getenv("HUB_TOKEN") + cfg.AppID = os.Getenv("APP_ID") + if cfg.AppID == "" { + return nil } - return nil -} -func parseConfigLine(line string) (key, value string, ok bool) { - key, value, ok = strings.Cut(line, "=") - if !ok { - return "", "", false - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - value = strings.Trim(value, `"'`) - return key, value, true + return cfg } diff --git a/harness/internal/config/config_test.go b/harness/internal/config/config_test.go index 5b3142c..ed363dd 100644 --- a/harness/internal/config/config_test.go +++ b/harness/internal/config/config_test.go @@ -2,120 +2,9 @@ package config import ( "os" - "path/filepath" "testing" ) -func TestRoundTrip(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - original := &Config{ - Model: "gemini-2.5-pro", - Provider: "gcp_vertex_ai", - MaxTurns: 300, - MaxFixIterations: 5, - } - - if err := Save(path, original); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if loaded.Model != original.Model { - t.Errorf("Model = %q, want %q", loaded.Model, original.Model) - } - if loaded.Provider != original.Provider { - t.Errorf("Provider = %q, want %q", loaded.Provider, original.Provider) - } - if loaded.MaxTurns != original.MaxTurns { - t.Errorf("MaxTurns = %d, want %d", loaded.MaxTurns, original.MaxTurns) - } - if loaded.MaxFixIterations != original.MaxFixIterations { - t.Errorf("MaxFixIterations = %d, want %d", loaded.MaxFixIterations, original.MaxFixIterations) - } -} - -func TestLoadDefaults(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_MODEL=\"test-model\"\nMH_PROVIDER=\"test-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if cfg.MaxTurns != DefaultMaxTurns { - t.Errorf("MaxTurns = %d, want default %d", cfg.MaxTurns, DefaultMaxTurns) - } - if cfg.MaxFixIterations != DefaultMaxFixIterations { - t.Errorf("MaxFixIterations = %d, want default %d", cfg.MaxFixIterations, DefaultMaxFixIterations) - } -} - -func TestLoadMissingModel(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_PROVIDER=\"test-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - _, err := Load(path) - if err == nil { - t.Fatal("expected error for missing MH_MODEL") - } -} - -func TestLoadMissingProvider(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_MODEL=\"test-model\"\n" - os.WriteFile(path, []byte(content), 0600) - - _, err := Load(path) - if err == nil { - t.Fatal("expected error for missing MH_PROVIDER") - } -} - -func TestLoadMissingFile(t *testing.T) { - clearKonveyorEnv(t) - _, err := Load("/nonexistent/path/config") - if err == nil { - t.Fatal("expected error for missing file") - } -} - -func TestSavePermissions(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config") - - cfg := &Config{Model: "m", Provider: "p", MaxTurns: 100, MaxFixIterations: 2} - if err := Save(path, cfg); err != nil { - t.Fatalf("Save: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat: %v", err) - } - if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("permissions = %o, want 0600", perm) - } -} - func clearKonveyorEnv(t *testing.T) { t.Helper() for _, k := range []string{ @@ -125,15 +14,25 @@ func clearKonveyorEnv(t *testing.T) { "KONVEYOR_MODEL_PRIMARY_API_KEY", "KONVEYOR_PARAM_MAX_TURNS", "KONVEYOR_PARAM_MAX_FIX_ITERATIONS", + "HUB_BASE_URL", + "HUB_TOKEN", + "APP_ID", } { t.Setenv(k, "") os.Unsetenv(k) } } +func setHubEnv(t *testing.T) { + t.Helper() + t.Setenv("HUB_BASE_URL", "https://hub.example.com") + t.Setenv("APP_ID", "42") +} + func TestLoadFromEnv(t *testing.T) { t.Run("returns config from env", func(t *testing.T) { clearKonveyorEnv(t) + setHubEnv(t) t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "claude-sonnet-4-5") t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "anthropic") t.Setenv("KONVEYOR_MODEL_PRIMARY_ENDPOINT", "https://api.anthropic.com") @@ -170,6 +69,7 @@ func TestLoadFromEnv(t *testing.T) { }) t.Run("reads optional param overrides", func(t *testing.T) { + setHubEnv(t) t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "gemini-2.5-pro") t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "gcp_vertex_ai") t.Setenv("KONVEYOR_PARAM_MAX_TURNS", "500") @@ -187,47 +87,3 @@ func TestLoadFromEnv(t *testing.T) { } }) } - -func TestLoadPrefersEnvOverFile(t *testing.T) { - t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "env-model") - t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "env-provider") - - dir := t.TempDir() - path := filepath.Join(dir, "config") - content := "MH_MODEL=\"file-model\"\nMH_PROVIDER=\"file-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Model != "env-model" { - t.Errorf("Model = %q, want %q (env should take precedence)", cfg.Model, "env-model") - } - if cfg.Provider != "env-provider" { - t.Errorf("Provider = %q, want %q (env should take precedence)", cfg.Provider, "env-provider") - } -} - -func TestParseConfigLineVariants(t *testing.T) { - tests := []struct { - line string - wantKey string - wantVal string - wantOK bool - }{ - {`MH_MODEL="claude"`, "MH_MODEL", "claude", true}, - {`MH_MODEL='claude'`, "MH_MODEL", "claude", true}, - {`MH_MODEL=claude`, "MH_MODEL", "claude", true}, - {`# comment`, "", "", false}, - {`no-equals-sign`, "", "", false}, - } - - for _, tt := range tests { - key, val, ok := parseConfigLine(tt.line) - if ok != tt.wantOK || key != tt.wantKey || val != tt.wantVal { - t.Errorf("parseConfigLine(%q) = (%q, %q, %v), want (%q, %q, %v)", - tt.line, key, val, ok, tt.wantKey, tt.wantVal, tt.wantOK) - } - } -} diff --git a/harness/internal/detect/detect.go b/harness/internal/detect/detect.go deleted file mode 100644 index 60aa41a..0000000 --- a/harness/internal/detect/detect.go +++ /dev/null @@ -1,251 +0,0 @@ -package detect - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -type Manifests struct { - PomXML bool `json:"pom_xml"` - PackageJSON bool `json:"package_json"` - PyprojectTOML bool `json:"pyproject_toml"` - RequirementsTXT bool `json:"requirements_txt"` - SetupPy bool `json:"setup_py"` - GoMod bool `json:"go_mod"` - CargoTOML bool `json:"cargo_toml"` - Gemfile bool `json:"gemfile"` -} - -type FileCounts struct { - Java int `json:"java"` - Python int `json:"python"` - JavaScript int `json:"javascript"` - TypeScript int `json:"typescript"` - Go int `json:"go"` - Rust int `json:"rust"` - CSharp int `json:"csharp"` - Ruby int `json:"ruby"` -} - -type GraphStats struct { - Nodes int `json:"nodes"` - Edges int `json:"edges"` - Communities int `json:"communities"` - GodNodes int `json:"god_nodes"` -} - -type DetectResult struct { - Repo string `json:"repo"` - Manifests Manifests `json:"manifests"` - Files FileCounts `json:"files"` - Graph GraphStats `json:"graph"` - GraphFile string `json:"graph_file"` -} - -type GraphJSON struct { - Nodes []GraphNode `json:"nodes"` - Links []GraphLink `json:"links"` - Communities []GraphCommunity `json:"communities"` -} - -type GraphNode struct { - ID string `json:"id"` - Label string `json:"label"` - SourceFile string `json:"source_file"` - Degree int `json:"degree"` -} - -type GraphLink struct { - Source string `json:"source"` - Target string `json:"target"` - Relation string `json:"relation"` -} - -type GraphCommunity struct { - ID any `json:"id"` - Nodes []string `json:"nodes"` -} - -func Run(ctx context.Context, repoDir, runDir string) (*DetectResult, error) { - logging.Header("Step 1: Detect") - - ensureGitignore(repoDir) - - logging.Info("1a. Checking manifest files...") - manifests := checkManifests(repoDir) - - logging.Info("1b. Building code graph...") - if err := runGraphify(ctx, repoDir); err != nil { - return nil, fmt.Errorf("graphify: %w", err) - } - - graphifyOut := filepath.Join(repoDir, "graphify-out") - graphPath := filepath.Join(graphifyOut, "graph.json") - reportPath := filepath.Join(graphifyOut, "GRAPH_REPORT.md") - - graph, err := parseGraphJSON(graphPath) - if err != nil { - return nil, fmt.Errorf("parse graph.json: %w", err) - } - - logging.Info("1c. Counting source files...") - files := countFiles(graph) - - stats := computeStats(graph) - - result := &DetectResult{ - Repo: repoDir, - Manifests: manifests, - Files: files, - Graph: stats, - GraphFile: "graph.json", - } - - // Copy artifacts to run dir - copyFile(graphPath, filepath.Join(runDir, "graph.json")) - copyFile(reportPath, filepath.Join(runDir, "GRAPH_REPORT.md")) - - detectPath := filepath.Join(runDir, "detect.json") - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal detect.json: %w", err) - } - if err := os.WriteFile(detectPath, data, 0644); err != nil { - return nil, fmt.Errorf("write detect.json: %w", err) - } - - logging.Ok("Detect complete: %d nodes, %d edges, %d communities", - stats.Nodes, stats.Edges, stats.Communities) - return result, nil -} - -func checkManifests(repoDir string) Manifests { - m := Manifests{} - check := func(file string) bool { - _, err := os.Stat(filepath.Join(repoDir, file)) - return err == nil - } - m.PomXML = check("pom.xml") - m.PackageJSON = check("package.json") - m.PyprojectTOML = check("pyproject.toml") - m.RequirementsTXT = check("requirements.txt") - m.SetupPy = check("setup.py") - m.GoMod = check("go.mod") - m.CargoTOML = check("Cargo.toml") - m.Gemfile = check("Gemfile") - return m -} - -func runGraphify(ctx context.Context, repoDir string) error { - graphifyPath, err := exec.LookPath("graphify") - if err != nil { - return fmt.Errorf("graphify not found in PATH: %w", err) - } - - cmd := exec.CommandContext(ctx, graphifyPath, "update", repoDir) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("graphify update: %w", err) - } - - graphPath := filepath.Join(repoDir, "graphify-out", "graph.json") - if _, err := os.Stat(graphPath); os.IsNotExist(err) { - return fmt.Errorf("graphify did not produce graph.json at %s", graphPath) - } - - return nil -} - -func parseGraphJSON(path string) (*GraphJSON, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var g GraphJSON - if err := json.Unmarshal(data, &g); err != nil { - return nil, err - } - return &g, nil -} - -func countFiles(graph *GraphJSON) FileCounts { - fc := FileCounts{} - for _, node := range graph.Nodes { - sf := strings.ToLower(node.SourceFile) - switch { - case strings.HasSuffix(sf, ".java"): - fc.Java++ - case strings.HasSuffix(sf, ".py"): - fc.Python++ - case strings.HasSuffix(sf, ".js") || strings.HasSuffix(sf, ".jsx"): - fc.JavaScript++ - case strings.HasSuffix(sf, ".ts") || strings.HasSuffix(sf, ".tsx"): - fc.TypeScript++ - case strings.HasSuffix(sf, ".go"): - fc.Go++ - case strings.HasSuffix(sf, ".rs"): - fc.Rust++ - case strings.HasSuffix(sf, ".cs"): - fc.CSharp++ - case strings.HasSuffix(sf, ".rb"): - fc.Ruby++ - } - } - return fc -} - -func computeStats(graph *GraphJSON) GraphStats { - godCount := 0 - for _, node := range graph.Nodes { - if node.Degree > 20 { - godCount++ - } - } - return GraphStats{ - Nodes: len(graph.Nodes), - Edges: len(graph.Links), - Communities: len(graph.Communities), - GodNodes: godCount, - } -} - -func ensureGitignore(repoDir string) { - path := filepath.Join(repoDir, ".gitignore") - entry := "graphify-out/" - - data, _ := os.ReadFile(path) - if strings.Contains(string(data), entry) { - return - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - - if len(data) > 0 && data[len(data)-1] != '\n' { - f.WriteString("\n") - } - f.WriteString(entry + "\n") -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - logging.Warn("copyFile: read %s: %v", src, err) - return - } - if err := os.WriteFile(dst, data, 0644); err != nil { - logging.Warn("copyFile: write %s: %v", dst, err) - } -} diff --git a/harness/internal/detect/detect_test.go b/harness/internal/detect/detect_test.go deleted file mode 100644 index 5c7daaf..0000000 --- a/harness/internal/detect/detect_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package detect - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestCheckManifests(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(""), 0644) - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) - - m := checkManifests(dir) - - if !m.PomXML { - t.Error("expected pom.xml detected") - } - if !m.GoMod { - t.Error("expected go.mod detected") - } - if m.PackageJSON { - t.Error("expected package.json NOT detected") - } - if m.CargoTOML { - t.Error("expected Cargo.toml NOT detected") - } -} - -func TestCountFiles(t *testing.T) { - graph := &GraphJSON{ - Nodes: []GraphNode{ - {ID: "1", SourceFile: "src/main/java/Foo.java"}, - {ID: "2", SourceFile: "src/main/java/Bar.java"}, - {ID: "3", SourceFile: "app.py"}, - {ID: "4", SourceFile: "index.js"}, - {ID: "5", SourceFile: "App.tsx"}, - {ID: "6", SourceFile: "main.go"}, - }, - } - - fc := countFiles(graph) - - if fc.Java != 2 { - t.Errorf("Java = %d, want 2", fc.Java) - } - if fc.Python != 1 { - t.Errorf("Python = %d, want 1", fc.Python) - } - if fc.JavaScript != 1 { - t.Errorf("JavaScript = %d, want 1", fc.JavaScript) - } - if fc.TypeScript != 1 { - t.Errorf("TypeScript = %d, want 1", fc.TypeScript) - } - if fc.Go != 1 { - t.Errorf("Go = %d, want 1", fc.Go) - } -} - -func TestComputeStats(t *testing.T) { - graph := &GraphJSON{ - Nodes: []GraphNode{ - {ID: "1", Degree: 5}, - {ID: "2", Degree: 25}, - {ID: "3", Degree: 30}, - {ID: "4", Degree: 10}, - }, - Links: []GraphLink{ - {Source: "1", Target: "2"}, - {Source: "2", Target: "3"}, - {Source: "3", Target: "4"}, - }, - Communities: []GraphCommunity{ - {ID: 0, Nodes: []string{"1", "2"}}, - {ID: 1, Nodes: []string{"3", "4"}}, - }, - } - - stats := computeStats(graph) - - if stats.Nodes != 4 { - t.Errorf("Nodes = %d, want 4", stats.Nodes) - } - if stats.Edges != 3 { - t.Errorf("Edges = %d, want 3", stats.Edges) - } - if stats.Communities != 2 { - t.Errorf("Communities = %d, want 2", stats.Communities) - } - if stats.GodNodes != 2 { - t.Errorf("GodNodes = %d, want 2", stats.GodNodes) - } -} - -func TestParseGraphJSON(t *testing.T) { - dir := t.TempDir() - graphPath := filepath.Join(dir, "graph.json") - - graph := GraphJSON{ - Nodes: []GraphNode{ - {ID: "a", Label: "ClassA", SourceFile: "A.java", Degree: 5}, - }, - Links: []GraphLink{ - {Source: "a", Target: "b", Relation: "imports"}, - }, - Communities: []GraphCommunity{ - {ID: 0, Nodes: []string{"a", "b"}}, - }, - } - - data, _ := json.Marshal(graph) - os.WriteFile(graphPath, data, 0644) - - parsed, err := parseGraphJSON(graphPath) - if err != nil { - t.Fatalf("parseGraphJSON: %v", err) - } - - if len(parsed.Nodes) != 1 { - t.Errorf("Nodes = %d, want 1", len(parsed.Nodes)) - } - if parsed.Nodes[0].SourceFile != "A.java" { - t.Errorf("SourceFile = %q, want A.java", parsed.Nodes[0].SourceFile) - } -} - -func TestDetectResultJSON(t *testing.T) { - result := DetectResult{ - Repo: "/path/to/repo", - Manifests: Manifests{PomXML: true, GoMod: true}, - Files: FileCounts{Java: 10, Go: 5}, - Graph: GraphStats{Nodes: 100, Edges: 200, Communities: 5, GodNodes: 2}, - GraphFile: "graph.json", - } - - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var parsed DetectResult - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if !parsed.Manifests.PomXML { - t.Error("expected pom_xml = true") - } - if parsed.Files.Java != 10 { - t.Errorf("Java = %d, want 10", parsed.Files.Java) - } - if parsed.Graph.Nodes != 100 { - t.Errorf("Nodes = %d, want 100", parsed.Graph.Nodes) - } -} diff --git a/harness/internal/execute/execute.go b/harness/internal/execute/execute.go deleted file mode 100644 index d284457..0000000 --- a/harness/internal/execute/execute.go +++ /dev/null @@ -1,234 +0,0 @@ -package execute - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - - "github.com/konveyor/migration-harness/internal/git" - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/plan" -) - -type ItemResult struct { - N int `json:"n"` - Path string `json:"path"` - Action string `json:"action"` - Status string `json:"status"` - Lesson string `json:"lesson,omitempty"` - ErrorLog string `json:"error_log,omitempty"` - FilesTouched []string `json:"files_touched,omitempty"` -} - -type Summary struct { - Ok int `json:"ok"` - Failed int `json:"failed"` - Skipped int `json:"skipped"` -} - -type CommitRecord struct { - SHA string - Message string - Step int -} - -type PushFn func() error - -func Run(ctx context.Context, repoDir, runDir, recipesDir string, p *plan.Plan, runner goose.Runner, repo *gogit.Repository, pushFn PushFn) ([]ItemResult, *Summary, []CommitRecord, error) { - logging.Header("Step 3: Execute") - - total := len(p.Items) - logging.Info("executing %d items (migration: %s)", total, p.MigrationType) - - execLog := filepath.Join(runDir, "execution-log.md") - initExecutionLog(execLog, p.MigrationType) - - var results []ItemResult - var commits []CommitRecord - summary := &Summary{} - - recipeFile := filepath.Join(recipesDir, "execute.yaml") - - for i, item := range p.Items { - current := i + 1 - outPath := filepath.Join(runDir, fmt.Sprintf("item-%03d.json", item.N)) - - if existing := loadExistingResult(outPath); existing != nil && existing.Status == "ok" { - logging.Ok("(%d/%d) #%d already done, skipping", current, total, item.N) - summary.Ok++ - results = append(results, *existing) - continue - } - - logging.Info("(%d/%d) #%d [%s] %s", current, total, item.N, item.Action, item.Path) - logging.Info(" invoking goose — this may take 15-60s per file...") - - result := executeItem(ctx, runner, recipeFile, repoDir, runDir, item, p.MigrationType) - writeItemResult(outPath, &result) - appendExecutionLog(execLog, &result) - - switch result.Status { - case "ok": - summary.Ok++ - plan.UpdateHintsChecklist(repoDir, item.N) - logging.Ok("(%d/%d) #%d done", current, total, item.N) - if result.Lesson != "" && len(result.Lesson) > 80 { - logging.Info(" lesson: %s...", result.Lesson[:80]) - } else if result.Lesson != "" { - logging.Info(" lesson: %s", result.Lesson) - } - case "skipped": - summary.Skipped++ - logging.Warn("(%d/%d) #%d skipped", current, total, item.N) - default: - summary.Failed++ - logging.Err("(%d/%d) #%d status=%s", current, total, item.N, result.Status) - } - - if repo != nil { - commitMsg := fmt.Sprintf("migrate: #%d %s", item.N, itemLabel(item)) - hash, err := git.CommitAll(repo, commitMsg) - if err != nil { - logging.Warn("git commit after item #%d: %v", item.N, err) - } else if hash != plumbing.ZeroHash { - commits = append(commits, CommitRecord{SHA: hash.String(), Message: commitMsg, Step: item.N}) - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("git push after item #%d: %v", item.N, err) - } - } - } - } - - results = append(results, result) - } - - summaryPath := filepath.Join(runDir, "execute-summary.json") - summaryData, _ := json.MarshalIndent(summary, "", " ") - os.WriteFile(summaryPath, summaryData, 0644) - - copyFile(execLog, filepath.Join(repoDir, "execution-log.md")) - - logging.Ok("Step 3/5 complete: %d ok, %d failed, %d skipped", summary.Ok, summary.Failed, summary.Skipped) - return results, summary, commits, nil -} - -func executeItem(ctx context.Context, runner goose.Runner, recipeFile, repoDir, runDir string, item plan.PlanItem, migrationType string) ItemResult { - result := ItemResult{ - N: item.N, - Path: item.Path, - Action: item.Action, - Status: "failed", - } - - output, err := runner.RunRecipe(ctx, recipeFile, 10, map[string]string{ - "repo": repoDir, - "plan_md_path": filepath.Join(runDir, "PLAN.md"), - "migration_type": migrationType, - "item_n": fmt.Sprintf("%d", item.N), - "item_path": item.Path, - "item_action": item.Action, - }) - if err != nil { - result.ErrorLog = err.Error() - return result - } - - var parsed struct { - Status string `json:"status"` - FilesTouched []string `json:"files_touched"` - Lesson string `json:"lesson"` - ErrorLog string `json:"error_log"` - } - if err := json.Unmarshal(output, &parsed); err != nil { - result.ErrorLog = fmt.Sprintf("parse goose output: %v", err) - return result - } - - result.Status = parsed.Status - result.FilesTouched = parsed.FilesTouched - result.Lesson = parsed.Lesson - result.ErrorLog = parsed.ErrorLog - - if result.Status == "" { - result.Status = "failed" - } - - return result -} - -func loadExistingResult(path string) *ItemResult { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var r ItemResult - if err := json.Unmarshal(data, &r); err != nil { - return nil - } - return &r -} - -func writeItemResult(path string, r *ItemResult) { - data, _ := json.MarshalIndent(r, "", " ") - os.WriteFile(path, data, 0644) -} - -func initExecutionLog(path, migrationType string) { - var b strings.Builder - b.WriteString("# Execution Log\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Started:** %s\n\n", time.Now().Format(time.RFC3339)) - b.WriteString("---\n\n") - os.WriteFile(path, []byte(b.String()), 0644) -} - -func appendExecutionLog(path string, r *ItemResult) { - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - - fmt.Fprintf(f, "## Step #%d: %s - %s\n\n", r.N, r.Action, r.Path) - fmt.Fprintf(f, "**Status:** %s\n", r.Status) - if len(r.FilesTouched) > 0 { - fmt.Fprintf(f, "**Files touched:** %s\n", strings.Join(r.FilesTouched, ", ")) - } - if r.Lesson != "" { - fmt.Fprintf(f, "\n**Lesson learned:**\n%s\n", r.Lesson) - } - if r.ErrorLog != "" { - fmt.Fprintf(f, "\n**Errors:**\n```\n%s\n```\n", r.ErrorLog) - } - fmt.Fprintf(f, "\n---\n\n") -} - -func itemLabel(item plan.PlanItem) string { - if item.Path != "" { - return item.Path - } - if item.Notes != "" { - if len(item.Notes) > 80 { - return item.Notes[:80] - } - return item.Notes - } - return item.Action -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - return - } - os.WriteFile(dst, data, 0644) -} diff --git a/harness/internal/execute/execute_test.go b/harness/internal/execute/execute_test.go deleted file mode 100644 index 6c59568..0000000 --- a/harness/internal/execute/execute_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package execute - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadExistingResult(t *testing.T) { - dir := t.TempDir() - - t.Run("no file returns nil", func(t *testing.T) { - r := loadExistingResult(filepath.Join(dir, "nonexistent.json")) - if r != nil { - t.Error("expected nil for nonexistent file") - } - }) - - t.Run("valid file returns result", func(t *testing.T) { - result := ItemResult{N: 1, Path: "pom.xml", Status: "ok"} - data, _ := json.Marshal(result) - path := filepath.Join(dir, "item-001.json") - os.WriteFile(path, data, 0644) - - r := loadExistingResult(path) - if r == nil { - t.Fatal("expected non-nil result") - } - if r.Status != "ok" { - t.Errorf("status = %q, want ok", r.Status) - } - }) -} - -func TestWriteItemResult(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "item-001.json") - r := &ItemResult{N: 1, Path: "test.java", Status: "ok", Lesson: "javax -> jakarta"} - writeItemResult(path, r) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - - var loaded ItemResult - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Lesson != "javax -> jakarta" { - t.Errorf("lesson = %q", loaded.Lesson) - } -} - -func TestInitExecutionLog(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "execution-log.md") - initExecutionLog(path, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - if !strings.Contains(content, "Execution Log") { - t.Error("missing header") - } - if !strings.Contains(content, "java-ee-to-quarkus") { - t.Error("missing migration type") - } -} - -func TestAppendExecutionLog(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "execution-log.md") - os.WriteFile(path, []byte("# Log\n\n"), 0644) - - r := &ItemResult{ - N: 1, - Path: "pom.xml", - Action: "migrate", - Status: "ok", - Lesson: "updated deps", - FilesTouched: []string{"pom.xml"}, - } - appendExecutionLog(path, r) - - data, _ := os.ReadFile(path) - content := string(data) - if !strings.Contains(content, "Step #1") { - t.Error("missing step header") - } - if !strings.Contains(content, "updated deps") { - t.Error("missing lesson") - } - if !strings.Contains(content, "pom.xml") { - t.Error("missing file touched") - } -} - -func TestSummaryJSON(t *testing.T) { - s := Summary{Ok: 10, Failed: 1, Skipped: 2} - data, err := json.Marshal(s) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var loaded Summary - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Ok != 10 || loaded.Failed != 1 || loaded.Skipped != 2 { - t.Errorf("summary = %+v", loaded) - } -} diff --git a/harness/internal/fixloop/fixloop.go b/harness/internal/fixloop/fixloop.go deleted file mode 100644 index d6e10b2..0000000 --- a/harness/internal/fixloop/fixloop.go +++ /dev/null @@ -1,206 +0,0 @@ -package fixloop - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - - "github.com/konveyor/migration-harness/internal/git" - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/verify" -) - -type FixResult struct { - Fixed bool `json:"fixed"` - FileChanged string `json:"file_changed,omitempty"` - Summary string `json:"summary,omitempty"` -} - -type FixLoopReport struct { - MigrationType string `json:"migration_type"` - Iterations int `json:"iterations"` - Status string `json:"status"` -} - -type CommitRecord struct { - SHA string - Message string - Iter int -} - -type PushFn func() error - -func Run(ctx context.Context, repoDir, runDir, recipesDir, migrationType string, maxIterations int, runner goose.Runner, repo *gogit.Repository, pushFn PushFn) (*FixLoopReport, []CommitRecord, error) { - logging.Header("Step 5: Fix Loop") - - verifyPath := filepath.Join(runDir, "verify.json") - if _, err := os.Stat(verifyPath); os.IsNotExist(err) { - return nil, nil, fmt.Errorf("no verification output found — run step 4 (verify) first") - } - - vr := loadVerifyResult(verifyPath) - if vr != nil && vr.BuildOk { - logging.Ok("build status: SUCCESS (from verification report)") - logging.Ok("fix loop not needed — skipping") - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: 0, - Status: "skipped", - } - logging.Ok("Step 5/5 complete (skipped - build already successful)") - return report, nil, nil - } - - logging.Warn("build status: FAILED (from verification report)") - logging.Info("starting fix loop (max %d iterations)...", maxIterations) - - var commits []CommitRecord - lastIter := 0 - - for iter := 1; iter <= maxIterations; iter++ { - lastIter = iter - verifyIterPath := filepath.Join(runDir, fmt.Sprintf("verify-fix-%d.json", iter)) - - logging.Info("iteration %d/%d — re-verifying...", iter, maxIterations) - iterResult, err := verify.Run(ctx, repoDir, runDir, recipesDir, migrationType, runner) - if err == nil && iterResult.BuildOk && len(iterResult.Errors) == 0 { - logging.Ok("iteration %d/%d — build clean, no errors!", iter, maxIterations) - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: iter, - Status: "success", - } - writeFixReport(runDir, repoDir, report, migrationType) - logging.Ok("Step 5/5 complete") - return report, commits, nil - } - - iterData, _ := json.MarshalIndent(iterResult, "", " ") - os.WriteFile(verifyIterPath, iterData, 0644) - - var currentVerify *verify.VerifyResult - if iter == 1 { - currentVerify = vr - } else { - currentVerify = iterResult - } - - if currentVerify == nil || len(currentVerify.Errors) == 0 { - logging.Warn("iteration %d/%d — build not OK but no errors reported; cannot auto-fix", iter, maxIterations) - break - } - - firstErr := currentVerify.Errors[0] - logging.Info("iteration %d/%d — fixing: %s", iter, maxIterations, firstErr.File) - logging.Info(" error: %s", truncate(firstErr.Message, 100)) - - fixResult := attemptFix(ctx, runner, recipesDir, repoDir, runDir, migrationType, firstErr, iter) - if !fixResult.Fixed { - logging.Err("iteration %d/%d — fix did not succeed (%s)", iter, maxIterations, firstErr.File) - break - } - - logging.Ok("iteration %d/%d — fixed %s", iter, maxIterations, fixResult.FileChanged) - - if repo != nil { - commitMsg := fmt.Sprintf("fix: %s", fixResult.FileChanged) - hash, err := git.CommitAll(repo, commitMsg) - if err != nil { - logging.Warn("git commit after fix #%d: %v", iter, err) - } else if hash != plumbing.ZeroHash { - commits = append(commits, CommitRecord{SHA: hash.String(), Message: commitMsg, Iter: iter}) - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("git push after fix #%d: %v", iter, err) - } - } - } - } - } - - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: lastIter, - Status: "manual_intervention_needed", - } - writeFixReport(runDir, repoDir, report, migrationType) - logging.Warn("hit max iterations (%d) — manual intervention needed", maxIterations) - return report, commits, nil -} - -func attemptFix(ctx context.Context, runner goose.Runner, recipesDir, repoDir, runDir, migrationType string, verifyErr verify.VerifyError, iter int) FixResult { - fixPath := filepath.Join(runDir, fmt.Sprintf("fix-%d.json", iter)) - recipeFile := filepath.Join(recipesDir, "fix.yaml") - - output, err := runner.RunRecipe(ctx, recipeFile, 8, map[string]string{ - "repo": repoDir, - "verification_report_path": filepath.Join(runDir, "verification-report.md"), - "migration_type": migrationType, - "error_file": verifyErr.File, - "error_message": verifyErr.Message, - }) - - var result FixResult - if err != nil { - result = FixResult{Fixed: false, Summary: err.Error()} - } else if err := json.Unmarshal(output, &result); err != nil { - result = FixResult{Fixed: false, Summary: fmt.Sprintf("parse goose output: %v", err)} - } - - data, _ := json.MarshalIndent(result, "", " ") - os.WriteFile(fixPath, data, 0644) - - return result -} - -func loadVerifyResult(path string) *verify.VerifyResult { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var r verify.VerifyResult - if err := json.Unmarshal(data, &r); err != nil { - return nil - } - return &r -} - -func writeFixReport(runDir, repoDir string, report *FixLoopReport, migrationType string) { - var b strings.Builder - b.WriteString("# Fix Loop Report\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Iterations:** %d\n", report.Iterations) - fmt.Fprintf(&b, "**Status:** %s\n\n", report.Status) - - if report.Status == "success" { - b.WriteString("## Result\n\nAll compilation errors resolved. Build is now successful.\n") - } else { - b.WriteString("## Next Steps\n\nManual intervention is required. Review the remaining errors in verification-report.md\n") - } - - path := filepath.Join(runDir, "fix-loop-report.md") - os.WriteFile(path, []byte(b.String()), 0644) - copyFile(path, filepath.Join(repoDir, "fix-loop-report.md")) -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - return - } - os.WriteFile(dst, data, 0644) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/harness/internal/fixloop/fixloop_test.go b/harness/internal/fixloop/fixloop_test.go deleted file mode 100644 index 4c8f340..0000000 --- a/harness/internal/fixloop/fixloop_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package fixloop - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/verify" -) - -func TestLoadVerifyResult(t *testing.T) { - dir := t.TempDir() - - t.Run("missing file returns nil", func(t *testing.T) { - r := loadVerifyResult(filepath.Join(dir, "nope.json")) - if r != nil { - t.Error("expected nil") - } - }) - - t.Run("valid file", func(t *testing.T) { - vr := verify.VerifyResult{BuildOk: true, TestsPassed: 5, TestsTotal: 5} - data, _ := json.Marshal(vr) - path := filepath.Join(dir, "verify.json") - os.WriteFile(path, data, 0644) - - r := loadVerifyResult(path) - if r == nil { - t.Fatal("expected non-nil") - } - if !r.BuildOk { - t.Error("expected build_ok = true") - } - }) -} - -func TestWriteFixReport(t *testing.T) { - runDir := t.TempDir() - repoDir := t.TempDir() - - report := &FixLoopReport{ - MigrationType: "java-ee-to-quarkus", - Iterations: 2, - Status: "success", - } - writeFixReport(runDir, repoDir, report, "java-ee-to-quarkus") - - data, err := os.ReadFile(filepath.Join(runDir, "fix-loop-report.md")) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - if !strings.Contains(content, "Fix Loop Report") { - t.Error("missing header") - } - if !strings.Contains(content, "success") { - t.Error("missing success status") - } - if !strings.Contains(content, "**Iterations:** 2") { - t.Error("missing iteration count") - } - - if _, err := os.Stat(filepath.Join(repoDir, "fix-loop-report.md")); err != nil { - t.Error("report not copied to repo dir") - } -} - -func TestTruncate(t *testing.T) { - if got := truncate("short", 100); got != "short" { - t.Errorf("truncate short = %q", got) - } - if got := truncate("abcdefghij", 5); got != "abcde..." { - t.Errorf("truncate long = %q", got) - } -} diff --git a/harness/internal/git/credentials.go b/harness/internal/git/credentials.go index 1886c83..b28f6b0 100644 --- a/harness/internal/git/credentials.go +++ b/harness/internal/git/credentials.go @@ -1,10 +1,6 @@ package git import ( - "fmt" - "os" - "time" - "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -15,42 +11,9 @@ type Credentials struct { Branch string } -func ReadFromEnv() (*Credentials, error) { - repoURL := os.Getenv("GIT_REPO_URL") - if repoURL == "" { - return nil, nil - } - - username := os.Getenv("GIT_USERNAME") - if username == "" { - username = "x-access-token" - } - - token := os.Getenv("GIT_TOKEN") - if token == "" { - return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TOKEN is missing") - } - - branch := os.Getenv("GIT_TARGET_BRANCH") - if branch == "" { - branch = fmt.Sprintf("konveyor-migrate-%s", time.Now().Format("20060102-150405")) - } - - return &Credentials{ - Username: username, - Token: token, - RepoURL: repoURL, - Branch: branch, - }, nil -} - func (c *Credentials) Auth() *http.BasicAuth { return &http.BasicAuth{ Username: c.Username, Password: c.Token, } } - -func ClearEnvCredentials() { - os.Unsetenv("GIT_TOKEN") -} diff --git a/harness/internal/git/git.go b/harness/internal/git/git.go index 2981aaa..d8cdb3e 100644 --- a/harness/internal/git/git.go +++ b/harness/internal/git/git.go @@ -6,17 +6,35 @@ import ( "fmt" "net/url" "os" + "path/filepath" + "strings" "time" gogit "github.com/go-git/go-git/v5" gogitcfg "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/konveyor/migration-harness/internal/watcher" ) +func isChildOf(path, root string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != "." && !strings.HasPrefix(rel, "..") +} + func Clone(ctx context.Context, cred *Credentials, destDir string) (*gogit.Repository, error) { + destDir, err := filepath.Abs(destDir) + if err != nil { + return nil, fmt.Errorf("resolve destination: %w", err) + } + if _, err := os.Stat(destDir); err == nil { - os.RemoveAll(destDir) + if !isChildOf(destDir, "/workspace") && !isChildOf(destDir, os.TempDir()) { + return nil, fmt.Errorf("refusing to remove %s: not under /workspace or temp", destDir) + } + if err := os.RemoveAll(destDir); err != nil { + return nil, fmt.Errorf("remove %s: %w", destDir, err) + } } repo, err := gogit.PlainCloneContext(ctx, destDir, false, &gogit.CloneOptions{ @@ -68,20 +86,37 @@ func StripCredentials(repo *gogit.Repository) error { } func CheckoutBranch(repo *gogit.Repository, branch string) error { + localRef := plumbing.NewBranchReferenceName(branch) + + // Already on the requested branch — nothing to do. + if head, err := repo.Head(); err == nil && head.Name() == localRef { + return nil + } + wt, err := repo.Worktree() if err != nil { return fmt.Errorf("get worktree: %w", err) } - ref := plumbing.NewBranchReferenceName(branch) + remoteRef := plumbing.NewRemoteReferenceName("origin", branch) + + // If the remote tracking branch exists, create local branch from it. + if hash, err := repo.ResolveRevision(plumbing.Revision(remoteRef)); err == nil { + return wt.Checkout(&gogit.CheckoutOptions{ + Branch: localRef, + Hash: *hash, + Create: true, + }) + } + // Otherwise create a new branch from HEAD. err = wt.Checkout(&gogit.CheckoutOptions{ - Branch: ref, + Branch: localRef, Create: true, }) if err != nil { err = wt.Checkout(&gogit.CheckoutOptions{ - Branch: ref, + Branch: localRef, }) if err != nil { return fmt.Errorf("checkout branch %s: %w", branch, err) @@ -104,8 +139,18 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { return plumbing.ZeroHash, nil } - if err := wt.AddWithOptions(&gogit.AddOptions{All: true}); err != nil { - return plumbing.ZeroHash, fmt.Errorf("add all: %w", err) + staged := false + for path, s := range status { + if s.Worktree == gogit.Untracked && !watcher.ShouldStageNewFile(path) { + continue + } + if _, err := wt.Add(path); err != nil { + return plumbing.ZeroHash, fmt.Errorf("add %s: %w", path, err) + } + staged = true + } + if !staged { + return plumbing.ZeroHash, nil } hash, err := wt.Commit(message, &gogit.CommitOptions{ @@ -122,6 +167,10 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { return hash, nil } +// Push force-pushes the branch. Force is intentional: the watcher's +// auto-commits may create non-fast-forward histories, and each stage +// owns its branch exclusively. Concurrent runs on the same branch are +// not supported. func Push(ctx context.Context, cred *Credentials, repo *gogit.Repository, branch string) error { refSpec := gogitcfg.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)) diff --git a/harness/internal/git/git_test.go b/harness/internal/git/git_test.go index c087937..e45d7cd 100644 --- a/harness/internal/git/git_test.go +++ b/harness/internal/git/git_test.go @@ -82,7 +82,7 @@ func TestCommitAllWithChanges(t *testing.T) { seedBareRepo(t, remoteDir) cloneDir, repo := cloneLocal(t, remoteDir) - os.WriteFile(filepath.Join(cloneDir, "new-file.txt"), []byte("hello\n"), 0644) + os.WriteFile(filepath.Join(cloneDir, "new-file.java"), []byte("class Hello {}\n"), 0644) hash, err := CommitAll(repo, "add new file") if err != nil { @@ -119,6 +119,49 @@ func TestCheckoutBranch(t *testing.T) { } } +func TestCheckoutBranchFromRemote(t *testing.T) { + remoteDir, _ := setupBareRemote(t) + seedBareRepo(t, remoteDir) + + // Push a file to a branch on the remote via a temporary clone. + tmpDir := filepath.Join(t.TempDir(), "pusher") + pusher, err := gogit.PlainClone(tmpDir, false, &gogit.CloneOptions{URL: remoteDir}) + if err != nil { + t.Fatalf("clone for push: %v", err) + } + wt, _ := pusher.Worktree() + wt.Checkout(&gogit.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("remote-branch"), Create: true}) + os.WriteFile(filepath.Join(tmpDir, "PLAN.md"), []byte("# Plan\n"), 0644) + wt.Add("PLAN.md") + wt.Commit("add plan", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com", When: time.Now()}, + }) + pusher.Push(&gogit.PushOptions{ + RefSpecs: []gogitcfg.RefSpec{"refs/heads/remote-branch:refs/heads/remote-branch"}, + }) + + // Fresh clone (only fetches default branch). + cloneDir := filepath.Join(t.TempDir(), "clone2") + repo, err := gogit.PlainClone(cloneDir, false, &gogit.CloneOptions{URL: remoteDir}) + if err != nil { + t.Fatalf("clone: %v", err) + } + + // CheckoutBranch should resolve the remote tracking branch. + if err := CheckoutBranch(repo, "remote-branch"); err != nil { + t.Fatalf("CheckoutBranch: %v", err) + } + + // Verify we got the remote content. + data, err := os.ReadFile(filepath.Join(cloneDir, "PLAN.md")) + if err != nil { + t.Fatalf("PLAN.md not found after checkout: %v", err) + } + if string(data) != "# Plan\n" { + t.Errorf("PLAN.md content = %q, want %q", string(data), "# Plan\n") + } +} + func TestFullLifecycle(t *testing.T) { remoteDir, _ := setupBareRemote(t) seedBareRepo(t, remoteDir) @@ -173,73 +216,6 @@ func TestFullLifecycle(t *testing.T) { } } -func TestReadFromEnv(t *testing.T) { - t.Run("no env returns nil", func(t *testing.T) { - os.Unsetenv("GIT_REPO_URL") - os.Unsetenv("GIT_TOKEN") - os.Unsetenv("GIT_USERNAME") - os.Unsetenv("GIT_TARGET_BRANCH") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred != nil { - t.Error("expected nil credentials when GIT_REPO_URL is unset") - } - }) - - t.Run("repo url without token errors", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "") - os.Unsetenv("GIT_TOKEN") - - _, err := ReadFromEnv() - if err == nil { - t.Error("expected error when GIT_TOKEN is missing") - } - }) - - t.Run("full env", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "ghp_test123") - t.Setenv("GIT_USERNAME", "myuser") - t.Setenv("GIT_TARGET_BRANCH", "my-branch") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred.Username != "myuser" { - t.Errorf("Username = %q, want myuser", cred.Username) - } - if cred.Token != "ghp_test123" { - t.Errorf("Token = %q, want ghp_test123", cred.Token) - } - if cred.RepoURL != "https://github.com/org/repo.git" { - t.Errorf("RepoURL = %q", cred.RepoURL) - } - if cred.Branch != "my-branch" { - t.Errorf("Branch = %q, want my-branch", cred.Branch) - } - }) - - t.Run("default username", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "ghp_test123") - os.Unsetenv("GIT_USERNAME") - t.Setenv("GIT_TARGET_BRANCH", "br") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred.Username != "x-access-token" { - t.Errorf("Username = %q, want x-access-token", cred.Username) - } - }) -} - func TestPushWithoutCredsToStrippedRemoteFails(t *testing.T) { remoteDir, _ := setupBareRemote(t) seedBareRepo(t, remoteDir) diff --git a/harness/internal/goose/acp_runner.go b/harness/internal/goose/acp_runner.go deleted file mode 100644 index f08f3f5..0000000 --- a/harness/internal/goose/acp_runner.go +++ /dev/null @@ -1,202 +0,0 @@ -package goose - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/acp" - "github.com/konveyor/migration-harness/internal/logging" -) - -// ACPRunner implements the Runner interface using goose serve (ACP over -// WebSocket) instead of goose run (CLI). The existing CLIRunner stays -// as fallback. All pipeline steps (plan, execute, verify, fixloop) -// work without modification — they call RunRecipe the same way. -type ACPRunner struct { - session *acp.SessionClient - serve *ServeProcess - provider string - model string - logDir string - cwd string -} - -// NewACPRunner creates a runner backed by a goose serve WebSocket connection. -func NewACPRunner(session *acp.SessionClient, serve *ServeProcess, provider, model, logDir, cwd string) *ACPRunner { - return &ACPRunner{ - session: session, - serve: serve, - provider: provider, - model: model, - logDir: logDir, - cwd: cwd, - } -} - -// RunRecipe implements the Runner interface. It parses the recipe YAML, -// renders templates, creates an ACP session, sends the prompt, and -// extracts structured output — producing the same json.RawMessage -// shape that CLIRunner returns. -func (r *ACPRunner) RunRecipe(ctx context.Context, recipe string, maxTurns int, params map[string]string) (json.RawMessage, error) { - if !r.serve.Alive() { - return nil, fmt.Errorf("goose serve is not running") - } - - recipeName := strings.TrimSuffix(filepath.Base(recipe), ".yaml") - logging.Info("ACP: running recipe %s (max %d turns)", recipeName, maxTurns) - - // Parse and render the recipe. For dynamically generated recipes - // (like the plan recipe), the file is already a rendered YAML — - // params may be nil/empty. - rec, err := ParseRecipe(recipe) - if err != nil { - return nil, fmt.Errorf("parse recipe: %w", err) - } - - if params == nil { - params = map[string]string{} - } - - instructions, prompt, err := rec.Render(params) - if err != nil { - return nil, fmt.Errorf("render recipe: %w", err) - } - - // Build the full prompt with schema - fullPrompt, err := rec.BuildACPPrompt(instructions, prompt) - if err != nil { - return nil, fmt.Errorf("build ACP prompt: %w", err) - } - - // Add max turns guidance to the prompt - if maxTurns > 0 { - fullPrompt += fmt.Sprintf("\n\nYou have a maximum of %d tool calls for this task.", maxTurns) - } - - // Create a fresh session for this recipe call (matches CLIRunner - // behavior — each RunRecipe is independent, no shared context) - sessionID, err := r.session.CreateSession(ctx, r.cwd, rec.MCPServers()) - if err != nil { - return nil, fmt.Errorf("create session: %w", err) - } - - // Send the prompt and collect streaming response - result, err := r.session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ - {Type: "text", Text: fullPrompt}, - }) - if err != nil { - return nil, fmt.Errorf("send prompt: %w", err) - } - - // Log the full response - logPath := filepath.Join(r.logDir, fmt.Sprintf("%s-%d.json", recipeName, os.Getpid())) - logData, _ := json.MarshalIndent(result, "", " ") - os.WriteFile(logPath, logData, 0600) - - // Check stop reason - switch result.StopReason { - case "end_turn": - // normal completion - case "max_tokens": - return nil, fmt.Errorf("model hit context limit — try a model with a larger context window") - case "max_turn_requests": - return nil, fmt.Errorf("hit max tool calls (%d) — increase max turns", maxTurns) - case "refusal": - return nil, fmt.Errorf("model refused the task") - default: - logging.Warn("unexpected stop reason: %s", result.StopReason) - } - - // Extract structured output from the collected message chunks - output, err := extractStructuredOutput(result.Chunks) - if err != nil { - return nil, fmt.Errorf("extract output: %w", err) - } - - return output, nil -} - -// extractStructuredOutput attempts to find JSON in the agent's response. -// The agent may return JSON directly, wrapped in markdown code blocks, -// or mixed with other text. -func extractStructuredOutput(chunks []string) (json.RawMessage, error) { - if len(chunks) == 0 { - return nil, fmt.Errorf("no response chunks received") - } - - full := strings.Join(chunks, "") - - // Try parsing the full text as JSON directly - if isJSON(full) { - return json.RawMessage(full), nil - } - - // Try extracting from markdown code blocks: ```json ... ``` - if extracted := extractFromCodeBlock(full); extracted != "" { - if isJSON(extracted) { - return json.RawMessage(extracted), nil - } - } - - // Try finding JSON object anywhere in the text - if extracted := extractJSONObject(full); extracted != "" { - return json.RawMessage(extracted), nil - } - - return nil, fmt.Errorf("no valid JSON found in agent response (%d bytes)", len(full)) -} - -func isJSON(s string) bool { - s = strings.TrimSpace(s) - if len(s) == 0 { - return false - } - var js json.RawMessage - return json.Unmarshal([]byte(s), &js) == nil -} - -func extractFromCodeBlock(s string) string { - markers := []string{"```json\n", "```JSON\n", "```\n"} - for _, start := range markers { - idx := strings.Index(s, start) - if idx < 0 { - continue - } - content := s[idx+len(start):] - end := strings.Index(content, "```") - if end < 0 { - continue - } - return strings.TrimSpace(content[:end]) - } - return "" -} - -func extractJSONObject(s string) string { - // Search from the END of the text — the structured output is - // typically the last JSON object in the response. Searching from - // the start would match JSON embedded in PLAN.md code examples. - for start := strings.LastIndex(s, "{"); start >= 0; start = strings.LastIndex(s[:start], "{") { - depth := 0 - for i := start; i < len(s); i++ { - switch s[i] { - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - candidate := s[start : i+1] - if isJSON(candidate) { - return candidate - } - break - } - } - } - } - return "" -} diff --git a/harness/internal/goose/acp_runner_test.go b/harness/internal/goose/acp_runner_test.go deleted file mode 100644 index c0b2d43..0000000 --- a/harness/internal/goose/acp_runner_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package goose - -import ( - "encoding/json" - "testing" -) - -func TestExtractStructuredOutput_DirectJSON(t *testing.T) { - chunks := []string{`{"status": "ok", "files_touched": ["pom.xml"]}`} - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["status"] != "ok" { - t.Errorf("expected status 'ok', got %v", parsed["status"]) - } -} - -func TestExtractStructuredOutput_CodeBlock(t *testing.T) { - chunks := []string{ - "Here's the result:\n\n```json\n", - `{"fixed": true, "summary": "added import"}`, - "\n```\n\nDone!", - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["fixed"] != true { - t.Errorf("expected fixed=true, got %v", parsed["fixed"]) - } -} - -func TestExtractStructuredOutput_EmbeddedJSON(t *testing.T) { - chunks := []string{ - "I've completed the migration. ", - `The result is: {"build_ok": true, "tests_passed": 5, "tests_total": 5, "errors": [], "summary": "all good"}`, - " Let me know if you need anything else.", - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["build_ok"] != true { - t.Errorf("expected build_ok=true, got %v", parsed["build_ok"]) - } -} - -func TestExtractStructuredOutput_MultiChunkJSON(t *testing.T) { - chunks := []string{ - `{"n": 1, `, - `"path": "pom.xml", `, - `"status": "ok", `, - `"files_touched": ["pom.xml"], `, - `"lesson": "", `, - `"error_log": ""}`, - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["status"] != "ok" { - t.Errorf("expected status 'ok', got %v", parsed["status"]) - } -} - -func TestExtractStructuredOutput_NoJSON(t *testing.T) { - chunks := []string{"I completed the task successfully."} - _, err := extractStructuredOutput(chunks) - if err == nil { - t.Error("expected error for non-JSON response") - } -} - -func TestExtractStructuredOutput_Empty(t *testing.T) { - _, err := extractStructuredOutput(nil) - if err == nil { - t.Error("expected error for empty chunks") - } -} diff --git a/harness/internal/goose/goose.go b/harness/internal/goose/goose.go deleted file mode 100644 index 9365596..0000000 --- a/harness/internal/goose/goose.go +++ /dev/null @@ -1,12 +0,0 @@ -package goose - -import ( - "context" - "encoding/json" -) - -// Runner is the interface for invoking goose. ACPRunner is the only -// implementation — it communicates with goose serve over WebSocket. -type Runner interface { - RunRecipe(ctx context.Context, recipe string, maxTurns int, params map[string]string) (json.RawMessage, error) -} diff --git a/harness/internal/goose/lifecycle.go b/harness/internal/goose/lifecycle.go index 2ee2857..1d9df24 100644 --- a/harness/internal/goose/lifecycle.go +++ b/harness/internal/goose/lifecycle.go @@ -2,8 +2,6 @@ package goose import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "net" "os" @@ -52,13 +50,7 @@ func StartServe(ctx context.Context, port int, provider, model, apiKey, endpoint secretKey := os.Getenv("KONVEYOR_ACP_SECRET_KEY") if secretKey == "" { - // REMOVE LATER: local testing only — in production the controller - // provides KONVEYOR_ACP_SECRET_KEY via a K8s Secret. - secretKey, err = generateLocalSecretKey() - if err != nil { - return nil, fmt.Errorf("generate secret key: %w", err) - } - logging.Warn("no KONVEYOR_ACP_SECRET_KEY set, generated local key for testing") + return nil, fmt.Errorf("KONVEYOR_ACP_SECRET_KEY is required") } cmd := exec.CommandContext(ctx, goosePath, "serve", @@ -145,16 +137,6 @@ func FindFreePort() (int, error) { return port, nil } -// REMOVE LATER: local testing only — generates a random secret key -// when KONVEYOR_ACP_SECRET_KEY is not set. -func generateLocalSecretKey() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - // providerEnv returns the current process environment with LLM provider // credentials translated to the env vars goose expects. Called before // starting goose serve so the process has the right credentials at diff --git a/harness/internal/goose/recipe.go b/harness/internal/goose/recipe.go deleted file mode 100644 index aa61c8d..0000000 --- a/harness/internal/goose/recipe.go +++ /dev/null @@ -1,139 +0,0 @@ -package goose - -import ( - "encoding/json" - "fmt" - "os" - "regexp" - "strings" - - "github.com/konveyor/migration-harness/internal/acp" - "gopkg.in/yaml.v3" -) - -// Recipe represents a goose recipe YAML file. -type Recipe struct { - Version string `yaml:"version"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Settings RecipeSettings `yaml:"settings"` - Parameters []RecipeParam `yaml:"parameters"` - Extensions []RecipeExt `yaml:"extensions"` - Instruction string `yaml:"instructions"` - Prompt string `yaml:"prompt"` - Response RecipeResp `yaml:"response"` -} - -type RecipeSettings struct { - Temperature float64 `yaml:"temperature"` -} - -type RecipeParam struct { - Key string `yaml:"key"` - InputType string `yaml:"input_type"` - Requirement string `yaml:"requirement"` - Description string `yaml:"description"` -} - -type RecipeExt struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - Timeout int `yaml:"timeout"` - Bundled bool `yaml:"bundled"` -} - -type RecipeResp struct { - JSONSchema yaml.Node `yaml:"json_schema"` -} - -// SchemaJSON converts the YAML json_schema node to JSON bytes. -func (r *RecipeResp) SchemaJSON() ([]byte, error) { - if r.JSONSchema.Kind == 0 { - return nil, nil - } - var raw any - if err := r.JSONSchema.Decode(&raw); err != nil { - return nil, err - } - return json.Marshal(raw) -} - -// ParseRecipe reads and parses a goose recipe YAML file. -func ParseRecipe(path string) (*Recipe, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read recipe %s: %w", path, err) - } - - var r Recipe - if err := yaml.Unmarshal(data, &r); err != nil { - return nil, fmt.Errorf("parse recipe %s: %w", path, err) - } - - return &r, nil -} - -var templatePattern = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) - -// Render substitutes {{ key }} placeholders in instructions and prompt -// with values from params. Returns an error if any required parameter -// is missing. -func (r *Recipe) Render(params map[string]string) (instructions string, prompt string, err error) { - instructions = renderTemplate(r.Instruction, params) - prompt = renderTemplate(r.Prompt, params) - - // Check for unsubstituted placeholders - remaining := templatePattern.FindAllString(instructions+prompt, -1) - if len(remaining) > 0 { - return "", "", fmt.Errorf("unsubstituted template parameters: %v", remaining) - } - - return instructions, prompt, nil -} - -func renderTemplate(tmpl string, params map[string]string) string { - return templatePattern.ReplaceAllStringFunc(tmpl, func(match string) string { - key := strings.TrimSpace(match[2 : len(match)-2]) - if val, ok := params[key]; ok { - return val - } - return match - }) -} - -// MCPServers translates recipe extensions to ACP MCPServer configs. -// For builtin extensions (like "developer"), goose serve already has -// them via --with-builtin. Returns an empty list for builtins. -func (r *Recipe) MCPServers() []acp.MCPServer { - var servers []acp.MCPServer - for _, ext := range r.Extensions { - if ext.Bundled { - continue - } - servers = append(servers, acp.MCPServer{ - Command: ext.Name, - Args: []string{}, - }) - } - return servers -} - -// BuildACPPrompt combines rendered instructions, prompt, and response -// schema into a single prompt string for ACP session/prompt. -func (r *Recipe) BuildACPPrompt(instructions, prompt string) (string, error) { - var b strings.Builder - b.WriteString(instructions) - b.WriteString("\n\n") - b.WriteString(prompt) - - schemaBytes, err := r.Response.SchemaJSON() - if err != nil { - return "", fmt.Errorf("marshal response schema: %w", err) - } - if len(schemaBytes) > 0 { - b.WriteString("\n\nYou MUST return your response as a JSON object matching this schema:\n") - b.Write(schemaBytes) - } - - return b.String(), nil -} diff --git a/harness/internal/goose/recipe_test.go b/harness/internal/goose/recipe_test.go deleted file mode 100644 index 018f836..0000000 --- a/harness/internal/goose/recipe_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package goose - -import ( - "path/filepath" - "runtime" - "strings" - "testing" -) - -func recipesDir() string { - _, thisFile, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(thisFile), "..", "..", "recipes") -} - -func TestParseRecipe(t *testing.T) { - for _, name := range []string{"execute.yaml", "verify.yaml", "fix.yaml"} { - t.Run(name, func(t *testing.T) { - path := filepath.Join(recipesDir(), name) - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe(%s): %v", name, err) - } - if r.Title == "" { - t.Error("expected non-empty title") - } - if len(r.Parameters) == 0 { - t.Error("expected at least one parameter") - } - if r.Instruction == "" { - t.Error("expected non-empty instructions") - } - if r.Prompt == "" { - t.Error("expected non-empty prompt") - } - t.Logf("%s: %d params, %d extensions", r.Title, len(r.Parameters), len(r.Extensions)) - }) - } -} - -func TestRenderRecipe(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - params := map[string]string{ - "repo": "/workspace/coolstore", - "verification_report_path": "/workspace/coolstore/verification-report.md", - "migration_type": "java-ee-to-quarkus", - "error_file": "src/main/java/com/redhat/coolstore/service/OrderService.java", - "error_message": "cannot find symbol: class EntityManager", - } - - instructions, prompt, err := r.Render(params) - if err != nil { - t.Fatalf("Render: %v", err) - } - - if instructions == "" || prompt == "" { - t.Fatal("expected non-empty rendered output") - } - - // Verify templates were substituted - if strings.Contains(instructions, "{{ repo }}") || strings.Contains(instructions, "{{repo}}") { - t.Error("unsubstituted {{ repo }} in instructions") - } - if !strings.Contains(instructions, "/workspace/coolstore") { - t.Error("expected rendered repo path in instructions") - } - if !strings.Contains(prompt, "cannot find symbol") { - t.Error("expected error message in prompt") - } - - t.Logf("Instructions length: %d bytes", len(instructions)) - t.Logf("Prompt length: %d bytes", len(prompt)) -} - -func TestRenderMissingParam(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - // Missing error_file and error_message - params := map[string]string{ - "repo": "/workspace/coolstore", - "verification_report_path": "/run/verify.md", - "migration_type": "java-ee-to-quarkus", - } - - _, _, err = r.Render(params) - if err == nil { - t.Fatal("expected error for missing params") - } - t.Logf("Got expected error: %v", err) -} - -func TestBuildACPPrompt(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - full, err := r.BuildACPPrompt("do this", "now please") - if err != nil { - t.Fatalf("BuildACPPrompt: %v", err) - } - if !strings.Contains(full, "do this") || !strings.Contains(full, "now please") { - t.Error("expected instructions and prompt in output") - } - if !strings.Contains(full, "fixed") || !strings.Contains(full, "boolean") { - t.Errorf("expected JSON schema with 'fixed' and 'boolean' in output, got: %s", full[len(full)-200:]) - } - t.Logf("Full ACP prompt: %d bytes", len(full)) -} diff --git a/harness/internal/handoff/handoff.go b/harness/internal/handoff/handoff.go deleted file mode 100644 index 47e23cb..0000000 --- a/harness/internal/handoff/handoff.go +++ /dev/null @@ -1,203 +0,0 @@ -package handoff - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/verify" -) - -type StepStatus struct { - Status string `json:"status"` - DurationSeconds int `json:"duration_seconds,omitempty"` -} - -type DetectStatus struct { - StepStatus - Nodes int `json:"nodes,omitempty"` - Edges int `json:"edges,omitempty"` - Communities int `json:"communities,omitempty"` -} - -type PlanStatus struct { - StepStatus - ItemsPlanned int `json:"items_planned,omitempty"` - ReferenceUsed string `json:"reference_used,omitempty"` -} - -type ExecuteStatus struct { - StepStatus - ItemsSucceeded int `json:"items_succeeded,omitempty"` - ItemsFailed int `json:"items_failed,omitempty"` - ItemsSkipped int `json:"items_skipped,omitempty"` -} - -type VerifyStatus struct { - StepStatus - BuildOk bool `json:"build_ok"` - TestsPassed int `json:"tests_passed,omitempty"` - TestsFailed int `json:"tests_failed,omitempty"` -} - -type FixLoopStatus struct { - StepStatus - Iterations int `json:"iterations"` -} - -type Pipeline struct { - Detect *DetectStatus `json:"detect,omitempty"` - Plan *PlanStatus `json:"plan,omitempty"` - Execute *ExecuteStatus `json:"execute,omitempty"` - Verify *VerifyStatus `json:"verify,omitempty"` - FixLoop *FixLoopStatus `json:"fix_loop,omitempty"` -} - -type CommitRecord struct { - SHA string `json:"sha"` - Message string `json:"message"` - Step int `json:"step"` -} - -type Session struct { - SchemaVersion string `json:"schema_version"` - SessionID string `json:"session_id"` - StartedAt time.Time `json:"started_at"` - CompletedAt time.Time `json:"completed_at,omitempty"` - Status string `json:"status"` - MigrationRequest string `json:"migration_request"` - SourceRepo string `json:"source_repo"` - TargetBranch string `json:"target_branch"` - Model string `json:"model"` - Provider string `json:"provider"` - Pipeline Pipeline `json:"pipeline"` - Commits []CommitRecord `json:"commits"` - Errors []string `json:"errors"` -} - -func NewSession(sessionID, request, sourceRepo, targetBranch, model, provider string) *Session { - return &Session{ - SchemaVersion: "1.0", - SessionID: sessionID, - StartedAt: time.Now(), - Status: "in_progress", - MigrationRequest: request, - SourceRepo: sourceRepo, - TargetBranch: targetBranch, - Model: model, - Provider: provider, - Commits: []CommitRecord{}, - Errors: []string{}, - } -} - -func WriteSession(repoDir string, session *Session) error { - konveyorDir := filepath.Join(repoDir, ".konveyor") - if err := os.MkdirAll(konveyorDir, 0755); err != nil { - return fmt.Errorf("create .konveyor dir: %w", err) - } - - data, err := json.MarshalIndent(session, "", " ") - if err != nil { - return fmt.Errorf("marshal session: %w", err) - } - - return os.WriteFile(filepath.Join(konveyorDir, "session.json"), data, 0644) -} - -func WriteHandoff(repoDir string, session *Session, p *plan.Plan, items []execute.ItemResult, vr *verify.VerifyResult) error { - konveyorDir := filepath.Join(repoDir, ".konveyor") - if err := os.MkdirAll(konveyorDir, 0755); err != nil { - return fmt.Errorf("create .konveyor dir: %w", err) - } - - var b strings.Builder - b.WriteString("# Migration Handoff\n\n") - - b.WriteString("## Request\n") - fmt.Fprintf(&b, "%s\n\n", session.MigrationRequest) - - status := session.Status - if len(status) > 0 { - status = strings.ToUpper(status[:1]) + status[1:] - } - fmt.Fprintf(&b, "## Status: %s\n\n", status) - - b.WriteString("## Summary\n") - ok, failed, skipped := countResults(items) - fmt.Fprintf(&b, "- %d of %d items migrated successfully\n", ok, len(items)) - if vr != nil { - if vr.BuildOk { - fmt.Fprintf(&b, "- Build passes, %d tests passing\n", vr.TestsPassed) - } else { - fmt.Fprintf(&b, "- Build failing, %d/%d tests passing\n", vr.TestsPassed, vr.TestsTotal) - } - } - if failed > 0 { - fmt.Fprintf(&b, "- %d item(s) failed\n", failed) - } - if skipped > 0 { - fmt.Fprintf(&b, "- %d item(s) skipped\n", skipped) - } - b.WriteString("\n") - - b.WriteString("## What Was Done\n") - for _, item := range items { - icon := "x" - if item.Status == "ok" { - icon = "check" - } - switch icon { - case "check": - fmt.Fprintf(&b, "%d. [x] %s — %s\n", item.N, item.Path, item.Action) - default: - fmt.Fprintf(&b, "%d. [ ] %s — %s (%s)\n", item.N, item.Path, item.Action, item.Status) - } - } - b.WriteString("\n") - - if failed > 0 || skipped > 0 { - b.WriteString("## What Needs Manual Attention\n") - for _, item := range items { - if item.Status != "ok" { - reason := item.ErrorLog - if reason == "" { - reason = item.Status - } - fmt.Fprintf(&b, "- %s — %s\n", item.Path, reason) - } - } - b.WriteString("\n") - } - - if vr != nil { - b.WriteString("## Verification\n") - if vr.BuildOk { - b.WriteString("- Build: passing\n") - } else { - b.WriteString("- Build: failing\n") - } - fmt.Fprintf(&b, "- Tests: %d passed, %d failed\n\n", vr.TestsPassed, vr.TestsTotal-vr.TestsPassed) - } - - return os.WriteFile(filepath.Join(konveyorDir, "handoff.md"), []byte(b.String()), 0644) -} - -func countResults(items []execute.ItemResult) (ok, failed, skipped int) { - for _, item := range items { - switch item.Status { - case "ok": - ok++ - case "skipped": - skipped++ - default: - failed++ - } - } - return -} diff --git a/harness/internal/handoff/handoff_test.go b/harness/internal/handoff/handoff_test.go deleted file mode 100644 index 59feb75..0000000 --- a/harness/internal/handoff/handoff_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package handoff - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/verify" -) - -func TestWriteSession(t *testing.T) { - dir := t.TempDir() - s := NewSession("test-id", "Migrate to Quarkus", "https://github.com/org/repo.git", - "konveyor-migrate", "gemini-2.5-pro", "gcp_vertex_ai") - s.Status = "completed" - s.Pipeline.Execute = &ExecuteStatus{ - StepStatus: StepStatus{Status: "completed"}, - ItemsSucceeded: 10, - ItemsFailed: 1, - } - - err := WriteSession(dir, s) - if err != nil { - t.Fatalf("WriteSession: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".konveyor", "session.json")) - if err != nil { - t.Fatalf("read session.json: %v", err) - } - - var loaded Session - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if loaded.SchemaVersion != "1.0" { - t.Errorf("schema_version = %q", loaded.SchemaVersion) - } - if loaded.SessionID != "test-id" { - t.Errorf("session_id = %q", loaded.SessionID) - } - if loaded.Status != "completed" { - t.Errorf("status = %q", loaded.Status) - } - if loaded.Pipeline.Execute.ItemsSucceeded != 10 { - t.Errorf("items_succeeded = %d", loaded.Pipeline.Execute.ItemsSucceeded) - } -} - -func TestWriteHandoffSuccess(t *testing.T) { - dir := t.TempDir() - s := NewSession("id", "Migrate to Quarkus", "https://github.com/org/repo.git", - "branch", "model", "provider") - s.Status = "completed" - - p := &plan.Plan{ - MigrationType: "java-ee-to-quarkus", - Items: []plan.PlanItem{ - {N: 1, Path: "pom.xml"}, - {N: 2, Path: "Foo.java"}, - }, - } - items := []execute.ItemResult{ - {N: 1, Path: "pom.xml", Action: "migrate", Status: "ok"}, - {N: 2, Path: "Foo.java", Action: "migrate", Status: "ok"}, - } - vr := &verify.VerifyResult{BuildOk: true, TestsPassed: 5, TestsTotal: 5} - - err := WriteHandoff(dir, s, p, items, vr) - if err != nil { - t.Fatalf("WriteHandoff: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".konveyor", "handoff.md")) - if err != nil { - t.Fatalf("read handoff.md: %v", err) - } - content := string(data) - - checks := []string{ - "Migration Handoff", - "Migrate to Quarkus", - "Completed", - "2 of 2 items migrated", - "Build passes", - "pom.xml", - } - for _, c := range checks { - if !strings.Contains(content, c) { - t.Errorf("missing %q in handoff.md", c) - } - } -} - -func TestWriteHandoffPartial(t *testing.T) { - dir := t.TempDir() - s := NewSession("id", "Migrate", "repo", "branch", "model", "provider") - s.Status = "partial" - - p := &plan.Plan{Items: []plan.PlanItem{ - {N: 1, Path: "a.java"}, - {N: 2, Path: "b.java"}, - }} - items := []execute.ItemResult{ - {N: 1, Path: "a.java", Action: "migrate", Status: "ok"}, - {N: 2, Path: "b.java", Action: "migrate", Status: "failed", ErrorLog: "compilation error"}, - } - - err := WriteHandoff(dir, s, p, items, nil) - if err != nil { - t.Fatalf("WriteHandoff: %v", err) - } - - data, _ := os.ReadFile(filepath.Join(dir, ".konveyor", "handoff.md")) - content := string(data) - - if !strings.Contains(content, "1 of 2 items migrated") { - t.Error("missing partial count") - } - if !strings.Contains(content, "Manual Attention") { - t.Error("missing manual attention section") - } - if !strings.Contains(content, "compilation error") { - t.Error("missing error in manual attention") - } -} - -func TestCountResults(t *testing.T) { - items := []execute.ItemResult{ - {Status: "ok"}, - {Status: "ok"}, - {Status: "failed"}, - {Status: "skipped"}, - } - ok, failed, skipped := countResults(items) - if ok != 2 { - t.Errorf("ok = %d, want 2", ok) - } - if failed != 1 { - t.Errorf("failed = %d, want 1", failed) - } - if skipped != 1 { - t.Errorf("skipped = %d, want 1", skipped) - } -} diff --git a/harness/internal/hub/client.go b/harness/internal/hub/client.go new file mode 100644 index 0000000..931269f --- /dev/null +++ b/harness/internal/hub/client.go @@ -0,0 +1,73 @@ +package hub + +import ( + "os" + "strconv" + + "github.com/konveyor/tackle2-hub/shared/api" + "github.com/konveyor/tackle2-hub/shared/binding" + "github.com/konveyor/tackle2-hub/shared/binding/auth" +) + +// Client wraps the Hub RichClient for the subset of operations the harness needs. +type Client struct { + rich *binding.RichClient +} + +// NewClient creates a Hub API client with bearer token auth. +func NewClient(baseURL, token string) *Client { + rc := binding.New(baseURL) + rc.Client.Use(auth.NewBearer(token)) + return &Client{rich: rc} +} + +// FetchApp retrieves application metadata including the source repository. +func (c *Client) FetchApp(appID uint) (*api.Application, error) { + return c.rich.Application.Get(appID) +} + +// FetchGitCreds retrieves git credentials for an application's source repo. +// Tries direct identity (app-specific, role=source) first, then falls back +// to an indirect identity (global default, kind=source). +func (c *Client) FetchGitCreds(appID uint) (*api.Identity, error) { + selected := c.rich.Application.Select(appID) + identity, found, err := selected.Identity.Decrypted().Search(). + Direct("source"). + Indirect("source"). + Find() + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + return identity, nil +} + +// FetchAnalysis retrieves analysis insights for an application. +// Returns an empty slice (not an error) if no analysis exists. +func (c *Client) FetchAnalysis(appID uint) ([]api.Insight, error) { + selected := c.rich.Application.Select(appID) + insights, err := selected.Analysis.ListInsights() + if err != nil { + return nil, err + } + return insights, nil +} + +// ParseAppID converts the APP_ID string to uint. +func ParseAppID(s string) (uint, error) { + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, err + } + return uint(n), nil +} + +// ClearEnv removes Hub credentials from the process environment +// so child processes (goose) cannot access them. +func ClearEnv() { + os.Unsetenv("HUB_BASE_URL") + os.Unsetenv("HUB_TOKEN") + os.Unsetenv("APP_ID") +} diff --git a/harness/internal/metrics/metrics.go b/harness/internal/metrics/metrics.go deleted file mode 100644 index 08980cc..0000000 --- a/harness/internal/metrics/metrics.go +++ /dev/null @@ -1,91 +0,0 @@ -package metrics - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -type StepTiming struct { - Step string `json:"step"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - Duration time.Duration `json:"-"` - Seconds float64 `json:"duration_seconds"` -} - -type Metrics struct { - Status string `json:"status"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - Seconds float64 `json:"total_seconds"` - Steps []StepTiming `json:"steps"` -} - -type Tracker struct { - startedAt time.Time - steps []StepTiming - current *StepTiming -} - -func NewTracker() *Tracker { - return &Tracker{startedAt: time.Now()} -} - -func (t *Tracker) StartStep(name string) { - if t.current != nil { - t.EndStep() - } - t.current = &StepTiming{ - Step: name, - StartedAt: time.Now(), - } -} - -func (t *Tracker) EndStep() { - if t.current == nil { - return - } - t.current.EndedAt = time.Now() - t.current.Duration = t.current.EndedAt.Sub(t.current.StartedAt) - t.current.Seconds = t.current.Duration.Seconds() - t.steps = append(t.steps, *t.current) - t.current = nil -} - -func (t *Tracker) Generate(status, model, provider string) *Metrics { - if t.current != nil { - t.EndStep() - } - now := time.Now() - return &Metrics{ - Status: status, - Model: model, - Provider: provider, - StartedAt: t.startedAt, - EndedAt: now, - Seconds: now.Sub(t.startedAt).Seconds(), - Steps: t.steps, - } -} - -func (t *Tracker) StepDuration(name string) int { - for _, s := range t.steps { - if s.Step == name { - return int(s.Seconds) - } - } - return 0 -} - -func WriteMetrics(runDir string, m *Metrics) error { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return fmt.Errorf("marshal metrics: %w", err) - } - return os.WriteFile(filepath.Join(runDir, "metrics.json"), data, 0644) -} diff --git a/harness/internal/metrics/metrics_test.go b/harness/internal/metrics/metrics_test.go deleted file mode 100644 index cd27732..0000000 --- a/harness/internal/metrics/metrics_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package metrics - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" -) - -func TestTrackerLifecycle(t *testing.T) { - tracker := NewTracker() - - tracker.StartStep("detect") - time.Sleep(10 * time.Millisecond) - tracker.EndStep() - - tracker.StartStep("plan") - time.Sleep(10 * time.Millisecond) - tracker.EndStep() - - m := tracker.Generate("completed", "test-model", "test-provider") - - if m.Status != "completed" { - t.Errorf("status = %q", m.Status) - } - if len(m.Steps) != 2 { - t.Fatalf("steps = %d, want 2", len(m.Steps)) - } - if m.Steps[0].Step != "detect" { - t.Errorf("step[0] = %q", m.Steps[0].Step) - } - if m.Steps[0].Seconds <= 0 { - t.Error("expected positive duration for detect") - } - if m.Seconds <= 0 { - t.Error("expected positive total duration") - } -} - -func TestTrackerAutoEndOnGenerate(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - m := tracker.Generate("completed", "test-model", "test-provider") - - if len(m.Steps) != 1 { - t.Fatalf("steps = %d, want 1", len(m.Steps)) - } -} - -func TestTrackerAutoEndOnStartStep(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - tracker.StartStep("plan") - tracker.EndStep() - - m := tracker.Generate("completed", "test-model", "test-provider") - if len(m.Steps) != 2 { - t.Fatalf("steps = %d, want 2", len(m.Steps)) - } -} - -func TestStepDuration(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - time.Sleep(50 * time.Millisecond) - tracker.EndStep() - - d := tracker.StepDuration("detect") - if d < 0 { - t.Errorf("duration = %d, want >= 0", d) - } - - if tracker.StepDuration("nonexistent") != 0 { - t.Error("expected 0 for nonexistent step") - } -} - -func TestWriteMetrics(t *testing.T) { - dir := t.TempDir() - m := &Metrics{ - Status: "completed", - StartedAt: time.Now(), - EndedAt: time.Now(), - Seconds: 42.5, - Steps: []StepTiming{ - {Step: "detect", Seconds: 10}, - }, - } - - err := WriteMetrics(dir, m) - if err != nil { - t.Fatalf("WriteMetrics: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, "metrics.json")) - if err != nil { - t.Fatalf("read: %v", err) - } - - var loaded Metrics - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Status != "completed" { - t.Errorf("status = %q", loaded.Status) - } - if loaded.Seconds != 42.5 { - t.Errorf("seconds = %f", loaded.Seconds) - } -} diff --git a/harness/internal/plan/approval.go b/harness/internal/plan/approval.go deleted file mode 100644 index 9a6f11f..0000000 --- a/harness/internal/plan/approval.go +++ /dev/null @@ -1,72 +0,0 @@ -package plan - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -type ApprovalResult int - -const ( - Approved ApprovalResult = iota - Edited - Rejected -) - -var AutoApprove bool - -func PromptApproval(planMDPath string) (ApprovalResult, error) { - content, err := os.ReadFile(planMDPath) - if err != nil { - return Rejected, fmt.Errorf("read PLAN.md: %w", err) - } - - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "══════════════════ PLAN ══════════════════") - fmt.Fprint(os.Stderr, string(content)) - fmt.Fprintln(os.Stderr, "══════════════════════════════════════════") - fmt.Fprintln(os.Stderr) - - if AutoApprove { - logging.Ok("plan auto-approved") - return Approved, nil - } - - reader := bufio.NewReader(os.Stdin) - fmt.Fprint(os.Stderr, "Approve and execute? [y/edit/N]: ") - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - - switch answer { - case "y", "yes": - logging.Ok("plan approved") - return Approved, nil - case "edit", "e": - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - parts := strings.Fields(editor) - if len(parts) == 0 { - parts = []string{"vi"} - } - editorArgs := append(parts[1:], planMDPath) - cmd := exec.Command(parts[0], editorArgs...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return Rejected, fmt.Errorf("editor: %w", err) - } - logging.Ok("plan edited") - return Edited, nil - default: - logging.Info("aborted by user") - return Rejected, nil - } -} diff --git a/harness/internal/plan/context.go b/harness/internal/plan/context.go deleted file mode 100644 index 2da28a7..0000000 --- a/harness/internal/plan/context.go +++ /dev/null @@ -1,147 +0,0 @@ -package plan - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func GatherContext(runDir, skillDir string) (string, error) { - var b strings.Builder - - b.WriteString("=== DETECTION SUMMARY ===\n") - detectData, err := os.ReadFile(filepath.Join(runDir, "detect.json")) - if err != nil { - return "", fmt.Errorf("read detect.json: %w", err) - } - b.Write(detectData) - b.WriteString("\n\n") - - graphPath := filepath.Join(runDir, "graph.json") - if _, err := os.Stat(graphPath); err == nil { - if err := writeGraphContext(&b, graphPath); err != nil { - return "", err - } - } else { - b.WriteString("=== FILE TREE ===\n") - b.WriteString("(Graph not available)\n\n") - } - - refsDir := filepath.Join(skillDir, "references") - if entries, err := os.ReadDir(refsDir); err == nil { - b.WriteString("=== AVAILABLE MIGRATION REFERENCES ===\n") - fmt.Fprintf(&b, "Directory: %s/\n", refsDir) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".md") { - fmt.Fprintf(&b, " - %s\n", e.Name()) - } - } - b.WriteString("\nRead the reference that matches the detected migration type.\n\n") - } - - return b.String(), nil -} - -func writeGraphContext(b *strings.Builder, graphPath string) error { - data, err := os.ReadFile(graphPath) - if err != nil { - return fmt.Errorf("read graph.json: %w", err) - } - var g detect.GraphJSON - if err := json.Unmarshal(data, &g); err != nil { - return fmt.Errorf("parse graph.json: %w", err) - } - - fileTypes := make(map[string]int) - for _, n := range g.Nodes { - if n.SourceFile == "" { - continue - } - parts := strings.Split(n.SourceFile, ".") - if len(parts) > 1 { - ext := parts[len(parts)-1] - if ext != "" { - fileTypes[ext]++ - } - } - } - - b.WriteString("=== CODE GRAPH OVERVIEW ===\n") - ftJSON, _ := json.Marshal(fileTypes) - fmt.Fprintf(b, `{"nodes":%d,"edges":%d,"communities":%d,"file_types":%s}`, - len(g.Nodes), len(g.Links), len(g.Communities), ftJSON) - b.WriteString("\n\n") - - b.WriteString("=== ARCHITECTURAL LAYERS (communities from graph clustering) ===\n") - fmt.Fprintf(b, "Graphify detected %d architectural clusters via community detection.\n", len(g.Communities)) - b.WriteString("These map to natural boundaries in your codebase (layers, modules, subsystems).\n\n") - - type comSize struct { - id string - size int - } - communityMap := make(map[string]int) - for _, c := range g.Communities { - key := fmt.Sprintf("%v", c.ID) - communityMap[key] += len(c.Nodes) - } - var comms []comSize - for id, size := range communityMap { - comms = append(comms, comSize{id, size}) - } - sort.Slice(comms, func(i, j int) bool { return comms[i].size > comms[j].size }) - b.WriteString("Top 10 communities by size:\n") - for i, c := range comms { - if i >= 10 { - break - } - fmt.Fprintf(b, " - Community %s: %d nodes\n", c.id, c.size) - } - b.WriteString("\n") - - b.WriteString("=== GOD NODES (high-degree abstractions - handle with care) ===\n") - b.WriteString("These nodes have many connections - changes here ripple across the system.\n") - type nodeInfo struct { - label string - degree int - sourceFile string - } - var sorted []nodeInfo - for _, n := range g.Nodes { - sorted = append(sorted, nodeInfo{n.Label, n.Degree, n.SourceFile}) - } - sort.Slice(sorted, func(i, j int) bool { return sorted[i].degree > sorted[j].degree }) - for i, n := range sorted { - if i >= 10 { - break - } - sf := n.sourceFile - if sf == "" { - sf = "unknown" - } - fmt.Fprintf(b, " - %s (%d edges) → %s\n", n.label, n.degree, sf) - } - b.WriteString("\nMark god nodes as HIGH RISK in the migration plan.\n\n") - - b.WriteString("=== FILE TREE (from graph - source + config only) ===\n") - var files []string - seen := make(map[string]bool) - for _, n := range g.Nodes { - if n.SourceFile != "" && !seen[n.SourceFile] { - files = append(files, n.SourceFile) - seen[n.SourceFile] = true - } - } - sort.Strings(files) - for _, f := range files { - fmt.Fprintf(b, "%s\n", f) - } - b.WriteString("\n") - - return nil -} diff --git a/harness/internal/plan/context_test.go b/harness/internal/plan/context_test.go deleted file mode 100644 index cab6470..0000000 --- a/harness/internal/plan/context_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func TestGatherContextBasic(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{ - Repo: "/test/repo", - Manifests: detect.Manifests{PomXML: true}, - Files: detect.FileCounts{Java: 10}, - Graph: detect.GraphStats{Nodes: 5, Edges: 3}, - } - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - if !strings.Contains(ctx, "DETECTION SUMMARY") { - t.Error("missing DETECTION SUMMARY") - } - if !strings.Contains(ctx, "pom_xml") { - t.Error("missing pom_xml in context") - } -} - -func TestGatherContextWithGraph(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{Files: detect.FileCounts{Java: 5}} - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - graph := detect.GraphJSON{ - Nodes: []detect.GraphNode{ - {ID: "1", Label: "ClassA", SourceFile: "A.java", Degree: 25}, - {ID: "2", Label: "ClassB", SourceFile: "B.java", Degree: 5}, - }, - Links: []detect.GraphLink{ - {Source: "1", Target: "2", Relation: "imports"}, - }, - Communities: []detect.GraphCommunity{ - {ID: 0, Nodes: []string{"1", "2"}}, - }, - } - gdata, _ := json.Marshal(graph) - os.WriteFile(filepath.Join(runDir, "graph.json"), gdata, 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - checks := []string{ - "CODE GRAPH OVERVIEW", - "ARCHITECTURAL LAYERS", - "GOD NODES", - "FILE TREE", - "ClassA", - "A.java", - } - for _, c := range checks { - if !strings.Contains(ctx, c) { - t.Errorf("missing %q in context", c) - } - } -} - -func TestGatherContextWithRefs(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{Files: detect.FileCounts{Java: 1}} - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - refsDir := filepath.Join(skillDir, "references") - os.MkdirAll(refsDir, 0755) - os.WriteFile(filepath.Join(refsDir, "javaee-quarkus.md"), []byte("# ref"), 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - if !strings.Contains(ctx, "AVAILABLE MIGRATION REFERENCES") { - t.Error("missing references section") - } - if !strings.Contains(ctx, "javaee-quarkus.md") { - t.Error("missing reference file name") - } -} diff --git a/harness/internal/plan/hints.go b/harness/internal/plan/hints.go deleted file mode 100644 index 9265b96..0000000 --- a/harness/internal/plan/hints.go +++ /dev/null @@ -1,55 +0,0 @@ -package plan - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -func WriteHints(repoDir string, plan *Plan, request string) error { - hintsPath := filepath.Join(repoDir, ".goosehints") - - var b strings.Builder - b.WriteString("# AUTO-GENERATED by migration-harness\n") - fmt.Fprintf(&b, "# %s | request: %s\n\n", time.Now().Format(time.RFC3339), request) - - b.WriteString("## TOKEN DISCIPLINE\n") - b.WriteString("- Read ONE file at a time\n") - b.WriteString("- After writing each file: STOP\n") - b.WriteString("- Do NOT re-read migrated files\n") - b.WriteString("- Do NOT compile unless explicitly asked\n\n") - - b.WriteString("## Migration\n") - fmt.Fprintf(&b, "- Source: %s\n", plan.SourceStack) - fmt.Fprintf(&b, "- Target: %s\n", plan.TargetStack) - fmt.Fprintf(&b, "- Type: %s\n\n", plan.MigrationType) - - b.WriteString("## Order\n") - for _, item := range plan.Items { - fmt.Fprintf(&b, "%d. [%s] %s — %s\n", item.N, item.Action, item.Path, item.Notes) - } - b.WriteString("\n") - - b.WriteString("## Checklist\n") - for _, item := range plan.Items { - fmt.Fprintf(&b, "- [ ] %d. %s\n", item.N, item.Path) - } - - return os.WriteFile(hintsPath, []byte(b.String()), 0644) -} - -func UpdateHintsChecklist(repoDir string, itemN int) error { - hintsPath := filepath.Join(repoDir, ".goosehints") - data, err := os.ReadFile(hintsPath) - if err != nil { - return err - } - - old := fmt.Sprintf("- [ ] %d.", itemN) - new := fmt.Sprintf("- [x] %d.", itemN) - content := strings.Replace(string(data), old, new, 1) - - return os.WriteFile(hintsPath, []byte(content), 0644) -} diff --git a/harness/internal/plan/hints_test.go b/harness/internal/plan/hints_test.go deleted file mode 100644 index f850ca5..0000000 --- a/harness/internal/plan/hints_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package plan - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestWriteHints(t *testing.T) { - dir := t.TempDir() - plan := &Plan{ - MigrationType: "java-ee-to-quarkus", - SourceStack: "Java EE 7", - TargetStack: "Quarkus 3", - Items: []PlanItem{ - {N: 1, Path: "pom.xml", Action: "migrate", Notes: "Update deps"}, - {N: 2, Path: "src/main/java/Foo.java", Action: "migrate", Notes: "javax to jakarta"}, - }, - } - - err := WriteHints(dir, plan, "Migrate to Quarkus") - if err != nil { - t.Fatalf("WriteHints: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".goosehints")) - if err != nil { - t.Fatalf("read .goosehints: %v", err) - } - content := string(data) - - checks := []string{ - "TOKEN DISCIPLINE", - "Source: Java EE 7", - "Target: Quarkus 3", - "Type: java-ee-to-quarkus", - "1. [migrate] pom.xml", - "- [ ] 1. pom.xml", - "- [ ] 2. src/main/java/Foo.java", - } - for _, check := range checks { - if !strings.Contains(content, check) { - t.Errorf("missing %q in .goosehints", check) - } - } -} - -func TestUpdateHintsChecklist(t *testing.T) { - dir := t.TempDir() - hintsPath := filepath.Join(dir, ".goosehints") - initial := "## Checklist\n- [ ] 1. pom.xml\n- [ ] 2. Foo.java\n" - os.WriteFile(hintsPath, []byte(initial), 0644) - - err := UpdateHintsChecklist(dir, 1) - if err != nil { - t.Fatalf("UpdateHintsChecklist: %v", err) - } - - data, _ := os.ReadFile(hintsPath) - content := string(data) - if !strings.Contains(content, "- [x] 1.") { - t.Error("expected item 1 to be checked") - } - if !strings.Contains(content, "- [ ] 2.") { - t.Error("expected item 2 to remain unchecked") - } -} diff --git a/harness/internal/plan/parser.go b/harness/internal/plan/parser.go deleted file mode 100644 index e976f49..0000000 --- a/harness/internal/plan/parser.go +++ /dev/null @@ -1,246 +0,0 @@ -package plan - -import ( - "regexp" - "strconv" - "strings" -) - -var ( - reStepHeader = regexp.MustCompile(`^### Step (\d+):\s*(.*)`) - reFileField = regexp.MustCompile(`^- File:\s*(.+)`) - reActionField = regexp.MustCompile(`^- Action:\s*(\w+)`) - - reItemLine = regexp.MustCompile("^(\\d+)\\.\\s+(?:\\*\\*(?:DELETE|REMOVE)[:\\*]*\\s*)?`([^`]+)`(.*)") - - reSectionHeader = regexp.MustCompile(`^##\s`) - reNextItem = regexp.MustCompile(`^\d+\.\s+`) - - reMigrationType = map[string]*regexp.Regexp{ - "java-ee-to-quarkus": regexp.MustCompile(`(?i)quarkus|java.?ee|jakarta|weblogic`), - "python2-to-python3": regexp.MustCompile(`(?i)python.?[23]|py2|py3`), - "react-class-to-hooks": regexp.MustCompile(`(?i)react|hooks|class.?component`), - "dotnet-upgrade": regexp.MustCompile(`(?i)\.net|asp\.net|csharp|c#|\.NET\s*(Core|Framework)`), - "spring-boot-upgrade": regexp.MustCompile(`(?i)spring.?boot|spring.?framework`), - } - - reSource = regexp.MustCompile(`(?i)\b(?:from|source)[:\s]+(.+?)(?:\n|→|->)`) - reTarget = regexp.MustCompile(`(?i)(?:\b(?:to|target)[:\s]+|(?:→|->)\s*)(.+?)(?:\n|$)`) -) - -func ParsePlanMD(content string) *Plan { - items := tryFormatA(content) - if len(items) == 0 { - items = tryFormatBC(content) - } - - for i := range items { - items[i].Layer = assignLayer(items[i].Path) - } - - mt := detectMigrationType(content) - src := extractMatch(reSource, content) - tgt := extractMatch(reTarget, content) - - return &Plan{ - MigrationType: mt, - SourceStack: src, - TargetStack: tgt, - Items: items, - } -} - -func tryFormatA(content string) []PlanItem { - lines := strings.Split(content, "\n") - var items []PlanItem - var current *PlanItem - var bodyLines []string - - flushCurrent := func() { - if current != nil { - for _, bl := range bodyLines { - bl = strings.TrimSpace(bl) - if m := reFileField.FindStringSubmatch(bl); m != nil { - current.Path = strings.TrimSpace(m[1]) - } - if m := reActionField.FindStringSubmatch(bl); m != nil { - current.Action = normalizeAction(strings.TrimSpace(m[1])) - } - } - if current.Action == "" { - current.Action = "migrate" - } - items = append(items, *current) - current = nil - bodyLines = nil - } - } - - for _, line := range lines { - if m := reStepHeader.FindStringSubmatch(line); m != nil { - flushCurrent() - n, _ := strconv.Atoi(m[1]) - title := strings.TrimSpace(m[2]) - title = strings.TrimRight(title, " ✅") - risk := "low" - if strings.Contains(title, "⚠️") || strings.Contains(strings.ToUpper(title), "COMPLEX") { - risk = "high" - } - current = &PlanItem{N: n, Notes: title, Risk: risk} - bodyLines = nil - continue - } - if current != nil { - if reSectionHeader.MatchString(line) || strings.HasPrefix(line, "### Step") { - flushCurrent() - } else { - bodyLines = append(bodyLines, line) - } - } - } - flushCurrent() - - return items -} - -func tryFormatBC(content string) []PlanItem { - lines := strings.Split(content, "\n") - var items []PlanItem - - for i, line := range lines { - m := reItemLine.FindStringSubmatch(line) - if m == nil { - continue - } - n, _ := strconv.Atoi(m[1]) - path := strings.TrimSpace(m[2]) - rest := strings.TrimSpace(m[3]) - fullLine := m[0] - - notes := collectNotes(lines, i+1, path) - actionContext := fullLine + " " + notes - action := detectAction(actionContext) - risk := "low" - if strings.Contains(fullLine, "⚠️") || strings.Contains(strings.ToUpper(fullLine), "COMPLEX") { - risk = "high" - } - - items = append(items, PlanItem{ - N: n, - Path: path, - Action: action, - Risk: risk, - Notes: notes, - }) - _ = rest - } - - return items -} - -func collectNotes(lines []string, startIdx int, fallback string) string { - var noteLines []string - for j := startIdx; j < len(lines) && len(noteLines) < 3; j++ { - l := strings.TrimSpace(lines[j]) - if l == "" { - continue - } - if reNextItem.MatchString(l) || reSectionHeader.MatchString(l) { - break - } - if strings.HasPrefix(l, "-") || strings.HasPrefix(l, "*") { - cleaned := strings.TrimLeft(l, "- *") - noteLines = append(noteLines, strings.TrimSpace(cleaned)) - } - } - if len(noteLines) == 0 { - return fallback - } - return strings.Join(noteLines, "; ") -} - -func detectAction(line string) string { - upper := strings.ToUpper(line) - if strings.Contains(upper, "DELETE") || strings.Contains(upper, "REMOVE") { - return "delete" - } - if strings.Contains(upper, "CREATE") { - return "create" - } - return "migrate" -} - -func normalizeAction(action string) string { - switch strings.ToLower(action) { - case "modify": - return "migrate" - case "create": - return "create" - case "delete": - return "delete" - default: - return strings.ToLower(action) - } -} - -func assignLayer(path string) string { - p := strings.ToLower(path) - - buildFiles := []string{"pom.xml", "package.json", "build.gradle", ".csproj", ".sln", - "cargo.toml", "gemfile", "go.mod", "requirements.txt", "pyproject.toml", "setup.py"} - for _, bf := range buildFiles { - if strings.Contains(p, bf) { - return "build" - } - } - - configFiles := []string{"application.properties", "application.yml", "appsettings", - "web.config", ".env", "config.yaml", "config.json", - "persistence.xml", "web.xml", "beans.xml", - "global.asax", "startup.cs", "program.cs"} - for _, cf := range configFiles { - if strings.Contains(p, cf) { - return "config" - } - } - - layerDirs := map[string][]string{ - "model": {"/model/", "/models/", "/domain/", "/entity/", "/entities/"}, - "service": {"/service/", "/services/"}, - "api": {"/rest/", "/controller/", "/controllers/", "/api/", "/endpoint/", "/endpoints/", "/handler/", "/handlers/"}, - "util": {"/utils/", "/util/", "/helper/", "/helpers/", "/common/"}, - "persistence": {"/persistence/", "/repository/", "/repositories/", "/dao/", "/data/"}, - "view": {"/views/", "/pages/"}, - } - for layer, dirs := range layerDirs { - for _, d := range dirs { - if strings.Contains(p, d) { - return layer - } - } - } - - if strings.Contains(p, "weblogic/") { - return "cleanup" - } - - return "unknown" -} - -func detectMigrationType(content string) string { - order := []string{"java-ee-to-quarkus", "python2-to-python3", "react-class-to-hooks", "dotnet-upgrade", "spring-boot-upgrade"} - for _, mt := range order { - if reMigrationType[mt].MatchString(content) { - return mt - } - } - return "custom" -} - -func extractMatch(re *regexp.Regexp, content string) string { - m := re.FindStringSubmatch(content) - if len(m) > 1 { - return strings.TrimSpace(m[1]) - } - return "" -} diff --git a/harness/internal/plan/parser_test.go b/harness/internal/plan/parser_test.go deleted file mode 100644 index 981a657..0000000 --- a/harness/internal/plan/parser_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package plan - -import ( - "testing" -) - -const formatA = `# Migration Plan - -### Step 1: Update build configuration ✅ -- File: pom.xml -- Action: MODIFY -- Convert Maven dependencies from Java EE to Quarkus - -### Step 2: Migrate data model ⚠️ COMPLEX -- File: src/main/java/model/User.java -- Action: MODIFY -- Convert javax.persistence to jakarta.persistence - -### Step 3: Create new config -- File: src/main/resources/application.properties -- Action: CREATE -- Add Quarkus configuration - -## Verification -Build and run tests. -` - -const formatB = "# Migration Plan\n\n" + - "1. `pom.xml`\n" + - " - Convert Maven dependencies to Quarkus BOM\n" + - " - Remove Java EE dependencies\n" + - "2. `src/main/java/model/User.java`\n" + - " - Convert javax to jakarta imports\n" + - "3. `src/main/java/service/UserService.java` ⚠️\n" + - " - Complex EJB to CDI conversion\n" + - "4. `src/main/resources/application.properties`\n" + - " - **CREATE NEW** Quarkus configuration\n\n" + - "## Notes\nDone.\n" - -const formatC = "# Migration Plan\n\n" + - "1. `pom.xml`\n" + - " - Update dependencies\n" + - "28. `src/main/java/model/Entity.java`\n" + - " - Migrate annotations\n" + - "29. **DELETE:** `src/main/webapp/weblogic-application.xml`\n" + - " - Remove WebLogic-specific descriptor\n" - -func TestParseFormatA(t *testing.T) { - plan := ParsePlanMD(formatA) - - if len(plan.Items) != 3 { - t.Fatalf("items = %d, want 3", len(plan.Items)) - } - - item1 := plan.Items[0] - if item1.N != 1 { - t.Errorf("item1.N = %d, want 1", item1.N) - } - if item1.Path != "pom.xml" { - t.Errorf("item1.Path = %q, want pom.xml", item1.Path) - } - if item1.Action != "migrate" { - t.Errorf("item1.Action = %q, want migrate", item1.Action) - } - if item1.Risk != "low" { - t.Errorf("item1.Risk = %q, want low", item1.Risk) - } - if item1.Layer != "build" { - t.Errorf("item1.Layer = %q, want build", item1.Layer) - } - - item2 := plan.Items[1] - if item2.Risk != "high" { - t.Errorf("item2.Risk = %q, want high", item2.Risk) - } - if item2.Layer != "model" { - t.Errorf("item2.Layer = %q, want model", item2.Layer) - } - - item3 := plan.Items[2] - if item3.Action != "create" { - t.Errorf("item3.Action = %q, want create", item3.Action) - } - if item3.Layer != "config" { - t.Errorf("item3.Layer = %q, want config", item3.Layer) - } -} - -func TestParseFormatB(t *testing.T) { - plan := ParsePlanMD(formatB) - - if len(plan.Items) != 4 { - t.Fatalf("items = %d, want 4", len(plan.Items)) - } - - if plan.Items[0].Path != "pom.xml" { - t.Errorf("item0.Path = %q, want pom.xml", plan.Items[0].Path) - } - if plan.Items[0].Layer != "build" { - t.Errorf("item0.Layer = %q, want build", plan.Items[0].Layer) - } - - if plan.Items[2].Risk != "high" { - t.Errorf("item2.Risk = %q, want high (⚠️)", plan.Items[2].Risk) - } - if plan.Items[2].Layer != "service" { - t.Errorf("item2.Layer = %q, want service", plan.Items[2].Layer) - } - - if plan.Items[3].Action != "create" { - t.Errorf("item3.Action = %q, want create", plan.Items[3].Action) - } -} - -func TestParseFormatC_Delete(t *testing.T) { - plan := ParsePlanMD(formatC) - - if len(plan.Items) != 3 { - t.Fatalf("items = %d, want 3", len(plan.Items)) - } - - del := plan.Items[2] - if del.N != 29 { - t.Errorf("del.N = %d, want 29", del.N) - } - if del.Action != "delete" { - t.Errorf("del.Action = %q, want delete", del.Action) - } - if del.Path != "src/main/webapp/weblogic-application.xml" { - t.Errorf("del.Path = %q", del.Path) - } -} - -func TestMigrationType(t *testing.T) { - tests := []struct { - content string - want string - }{ - {"Migrate from Java EE to Quarkus", "java-ee-to-quarkus"}, - {"Convert WebLogic app to Jakarta EE", "java-ee-to-quarkus"}, - {"Upgrade from Python 2 to Python 3", "python2-to-python3"}, - {"Convert React class components to hooks", "react-class-to-hooks"}, - {"Upgrade .NET Framework to .NET Core", "dotnet-upgrade"}, - {"Migrate Spring Boot 2 to Spring Boot 3", "spring-boot-upgrade"}, - {"Something entirely custom", "custom"}, - } - - for _, tt := range tests { - got := detectMigrationType(tt.content) - if got != tt.want { - t.Errorf("detectMigrationType(%q) = %q, want %q", tt.content[:30], got, tt.want) - } - } -} - -func TestAssignLayer(t *testing.T) { - tests := []struct { - path string - want string - }{ - {"pom.xml", "build"}, - {"package.json", "build"}, - {"src/main/resources/application.properties", "config"}, - {"src/main/java/model/User.java", "model"}, - {"src/main/java/service/UserService.java", "service"}, - {"src/main/java/rest/UserEndpoint.java", "api"}, - {"src/main/java/controller/HomeController.java", "api"}, - {"src/main/java/utils/StringHelper.java", "util"}, - {"src/main/java/repository/UserRepo.java", "persistence"}, - {"src/main/webapp/views/index.jsp", "view"}, - {"src/main/java/SomeRandom.java", "unknown"}, - } - - for _, tt := range tests { - got := assignLayer(tt.path) - if got != tt.want { - t.Errorf("assignLayer(%q) = %q, want %q", tt.path, got, tt.want) - } - } -} - -func TestCollectNotes(t *testing.T) { - lines := []string{ - "1. `pom.xml`", - " - Convert Maven dependencies", - " - Remove old deps", - "2. `next.java`", - } - notes := collectNotes(lines, 1, "fallback") - if notes == "fallback" { - t.Error("expected notes from bullet points, got fallback") - } -} diff --git a/harness/internal/plan/plan.go b/harness/internal/plan/plan.go deleted file mode 100644 index 0b93e33..0000000 --- a/harness/internal/plan/plan.go +++ /dev/null @@ -1,129 +0,0 @@ -package plan - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" -) - -func Run(ctx context.Context, repoDir, runDir, request string, skillDir string, runner goose.Runner) (*Plan, error) { - logging.Header("Step 2: Plan") - - logging.Info("2a. gathering project context...") - contextText, err := GatherContext(runDir, skillDir) - if err != nil { - return nil, fmt.Errorf("gather context: %w", err) - } - ctxPath := filepath.Join(runDir, "plan-context.txt") - os.WriteFile(ctxPath, []byte(contextText), 0644) - logging.Ok("2a. gathered %d bytes of project context", len(contextText)) - - logging.Info("2b. loading planner skill and migration references...") - refCount := countRefs(skillDir) - logging.Ok("2b. loaded planner skill + %d reference doc(s)", refCount) - - logging.Info("2c. building plan recipe...") - recipe := RenderRecipe(repoDir, request, contextText, skillDir) - recipePath := filepath.Join(runDir, "plan-recipe.yaml") - os.WriteFile(recipePath, []byte(recipe), 0644) - logging.Ok("2c. recipe ready (%dKB)", len(recipe)/1024) - - turns := CalcPlanTurns(runDir, skillDir) - logging.Info("2d. running goose planner (max %d turns)...", turns) - logging.Info(" this is the LLM step — may take 30-90s depending on model") - - _, err = runner.RunRecipe(ctx, recipePath, turns, nil) - if err != nil { - logging.Warn("goose planner: %v", err) - } - - planMD := filepath.Join(repoDir, "PLAN.md") - if _, err := os.Stat(planMD); os.IsNotExist(err) { - return nil, fmt.Errorf("plan failed — PLAN.md not written; see %s/logs/", runDir) - } - - copyFile(planMD, filepath.Join(runDir, "PLAN.md")) - logging.Ok("2e. PLAN.md written") - - logging.Info("2f. parsing PLAN.md → plan.json...") - content, err := os.ReadFile(planMD) - if err != nil { - return nil, fmt.Errorf("read PLAN.md: %w", err) - } - plan := ParsePlanMD(string(content)) - - planJSON, err := json.MarshalIndent(plan, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal plan.json: %w", err) - } - planJSONPath := filepath.Join(runDir, "plan.json") - if err := os.WriteFile(planJSONPath, planJSON, 0644); err != nil { - return nil, fmt.Errorf("write plan.json: %w", err) - } - logging.Ok("2f. parsed %d items from PLAN.md", len(plan.Items)) - - approval, err := PromptApproval(planMD) - if err != nil { - return nil, fmt.Errorf("approval: %w", err) - } - switch approval { - case Rejected: - return nil, fmt.Errorf("plan rejected by user") - case Edited: - copyFile(planMD, filepath.Join(runDir, "PLAN.md")) - content, err = os.ReadFile(planMD) - if err != nil { - logging.Warn("re-read edited PLAN.md: %v", err) - } - plan = ParsePlanMD(string(content)) - planJSON, err = json.MarshalIndent(plan, "", " ") - if err != nil { - logging.Warn("re-marshal plan.json: %v", err) - } else if err := os.WriteFile(planJSONPath, planJSON, 0644); err != nil { - logging.Warn("re-write plan.json: %v", err) - } - logging.Ok("2g. plan edited and re-parsed") - case Approved: - logging.Ok("2g. plan approved") - } - - logging.Info("2h. writing .goosehints...") - if err := WriteHints(repoDir, plan, request); err != nil { - return nil, fmt.Errorf("write hints: %w", err) - } - logging.Ok("2h. .goosehints written") - - logging.Ok("Step 2/5 complete") - return plan, nil -} - -func countRefs(skillDir string) int { - refsDir := filepath.Join(skillDir, "references") - entries, err := os.ReadDir(refsDir) - if err != nil { - return 0 - } - count := 0 - for _, e := range entries { - if filepath.Ext(e.Name()) == ".md" { - count++ - } - } - return count -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - logging.Warn("copyFile: read %s: %v", src, err) - return - } - if err := os.WriteFile(dst, data, 0644); err != nil { - logging.Warn("copyFile: write %s: %v", dst, err) - } -} diff --git a/harness/internal/plan/recipe.go b/harness/internal/plan/recipe.go deleted file mode 100644 index 2d76904..0000000 --- a/harness/internal/plan/recipe.go +++ /dev/null @@ -1,113 +0,0 @@ -package plan - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -func RenderRecipe(repoDir, request, context, skillDir string) string { - var plannerSkill string - skillPath := filepath.Join(skillDir, "skills", "migration-plan", "SKILL.md") - data, err := os.ReadFile(skillPath) - if err != nil { - logging.Warn("failed to read planner skill at %s: %v", skillPath, err) - plannerSkill = indentBlock(fmt.Sprintf("WARNING: planner skill not found at %s", skillPath), 4) - } else { - plannerSkill = indentBlock(string(data), 4) - } - - indentedCtx := indentBlock(context, 4) - - var b strings.Builder - b.WriteString(`version: "1.0.0" -title: "Migration plan" -description: "Generate PLAN.md using the planner skill." - -settings: - temperature: 1 - -extensions: - - type: builtin - name: developer - timeout: 600 - bundled: true - -instructions: | - You are the planner sub-skill. Your ONLY job is to produce PLAN.md in the - repo root. Do NOT modify any source files. - - === PLANNER SKILL === -`) - b.WriteString(plannerSkill) - b.WriteString("\n === END PLANNER SKILL ===\n\n") - b.WriteString(" === PRE-GATHERED CONTEXT ===\n") - b.WriteString(" The following has been pre-collected: detect.json, source file tree,\n") - b.WriteString(" and build manifests. Do NOT re-run these discovery commands.\n") - b.WriteString(indentedCtx) - b.WriteString("\n === END PRE-GATHERED CONTEXT ===\n\n") - b.WriteString(` YOUR JOB (follow this order strictly): - - PHASE 1 — Quick scan (max 3 reads): - 1. Read the pre-gathered context above (detect.json, file tree) — already done. - 2. Read the build manifest (pom.xml, package.json, .csproj, etc.) — 1 read. - 3. Check AVAILABLE REFERENCES list. If one matches, read it — 1 read. - You MUST report which reference file you read in your final response. - - PHASE 2 — Write a DRAFT PLAN.md: - 4. Based on what you know so far, write PLAN.md to `) - b.WriteString(repoDir) - b.WriteString(`/PLAN.md NOW. - For files you haven't read, make your best guess from the file name - and mark uncertain steps with ⚠️. - - PHASE 3 — Refine (max 5 reads): - 5. Read ONLY the source files where your guess might be wrong — complex - patterns like MDBs, JNDI lookups, lifecycle listeners, etc. - 6. Update PLAN.md with corrections based on what you read. - If no corrections needed, skip this phase. - - RULES: - - Write PLAN.md BEFORE reading source files. Draft first, refine after. - - Max 8 file reads total across all phases. - - If uncertain about a file, mark it ⚠️ and move on. Do NOT read every file. - - In your response, report which reference file you read (or "none" if no match). - -prompt: | - Repo: `) - b.WriteString(repoDir) - b.WriteString("\n Migration request: ") - b.WriteString(request) - b.WriteString(` - - Follow the planner skill phases. All discovery data and references are - already in your instructions. Read only complex source files you need, - then write PLAN.md. - -response: - json_schema: - type: object - required: [plan_written, step_count, reference_used] - properties: - plan_written: { type: boolean } - step_count: { type: integer } - complex_count: { type: integer } - reference_used: { type: string, description: "Name of reference file read, or 'none'" } - summary: { type: string } -`) - return b.String() -} - -func indentBlock(s string, spaces int) string { - prefix := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, l := range lines { - if l != "" { - lines[i] = prefix + l - } - } - return strings.Join(lines, "\n") -} diff --git a/harness/internal/plan/turns.go b/harness/internal/plan/turns.go deleted file mode 100644 index a701711..0000000 --- a/harness/internal/plan/turns.go +++ /dev/null @@ -1,102 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func CalcPlanTurns(runDir, skillDir string) int { - turns := 10 - - detectPath := filepath.Join(runDir, "detect.json") - data, err := os.ReadFile(detectPath) - if err != nil { - return clampTurns(turns) - } - - var dr detect.DetectResult - if err := json.Unmarshal(data, &dr); err != nil { - return clampTurns(turns) - } - - if !hasMatchingReference(skillDir, dr) { - turns += 2 - } - - primaryFiles := maxLangCount(dr.Files) - turns += primaryFiles / 50 - - langCount := countLanguages(dr.Files) - if langCount > 2 { - turns += 3 - } - - return clampTurns(turns) -} - -func hasMatchingReference(skillDir string, dr detect.DetectResult) bool { - refsDir := filepath.Join(skillDir, "references") - if _, err := os.Stat(refsDir); err != nil { - return false - } - - if dr.Manifests.PomXML { - if _, err := os.Stat(filepath.Join(refsDir, "javaee-quarkus.md")); err == nil { - return true - } - } - - if dr.Files.Python > 0 { - entries, err := os.ReadDir(refsDir) - if err == nil { - for _, e := range entries { - if strings.Contains(strings.ToLower(e.Name()), "python") { - return true - } - } - } - } - - if dr.Files.CSharp > 0 { - if _, err := os.Stat(filepath.Join(refsDir, "dotnet-framework-to-core.md")); err == nil { - return true - } - } - - return false -} - -func maxLangCount(fc detect.FileCounts) int { - counts := []int{fc.Java, fc.Python, fc.JavaScript, fc.TypeScript, fc.Go, fc.Rust, fc.CSharp, fc.Ruby} - max := 0 - for _, c := range counts { - if c > max { - max = c - } - } - return max -} - -func countLanguages(fc detect.FileCounts) int { - n := 0 - for _, c := range []int{fc.Java, fc.Python, fc.JavaScript, fc.TypeScript, fc.Go, fc.Rust, fc.CSharp, fc.Ruby} { - if c > 0 { - n++ - } - } - return n -} - -func clampTurns(turns int) int { - if turns < 12 { - return 12 - } - if turns > 50 { - return 50 - } - return turns -} diff --git a/harness/internal/plan/turns_test.go b/harness/internal/plan/turns_test.go deleted file mode 100644 index d7f9a29..0000000 --- a/harness/internal/plan/turns_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func writeDetectJSON(t *testing.T, dir string, dr detect.DetectResult) { - t.Helper() - data, _ := json.Marshal(dr) - os.WriteFile(filepath.Join(dir, "detect.json"), data, 0644) -} - -func TestCalcPlanTurnsDefault(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 10}, - Manifests: detect.Manifests{PomXML: true}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 12 { - t.Errorf("turns = %d, want >= 12", turns) - } -} - -func TestCalcPlanTurnsMultiLang(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 10, Python: 5, Go: 3}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 15 { - t.Errorf("turns = %d, want >= 15 for multi-lang", turns) - } -} - -func TestCalcPlanTurnsLargeProject(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 200}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 14 { - t.Errorf("turns = %d, want >= 14 for 200 files", turns) - } -} - -func TestCalcPlanTurnsClamp(t *testing.T) { - if c := clampTurns(5); c != 12 { - t.Errorf("clampTurns(5) = %d, want 12", c) - } - if c := clampTurns(100); c != 50 { - t.Errorf("clampTurns(100) = %d, want 50", c) - } - if c := clampTurns(25); c != 25 { - t.Errorf("clampTurns(25) = %d, want 25", c) - } -} - -func TestMaxLangCount(t *testing.T) { - fc := detect.FileCounts{Java: 100, Python: 50, Go: 200} - if m := maxLangCount(fc); m != 200 { - t.Errorf("maxLangCount = %d, want 200", m) - } -} - -func TestCountLanguages(t *testing.T) { - fc := detect.FileCounts{Java: 10, Python: 0, Go: 5} - if c := countLanguages(fc); c != 2 { - t.Errorf("countLanguages = %d, want 2", c) - } -} diff --git a/harness/internal/plan/types.go b/harness/internal/plan/types.go deleted file mode 100644 index 9ac9fa6..0000000 --- a/harness/internal/plan/types.go +++ /dev/null @@ -1,17 +0,0 @@ -package plan - -type Plan struct { - MigrationType string `json:"migration_type"` - SourceStack string `json:"source_stack"` - TargetStack string `json:"target_stack"` - Items []PlanItem `json:"items"` -} - -type PlanItem struct { - N int `json:"n"` - Path string `json:"path"` - Action string `json:"action"` - Risk string `json:"risk"` - Notes string `json:"notes"` - Layer string `json:"layer"` -} diff --git a/harness/internal/rundir/rundir.go b/harness/internal/rundir/rundir.go deleted file mode 100644 index 87a9c86..0000000 --- a/harness/internal/rundir/rundir.go +++ /dev/null @@ -1,52 +0,0 @@ -package rundir - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "time" -) - -func New(runsDir, repoName string) (string, error) { - ts := time.Now().Format("20060102-150405") - dir := filepath.Join(runsDir, fmt.Sprintf("%s-%s", repoName, ts)) - logsDir := filepath.Join(dir, "logs") - if err := os.MkdirAll(logsDir, 0755); err != nil { - return "", fmt.Errorf("create run dir: %w", err) - } - return dir, nil -} - -func Latest(runsDir string) (string, error) { - entries, err := os.ReadDir(runsDir) - if err != nil { - return "", fmt.Errorf("read runs dir: %w", err) - } - - var dirs []os.DirEntry - for _, e := range entries { - if e.IsDir() { - dirs = append(dirs, e) - } - } - if len(dirs) == 0 { - return "", fmt.Errorf("no runs found in %s", runsDir) - } - - sort.Slice(dirs, func(i, j int) bool { - infoI, _ := dirs[i].Info() - infoJ, _ := dirs[j].Info() - if infoI == nil || infoJ == nil { - return dirs[i].Name() < dirs[j].Name() - } - return infoI.ModTime().After(infoJ.ModTime()) - }) - - return filepath.Join(runsDir, dirs[0].Name()), nil -} - -func DefaultRunsDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".migration-harness", "runs") -} diff --git a/harness/internal/rundir/rundir_test.go b/harness/internal/rundir/rundir_test.go deleted file mode 100644 index 7ca0908..0000000 --- a/harness/internal/rundir/rundir_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package rundir - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestNew(t *testing.T) { - dir := t.TempDir() - - runDir, err := New(dir, "myapp") - if err != nil { - t.Fatalf("New: %v", err) - } - - if _, err := os.Stat(runDir); os.IsNotExist(err) { - t.Fatalf("run dir not created: %s", runDir) - } - - logsDir := filepath.Join(runDir, "logs") - if _, err := os.Stat(logsDir); os.IsNotExist(err) { - t.Fatalf("logs dir not created: %s", logsDir) - } -} - -func TestLatest(t *testing.T) { - dir := t.TempDir() - - os.MkdirAll(filepath.Join(dir, "app-older"), 0755) - time.Sleep(10 * time.Millisecond) - os.MkdirAll(filepath.Join(dir, "app-newer"), 0755) - - latest, err := Latest(dir) - if err != nil { - t.Fatalf("Latest: %v", err) - } - - if filepath.Base(latest) != "app-newer" { - t.Errorf("Latest = %s, want app-newer", filepath.Base(latest)) - } -} - -func TestLatestEmpty(t *testing.T) { - dir := t.TempDir() - - _, err := Latest(dir) - if err == nil { - t.Fatal("expected error for empty runs dir") - } -} diff --git a/harness/internal/verify/verify.go b/harness/internal/verify/verify.go deleted file mode 100644 index 61c3404..0000000 --- a/harness/internal/verify/verify.go +++ /dev/null @@ -1,158 +0,0 @@ -package verify - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" -) - -type VerifyError struct { - File string `json:"file"` - Line int `json:"line,omitempty"` - Message string `json:"message"` -} - -type VerifyResult struct { - BuildOk bool `json:"build_ok"` - TestsPassed int `json:"tests_passed"` - TestsTotal int `json:"tests_total"` - Errors []VerifyError `json:"errors"` - FixAttempts int `json:"fix_attempts"` - FixesApplied string `json:"fixes_applied,omitempty"` - Summary string `json:"summary,omitempty"` -} - -func Run(ctx context.Context, repoDir, runDir, recipesDir, migrationType string, runner goose.Runner) (*VerifyResult, error) { - logging.Header("Step 4: Verify") - logging.Info("verifying build + tests (migration: %s)", migrationType) - - outPath := filepath.Join(runDir, "verify.json") - - result, err := runVerify(ctx, runner, recipesDir, repoDir, runDir, migrationType) - if err != nil { - return nil, err - } - - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - logging.Warn("marshal verify.json: %v", err) - } else if err := os.WriteFile(outPath, data, 0644); err != nil { - logging.Warn("write verify.json: %v", err) - } - - if result.FixAttempts > 0 { - logging.Info("fix attempts: %d", result.FixAttempts) - } - - if result.BuildOk { - logging.Ok("build: OK | tests: %d/%d passed | errors: %d", - result.TestsPassed, result.TestsTotal, len(result.Errors)) - } else { - logging.Err("build: FAILED | tests: %d/%d passed | errors: %d", - result.TestsPassed, result.TestsTotal, len(result.Errors)) - } - - if len(result.Errors) > 0 { - logging.Info("first errors:") - limit := 3 - if len(result.Errors) < limit { - limit = len(result.Errors) - } - for _, e := range result.Errors[:limit] { - logging.Info(" %s:%d — %s", e.File, e.Line, e.Message) - } - } - - writeVerifyReport(filepath.Join(runDir, "verification-report.md"), result, migrationType) - if err := copyFile(filepath.Join(runDir, "verification-report.md"), filepath.Join(repoDir, "verification-report.md")); err != nil { - logging.Warn("copy verification report to repo: %v", err) - } - - logging.Ok("Step 4/5 complete") - return result, nil -} - -func runVerify(ctx context.Context, runner goose.Runner, recipesDir, repoDir, runDir, migrationType string) (*VerifyResult, error) { - recipeFile := filepath.Join(recipesDir, "verify.yaml") - output, err := runner.RunRecipe(ctx, recipeFile, 50, map[string]string{ - "repo": repoDir, - "plan_md_path": filepath.Join(runDir, "PLAN.md"), - "execution_log_path": filepath.Join(repoDir, "execution-log.md"), - "migration_type": migrationType, - }) - if err != nil { - return &VerifyResult{ - BuildOk: false, - Errors: []VerifyError{{Message: err.Error()}}, - Summary: fmt.Sprintf("goose verify failed: %v", err), - }, nil - } - - var result VerifyResult - if err := json.Unmarshal(output, &result); err != nil { - return &VerifyResult{ - BuildOk: false, - Errors: []VerifyError{{Message: fmt.Sprintf("parse goose output: %v", err)}}, - }, nil - } - - return &result, nil -} - -func writeVerifyReport(path string, r *VerifyResult, migrationType string) { - var b strings.Builder - b.WriteString("# Verification Report\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Timestamp:** %s\n\n", time.Now().Format(time.RFC3339)) - - b.WriteString("## Build Status\n\n") - if r.BuildOk { - b.WriteString("- Compilation: **SUCCESS**\n") - } else { - b.WriteString("- Compilation: **FAILED**\n") - } - fmt.Fprintf(&b, "- Tests: %d/%d passed\n\n", r.TestsPassed, r.TestsTotal) - - if r.FixAttempts > 0 { - b.WriteString("## Auto-Fix Attempts\n\n") - fmt.Fprintf(&b, "- Fix iterations: %d\n", r.FixAttempts) - if r.FixesApplied != "" { - fmt.Fprintf(&b, "- Fixes applied: %s\n", r.FixesApplied) - } - b.WriteString("\n") - } - - if len(r.Errors) > 0 { - fmt.Fprintf(&b, "## Remaining Errors (%d total)\n\n", len(r.Errors)) - for _, e := range r.Errors { - fmt.Fprintf(&b, "### %s:%d\n\n```\n%s\n```\n\n", e.File, e.Line, e.Message) - } - } - - b.WriteString("## Summary\n\n") - if r.Summary != "" { - b.WriteString(r.Summary) - } else { - b.WriteString("No summary provided") - } - b.WriteString("\n") - - if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { - logging.Warn("write verify report %s: %v", path, err) - } -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return fmt.Errorf("read %s: %w", src, err) - } - return os.WriteFile(dst, data, 0644) -} diff --git a/harness/internal/verify/verify_test.go b/harness/internal/verify/verify_test.go deleted file mode 100644 index 075b107..0000000 --- a/harness/internal/verify/verify_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package verify - -import ( - "os" - "strings" - "testing" -) - -func TestWriteVerifyReport(t *testing.T) { - dir := t.TempDir() - path := dir + "/report.md" - - r := &VerifyResult{ - BuildOk: true, - TestsPassed: 42, - TestsTotal: 42, - Errors: nil, - Summary: "All tests pass", - } - writeVerifyReport(path, r, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - checks := []string{ - "Verification Report", - "SUCCESS", - "42/42", - "All tests pass", - } - for _, c := range checks { - if !strings.Contains(content, c) { - t.Errorf("missing %q in report", c) - } - } -} - -func TestWriteVerifyReportWithErrors(t *testing.T) { - dir := t.TempDir() - path := dir + "/report.md" - - r := &VerifyResult{ - BuildOk: false, - TestsPassed: 10, - TestsTotal: 15, - Errors: []VerifyError{ - {File: "Foo.java", Line: 42, Message: "cannot find symbol"}, - }, - FixAttempts: 2, - FixesApplied: "fixed import", - } - writeVerifyReport(path, r, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - if !strings.Contains(content, "FAILED") { - t.Error("missing FAILED status") - } - if !strings.Contains(content, "Foo.java:42") { - t.Error("missing error location") - } - if !strings.Contains(content, "cannot find symbol") { - t.Error("missing error message") - } - if !strings.Contains(content, "Fix iterations: 2") { - t.Error("missing fix attempts") - } -} diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go new file mode 100644 index 0000000..dd8d776 --- /dev/null +++ b/harness/internal/watcher/patterns.go @@ -0,0 +1,42 @@ +package watcher + +import ( + "path/filepath" + "strings" +) + +var sourceExts = map[string]bool{ + ".java": true, ".xml": true, ".properties": true, + ".md": true, ".json": true, ".yaml": true, ".yml": true, + ".gradle": true, ".kts": true, ".kt": true, ".groovy": true, +} + +var excludeDirs = map[string]bool{ + ".goose": true, "__pycache__": true, ".git": true, + ".konveyor": true, "node_modules": true, "target": true, "graphify-out": true, +} + +var excludeExts = map[string]bool{ + ".tmp": true, ".swp": true, ".bak": true, +} + +func ShouldStageNewFile(path string) bool { + base := filepath.Base(path) + + if base == "pom.xml" { + return true + } + + for _, part := range strings.Split(filepath.Dir(path), string(filepath.Separator)) { + if excludeDirs[part] { + return false + } + } + + ext := filepath.Ext(base) + if excludeExts[ext] { + return false + } + + return sourceExts[ext] +} diff --git a/harness/internal/watcher/patterns_test.go b/harness/internal/watcher/patterns_test.go new file mode 100644 index 0000000..b9f12f7 --- /dev/null +++ b/harness/internal/watcher/patterns_test.go @@ -0,0 +1,32 @@ +package watcher + +import "testing" + +func TestShouldStageNewFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"src/main/java/com/example/App.java", true}, + {"pom.xml", true}, + {"src/main/resources/application.properties", true}, + {".konveyor/results.json", false}, + {"PLAN.md", true}, + {"graph.json", true}, + {".goose/cache/foo.txt", false}, + {"__pycache__/mod.pyc", false}, + {"target/classes/App.class", false}, + {"scratch.tmp", false}, + {"file.swp", false}, + {"random.txt", false}, + {"src/main/java/.goose/internal.java", false}, + {"graphify-out/model/graph.json", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := ShouldStageNewFile(tt.path); got != tt.want { + t.Errorf("ShouldStageNewFile(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/harness/internal/watcher/watcher.go b/harness/internal/watcher/watcher.go new file mode 100644 index 0000000..8a3b18b --- /dev/null +++ b/harness/internal/watcher/watcher.go @@ -0,0 +1,194 @@ +package watcher + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/konveyor/migration-harness/internal/logging" +) + +// DefaultQuietPeriod is the debounce window: after the last relevant +// filesystem event, the watcher waits this long before committing. +const DefaultQuietPeriod = 30 * time.Second + +type CommitPushFn func() error + +type Watcher struct { + dir string + commitFn CommitPushFn + fsw *fsnotify.Watcher + quietPeriod time.Duration + cancel context.CancelFunc + wg sync.WaitGroup +} + +func New(dir string, commitFn CommitPushFn) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + return &Watcher{ + dir: dir, + commitFn: commitFn, + fsw: fsw, + quietPeriod: DefaultQuietPeriod, + }, nil +} + +// WithQuietPeriod sets a custom debounce period (for testing). +func (w *Watcher) WithQuietPeriod(d time.Duration) *Watcher { + w.quietPeriod = d + return w +} + +func (w *Watcher) Start(ctx context.Context) error { + if err := w.addDirRecursive(w.dir); err != nil { + return err + } + + ctx, w.cancel = context.WithCancel(ctx) + w.wg.Add(1) + go w.loop(ctx) + logging.Info("filesystem watcher started (quiet period: %s)", w.quietPeriod) + return nil +} + +func (w *Watcher) Stop() { + if w.cancel != nil { + w.cancel() + } + w.wg.Wait() + w.fsw.Close() +} + +func (w *Watcher) loop(ctx context.Context) { + defer w.wg.Done() + timer := time.NewTimer(w.quietPeriod) + timer.Stop() + dirty := false + + for { + select { + case <-ctx.Done(): + timer.Stop() + return + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) == 0 { + continue + } + rel, err := filepath.Rel(w.dir, event.Name) + if err != nil { + continue + } + if !isRelevantChange(rel) { + continue + } + if event.Op&fsnotify.Create != 0 { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + // Only add the directory if it's not excluded + dirName := filepath.Base(event.Name) + if !excludeDirs[dirName] && dirName != ".konveyor" { + w.fsw.Add(event.Name) + } + } + } + dirty = true + timer.Reset(w.quietPeriod) + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + logging.Warn("watcher error: %v", err) + case <-timer.C: + if dirty { + w.doCommit() + dirty = false + } + } + } +} + +func isRelevantChange(relPath string) bool { + // Check if the path itself or any of its directory components are excluded + parts := strings.Split(relPath, string(filepath.Separator)) + for _, part := range parts { + if excludeDirs[part] { + return false + } + } + + base := filepath.Base(relPath) + ext := filepath.Ext(base) + return !excludeExts[ext] +} + +func (w *Watcher) doCommit() { + if err := w.stageFiles(); err != nil { + logging.Warn("watcher stage: %v", err) + return + } + if err := w.commitFn(); err != nil { + logging.Warn("watcher commit+push: %v", err) + } +} + +func (w *Watcher) stageFiles() error { + cmd := exec.Command("git", "-C", w.dir, "add", "-u") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add -u: %s: %w", out, err) + } + + entries, err := w.findNewFiles() + if err != nil { + return err + } + for _, f := range entries { + cmd := exec.Command("git", "-C", w.dir, "add", "--", f) + if out, err := cmd.CombinedOutput(); err != nil { + logging.Warn("git add %s: %s", f, out) + } + } + return nil +} + +func (w *Watcher) findNewFiles() ([]string, error) { + cmd := exec.Command("git", "-C", w.dir, "ls-files", "--others", "--exclude-standard") + out, err := cmd.Output() + if err != nil { + return nil, err + } + var staged []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" && ShouldStageNewFile(line) { + staged = append(staged, line) + } + } + return staged, nil +} + +func (w *Watcher) addDirRecursive(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if excludeDirs[name] || name == ".konveyor" { + return filepath.SkipDir + } + return w.fsw.Add(path) + } + return nil + }) +} diff --git a/harness/internal/watcher/watcher_test.go b/harness/internal/watcher/watcher_test.go new file mode 100644 index 0000000..cc355e1 --- /dev/null +++ b/harness/internal/watcher/watcher_test.go @@ -0,0 +1,112 @@ +package watcher + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +const testQuietPeriod = 1 * time.Second + +func TestWatcherDetectsFileChange(t *testing.T) { + dir := t.TempDir() + + // Initialize a git repo so git add -u works + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + w.WithQuietPeriod(testQuietPeriod) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Modify a tracked file + writeFile(t, filepath.Join(dir, "App.java"), "class App { int x; }") + + // Wait for quiet period + buffer + time.Sleep(testQuietPeriod + 2*time.Second) + + if commitCount.Load() == 0 { + t.Error("expected at least one commit after quiet period") + } +} + +func TestWatcherIgnoresExcludedDirs(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + w.WithQuietPeriod(testQuietPeriod) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Write to an excluded dir — should NOT trigger commit + gooseDir := filepath.Join(dir, ".goose") + os.MkdirAll(gooseDir, 0755) + writeFile(t, filepath.Join(gooseDir, "cache.db"), "data") + + time.Sleep(testQuietPeriod + 2*time.Second) + + if commitCount.Load() != 0 { + t.Error("expected no commits for changes in excluded dirs") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0755) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } +} diff --git a/harness/recipes/execute.yaml b/harness/recipes/execute.yaml deleted file mode 100644 index 3e980ae..0000000 --- a/harness/recipes/execute.yaml +++ /dev/null @@ -1,105 +0,0 @@ -version: "1.0.0" -title: "Execute migration item" -description: "Migrate one item from the plan. The matching execution sub-skill auto-activates." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the source repository" - - key: plan_md_path - input_type: string - requirement: required - description: "Path to PLAN.md file with goal and steps" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus, python2-to-python3" - - key: item_n - input_type: string - requirement: required - description: "1-based item number in the migration plan" - - key: item_path - input_type: string - requirement: required - description: "File or marker path for this item" - - key: item_action - input_type: string - requirement: required - description: "Action for this item: migrate, create, or delete" - -extensions: - - type: builtin - name: developer - timeout: 1200 - bundled: true - -instructions: | - You are executing ONE migration step from a comprehensive migration plan. - - PHASE 1 — UNDERSTAND THE CONTEXT (2 reads max): - 1. Read {{ plan_md_path }} to understand: - - The migration GOAL (what we're trying to achieve overall) - - The PROJECT SUMMARY (complexity, key challenges) - - The specific step you're working on (Step #{{ item_n }}) - - What dependencies this step has (what should already be done) - - 2. Read {{ repo }}/.goosehints for: - - Project-specific guidance - - Token discipline rules - - The checklist of all steps - - PHASE 2 — EXECUTE THE STEP: - 3. Apply the transformation described in your step: - - {{ item_action }} the file: {{ item_path }} - - Follow the step's "What to do" instructions exactly - - Use the GOAL to guide any judgment calls - - 4. If you encounter problems: - - Syntax errors → document in your response - - Compilation errors → document (but don't try to compile) - - Missing files → document - - Uncertainty → document what you're unsure about - - RULES: - - Execute ONLY step #{{ item_n }}, do NOT touch other files - - Do NOT compile, test, or verify — that's a separate step - - Read files as needed to understand the change, but minimize reads - - If something is unclear, make your best attempt and document the uncertainty - - Lessons learned are OPTIONAL — only include if there's something genuinely useful - - WHAT TO RETURN: - - n: The step number ({{ item_n }}) - - path: The primary file path ({{ item_path }}) - - status: "ok" (you made the change), "failed" (couldn't do it), or "skipped" (not needed) - - files_touched: List of files you modified/created/deleted - - lesson: String describing what you learned (can be empty string) - - error_log: Any errors encountered (syntax, missing files, etc.) — empty string if none - - summary: Brief description of what was done - -prompt: | - Execute Step #{{ item_n }} from the migration plan. - - Action: {{ item_action }} - File: {{ item_path }} - Repository: {{ repo }} - Migration Type: {{ migration_type }} - - First, read {{ plan_md_path }} to understand the goal and this specific step. - Then execute the transformation and report results. - -response: - json_schema: - type: object - required: [n, path, status, files_touched, lesson, error_log] - properties: - n: { type: integer, description: "Step number" } - path: { type: string, description: "Primary file path" } - status: { type: string, enum: [ok, failed, skipped] } - files_touched: { type: array, items: { type: string }, description: "All files modified/created/deleted" } - lesson: { type: string, description: "What was learned (can be empty string)" } - error_log: { type: string, description: "Syntax errors, compilation issues, etc. (empty if none)" } - summary: { type: string, description: "Brief description of what was done" } diff --git a/harness/recipes/fix.yaml b/harness/recipes/fix.yaml deleted file mode 100644 index 6cfdccd..0000000 --- a/harness/recipes/fix.yaml +++ /dev/null @@ -1,79 +0,0 @@ -version: "1.0.0" -title: "Fix migration error" -description: "Address a single compile or test error. Conservative — minimal edits." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the repository" - - key: verification_report_path - input_type: string - requirement: required - description: "Path to verification-report.md with build errors" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus" - - key: error_file - input_type: string - requirement: required - description: "Path of the file reported in the verify error" - - key: error_message - input_type: string - requirement: required - description: "The error message text from verify" - -extensions: - - type: builtin - name: developer - timeout: 900 - bundled: true - -instructions: | - Fix exactly ONE compilation error from the verification report. - - PHASE 1 — UNDERSTAND THE ERROR: - 1. Read {{ verification_report_path }} to see: - - Full list of compilation errors - - Context from verification - - What Step 4 (verify) already attempted to fix - - 2. Focus on the specific error: - - File: {{ error_file }} - - Message: {{ error_message }} - - PHASE 2 — FIX THE ERROR: - 3. Make the minimal edit needed to fix this ONE error - 4. Only touch {{ error_file }} (or build manifest if it's a missing dependency) - 5. Do NOT re-run compile or tests (Step 5 will re-verify) - - RULES: - - Fix only ONE error at a time - - Minimal changes only (no refactoring) - - If it's a missing import/dependency, add it - - If it's a syntax error, fix the syntax - - Return success only if you made a change - -prompt: | - Fix a compilation error in {{ repo }}. - - Verification report: {{ verification_report_path }} - Migration type: {{ migration_type }} - Error file: {{ error_file }} - Error message: {{ error_message }} - - Read the verification report for context, then apply the minimal fix needed. - -response: - json_schema: - type: object - required: [fixed, summary] - properties: - fixed: { type: boolean } - file_changed: { type: string } - summary: { type: string } - pom_changed: { type: boolean, description: "True if build manifest was modified (means a re-resolve is needed)" } diff --git a/harness/recipes/verify.yaml b/harness/recipes/verify.yaml deleted file mode 100644 index 4e0aa8b..0000000 --- a/harness/recipes/verify.yaml +++ /dev/null @@ -1,104 +0,0 @@ -version: "1.0.0" -title: "Verify migration" -description: "Compile and test the migrated repo. Attempts up to 3 fix iterations for compilation errors." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the (now-migrated) repository" - - key: plan_md_path - input_type: string - requirement: required - description: "Path to PLAN.md with verification section" - - key: execution_log_path - input_type: string - requirement: required - description: "Path to execution-log.md with execution details" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus, python2-to-python3" - -extensions: - - type: builtin - name: developer - timeout: 1800 - bundled: true - -instructions: | - You are verifying a migrated codebase and can attempt fixes if needed. - - PHASE 1 — READ VERIFICATION STEPS: - 1. Read {{ plan_md_path }} and locate the "## Verification" section - 2. This section contains the verification commands to run (e.g., mvn compile, npm test) - 3. Note: Read ONLY the Verification section, ignore Goal, Summary, and Steps sections - - PHASE 2 — RUN VERIFICATION: - 4. Execute the verification commands from PLAN.md - 5. Capture: - - Build status (success/failure) - - Test results (passed/failed counts) - - Compilation errors (file, line, message) - - PHASE 3 — AUTO-FIX (if needed, max 3 iterations): - 6. If compilation/build FAILS: - - Read {{ execution_log_path }} to understand what execution attempted - - Identify root cause of failures - - Make targeted fixes to resolve compilation errors - - Re-run verification - - Repeat up to 3 times total - - 7. If still failing after 3 attempts: - - Document remaining errors - - Return current state - - RULES: - - Focus on compilation errors first (tests can fail, that's okay) - - Only fix what's broken, don't refactor working code - - Read execution-log.md only if you have compilation failures - - Track your fix attempts in the response - - Maximum 3 fix attempts, then stop - - WHAT TO RETURN: - - build_ok: true if compiles, false if not - - tests_passed/tests_total: test counts - - errors: array of remaining compilation errors - - fix_attempts: number of fix iterations you tried (0-3) - - fixes_applied: description of what you fixed - - summary: overall status - -prompt: | - Verify the migrated codebase at {{ repo }}. - - Migration type: {{ migration_type }} - Plan with verification steps: {{ plan_md_path }} - Execution log (for context if needed): {{ execution_log_path }} - - Read the Verification section from PLAN.md, run those commands, and attempt - fixes if compilation fails (up to 3 iterations). Return the final state. - -response: - json_schema: - type: object - required: [build_ok, summary, fix_attempts, fixes_applied] - properties: - build_ok: { type: boolean, description: "True if compilation succeeds" } - tests_total: { type: integer, description: "Total number of tests" } - tests_passed: { type: integer, description: "Number of tests that passed" } - tests_failed: { type: integer, description: "Number of tests that failed" } - fix_attempts: { type: integer, description: "Number of fix iterations attempted (0-3)" } - fixes_applied: { type: string, description: "Description of fixes made (empty if none)" } - errors: - type: array - description: "Remaining compilation errors after fix attempts" - items: - type: object - properties: - file: { type: string } - line: { type: integer } - message: { type: string } - summary: { type: string, description: "Overall verification status" } diff --git a/harness/skill-bundle/goose-migration/SKILL.md b/harness/skill-bundle/goose-migration/SKILL.md deleted file mode 100644 index 98058be..0000000 --- a/harness/skill-bundle/goose-migration/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: goose-migration -description: > - Umbrella migration skill for Goose AI agent sessions. Triggers on ANY of: "migrate - this app", "migrate with goose", "goose migration", "clone and migrate", "java ee to - quarkus", "python 2 to 3", "react class to hooks", "node upgrade", "goose keeps - hitting rate limits", "set up goose for migration", "migration script". Handles the - full end-to-end flow by routing to the correct sub-skill: first migration-plan (which - discovers the project and gets user approval), then the appropriate execution skill - (e.g. javaee-quarkus) once the plan is approved. ---- - -# Goose Migration — Umbrella Skill - -This skill orchestrates the full migration workflow in two stages: - -``` -Stage 1: migration-plan → discover → build plan → get approval -Stage 2: → migrate one file at a time → compile → verify -``` - ---- - -## Supported Migration Types - -| User says | Execution skill | -|---|---| -| Java EE → Quarkus | `skills/javaee-quarkus/SKILL.md` | -| Python 2 → Python 3 | `skills/python2-to-python3/SKILL.md` | - ---- - -## How to Route - -### Step 1 — Identify migration type - -Ask the user if not already stated: -- What is the **source** stack? (e.g. Java EE 7, Python 2, React class components) -- What is the **target** stack? (e.g. Quarkus 3, Python 3, React hooks) -- Is the repo already cloned, or do you have a GitHub URL? - -### Step 2 — Run migration-plan sub-skill - -Always run `skills/migration-plan/SKILL.md` first regardless of migration type. -It handles discovery, plan generation, and approval gate. - -### Step 3 — Run execution sub-skill - -Only after the plan is approved, load the matching execution skill. -Pass the approved plan and project context to it. - ---- - -## Token Safety Rules (apply throughout all sub-skills) - -These rules are non-negotiable — enforce them in every stage: - -- Read **ONE file at a time** — never `find | xargs cat` or for-loops -- After each file: **STOP and wait** for user to say `next` -- Do **NOT** re-read migrated files -- Do **NOT** reprint the full plan each turn -- Do **NOT** compile unless user types `compile` - ---- - -## Session Commands (tell user at start) - -| Command | Action | -|---|---| -| `next` | Migrate the next file, then stop | -| `compile` | Run build check, show first 30 lines only | -| `fix` | Fix first compiler error only, then stop | -| `status` | List completed vs remaining files | -| `retry` | Retry after rate limit (wait 60s first) | - ---- - -## Model Guidance - -| Model | Recommended for | -|---|---| -| claude-sonnet-5 | Complex transforms (MDB, JNDI) | -| claude-haiku-4-5 | Mechanical imports, simple renames | - -If rate limits hit: `goose configure` → switch to a faster model like `claude-haiku-4-5` diff --git a/harness/skill-bundle/goose-migration/references/README.md b/harness/skill-bundle/goose-migration/references/README.md deleted file mode 100644 index 6fb8cca..0000000 --- a/harness/skill-bundle/goose-migration/references/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# Migration References — Writing Guide - -This directory contains **migration knowledge** files that teach the planner how to migrate between technology stacks. Each reference is a **pattern catalog** that the LLM reads during the planning step to generate specific migration plans. - ---- - -## What is a Reference File? - -A reference file is **NOT executable code** — it's a knowledge document that describes: -- What needs to change (imports, annotations, patterns) -- How to change it (before/after examples) -- What order to follow (layer dependencies) -- What to verify (build commands, grep checks) - -Think of it as a **migration cookbook** for a specific source → target pair. - ---- - -## When to Create a Reference - -Create a reference when you want to migrate between: -- **Frameworks**: Java EE → Quarkus, Spring Boot 2 → 3, Express 4 → 5 -- **Languages**: Python 2 → 3, JavaScript → TypeScript -- **Platforms**: .NET Framework → .NET 8, Node 16 → 20 -- **Architectures**: Monolith → Microservices, REST → GraphQL - ---- - -## Reference File Structure - -Use this template for ALL references (language-agnostic): - -```markdown ---- -name: -to- -description: Migration patterns for to -applies_to: - manifests: - : true # e.g., pom_xml, package_json, go_mod, cargo_toml - graph_patterns: - - "" # e.g., "imports contains javax.ejb" - - "" # e.g., "annotations contains @MessageDriven" ---- - -# : Migration Reference - -## Migration Order (Layer Dependency) - -Always migrate in this order — dependencies flow upward: - -1. **Build config** - -2. **App config** - -3. **Utils/Common** - -4. **Data layer** - -5. **Business logic** - -6. **API layer** - -7. **Cleanup** - delete legacy files - -**Why this order**: - ---- - -## Import/Package Transformations - -Simple find-and-replace (mechanical changes): - -| Old | New | Notes | -|-----|-----|-------| -| `old.package.*` | `new.package.*` | Optional explanation | - ---- - -## Annotation/Decorator Transformations - -| Old | New | Notes | -|-----|-----|-------| -| `@OldAnnotation` | `@NewAnnotation` | Why this changed | - ---- - -## Pattern Catalog - -For each complex structural change, provide a before/after example. - -### Pattern: - -**BEFORE:** -``` - -``` - -**AFTER:** -``` - -``` - -**Specific changes:** -1. Remove: -2. Add: -3. Replace: - -**Why**: - ---- - -## Files to DELETE - -| Delete this | Replaced by | -|-------------|-------------| -| `path/to/old/file` | `new/path/or/config` or "Not needed" | - -**Why delete**: - ---- - -## Files to CREATE - -| File | Purpose | Key contents | -|------|---------|--------------| -| `path/to/new/file` | |