Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6cfef912 | |||
| 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,75 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for createMessage — the ChatMessage factory from types.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMessage } from "../tabs/chat/types";
|
||||
|
||||
describe("createMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Freeze time so timestamp is deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
|
||||
// Stub crypto.randomUUID so message IDs are deterministic.
|
||||
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a message with the correct role", () => {
|
||||
const userMsg = createMessage("user", "hello");
|
||||
expect(userMsg.role).toBe("user");
|
||||
|
||||
const agentMsg = createMessage("agent", "hi there");
|
||||
expect(agentMsg.role).toBe("agent");
|
||||
|
||||
const systemMsg = createMessage("system", "prompt loaded");
|
||||
expect(systemMsg.role).toBe("system");
|
||||
});
|
||||
|
||||
it("creates a message with the correct content", () => {
|
||||
const msg = createMessage("user", "Deploy the agent now");
|
||||
expect(msg.content).toBe("Deploy the agent now");
|
||||
});
|
||||
|
||||
it("sets a deterministic id via crypto.randomUUID", () => {
|
||||
const msg = createMessage("agent", "response");
|
||||
expect(msg.id).toBe("fixed-uuid-1234");
|
||||
});
|
||||
|
||||
it("sets a deterministic ISO timestamp", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits attachments field when none provided", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits attachments field when empty array is provided", () => {
|
||||
const msg = createMessage("agent", "result", []);
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes attachments field when non-empty array is provided", () => {
|
||||
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
|
||||
const msg = createMessage("agent", "see attached", atts);
|
||||
expect(msg.attachments).toEqual(atts);
|
||||
});
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.keys(msg).sort()).toEqual(
|
||||
["id", "role", "content", "timestamp"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getIcon } from "../tabs/FilesTab/tree";
|
||||
|
||||
describe("getIcon", () => {
|
||||
// ─── Directories ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📁 for directories regardless of extension", () => {
|
||||
expect(getIcon("src", true)).toBe("📁");
|
||||
expect(getIcon("node_modules", true)).toBe("📁");
|
||||
expect(getIcon(".claude", true)).toBe("📁");
|
||||
expect(getIcon("foo/bar/baz", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns 📁 even for paths that look like files", () => {
|
||||
expect(getIcon("foo.txt", true)).toBe("📁");
|
||||
expect(getIcon("script.sh", true)).toBe("📁");
|
||||
});
|
||||
|
||||
// ─── Files by extension ────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for .md files", () => {
|
||||
expect(getIcon("README.md", false)).toBe("📄");
|
||||
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
|
||||
expect(getIcon("docs/guide.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns ⚙ for .yaml and .yml files", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
expect(getIcon("values.yml", false)).toBe("⚙");
|
||||
expect(getIcon("deploy.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns 🐍 for .py files", () => {
|
||||
expect(getIcon("main.py", false)).toBe("🐍");
|
||||
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns 💠 for .ts and .tsx files", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
expect(getIcon("Component.tsx", false)).toBe("💠");
|
||||
expect(getIcon("types.d.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns 📜 for .js files", () => {
|
||||
expect(getIcon("bundle.js", false)).toBe("📜");
|
||||
expect(getIcon("src/index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns {} for .json files", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
expect(getIcon("config.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 🌐 for .html files", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
expect(getIcon("templates/page.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns 🎨 for .css files", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
expect(getIcon("src/app.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns ▸ for .sh files", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for unknown extensions", () => {
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("notes.txt", false)).toBe("📄");
|
||||
expect(getIcon("archive.tar.gz", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns 📄 for paths with no extension", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
// ─── Case sensitivity ──────────────────────────────────────────────────────
|
||||
|
||||
it("is case-insensitive for extension lookup", () => {
|
||||
expect(getIcon("image.PNG", false)).toBe("📄");
|
||||
expect(getIcon("data.JSON", false)).toBe("{}");
|
||||
expect(getIcon("script.SH", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Nested paths ─────────────────────────────────────────────────────────
|
||||
|
||||
it("uses the leaf extension for nested paths", () => {
|
||||
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
|
||||
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
|
||||
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRuntimeProfile,
|
||||
provisionTimeoutForRuntime,
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
RUNTIME_PROFILES,
|
||||
} from "../runtimeProfiles";
|
||||
|
||||
describe("getRuntimeProfile", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
|
||||
const result = getRuntimeProfile("");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
|
||||
const result = getRuntimeProfile("unknown-lang");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
|
||||
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
|
||||
expect(RUNTIME_PROFILES).toEqual({});
|
||||
const result = getRuntimeProfile("claude-code");
|
||||
expect(result.provisionTimeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(300_000);
|
||||
});
|
||||
|
||||
it("overrides wins over RUNTIME_PROFILES entry", () => {
|
||||
// Even if RUNTIME_PROFILES had an entry, overrides take priority
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(999_000);
|
||||
});
|
||||
|
||||
it("uses overrides even when runtime is undefined", () => {
|
||||
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
|
||||
// The return type is guaranteed non-nullable
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(typeof result.provisionTimeoutMs).toBe("number");
|
||||
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionTimeoutForRuntime", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
|
||||
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns overrides value when overrides provided", () => {
|
||||
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
|
||||
});
|
||||
|
||||
it("returns 120_000 for any unknown runtime", () => {
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
|
||||
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
|
||||
[undefined, undefined],
|
||||
["claude-code", undefined],
|
||||
["langgraph", { provisionTimeoutMs: 500_000 }],
|
||||
[undefined, { provisionTimeoutMs: 45_000 }],
|
||||
];
|
||||
for (const [runtime, overrides] of cases) {
|
||||
const profile = getRuntimeProfile(runtime, overrides);
|
||||
const direct = provisionTimeoutForRuntime(runtime, overrides);
|
||||
expect(direct).toBe(profile.provisionTimeoutMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user