agent/pkg/agentic/verify.go
Snider 537226bd4d feat: AX v0.8.0 upgrade — Core features + quality gates
AX Quality Gates (RFC-025):
- Eliminate os/exec from all test + production code (12+ files)
- Eliminate encoding/json from all test files (15 files, 66 occurrences)
- Eliminate os from all test files except TestMain (Go runtime contract)
- Eliminate path/filepath, net/url from all files
- String concat: 39 violations replaced with core.Concat()
- Test naming AX-7: 264 test functions renamed across all 6 packages
- Example test 1:1 coverage complete

Core Features Adopted:
- Task Composition: agent.completion pipeline (QA → PR → Verify → Ingest → Poke)
- PerformAsync: completion pipeline runs with WaitGroup + progress tracking
- Config: agents.yaml loaded once, feature flags (auto-qa/pr/merge/ingest)
- Named Locks: c.Lock("drain") for queue serialisation
- Registry: workspace state with cross-package QUERY access
- QUERY: c.QUERY(WorkspaceQuery{Status: "running"}) for cross-service queries
- Action descriptions: 25+ Actions self-documenting
- Data mounts: prompts/tasks/flows/personas/workspaces via c.Data()
- Content Actions: agentic.prompt/task/flow/persona callable via IPC
- Drive endpoints: forge + brain registered with tokens
- Drive REST helpers: DriveGet/DrivePost/DriveDo for Drive-aware HTTP
- HandleIPCEvents: auto-discovered by WithService (no manual wiring)
- Entitlement: frozen-queue gate on write Actions
- CLI dispatch: workspace dispatch wired to real dispatch method
- CLI: --quiet/-q and --debug/-d global flags
- CLI: banner, version, check (with service/action/command counts), env
- main.go: minimal — 5 services + c.Run(), no os import
- cmd tests: 84.2% coverage (was 0%)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 06:38:02 +00:00

