agent/pkg/agentic/verify.go
Virgil 3ef37ff453 feat(agentic): clean up forge branches after publish
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:59:03 +00:00

289 lines
9.5 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
)
// s.autoVerifyAndMerge("/srv/core/workspace/core/go-io/task-5")
func (s *PrepSubsystem) autoVerifyAndMerge(workspaceDir string) {
result := ReadStatusResult(workspaceDir)
workspaceStatus, ok := workspaceStatusValue(result)
if !ok || workspaceStatus.PRURL == "" || workspaceStatus.Repo == "" {
return
}
repoDir := WorkspaceRepoDir(workspaceDir)
org := workspaceStatus.Org
if org == "" {
org = "core"
}
pullRequestNumber := extractPullRequestNumber(workspaceStatus.PRURL)
if pullRequestNumber == 0 {
return
}
markMerged := func() {
if result := ReadStatusResult(workspaceDir); result.OK {
st2, ok := workspaceStatusValue(result)
if !ok {
return
}
st2.Status = "merged"
writeStatusResult(workspaceDir, st2)
}
}
mergeOutcome := s.attemptVerifyAndMerge(repoDir, org, workspaceStatus.Repo, workspaceStatus.Branch, pullRequestNumber)
if mergeOutcome == mergeSuccess {
markMerged()
return
}
if mergeOutcome == mergeConflict || mergeOutcome == testFailed {
if s.rebaseBranch(repoDir, workspaceStatus.Branch) {
if s.attemptVerifyAndMerge(repoDir, org, workspaceStatus.Repo, workspaceStatus.Branch, pullRequestNumber) == mergeSuccess {
markMerged()
return
}
}
}
s.flagForReview(org, workspaceStatus.Repo, pullRequestNumber, mergeOutcome)
if result := ReadStatusResult(workspaceDir); result.OK {
workspaceStatusUpdate, ok := workspaceStatusValue(result)
if !ok {
return
}
workspaceStatusUpdate.Question = "Flagged for review — auto-merge failed after retry"
writeStatusResult(workspaceDir, workspaceStatusUpdate)
}
}
type mergeResult int
const (
mergeSuccess mergeResult = iota
testFailed
mergeConflict
)
// s.attemptVerifyAndMerge("/srv/core/workspace/core/go-io/task-5/repo", "core", "go-io", "feature/ax-cleanup", 42)
func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string, pullRequestNumber 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, pullRequestNumber, comment)
return testFailed
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if mergeAttempt := s.forgeMergePR(ctx, org, repo, pullRequestNumber); !mergeAttempt.OK {
comment := core.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed", testResult.testCmd)
s.commentOnIssue(context.Background(), org, repo, pullRequestNumber, 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, pullRequestNumber, comment)
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo)
s.cleanupForgeBranch(ctx, repoDir, forgeRemote, branch)
return mergeSuccess
}
// s.rebaseBranch("/srv/core/workspace/core/go-io/task-5/repo", "feature/ax-cleanup")
func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool {
ctx := context.Background()
process := s.Core().Process()
base := s.DefaultBranch(repoDir)
if !process.RunIn(ctx, repoDir, "git", "fetch", "origin", base).OK {
return false
}
if !process.RunIn(ctx, repoDir, "git", "rebase", core.Concat("origin/", base)).OK {
process.RunIn(ctx, repoDir, "git", "rebase", "--abort")
return false
}
result := ReadStatusResult(core.PathDir(repoDir))
workspaceStatus, ok := workspaceStatusValue(result)
org := "core"
repo := ""
if ok {
if workspaceStatus.Org != "" {
org = workspaceStatus.Org
}
repo = workspaceStatus.Repo
}
forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo)
return process.RunIn(ctx, repoDir, "git", "push", "--force-with-lease", forgeRemote, branch).OK
}
// s.flagForReview("core", "go-io", 42, mergeConflict)
func (s *PrepSubsystem) flagForReview(org, repo string, pullRequestNumber int, mergeOutcome mergeResult) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
s.ensureLabel(ctx, org, repo, "needs-review", "e11d48")
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, pullRequestNumber)
HTTPPost(ctx, url, payload, s.forgeToken, "token")
reason := "Tests failed after rebase"
if mergeOutcome == 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, pullRequestNumber, comment)
}
// s.ensureLabel(context.Background(), "core", "go-io", "needs-review", "e11d48")
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")
}
// s.getLabelID(context.Background(), "core", "go-io", "needs-review")
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)
getResult := HTTPGet(ctx, url, s.forgeToken, "token")
if !getResult.OK {
return 0
}
var labels []struct {
ID int `json:"id"`
Name string `json:"name"`
}
core.JSONUnmarshalString(getResult.Value.(string), &labels)
for _, l := range labels {
if l.Name == name {
return l.ID
}
}
return 0
}
type verifyResult struct {
passed bool
output string
exitCode int
testCmd string
}
func resultText(result core.Result) string {
if text, ok := result.Value.(string); ok {
return text
}
if result.Value != nil {
return core.Sprint(result.Value)
}
return ""
}
// s.runVerification("/srv/core/workspace/core/go-io/task-5/repo")
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()
process := s.Core().Process()
processResult := process.RunWithEnv(ctx, repoDir, []string{"GOWORK=off"}, "go", "test", "./...", "-count=1", "-timeout", "120s")
out := resultText(processResult)
exitCode := 0
if !processResult.OK {
exitCode = 1
}
return verifyResult{passed: processResult.OK, output: out, exitCode: exitCode, testCmd: "go test ./..."}
}
func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult {
ctx := context.Background()
process := s.Core().Process()
composerResult := process.RunIn(ctx, repoDir, "composer", "test", "--no-interaction")
if !composerResult.OK {
fallbackResult := process.RunIn(ctx, repoDir, "./vendor/bin/pest", "--no-interaction")
if !fallbackResult.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: resultText(fallbackResult), exitCode: 0, testCmd: "vendor/bin/pest"}
}
return verifyResult{passed: true, output: resultText(composerResult), exitCode: 0, testCmd: "composer test"}
}
func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult {
packageResult := fs.Read(core.JoinPath(repoDir, "package.json"))
if !packageResult.OK {
return verifyResult{passed: true, testCmd: "none", output: "Could not read package.json"}
}
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if parseResult := core.JSONUnmarshalString(packageResult.Value.(string), &pkg); !parseResult.OK || pkg.Scripts["test"] == "" {
return verifyResult{passed: true, testCmd: "none", output: "No test script in package.json"}
}
ctx := context.Background()
process := s.Core().Process()
testResult := process.RunIn(ctx, repoDir, "npm", "test")
out := resultText(testResult)
exitCode := 0
if !testResult.OK {
exitCode = 1
}
return verifyResult{passed: testResult.OK, output: out, exitCode: exitCode, testCmd: "npm test"}
}
// s.forgeMergePR(context.Background(), "core", "go-io", 42)
func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, pullRequestNumber 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, pullRequestNumber)
return HTTPPost(ctx, url, payload, s.forgeToken, "token")
}
// extractPullRequestNumber("https://forge.lthn.ai/core/go-io/pulls/42")
func extractPullRequestNumber(pullRequestURL string) int {
parts := core.Split(pullRequestURL, "/")
if len(parts) == 0 {
return 0
}
return parseInt(parts[len(parts)-1])
}
func fileExists(path string) bool {
return fs.IsFile(path)
}