gitlink-cli/shortcuts/workflow/workflow_test.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)
}
}