a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/forge"
|
|
)
|
|
|
|
// resetPRsSeams restores the prs.go package-var seams and flags after a test
|
|
// mutates them, so cases stay independent.
|
|
func resetPRsSeams(t *testing.T) {
|
|
t.Helper()
|
|
availPrev := forgeAvailable
|
|
listPrev := forgeListPRs
|
|
filesPrev := forgePRFiles
|
|
toolPrev := prsDaemonTool
|
|
basePrev := prsBase
|
|
repoPrev := prsRepo
|
|
fmtPrev := prsFormat
|
|
wtPrev := prsWorktrees
|
|
triagePrev := prsTriage
|
|
conflictsPrev := prsConflicts
|
|
useLLMPrev := prsUseLLM
|
|
t.Cleanup(func() {
|
|
forgeAvailable = availPrev
|
|
forgeListPRs = listPrev
|
|
forgePRFiles = filesPrev
|
|
prsDaemonTool = toolPrev
|
|
prsBase = basePrev
|
|
prsRepo = repoPrev
|
|
prsFormat = fmtPrev
|
|
prsWorktrees = wtPrev
|
|
prsTriage = triagePrev
|
|
prsConflicts = conflictsPrev
|
|
prsUseLLM = useLLMPrev
|
|
})
|
|
// Deterministic defaults for every case.
|
|
prsBase = ""
|
|
prsRepo = ""
|
|
prsFormat = "text"
|
|
prsWorktrees = false
|
|
prsTriage = false
|
|
prsConflicts = false
|
|
prsUseLLM = false
|
|
}
|
|
|
|
// runPRsCmd invokes the prs command with args, capturing its stdout.
|
|
func runPRsCmd(t *testing.T, args ...string) (string, error) {
|
|
t.Helper()
|
|
cmd := *prsCmd // shallow copy so SetArgs/SetOut don't leak across cases
|
|
var buf strings.Builder
|
|
cmd.SetOut(&buf)
|
|
cmd.SetErr(&buf)
|
|
err := runPRs(&cmd, args)
|
|
return buf.String(), err
|
|
}
|
|
|
|
func cannedPRs() []forge.PR {
|
|
now := time.Now().UTC()
|
|
return []forge.PR{
|
|
{
|
|
Number: 7,
|
|
Title: "Add forge",
|
|
Author: "alice",
|
|
BaseRef: "main",
|
|
HeadRef: "feature",
|
|
UpdatedAt: now.Add(-2 * 24 * time.Hour),
|
|
CIRollup: "SUCCESS",
|
|
},
|
|
{
|
|
Number: 8,
|
|
Title: "Refactor resolver",
|
|
Author: "bob",
|
|
BaseRef: "main",
|
|
HeadRef: "refactor",
|
|
ReviewDecision: "CHANGES_REQUESTED",
|
|
UpdatedAt: now.Add(-1 * 24 * time.Hour),
|
|
CIRollup: "FAILURE",
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestPRsList_Table renders the dashboard table for an authed repo from
|
|
// canned PRs and asserts the rows show up with their classifications.
|
|
func TestPRsList_Table(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return true }
|
|
forgeListPRs = func(_ context.Context, _ string, _ forge.ListOpts) ([]forge.PR, error) {
|
|
return cannedPRs(), nil
|
|
}
|
|
prsBase = "main" // pin the base so DefaultBranch probing doesn't run
|
|
|
|
out, err := runPRsCmd(t)
|
|
if err != nil {
|
|
t.Fatalf("runPRs: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"STATE", "CI", "REVIEW", "AUTHOR", "TITLE",
|
|
"7", "Add forge", "alice", "SUCCESS", "READY",
|
|
"8", "Refactor resolver", "bob", "FAILURE", "CHANGES_REQUESTED",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("table output missing %q\n----\n%s\n----", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPRsList_BaseFlipsMismatch asserts --base re-targets the default base so
|
|
// a PR onto a different branch classifies as BASE_MISMATCH.
|
|
func TestPRsList_BaseFlipsMismatch(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return true }
|
|
forgeListPRs = func(_ context.Context, _ string, _ forge.ListOpts) ([]forge.PR, error) {
|
|
return cannedPRs(), nil
|
|
}
|
|
prsFormat = "json"
|
|
|
|
// Default base == "main": PR #7 (onto main) is READY, not BASE_MISMATCH.
|
|
prsBase = "main"
|
|
outMain, err := runPRsCmd(t)
|
|
if err != nil {
|
|
t.Fatalf("runPRs (base=main): %v", err)
|
|
}
|
|
mainRows := decodePRRows(t, outMain)
|
|
if got := stateOf(mainRows, 7); got == "BASE_MISMATCH" {
|
|
t.Errorf("PR #7 onto main should NOT be BASE_MISMATCH, got %q", got)
|
|
}
|
|
|
|
// --base develop: every PR (all onto main) now BASE_MISMATCH.
|
|
prsBase = "develop"
|
|
outDev, err := runPRsCmd(t)
|
|
if err != nil {
|
|
t.Fatalf("runPRs (base=develop): %v", err)
|
|
}
|
|
devRows := decodePRRows(t, outDev)
|
|
if got := stateOf(devRows, 7); got != "BASE_MISMATCH" {
|
|
t.Errorf("PR #7 with --base develop should be BASE_MISMATCH, got %q", got)
|
|
}
|
|
// And the blocker list must name base-mismatch.
|
|
if !hasBlocker(devRows, 7, "base-mismatch") {
|
|
t.Errorf("PR #7 blockers should include base-mismatch: %+v", devRows)
|
|
}
|
|
}
|
|
|
|
// TestPRsList_JSONShape asserts --format json round-trips the documented
|
|
// {prs:[{number,title,author,age_days,ci,review,state,blockers}]} shape.
|
|
func TestPRsList_JSONShape(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return true }
|
|
forgeListPRs = func(_ context.Context, _ string, _ forge.ListOpts) ([]forge.PR, error) {
|
|
return cannedPRs(), nil
|
|
}
|
|
prsBase = "main"
|
|
prsFormat = "json"
|
|
|
|
out, err := runPRsCmd(t)
|
|
if err != nil {
|
|
t.Fatalf("runPRs: %v", err)
|
|
}
|
|
|
|
var payload struct {
|
|
PRs []struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
AgeDays int `json:"age_days"`
|
|
CI string `json:"ci"`
|
|
Review string `json:"review"`
|
|
State string `json:"state"`
|
|
Blockers []string `json:"blockers"`
|
|
} `json:"prs"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
|
t.Fatalf("decode json output: %v\n%s", err, out)
|
|
}
|
|
if len(payload.PRs) != 2 {
|
|
t.Fatalf("got %d PRs, want 2", len(payload.PRs))
|
|
}
|
|
p := payload.PRs[0]
|
|
if p.Number != 7 || p.Title != "Add forge" || p.Author != "alice" {
|
|
t.Errorf("PR[0] = %+v", p)
|
|
}
|
|
if p.CI != "SUCCESS" || p.State != "READY" {
|
|
t.Errorf("PR[0] ci/state = %q/%q, want SUCCESS/READY", p.CI, p.State)
|
|
}
|
|
if p.AgeDays != 2 {
|
|
t.Errorf("PR[0] age_days = %d, want 2", p.AgeDays)
|
|
}
|
|
if p.Blockers == nil {
|
|
t.Errorf("blockers must be a non-nil array even when empty")
|
|
}
|
|
// The CHANGES_REQUESTED PR must carry its blocker.
|
|
if !hasBlockerStruct(payload.PRs, 8, "changes-requested") {
|
|
t.Errorf("PR #8 must list changes-requested blocker: %+v", payload.PRs)
|
|
}
|
|
}
|
|
|
|
// TestPRsList_NoTokenHint asserts the unavailable-forge path prints an
|
|
// actionable GH_TOKEN hint and exits 0 (no error).
|
|
func TestPRsList_NoTokenHint(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return false }
|
|
called := false
|
|
forgeListPRs = func(_ context.Context, _ string, _ forge.ListOpts) ([]forge.PR, error) {
|
|
called = true
|
|
return nil, nil
|
|
}
|
|
|
|
out, err := runPRsCmd(t)
|
|
if err != nil {
|
|
t.Fatalf("no-token path must NOT return an error, got %v", err)
|
|
}
|
|
if called {
|
|
t.Error("forge list must not be called when no token is available")
|
|
}
|
|
if !strings.Contains(out, "GH_TOKEN") {
|
|
t.Errorf("no-token hint must name GH_TOKEN, got: %q", out)
|
|
}
|
|
}
|
|
|
|
// TestPRsDeepDive_PassesFilesAndRenders asserts the deep-dive fetches the PR
|
|
// file set, forwards it to the daemon get_pr_impact tool as a JSON-array
|
|
// string, and renders the changed files + blast radius from the result.
|
|
func TestPRsDeepDive_PassesFilesAndRenders(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return true }
|
|
forgePRFiles = func(_ context.Context, _ string, num int) ([]string, error) {
|
|
if num != 7 {
|
|
t.Errorf("PRFiles called with num=%d, want 7", num)
|
|
}
|
|
return []string{"internal/forge/forge.go", "internal/forge/ghrest.go"}, nil
|
|
}
|
|
|
|
var gotTool string
|
|
var gotArgs map[string]any
|
|
prsDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) {
|
|
gotTool = tool
|
|
gotArgs = args
|
|
return json.RawMessage(`{
|
|
"number": 7,
|
|
"risk": "HIGH",
|
|
"score": 68.5,
|
|
"review_priorities": [{"axis":"flow","score":72.0,"reason":"wide fan-in"}],
|
|
"changed_files": ["internal/forge/forge.go","internal/forge/ghrest.go"],
|
|
"changed_symbols": [{"id":"internal/forge/forge.go::ListPRs","name":"ListPRs","kind":"function","file":"internal/forge/forge.go"}],
|
|
"communities": ["c1","c2"]
|
|
}`), nil
|
|
}
|
|
|
|
out, err := runPRsCmd(t, "7")
|
|
if err != nil {
|
|
t.Fatalf("deep-dive: %v", err)
|
|
}
|
|
|
|
if gotTool != "get_pr_impact" {
|
|
t.Errorf("daemon tool = %q, want get_pr_impact", gotTool)
|
|
}
|
|
if n, _ := gotArgs["number"].(int); n != 7 {
|
|
t.Errorf("number arg = %v, want 7", gotArgs["number"])
|
|
}
|
|
// files must be forwarded as a JSON-array-encoded string (the daemon's
|
|
// get_pr_impact reads `files` as a JSON string).
|
|
filesArg, ok := gotArgs["files"].(string)
|
|
if !ok {
|
|
t.Fatalf("files arg type = %T, want string", gotArgs["files"])
|
|
}
|
|
var files []string
|
|
if err := json.Unmarshal([]byte(filesArg), &files); err != nil {
|
|
t.Fatalf("files arg is not a JSON array: %v (%q)", err, filesArg)
|
|
}
|
|
if len(files) != 2 || files[0] != "internal/forge/forge.go" {
|
|
t.Errorf("forwarded files = %v", files)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"PR #7", "HIGH", "68.5",
|
|
"internal/forge/forge.go",
|
|
"internal/forge/ghrest.go",
|
|
"ListPRs",
|
|
"flow",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("deep-dive output missing %q\n----\n%s\n----", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPRsDeepDive_NoTokenStillCallsDaemon asserts that with no forge token
|
|
// the deep-dive omits `files` (no PRFiles fetch) and lets the daemon
|
|
// self-serve — it must not error out before reaching the daemon.
|
|
func TestPRsDeepDive_NoTokenStillCallsDaemon(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
forgeAvailable = func(context.Context) bool { return false }
|
|
forgePRFiles = func(context.Context, string, int) ([]string, error) {
|
|
t.Error("PRFiles must not be called when no token is available")
|
|
return nil, nil
|
|
}
|
|
var gotArgs map[string]any
|
|
prsDaemonTool = func(_ string, _ string, args map[string]any) (json.RawMessage, error) {
|
|
gotArgs = args
|
|
return json.RawMessage(`{"number":7,"risk":"LOW","score":1.0,"changed_files":[],"changed_symbols":[],"communities":[]}`), nil
|
|
}
|
|
|
|
if _, err := runPRsCmd(t, "7"); err != nil {
|
|
t.Fatalf("deep-dive (no token): %v", err)
|
|
}
|
|
if _, ok := gotArgs["files"]; ok {
|
|
t.Errorf("files arg must be omitted when no token is available, got %v", gotArgs["files"])
|
|
}
|
|
}
|
|
|
|
// TestPRsDeepDive_BadNumber rejects a non-numeric / non-positive PR argument.
|
|
func TestPRsDeepDive_BadNumber(t *testing.T) {
|
|
resetPRsSeams(t)
|
|
if _, err := runPRsCmd(t, "notanumber"); err == nil {
|
|
t.Error("expected an error for a non-numeric PR number")
|
|
}
|
|
if _, err := runPRsCmd(t, "0"); err == nil {
|
|
t.Error("expected an error for PR number 0")
|
|
}
|
|
}
|
|
|
|
// --- decode helpers ---
|
|
|
|
func decodePRRows(t *testing.T, jsonOut string) []prRow {
|
|
t.Helper()
|
|
var payload struct {
|
|
PRs []prRow `json:"prs"`
|
|
}
|
|
if err := json.Unmarshal([]byte(jsonOut), &payload); err != nil {
|
|
t.Fatalf("decode pr rows: %v\n%s", err, jsonOut)
|
|
}
|
|
return payload.PRs
|
|
}
|
|
|
|
func stateOf(rows []prRow, number int) string {
|
|
for _, r := range rows {
|
|
if r.Number == number {
|
|
return r.State
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hasBlocker(rows []prRow, number int, blocker string) bool {
|
|
for _, r := range rows {
|
|
if r.Number != number {
|
|
continue
|
|
}
|
|
for _, b := range r.Blockers {
|
|
if b == blocker {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasBlockerStruct[T any](rows []T, number int, blocker string) bool {
|
|
// rows is the anonymous JSON struct slice; marshal/unmarshal into prRow
|
|
// for a uniform check.
|
|
raw, _ := json.Marshal(rows)
|
|
var prRows []prRow
|
|
_ = json.Unmarshal(raw, &prRows)
|
|
return hasBlocker(prRows, number, blocker)
|
|
}
|