280 lines
8.8 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
)
// autoVerifyAndMerge runs inline tests (fast gate) and merges if they pass.
// If tests fail or merge fails due to conflict, attempts one rebase+retry.
// If the retry also fails, labels the PR "needs-review" for human attention.
//
// For deeper review (security, conventions), dispatch a separate task:
//
// agentic_dispatch repo=go-crypt template=verify persona=engineering/engineering-security-engineer
func (s *PrepSubsystem) autoVerifyAndMerge(wsDir string) {
st, err := ReadStatus(wsDir)
if err != nil || st.PRURL == "" || st.Repo == "" {
return
}
repoDir := core.JoinPath(wsDir, "repo")
org := st.Org
if org == "" {
org = "core"
}
prNum := extractPRNumber(st.PRURL)
if prNum == 0 {
return
}
// markMerged is a helper to avoid repeating the status update.
markMerged := func() {
if st2, err := ReadStatus(wsDir); err == nil {
st2.Status = "merged"
writeStatus(wsDir, st2)
}
}
// Attempt 1: run tests and try to merge
result := s.attemptVerifyAndMerge(repoDir, org, st.Repo, st.Branch, prNum)
if result == mergeSuccess {
markMerged()
return
}
// Attempt 2: rebase onto main and retry
if result == mergeConflict || result == testFailed {
if s.rebaseBranch(repoDir, st.Branch) {
if s.attemptVerifyAndMerge(repoDir, org, st.Repo, st.Branch, prNum) == mergeSuccess {
markMerged()
return
}
}
}
// Both attempts failed — flag for human review
s.flagForReview(org, st.Repo, prNum, result)
if st2, err := ReadStatus(wsDir); err == nil {
st2.Question = "Flagged for review — auto-merge failed after retry"
writeStatus(wsDir, st2)
}
}
type mergeResult int
const (
mergeSuccess mergeResult = iota
testFailed // tests didn't pass
mergeConflict // tests passed but merge failed (conflict)
)
// attemptVerifyAndMerge runs tests and tries to merge. Returns the outcome.
func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string, prNum int) mergeResult {
testResult := s.runVerification(repoDir)
if !testResult.passed {
comment := core.Sprintf("## Verification Failed\n\n**Command:** `%s`\n\n```\n%s\n```\n\n**Exit code:** %d",
testResult.testCmd, truncate(testResult.output, 2000), testResult.exitCode)
s.commentOnIssue(context.Background(), org, repo, prNum, comment)
return testFailed
}
// Tests passed — try merge
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if r := s.forgeMergePR(ctx, org, repo, prNum); !r.OK {
comment := core.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed", testResult.testCmd)
s.commentOnIssue(context.Background(), org, repo, prNum, comment)
return mergeConflict
}
comment := core.Sprintf("## Auto-Verified & Merged\n\n**Tests:** `%s` — PASS\n\nAuto-merged by core-agent dispatch system.", testResult.testCmd)
s.commentOnIssue(context.Background(), org, repo, prNum, comment)
return mergeSuccess
}
// rebaseBranch rebases the current branch onto the default branch and force-pushes.
func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
ctx := context.Background()
base := s.DefaultBranch(repoDir)
if !s.gitCmdOK(ctx, repoDir, "fetch", "origin", base) {
return false
}
if !s.gitCmdOK(ctx, repoDir, "rebase", core.Concat("origin/", base)) {
s.gitCmdOK(ctx, repoDir, "rebase", "--abort")
return false
}
st, _ := ReadStatus(core.PathDir(repoDir))
org := "core"
repo := ""
if st != nil {
if st.Org != "" {
org = st.Org
}
repo = st.Repo
}
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo)
return s.gitCmdOK(ctx, repoDir, "push", "--force-with-lease", forgeRemote, branch)
}
// flagForReview adds the "needs-review" label to the PR via Forge API.
func (s *PrepSubsystem) flagForReview(org, repo string, prNum int, result mergeResult) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Ensure the label exists
s.ensureLabel(ctx, org, repo, "needs-review", "e11d48")
// Add label to PR
payload := core.JSONMarshalString(map[string]any{
"labels": []int{s.getLabelID(ctx, org, repo, "needs-review")},
})
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", s.forgeURL, org, repo, prNum)
HTTPPost(ctx, url, payload, s.forgeToken, "token")
// Comment explaining the situation
reason := "Tests failed after rebase"
if result == mergeConflict {
reason = "Merge conflict persists after rebase"
}
comment := core.Sprintf("## Needs Review\n\n%s. Auto-merge gave up after retry.\n\nLabelled `needs-review` for human attention.", reason)
s.commentOnIssue(ctx, org, repo, prNum, comment)
}
// ensureLabel creates a label if it doesn't exist.
func (s *PrepSubsystem) ensureLabel(ctx context.Context, org, repo, name, colour string) {
payload := core.JSONMarshalString(map[string]string{
"name": name,
"color": core.Concat("#", colour),
})
url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
HTTPPost(ctx, url, payload, s.forgeToken, "token")
}
// getLabelID fetches the ID of a label by name.
func (s *PrepSubsystem) getLabelID(ctx context.Context, org, repo, name string) int {
url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
r := HTTPGet(ctx, url, s.forgeToken, "token")
if !r.OK {
return 0
}
var labels []struct {
ID int `json:"id"`
Name string `json:"name"`
}
core.JSONUnmarshalString(r.Value.(string), &labels)
for _, l := range labels {
if l.Name == name {
return l.ID
}
}
return 0
}
// verifyResult holds the outcome of running tests.
type verifyResult struct {
passed bool
output string
exitCode int
testCmd string
}
// runVerification detects the project type and runs the appropriate test suite.
func (s *PrepSubsystem) runVerification(repoDir string) verifyResult {
if fileExists(core.JoinPath(repoDir, "go.mod")) {
return s.runGoTests(repoDir)
}
if fileExists(core.JoinPath(repoDir, "composer.json")) {
return s.runPHPTests(repoDir)
}
if fileExists(core.JoinPath(repoDir, "package.json")) {
return s.runNodeTests(repoDir)
}
return verifyResult{passed: true, testCmd: "none", output: "No test runner detected"}
}
func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult {
ctx := context.Background()
r := s.runCmdEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s")
out := r.Value.(string)
exitCode := 0
if !r.OK {
exitCode = 1
}
return verifyResult{passed: r.OK, output: out, exitCode: exitCode, testCmd: "go test ./..."}
}
func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult {
ctx := context.Background()
r := s.runCmd(ctx, repoDir, "composer", "test", "--no-interaction")
if !r.OK {
// Try pest as fallback
r2 := s.runCmd(ctx, repoDir, "./vendor/bin/pest", "--no-interaction")
if !r2.OK {
return verifyResult{passed: false, testCmd: "none", output: "No PHP test runner found (composer test and vendor/bin/pest both unavailable)", exitCode: 1}
}
return verifyResult{passed: true, output: r2.Value.(string), exitCode: 0, testCmd: "vendor/bin/pest"}
}
return verifyResult{passed: true, output: r.Value.(string), exitCode: 0, testCmd: "composer test"}
}
func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult {
r := fs.Read(core.JoinPath(repoDir, "package.json"))
if !r.OK {
return verifyResult{passed: true, testCmd: "none", output: "Could not read package.json"}
}
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if ur := core.JSONUnmarshalString(r.Value.(string), &pkg); !ur.OK || pkg.Scripts["test"] == "" {
return verifyResult{passed: true, testCmd: "none", output: "No test script in package.json"}
}
ctx := context.Background()
r = s.runCmd(ctx, repoDir, "npm", "test")
out := r.Value.(string)
exitCode := 0
if !r.OK {
exitCode = 1
}
return verifyResult{passed: r.OK, output: out, exitCode: exitCode, testCmd: "npm test"}
}
// forgeMergePR merges a PR via the Forge API.
func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNum int) core.Result {
payload := core.JSONMarshalString(map[string]any{
"Do": "merge",
"merge_message_field": "Auto-merged by core-agent after verification\n\nCo-Authored-By: Virgil <virgil@lethean.io>",
"delete_branch_after_merge": true,
})
url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", s.forgeURL, org, repo, prNum)
return HTTPPost(ctx, url, payload, s.forgeToken, "token")
}
// extractPRNumber gets the PR number from a Forge PR URL.
func extractPRNumber(prURL string) int {
parts := core.Split(prURL, "/")
if len(parts) == 0 {
return 0
}
return parseInt(parts[len(parts)-1])
}
// fileExists checks if a file exists.
func fileExists(path string) bool {
return fs.IsFile(path)
}