forked from chroe/gitlink-cli
566 lines
16 KiB
Go
566 lines
16 KiB
Go
package workflow
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
func TestRegistry(t *testing.T) {
|
|
if len(registry) != 5 {
|
|
t.Fatalf("expected 5 workflows, got %d", len(registry))
|
|
}
|
|
|
|
for _, name := range []string{"community-ops", "code-quality", "project-init", "multi-repo", "contributor-growth"} {
|
|
wf := Get(name)
|
|
if wf == nil {
|
|
t.Fatalf("workflow %q not found", name)
|
|
}
|
|
if len(wf.Steps) == 0 {
|
|
t.Fatalf("workflow %q has no steps", name)
|
|
}
|
|
if wf.Trigger.Type == "" {
|
|
t.Fatalf("workflow %q has no trigger.type", name)
|
|
}
|
|
}
|
|
|
|
all := All()
|
|
if len(all) != 5 {
|
|
t.Fatalf("All() returned %d workflows, expected 5", len(all))
|
|
}
|
|
}
|
|
|
|
func TestGetNonexistent(t *testing.T) {
|
|
if Get("nonexistent") != nil {
|
|
t.Fatal("expected nil for nonexistent workflow")
|
|
}
|
|
}
|
|
|
|
func TestShortcutsCount(t *testing.T) {
|
|
sc := Shortcuts()
|
|
if len(sc) != 11 {
|
|
t.Fatalf("expected 11 shortcuts (list, info, run, init, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
|
|
}
|
|
names := map[string]bool{
|
|
"list": false, "info": false, "run": false, "init": false, "watch": false,
|
|
"schedule": false, "start": false, "stop": false, "status": false,
|
|
"logs": false, "install-systemd": false,
|
|
}
|
|
for _, s := range sc {
|
|
if _, ok := names[s.Name]; !ok {
|
|
t.Fatalf("unexpected shortcut: %s", s.Name)
|
|
}
|
|
names[s.Name] = true
|
|
}
|
|
for n, found := range names {
|
|
if !found {
|
|
t.Fatalf("missing shortcut: %s", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProjectInitShortcut(t *testing.T) {
|
|
var initShortcut *common.Shortcut
|
|
for _, s := range Shortcuts() {
|
|
if s.Name == "init" {
|
|
initShortcut = s
|
|
break
|
|
}
|
|
}
|
|
if initShortcut == nil {
|
|
t.Fatal("missing init shortcut")
|
|
}
|
|
if initShortcut.Description == "" {
|
|
t.Fatal("init shortcut should have a description")
|
|
}
|
|
if len(initShortcut.Flags) != 3 {
|
|
t.Fatalf("init shortcut should have 3 flags (dry-run, ai, no-ai), got %d: %+v", len(initShortcut.Flags), initShortcut.Flags)
|
|
}
|
|
hasDryRun := false
|
|
for _, f := range initShortcut.Flags {
|
|
if f.Name == "dry-run" && f.Bool {
|
|
hasDryRun = true
|
|
}
|
|
}
|
|
if !hasDryRun {
|
|
t.Fatal("init shortcut should expose bool --dry-run flag")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath(t *testing.T) {
|
|
cases := []struct {
|
|
template, owner, repo, expected string
|
|
}{
|
|
{"{v1}/issues", "chroe", "gitlink-cli", "/v1/chroe/gitlink-cli/issues"},
|
|
{"{base}/pulls", "chroe", "gitlink-cli", "/chroe/gitlink-cli/pulls"},
|
|
{"{base}", "org", "proj", "/org/proj"},
|
|
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
|
|
}
|
|
for _, tc := range cases {
|
|
got := resolvePath(tc.template, tc.owner, tc.repo)
|
|
if got != tc.expected {
|
|
t.Fatalf("resolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTriggers(t *testing.T) {
|
|
expected := map[string]struct {
|
|
on string
|
|
typ string
|
|
}{
|
|
"community-ops": {"issue.created", "poll"},
|
|
"code-quality": {"pr.opened", "poll"},
|
|
"project-init": {"manual", "manual"},
|
|
"multi-repo": {"0 9 * * 1", "cron"},
|
|
"contributor-growth": {"0 9 * * 1", "cron"},
|
|
}
|
|
for name, want := range expected {
|
|
wf := Get(name)
|
|
if wf.Trigger.On != want.on {
|
|
t.Fatalf("%s: trigger.on = %q, want %q", name, wf.Trigger.On, want.on)
|
|
}
|
|
if wf.Trigger.Type != want.typ {
|
|
t.Fatalf("%s: trigger.type = %q, want %q", name, wf.Trigger.Type, want.typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStepTypes(t *testing.T) {
|
|
wf := Get("community-ops")
|
|
if wf == nil {
|
|
t.Fatal("community-ops not found")
|
|
}
|
|
|
|
typeCounts := map[StepType]int{}
|
|
for _, s := range wf.Steps {
|
|
typeCounts[s.Type]++
|
|
}
|
|
if typeCounts[StepTypeCommand] < 1 {
|
|
t.Fatal("community-ops should have at least one command step")
|
|
}
|
|
if typeCounts[StepTypeSkill] < 1 {
|
|
t.Fatal("community-ops should have at least one skill step")
|
|
}
|
|
}
|
|
|
|
func TestSkillStepDependsOn(t *testing.T) {
|
|
wf := Get("community-ops")
|
|
if wf == nil {
|
|
t.Fatal("community-ops not found")
|
|
}
|
|
|
|
var triage *StepDef
|
|
for i := range wf.Steps {
|
|
if wf.Steps[i].Name == "triage" {
|
|
triage = &wf.Steps[i]
|
|
break
|
|
}
|
|
}
|
|
if triage == nil {
|
|
t.Fatal("triage step not found")
|
|
}
|
|
if len(triage.DependsOn) != 3 {
|
|
t.Fatalf("triage step should have 3 dependencies, got %d: %v", len(triage.DependsOn), triage.DependsOn)
|
|
}
|
|
expectedDeps := map[string]bool{"open-issues": false, "labels": false, "members": false}
|
|
for _, dep := range triage.DependsOn {
|
|
if _, ok := expectedDeps[dep]; !ok {
|
|
t.Fatalf("unexpected dependency: %s", dep)
|
|
}
|
|
expectedDeps[dep] = true
|
|
}
|
|
}
|
|
|
|
func TestParseCommandTarget(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
expected []string
|
|
}{
|
|
{"issue +list --state open", []string{"issue", "+list", "--state", "open"}},
|
|
{"repo +info", []string{"repo", "+info"}},
|
|
{"pr +list --state merged --limit 50", []string{"pr", "+list", "--state", "merged", "--limit", "50"}},
|
|
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
|
|
}
|
|
for _, tc := range cases {
|
|
got := parseCommandTarget(tc.input)
|
|
if len(got) != len(tc.expected) {
|
|
t.Fatalf("parseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.expected[i] {
|
|
t.Fatalf("parseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Security whitelist tests ---
|
|
|
|
func TestActionAllowed(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
action AIAction
|
|
allowed bool
|
|
}{
|
|
{"api GET", AIAction{Type: "api", Method: "GET"}, true},
|
|
{"api POST", AIAction{Type: "api", Method: "POST"}, true},
|
|
{"api PATCH", AIAction{Type: "api", Method: "PATCH"}, true},
|
|
{"api DELETE blocked", AIAction{Type: "api", Method: "DELETE"}, false},
|
|
{"cli issue comment", AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
|
|
{"cli delete blocked", AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
|
|
{"cli fork blocked", AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
|
|
{"cli repo module blocked", AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isActionAllowed(tc.action); got != tc.allowed {
|
|
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- State tests ---
|
|
|
|
func TestStateSaveLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
|
|
|
s := &WorkflowState{
|
|
Workflow: "test-wf",
|
|
TotalRuns: 5,
|
|
Snapshots: map[string]string{"step1": "abc123"},
|
|
}
|
|
if err := s.Save(); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadState("test-wf")
|
|
if err != nil {
|
|
t.Fatalf("LoadState failed: %v", err)
|
|
}
|
|
if loaded.TotalRuns != 5 {
|
|
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
|
|
}
|
|
if loaded.Snapshots["step1"] != "abc123" {
|
|
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
|
|
}
|
|
|
|
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
|
|
}
|
|
|
|
func TestStateDiff(t *testing.T) {
|
|
s := &WorkflowState{
|
|
Workflow: "test-diff",
|
|
Snapshots: map[string]string{"step1": "oldhash"},
|
|
}
|
|
|
|
results := []StepResult{
|
|
{Step: "step1", OK: true, Data: "changed data"},
|
|
{Step: "step2", OK: true, Data: "new step"},
|
|
{Step: "step3", OK: false, Data: "ignored"},
|
|
}
|
|
|
|
changed := s.Diff(results)
|
|
if len(changed) != 1 {
|
|
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
|
|
}
|
|
if changed[0] != "step1" {
|
|
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
|
|
}
|
|
if _, ok := s.Snapshots["step2"]; !ok {
|
|
t.Fatal("step2 should be added to snapshots")
|
|
}
|
|
if _, ok := s.Snapshots["step3"]; ok {
|
|
t.Fatal("step3 (failed) should NOT be added to snapshots")
|
|
}
|
|
}
|
|
|
|
func TestLoadStateNotExist(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
|
|
|
s, err := LoadState("nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("LoadState should not error for missing file: %v", err)
|
|
}
|
|
if s.Workflow != "nonexistent" {
|
|
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
|
|
}
|
|
if s.Snapshots == nil {
|
|
t.Fatal("Snapshots should be initialized as empty map")
|
|
}
|
|
}
|
|
|
|
// --- Engine integration tests ---
|
|
|
|
func TestRunWithAPISteps(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
|
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
|
{"id": 1, "subject": "bug"},
|
|
{"id": 2, "subject": "feature"},
|
|
}, nil))
|
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
|
|
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
|
|
{"id": 10, "name": "bug"},
|
|
{"id": 11, "name": "enhancement"},
|
|
}, nil))
|
|
default:
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-api",
|
|
Steps: []StepDef{
|
|
{Type: StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
|
|
{Type: StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, false)
|
|
if err != nil {
|
|
t.Fatalf("Run() failed: %v", err)
|
|
}
|
|
if result.Owner != "owner" || result.Repo != "repo" {
|
|
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
|
|
}
|
|
if len(result.Steps) != 2 {
|
|
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
|
|
}
|
|
for _, sr := range result.Steps {
|
|
if !sr.OK {
|
|
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSkillStepReceivesUpstream(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1/owner/repo/issues.json" {
|
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
|
"issues": []map[string]interface{}{{"id": 1}},
|
|
}, nil))
|
|
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
|
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
|
|
"labels": []map[string]interface{}{{"name": "bug"}},
|
|
}, nil))
|
|
} else {
|
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-skill-upstream",
|
|
Steps: []StepDef{
|
|
{Type: StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
|
{Type: StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
|
{Type: StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, false)
|
|
if err != nil {
|
|
t.Fatalf("Run() failed: %v", err)
|
|
}
|
|
|
|
skillData, ok := result.Steps[2].Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("skill step data is not a map")
|
|
}
|
|
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("skill step missing _upstream map")
|
|
}
|
|
if _, hasIssues := upstream["get-issues"]; !hasIssues {
|
|
t.Fatal("_upstream missing get-issues key")
|
|
}
|
|
if _, hasLabels := upstream["get-labels"]; !hasLabels {
|
|
t.Fatal("_upstream missing get-labels key")
|
|
}
|
|
if skillData["_skill"] != "gitlink-triage" {
|
|
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
|
|
}
|
|
}
|
|
|
|
// TestSkillStepWithDependsOn verifies that when DependsOn is set,
|
|
// only those specific upstream steps are collected.
|
|
func TestSkillStepWithDependsOn(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-depends-on",
|
|
Steps: []StepDef{
|
|
{Type: StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
|
|
{Type: StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
|
|
{Type: StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
|
|
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
|
|
DependsOn: []string{"open-issues", "labels"}},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, false)
|
|
if err != nil {
|
|
t.Fatalf("Run() failed: %v", err)
|
|
}
|
|
|
|
skillData, ok := result.Steps[3].Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("skill step data is not a map")
|
|
}
|
|
upstream, ok := skillData["_upstream"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("skill step missing _upstream map")
|
|
}
|
|
if _, hasIssues := upstream["open-issues"]; !hasIssues {
|
|
t.Fatal("_upstream missing open-issues key")
|
|
}
|
|
if _, hasLabels := upstream["labels"]; !hasLabels {
|
|
t.Fatal("_upstream missing labels key")
|
|
}
|
|
if _, hasMembers := upstream["members"]; hasMembers {
|
|
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
|
|
}
|
|
}
|
|
|
|
func TestRunStepFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(500)
|
|
writeJSON(t, w, map[string]interface{}{
|
|
"ok": false, "error": "internal server error",
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-fail",
|
|
Steps: []StepDef{
|
|
{Type: StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, false)
|
|
if err != nil {
|
|
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
|
|
}
|
|
if result.Steps[0].OK {
|
|
t.Fatal("expected step to fail, but it passed")
|
|
}
|
|
}
|
|
|
|
func TestRunUnknownStepType(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("no request expected")
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-unknown",
|
|
Steps: []StepDef{
|
|
{Type: StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, false)
|
|
if err != nil {
|
|
t.Fatalf("Run() returned error: %v", err)
|
|
}
|
|
if result.Steps[0].OK {
|
|
t.Fatal("unknown step type should fail")
|
|
}
|
|
}
|
|
|
|
func TestCodeQualityHasReviewStep(t *testing.T) {
|
|
wf := Get("code-quality")
|
|
if wf == nil {
|
|
t.Fatal("code-quality not found")
|
|
}
|
|
if len(wf.Steps) < 7 {
|
|
t.Fatalf("code-quality should have at least 7 steps (including review), got %d", len(wf.Steps))
|
|
}
|
|
found := false
|
|
for _, s := range wf.Steps {
|
|
if s.Target == "gitlink-review" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("code-quality missing gitlink-review skill step")
|
|
}
|
|
}
|
|
|
|
func TestSkillStepDryRun(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := newTestContext(t, server)
|
|
wf := &WorkflowDef{
|
|
Name: "test-dry-run",
|
|
Steps: []StepDef{
|
|
{Type: StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
|
|
{Type: StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
|
|
DependsOn: []string{"get-data"}},
|
|
},
|
|
}
|
|
|
|
result, err := Run(ctx, wf, true)
|
|
if err != nil {
|
|
t.Fatalf("Run() dry-run failed: %v", err)
|
|
}
|
|
|
|
skillData, ok := result.Steps[1].Data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("skill step data is not a map")
|
|
}
|
|
if v, _ := skillData["_dry_run"]; v != true {
|
|
t.Fatal("dry-run skill step should have _dry_run=true")
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
|
|
t.Helper()
|
|
return &common.RuntimeContext{
|
|
Client: &client.Client{
|
|
HTTP: server.Client(),
|
|
BaseURL: server.URL,
|
|
},
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
Format: "json",
|
|
Args: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
t.Fatalf("failed to write response: %v", err)
|
|
}
|
|
}
|