From 813f9e6fef905899b20e15ea3c4562bf4e585578 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 10:17:50 +0100 Subject: [PATCH] feat(server): derive declared_outputs from script + slugify --- server/internal/services/stepscan.go | 44 ++++++++++++++++++++++ server/internal/services/stepscan_test.go | 45 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 server/internal/services/stepscan.go create mode 100644 server/internal/services/stepscan_test.go diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go new file mode 100644 index 0000000..df0d281 --- /dev/null +++ b/server/internal/services/stepscan.go @@ -0,0 +1,44 @@ +package services + +import ( + "regexp" + "strings" +) + +// keyAssign matches an env-var assignment target: KEY= (captures KEY). +var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`) + +// DeriveOutputs scans a step script and returns the output keys it writes to +// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are +// considered. Deduplicated, first-seen order preserved. +func DeriveOutputs(script string) []string { + out := []string{} + seen := map[string]bool{} + for _, line := range strings.Split(script, "\n") { + if !strings.Contains(line, "WORKFLOW_ENV") { + continue + } + for _, m := range keyAssign.FindAllStringSubmatch(line, -1) { + key := m[1] + // Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments). + if key == "WORKFLOW_ENV" || key == "env" { + continue + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, key) + } + } + return out +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// Slugify converts a step name into a stable kebab-case slug. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} diff --git a/server/internal/services/stepscan_test.go b/server/internal/services/stepscan_test.go new file mode 100644 index 0000000..c02c032 --- /dev/null +++ b/server/internal/services/stepscan_test.go @@ -0,0 +1,45 @@ +package services + +import ( + "reflect" + "testing" +) + +func TestDeriveOutputs(t *testing.T) { + script := `#!/bin/bash +echo "test=123" >> $WORKFLOW_ENV +echo "other=hi" >> "$WORKFLOW_ENV" +printf 'third=1\n' >> $WORKFLOW_ENV +echo "test=456" >> $WORKFLOW_ENV +echo "ignored=nope" +NORMAL=assignment +` + got := DeriveOutputs(script) + want := []string{"test", "other", "third"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestDeriveOutputsPowershell(t *testing.T) { + script := `"result=ok" >> $env:WORKFLOW_ENV +Add-Content $env:WORKFLOW_ENV "count=5"` + got := DeriveOutputs(script) + want := []string{"result", "count"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestDeriveOutputsNone(t *testing.T) { + got := DeriveOutputs("echo hello\nNOPE=1") + if len(got) != 0 { + t.Fatalf("got %v want empty", got) + } +} + +func TestSlugify(t *testing.T) { + if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" { + t.Fatalf("got %q", got) + } +}