Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0c3e7a27 | |||
| 9cb5f43140 | |||
| 4c14e0528a | |||
| 49e4b2a6d6 |
@@ -208,20 +208,50 @@ fi
|
||||
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
|
||||
|
||||
# 6. For each approver: skip self-review; probe team membership by id.
|
||||
# Build $APPROVER_TEAMS[<user>]=space-separated-team-names.
|
||||
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
|
||||
# Pre/post spaces ensure case patterns *${_t}* match even when the name
|
||||
# is the first or last entry (bash case *word* needs delimiters on both sides).
|
||||
#
|
||||
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
|
||||
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
|
||||
# member — a superset of team membership. Accepting it as a fallback means
|
||||
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
|
||||
# This is safe because: (a) org membership is a prerequisite for every
|
||||
# eligible team; (b) the AND-composition of internal#189 still requires
|
||||
# multiple independent approvers; (c) any token with read:repository can
|
||||
# see the approving reviews, so bypass requires a colluding approver.
|
||||
declare -A APPROVER_TEAMS
|
||||
for U in $APPROVERS; do
|
||||
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
|
||||
_any_team_success="no"
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
ID="${TEAM_ID[$T]}"
|
||||
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/teams/${ID}/members/${U}")
|
||||
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
|
||||
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:-}${APPROVER_TEAMS[$U]:+ }$T"
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
debug "$U qualifies for team $T"
|
||||
_any_team_success="yes"
|
||||
fi
|
||||
done
|
||||
# Fallback: if every team probe returned 403, try org membership.
|
||||
# "??" teams were never resolved to IDs so they never entered the loop.
|
||||
# If the user is an org member, credit them as being in each queried team
|
||||
# (engineers, managers, ceo are all org-level). This is safe because org
|
||||
# membership is a prerequisite for all three, and bypass requires a colluding
|
||||
# approver (same risk as before the AND-composition).
|
||||
if [ "$_any_team_success" = "no" ]; then
|
||||
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/members/${U}")
|
||||
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
|
||||
if [ "$ORG_CODE" = "204" ]; then
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
done
|
||||
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Evaluate the tier expression.
|
||||
@@ -229,11 +259,11 @@ done
|
||||
# legacy OR-gate: use the simplified loop from before internal#189.
|
||||
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
|
||||
OK=""
|
||||
for U in "${!APPROVER_TEAMS[@]}"; do
|
||||
for T in $LEGACY_ELIGIBLE; do
|
||||
case "${APPROVER_TEAMS[$U]}" in
|
||||
*"$T"*)
|
||||
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
for _t2 in $LEGACY_ELIGIBLE; do
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*${_t2}*)
|
||||
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
|
||||
OK="yes"
|
||||
break
|
||||
;;
|
||||
@@ -265,8 +295,10 @@ for _raw_clause in $EXPR; do
|
||||
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
|
||||
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
|
||||
# Pattern *${_t}* matches team name anywhere in the space-padded string.
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*"$_t"*)
|
||||
*${_t}*)
|
||||
_clause_passed="yes"
|
||||
debug "clause \"$_t\": satisfied by $_u"
|
||||
break
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts.
|
||||
*
|
||||
* Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the
|
||||
* two share the same describe block convention and the fixture types
|
||||
* are close at hand. Separate file keeps the deploy-preflight fixture
|
||||
* count bounded.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
expect(resolveRuntime("my-custom-template")).toBe("my-custom-template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string", () => {
|
||||
// Falls through to the replace branch
|
||||
expect(resolveRuntime("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles id that is just '-default'", () => {
|
||||
expect(resolveRuntime("-default")).toBe("");
|
||||
});
|
||||
|
||||
it("multiple -default suffixes only strips the last one", () => {
|
||||
// The JS replace only replaces the first match by default
|
||||
expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for pure utility functions in canvas-topology.ts:
|
||||
* sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid,
|
||||
* parentMinSize, parentMinSizeFromChildren.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sortParentsBeforeChildren,
|
||||
defaultChildSlot,
|
||||
childSlotInGrid,
|
||||
parentMinSize,
|
||||
parentMinSizeFromChildren,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
|
||||
|
||||
describe("sortParentsBeforeChildren", () => {
|
||||
it("returns [] for empty input", () => {
|
||||
expect(sortParentsBeforeChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single node unchanged", () => {
|
||||
const nodes = [{ id: "a", parentId: undefined }];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("places parent before child", () => {
|
||||
// Deliberately reversed so naive iteration would place child first
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("parent");
|
||||
expect(result[1].id).toBe("child");
|
||||
});
|
||||
|
||||
it("places grandparent before parent before child (deep chain)", () => {
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "grandchild", parentId: "child" },
|
||||
{ id: "parent", parentId: "grandparent" },
|
||||
{ id: "grandparent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
const ids = result.map((n) => n.id);
|
||||
expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]);
|
||||
});
|
||||
|
||||
it("siblings share the same parent", () => {
|
||||
const nodes = [
|
||||
{ id: "b", parentId: "a" },
|
||||
{ id: "a", parentId: undefined },
|
||||
{ id: "c", parentId: "a" },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("a");
|
||||
expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"]));
|
||||
});
|
||||
|
||||
it("no-ops when children already precede parents", () => {
|
||||
// Already sorted — output should be in the same order
|
||||
const nodes = [
|
||||
{ id: "root", parentId: undefined },
|
||||
{ id: "child", parentId: "root" },
|
||||
];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("handles orphan nodes (no parentId)", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }];
|
||||
expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a new array (does not mutate input)", () => {
|
||||
const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result).not.toBe(nodes);
|
||||
});
|
||||
|
||||
it("deduplicates already-visited nodes", () => {
|
||||
// Child's parent is also in the list — visited guard prevents loops
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["parent", "child"]);
|
||||
});
|
||||
|
||||
it("does not crash when parentId references a missing node", () => {
|
||||
const nodes = [
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
|
||||
it("slot 0 → column 0, row 0", () => {
|
||||
const s = defaultChildSlot(0);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 1 → column 1, row 0", () => {
|
||||
const s = defaultChildSlot(1);
|
||||
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("slot 2 → column 0, row 1", () => {
|
||||
const s = defaultChildSlot(2);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
|
||||
});
|
||||
|
||||
it("slot 3 → column 1, row 1", () => {
|
||||
const s = defaultChildSlot(3);
|
||||
expect(s.x).toBe(16 + 240 + 14);
|
||||
expect(s.y).toBe(130 + 130 + 14);
|
||||
});
|
||||
|
||||
it("slot 4 → column 0, row 2", () => {
|
||||
const s = defaultChildSlot(4);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
|
||||
});
|
||||
});
|
||||
|
||||
// ─── childSlotInGrid ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("childSlotInGrid — variable-size siblings", () => {
|
||||
it("empty siblingSizes returns side-padded position", () => {
|
||||
const s = childSlotInGrid(0, []);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 0 in uniform-size siblings matches defaultChildSlot", () => {
|
||||
const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }];
|
||||
const s = childSlotInGrid(0, sizes);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("taller sibling bumps next row down", () => {
|
||||
// Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300
|
||||
const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }];
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
// Slot 1 is in column 1, row 0; x = 16 + 1*(240+14)
|
||||
expect(slot1.x).toBe(16 + 240 + 14);
|
||||
expect(slot1.y).toBe(130);
|
||||
// Slot 2 (col 0, row 1) — y must include row 0 height + gutter
|
||||
const slot2 = childSlotInGrid(2, sizes);
|
||||
expect(slot2.x).toBe(16);
|
||||
expect(slot2.y).toBe(130 + 300 + 14);
|
||||
});
|
||||
|
||||
it("colW is the maximum sibling width, not the column of the target slot", () => {
|
||||
// Column width is always the max — slot at col 0 uses colW of wider col 1 sibling
|
||||
const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }];
|
||||
const slot0 = childSlotInGrid(0, sizes);
|
||||
expect(slot0.x).toBe(16); // col 0
|
||||
// x for col 1 would be 16 + 300 + 14 = 330
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
expect(slot1.x).toBe(16 + 300 + 14);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSize — uniform-size children", () => {
|
||||
it("0 children → compact default (210×120)", () => {
|
||||
expect(parentMinSize(0)).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("1 child → 1 col, 1 row", () => {
|
||||
const s = parentMinSize(1);
|
||||
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 240);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("2 children → 2 cols, 1 row", () => {
|
||||
const s = parentMinSize(2);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("3 children → 2 cols, 2 rows", () => {
|
||||
const s = parentMinSize(3);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
// height = 130 + 2*130 + 1*14 + 16 = 416
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("4 children → 2 cols, 2 rows (full grid)", () => {
|
||||
const s = parentMinSize(4);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("5 children → 2 cols, 3 rows", () => {
|
||||
const s = parentMinSize(5);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSizeFromChildren ────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
it("empty array → compact default (210×120)", () => {
|
||||
expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("single child matches defaultChildSlot bounding box", () => {
|
||||
const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
// cols=1, rows=1, colW=240
|
||||
expect(s.width).toBe(16 * 2 + 240); // 272
|
||||
expect(s.height).toBe(130 + 130 + 16); // 276
|
||||
});
|
||||
|
||||
it("two equal-width children → same as parentMinSize(2)", () => {
|
||||
const fromChildren = parentMinSizeFromChildren([
|
||||
{ width: 240, height: 130 },
|
||||
{ width: 240, height: 130 },
|
||||
]);
|
||||
expect(fromChildren.width).toBe(parentMinSize(2).width);
|
||||
expect(fromChildren.height).toBe(parentMinSize(2).height);
|
||||
});
|
||||
|
||||
it("taller child increases height", () => {
|
||||
const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]);
|
||||
const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
expect(tall.height).toBeGreaterThan(short.height);
|
||||
});
|
||||
|
||||
it("wider child increases width", () => {
|
||||
const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]);
|
||||
const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]);
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user