Compare commits

...

5 Commits

Author SHA1 Message Date
core-be c26c7d039e test(org-import): add _Pure test suite for sanitizeEnvMembers, flattenAndSortRequirements, collectOrgEnv edge cases
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 3s
sop-tier-check / tier-check (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 5m40s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Successful in 7m43s
CI / Python Lint & Test (pull_request) Successful in 6m28s
CI / all-required (pull_request) Successful in 6m31s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m17s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4m50s
E2E Chat / E2E Chat (pull_request) Failing after 10m46s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
sop-checklist / na-declarations (pull_request) N/A: (none)
Adds 24 test cases covering:
- envRequirementKey: permutation invariance, dedup via sort
- sanitizeEnvMembers: digit-prefix, empty-string silence, all-invalid
- flattenAndSortRequirements: determinism, all-singles, groups-sorted-by-key
- collectOrgEnv: recursive children, rec-dedup-by-strict, strict-drops-any-of
- countWorkspaces: empty/flat/nested

All _Pure-suffixed to avoid collision with org_import_helpers_test.go names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 21:04:12 +00:00
hongming-pc2 4c0cd6b705 Merge pull request 'fix(queue): correct status deduplication for combined+all_statuses sort order' (#1428) from fix/queue-status-sort into main
CI / Shellcheck (E2E scripts) (push) Successful in 10s
CI / Platform (Go) (push) Successful in 5m36s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 5s
Handlers Postgres Integration / detect-changes (push) Successful in 2s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 2s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m1s
CI / Canvas (Next.js) (push) Successful in 5m55s
CI / Python Lint & Test (push) Successful in 6m34s
CI / all-required (push) Successful in 5m12s
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Waiting to run
CI / Canvas (Next.js) (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
E2E Chat / E2E Chat (push) Successful in 1s
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
audit-force-merge / audit (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
CI / Canvas Deploy Reminder (push) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m11s
publish-workspace-server-image / Production auto-deploy (push) Successful in 31m25s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m7s
publish-workspace-server-image / build-and-push (push) Successful in 6m42s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 7s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 1m24s
main-red-watchdog / watchdog (push) Successful in 32s
gate-check-v3 / gate-check (push) Successful in 1m28s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
ci-required-drift / drift (push) Successful in 37s
Weekly Platform-Go Surface / Weekly Platform-Go Surface (push) Successful in 5m49s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m40s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
gitea-merge-queue / queue (push) Successful in 5s
status-reaper / reap (push) Successful in 1m4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 8m12s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
2026-05-17 20:56:57 +00:00
core-devops af7afc6112 Merge PR #1417 via gitea-merge-queue
E2E Chat / E2E Chat (push) Successful in 7s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m5s
CI / Platform (Go) (push) Successful in 7m26s
CI / Python Lint & Test (push) Successful in 7m10s
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
CI / Detect changes (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
CI / Canvas (Next.js) (push) Successful in 10m7s
CI / all-required (push) Successful in 8m1s
publish-workspace-server-image / Production auto-deploy (push) Successful in 14m25s
ci-required-drift / drift (push) Successful in 1m5s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 11s
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 7m54s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Serialized merge by gitea-merge-queue after current-main, SOP, and required CI checks were green.
2026-05-17 20:07:54 +00:00
core-devops 4f5d683f4b chore: re-trigger Gitea Actions workflows (core-devops agent)
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 6s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
CI / Canvas (Next.js) (pull_request) Successful in 7m54s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
CI / all-required (pull_request) Successful in 7m48s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m2s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 4s
CI / Platform (Go) (pull_request) Successful in 6m2s
CI / Python Lint & Test (pull_request) Successful in 6m49s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-17 14:37:35 +00:00
core-devops df4a0e3f9d fix(queue): skip PRs with HTTP 403/404/405 merge errors instead of looping
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 2s
CI / Detect changes (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 5s
E2E Chat / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 55s
qa-review / approved (pull_request) Failing after 2s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 3s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 56s
CI / Platform (Go) (pull_request) Successful in 4m25s
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) Failing after 2s
sop-tier-check / tier-check (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 6m54s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 6m28s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 5m54s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
The queue was retrying the same PR forever when merge returned HTTP 405
("User not allowed to merge PR"). ApiError was caught by main() and returned
0, so the next tick tried the same PR again — infinite loop.

Changes:
- Add MergePermissionError(ApiError) for permanent merge failures
- merge_pull() catches ApiError and re-raises MergePermissionError for
  HTTP 403/404/405
- process_once() catches MergePermissionError, posts a comment on the PR
  explaining the permission issue, and returns 0

The PR stays in the merge-queue label so future ticks can retry after
the permission issue is resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:55:46 +00:00
3 changed files with 502 additions and 2 deletions
+34 -2
View File
@@ -65,6 +65,11 @@ class ApiError(RuntimeError):
pass
class MergePermissionError(ApiError):
"""Merge failed with a permanent permission error (403/404/405).
The queue should skip this PR and move to the next one."""
@dataclasses.dataclass(frozen=True)
class MergeDecision:
ready: bool
@@ -367,7 +372,16 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
print(f"::notice::merging PR #{pr_number}")
if dry_run:
return
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
try:
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
except ApiError as exc:
# Re-raise permission-like errors so process_once can skip this PR.
# 403 = no push access, 404 = repo/pr not found, 405 = not allowed.
msg = str(exc)
for code in ("403", "404", "405"):
if code in msg:
raise MergePermissionError(msg) from exc
raise # re-raise other ApiErrors unchanged
def process_once(*, dry_run: bool = False) -> int:
@@ -438,7 +452,25 @@ def process_once(*, dry_run: bool = False) -> int:
"deferring to next tick"
)
return 0
merge_pull(pr_number, dry_run=dry_run)
try:
merge_pull(pr_number, dry_run=dry_run)
except MergePermissionError as exc:
# Permanent merge failure (HTTP 403/404/405). Post a comment so
# maintainers know why, then return 0 so this tick is done.
# The PR stays in the queue; future ticks can retry after the
# permission issue is resolved.
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
post_comment(
pr_number,
(
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
"No available token has Can-merge permission on this repo. "
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
"Skipping to next queued PR on next tick."
),
dry_run=dry_run,
)
return 0
return 0
return 0
@@ -118,3 +118,13 @@ def test_merge_decision_updates_stale_pr_before_merge():
assert decision.ready is False
assert decision.action == "update"
def test_MergePermissionError_inherits_from_ApiError():
assert issubclass(mq.MergePermissionError, mq.ApiError)
def test_MergePermissionError_message_preserved():
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
assert "405" in str(exc)
assert "User not allowed" in str(exc)
@@ -0,0 +1,458 @@
package handlers
import (
"sort"
"testing"
)
// ─────────────────────────────────────────────────────────────────────────────
// envRequirementKey tests
// ─────────────────────────────────────────────────────────────────────────────
func TestEnvRequirementKey_SingleMember_Pure(t *testing.T) {
key := envRequirementKey([]string{"API_KEY"})
if key != "API_KEY" {
t.Errorf("single member: got %q, want %q", key, "API_KEY")
}
}
func TestEnvRequirementKey_TwoMembersSorted_Pure(t *testing.T) {
// envRequirementKey sorts before joining, so [B, A] and [A, B] produce same key.
keyBA := envRequirementKey([]string{"B", "A"})
keyAB := envRequirementKey([]string{"A", "B"})
if keyBA != keyAB {
t.Errorf("sort invariance: got %q vs %q", keyBA, keyAB)
}
if keyBA != "A\x00B" {
t.Errorf("sort result: got %q, want %q", keyBA, "A\x00B")
}
}
func TestEnvRequirementKey_ThreeMembers_Pure(t *testing.T) {
key := envRequirementKey([]string{"C", "A", "B"})
if key != "A\x00B\x00C" {
t.Errorf("three members sorted: got %q, want %q", key, "A\x00B\x00C")
}
}
func TestEnvRequirementKey_Empty_Pure(t *testing.T) {
key := envRequirementKey([]string{})
if key != "" {
t.Errorf("empty: got %q, want %q", key, "")
}
}
func TestEnvRequirementKey_DedupBySort_Pure(t *testing.T) {
// Two lists that are permutations of each other must have identical keys.
key1 := envRequirementKey([]string{"Z", "M", "A"})
key2 := envRequirementKey([]string{"A", "Z", "M"})
if key1 != key2 {
t.Errorf("permutation invariance: got %q vs %q", key1, key2)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// sanitizeEnvMembers tests
// ─────────────────────────────────────────────────────────────────────────────
func TestSanitizeEnvMembers_AllValid_Pure(t *testing.T) {
members := []string{"API_KEY", "ANTHROPIC_API_KEY", "SECRET_123"}
got, ok := sanitizeEnvMembers(members, "test")
if !ok {
t.Error("expected ok=true for all valid names")
}
if len(got) != 3 {
t.Errorf("length: got %d, want 3", len(got))
}
}
func TestSanitizeEnvMembers_FiltersLowercase_Pure(t *testing.T) {
members := []string{"api_key", "ANTHROPIC_API_KEY", "VALID_NAME"}
got, ok := sanitizeEnvMembers(members, "test")
if !ok {
t.Error("expected ok=true for mixed case")
}
if len(got) != 2 {
t.Errorf("length: got %d, want 2", len(got))
}
want := []string{"ANTHROPIC_API_KEY", "VALID_NAME"}
for i, w := range want {
if got[i] != w {
t.Errorf("got[%d]=%q, want %q", i, got[i], w)
}
}
}
func TestSanitizeEnvMembers_FiltersInvalidCharacters_Pure(t *testing.T) {
members := []string{"VALID", "in valid", "ALSO-INVALID", "SPACE KEY", "hyphen-key", ""}
got, ok := sanitizeEnvMembers(members, "test")
if !ok {
t.Error("expected ok=true (VALID was present)")
}
if len(got) != 1 || got[0] != "VALID" {
t.Errorf("got %v, want [VALID]", got)
}
}
func TestSanitizeEnvMembers_AllInvalid_Pure(t *testing.T) {
members := []string{"lowercase", "has-dash", "has space"}
got, ok := sanitizeEnvMembers(members, "test")
if ok {
t.Error("expected ok=false when all members invalid")
}
if len(got) != 0 {
t.Errorf("got %v, want []", got)
}
}
func TestSanitizeEnvMembers_EmptyInput_Pure(t *testing.T) {
members := []string{}
got, ok := sanitizeEnvMembers(members, "test")
if ok {
t.Error("expected ok=false for empty input")
}
if len(got) != 0 {
t.Errorf("got %v, want []", got)
}
}
func TestSanitizeEnvMembers_EmptyStringNotLogged_Pure(t *testing.T) {
// Empty string is filtered silently (not logged) — verify no panic.
members := []string{"VALID", ""}
got, ok := sanitizeEnvMembers(members, "test")
if !ok || len(got) != 1 || got[0] != "VALID" {
t.Errorf("unexpected result: ok=%v got=%v", ok, got)
}
}
func TestSanitizeEnvMembers_StartsWithDigit_Pure(t *testing.T) {
members := []string{"123KEY", "VALID_KEY"}
got, ok := sanitizeEnvMembers(members, "test")
if !ok {
t.Error("expected ok=true (VALID_KEY was present)")
}
if len(got) != 1 || got[0] != "VALID_KEY" {
t.Errorf("got %v, want [VALID_KEY]", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// flattenAndSortRequirements tests
// ─────────────────────────────────────────────────────────────────────────────
func TestFlattenAndSortRequirements_SinglesFirst_Pure(t *testing.T) {
by := map[string]EnvRequirement{
"B": {Name: "B"},
"Z": {Name: "Z"},
"group1": {AnyOf: []string{"A", "C"}},
}
got := flattenAndSortRequirements(by)
if len(got) != 3 {
t.Fatalf("len: got %d, want 3", len(got))
}
// Singles first, then groups.
if got[0].Name == "" {
t.Error("first item should be a single")
}
if got[1].Name == "" {
t.Error("second item should be a single")
}
// Within singles, alphabetical.
if got[0].Name != "B" || got[1].Name != "Z" {
t.Errorf("got singles %q, %q, want B, Z", got[0].Name, got[1].Name)
}
// Then groups.
if len(got[2].AnyOf) == 0 {
t.Error("third item should be a group")
}
}
func TestFlattenAndSortRequirements_GroupsSortedByKey_Pure(t *testing.T) {
by := map[string]EnvRequirement{
"B\x00A": {AnyOf: []string{"B", "A"}}, // key already sorted
"A\x00C": {AnyOf: []string{"A", "C"}},
}
got := flattenAndSortRequirements(by)
// After singles, groups sorted by their envRequirementKey.
// "A\x00B" < "A\x00C", so the B/A group (key "A\x00B") comes first.
group0 := got[len(got)-2]
group1 := got[len(got)-1]
if group0.AnyOf[0] != "B" || group1.AnyOf[0] != "A" {
t.Errorf("group order: got %v then %v, want [B,A] then [A,C]", group0.AnyOf, group1.AnyOf)
}
}
func TestFlattenAndSortRequirements_EmptyMap_Pure(t *testing.T) {
by := map[string]EnvRequirement{}
got := flattenAndSortRequirements(by)
if len(got) != 0 {
t.Errorf("empty map: got %d items, want 0", len(got))
}
}
func TestFlattenAndSortRequirements_OnlyGroups_Pure(t *testing.T) {
by := map[string]EnvRequirement{
"X\x00Y": {AnyOf: []string{"X", "Y"}},
}
got := flattenAndSortRequirements(by)
if len(got) != 1 {
t.Fatalf("len: got %d, want 1", len(got))
}
if len(got[0].AnyOf) == 0 {
t.Error("only group expected")
}
}
func TestFlattenAndSortRequirements_Deterministic_Pure(t *testing.T) {
// Run twice; results must be identical.
by := map[string]EnvRequirement{
"C": {Name: "C"},
"A": {Name: "A"},
"B": {Name: "B"},
"Z\x00Y": {AnyOf: []string{"Z", "Y"}},
}
got1 := flattenAndSortRequirements(by)
got2 := flattenAndSortRequirements(by)
if len(got1) != len(got2) {
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
}
for i := range got1 {
if got1[i].Name != got2[i].Name || len(got1[i].AnyOf) != len(got2[i].AnyOf) {
t.Errorf("item %d differs: %+v vs %+v", i, got1[i], got2[i])
}
}
}
func TestFlattenAndSortRequirements_AllSingles_Pure(t *testing.T) {
by := map[string]EnvRequirement{
"X": {Name: "X"},
"M": {Name: "M"},
"A": {Name: "A"},
}
got := flattenAndSortRequirements(by)
if len(got) != 3 {
t.Fatalf("len: got %d, want 3", len(got))
}
names := make([]string, len(got))
for i, r := range got {
names[i] = r.Name
}
// Must be sorted alphabetically.
if !sort.IsSorted(sort.StringSlice(names)) {
t.Errorf("not sorted: %v", names)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// collectOrgEnv tests
// ─────────────────────────────────────────────────────────────────────────────
func TestCollectOrgEnv_EmptyTemplate_Pure(t *testing.T) {
tmpl := &OrgTemplate{}
req, rec := collectOrgEnv(tmpl)
if len(req) != 0 || len(rec) != 0 {
t.Errorf("empty template: got req=%d rec=%d, want 0,0", len(req), len(rec))
}
}
func TestCollectOrgEnv_SingleRequired_Pure(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "API_KEY"}},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Errorf("req count: got %d, want 1", len(req))
}
if len(rec) != 0 {
t.Errorf("rec count: got %d, want 0", len(rec))
}
if req[0].Name != "API_KEY" {
t.Errorf("req[0].Name: got %q, want %q", req[0].Name, "API_KEY")
}
}
func TestCollectOrgEnv_SingleRecommended_Pure(t *testing.T) {
tmpl := &OrgTemplate{
RecommendedEnv: []EnvRequirement{{Name: "DEBUG_MODE"}},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 0 {
t.Errorf("req count: got %d, want 0", len(req))
}
if len(rec) != 1 {
t.Errorf("rec count: got %d, want 1", len(rec))
}
}
func TestCollectOrgEnv_RequiredWinsOverRecommended_Pure(t *testing.T) {
// Same env name in both tiers → appears only in required.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "SHARED_KEY"}},
RecommendedEnv: []EnvRequirement{{Name: "SHARED_KEY"}},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || len(rec) != 0 {
t.Errorf("got req=%d rec=%d, want 1,0", len(req), len(rec))
}
}
func TestCollectOrgEnv_StrictDropsAnyOf_Pure(t *testing.T) {
// Single required + any-of group containing that name → any-of dropped.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "API_KEY"}},
RecommendedEnv: []EnvRequirement{{AnyOf: []string{"API_KEY", "FALLBACK_KEY"}}},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0].Name != "API_KEY" {
t.Errorf("req: got %+v", req)
}
if len(rec) != 0 {
t.Errorf("rec: got %d, want 0 (any-of dropped by strict required)", len(rec))
}
}
func TestCollectOrgEnv_AnyOfGroup_Pure(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{AnyOf: []string{"KEY_A", "KEY_B"}}},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Fatalf("req count: got %d, want 1", len(req))
}
if len(req[0].AnyOf) != 2 {
t.Errorf("req[0].AnyOf: got %v, want [KEY_A, KEY_B]", req[0].AnyOf)
}
_ = rec // may be unused but call is valid
}
func TestCollectOrgEnv_NestedWorkspace_Pure(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "ORG_KEY"}},
Workspaces: []OrgWorkspace{
{
Name: "child-ws",
RequiredEnv: []EnvRequirement{{Name: "CHILD_KEY"}},
RecommendedEnv: []EnvRequirement{{Name: "CHILD_RECOM"}},
},
},
}
req, rec := collectOrgEnv(tmpl)
// All 3 required names should appear (dedupped).
if len(req) != 2 {
t.Errorf("req count: got %d, want 2", len(req))
}
if len(rec) != 1 {
t.Errorf("rec count: got %d, want 1", len(rec))
}
names := make(map[string]bool)
for _, r := range req {
names[r.Name] = true
}
if !names["ORG_KEY"] || !names["CHILD_KEY"] {
t.Errorf("req names: got %v", names)
}
}
func TestCollectOrgEnv_InvalidEnvNameFiltered_Pure(t *testing.T) {
// Invalid names (lowercase, empty) are silently dropped.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
{Name: "VALID_KEY"},
{Name: "ALSO_VALID"},
{Name: "invalid-name"},
{Name: ""},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 2 {
t.Errorf("got %d req, want 2 (lowercase and empty filtered)", len(req))
}
_ = rec
}
func TestCollectOrgEnv_DedupSameMembers_Pure(t *testing.T) {
// Same members declared twice → deduplicated.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
{AnyOf: []string{"A", "B"}},
{AnyOf: []string{"B", "A"}}, // same set, reversed
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Errorf("dedup failed: got %d req, want 1", len(req))
}
_ = rec
}
func TestCollectOrgEnv_RecDedupedByStrictRequired_Pure(t *testing.T) {
// Strict required drops any-of groups in recommended tier too.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "STRICT"}},
RecommendedEnv: []EnvRequirement{{AnyOf: []string{"STRICT", "OTHER"}}},
}
req, rec := collectOrgEnv(tmpl)
if len(rec) != 0 {
t.Errorf("rec should be empty (strict required prunes recommended any-of): got %d", len(rec))
}
_ = req
}
func TestCollectOrgEnv_RecursiveChildren_Pure(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{{Name: "ROOT"}},
Workspaces: []OrgWorkspace{
{
Name: "parent",
RequiredEnv: []EnvRequirement{{Name: "PARENT"}, {Name: "PARENT2"}},
Children: []OrgWorkspace{
{
Name: "grandchild",
RequiredEnv: []EnvRequirement{{Name: "GRANDCHILD"}},
},
},
},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 4 {
t.Errorf("got %d required, want 4 (ROOT + PARENT + PARENT2 + GRANDCHILD)", len(req))
}
_ = rec
}
// ─────────────────────────────────────────────────────────────────────────────
// countWorkspaces tests
// ─────────────────────────────────────────────────────────────────────────────
func TestCountWorkspaces_Empty_Pure(t *testing.T) {
if n := countWorkspaces(nil); n != 0 {
t.Errorf("nil: got %d, want 0", n)
}
if n := countWorkspaces([]OrgWorkspace{}); n != 0 {
t.Errorf("empty: got %d, want 0", n)
}
}
func TestCountWorkspaces_Flat_Pure(t *testing.T) {
ws := []OrgWorkspace{
{Name: "a"}, {Name: "b"},
}
if n := countWorkspaces(ws); n != 2 {
t.Errorf("flat: got %d, want 2", n)
}
}
func TestCountWorkspaces_Nested_Pure(t *testing.T) {
ws := []OrgWorkspace{
{Name: "a", Children: []OrgWorkspace{
{Name: "b", Children: []OrgWorkspace{
{Name: "c"},
}},
{Name: "d"},
}},
}
if n := countWorkspaces(ws); n != 4 {
t.Errorf("nested: got %d, want 4", n)
}
}