Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions config/core-repos.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Core repositories under automation coverage (scanner/fixer programs).
#
# One "owner/name" per line. Blank lines and lines starting with "#" are ignored.
# Read via get_core_repos() in scripts/program-lib.sh; all programs derive their
# repo set from this file so coverage is defined in one place.
# Bare repo names, one per line. The owner is derived from the loaded ORG
# (see load_org_profile / get_core_repos in scripts/program-lib.sh), so this
# same list works for any org the suite is pointed at. Blank lines and lines
# starting with "#" are ignored.
#
# Scope: the curated "core" repos that carry a real maintenance commitment.
# Archived and non-core repos are intentionally excluded (excluding archived
Expand All @@ -12,11 +13,11 @@
# rossoctl/rossoctl#1811), this list can be replaced by a live query for
# tier=core. Until then, this explicit allowlist is the source of truth.

rossoctl/rossoctl
rossoctl/automation
rossoctl/agent-skills
rossoctl/.github
rossoctl/cortex
rossoctl/examples
rossoctl/operator
rossoctl/workload-harness
rossoctl
automation
agent-skills
.github
cortex
examples
operator
workload-harness
19 changes: 19 additions & 0 deletions config/org.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Default org identity profile for the automation suite.
#
# Assigns ONLY PROFILE_-prefixed names so that sourcing this file never
# clobbers a flag- or env-provided value before load_org_profile() resolves
# precedence (--flag > env > profile > default). See
# docs/specs/2026-07-29-org-portability-design.md.
#
# To target a different org, copy this to config/org.<name>.env and select it
# with --profile <name> or ORG_PROFILE=<name>.

PROFILE_ORG=rossoctl
PROFILE_FORK_OWNER=clawgenti
PROFILE_MAIN_REPO=rossoctl/rossoctl
PROFILE_REPOS_DIR=${HOME}/rossoctl

# TRANSITIONAL: maps pre-rename clone-dir basenames to canonical repo names.
# Self-retires once host clone dirs are renamed (rossoctl/automation#37):
# delete this line and the remap becomes pure identity.
PROFILE_REMAP="kagenti:rossoctl kagenti-extensions:cortex"
121 changes: 101 additions & 20 deletions scripts/program-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -796,20 +796,81 @@ create_fork_pr() {
# an API reference. Never rely on the rename redirect for filtered `gh pr list`
# queries (--label/--author silently return empty across a redirect).

# Load org identity from a profile file and resolve each fact by precedence:
# --flag > env var > profile value > built-in default.
#
# The profile file assigns ONLY PROFILE_-prefixed names (PROFILE_ORG, ...),
# so sourcing it can never clobber an env-provided ORG before resolution.
#
# Profile selection: $ORG_PROFILE_FILE (absolute path, used by tests) wins;
# else --profile/$ORG_PROFILE names config/org.<name>.env; else config/org.env.
#
# Callers may pre-set *_FLAG vars from their own arg parsing (ORG_FLAG,
# FORK_OWNER_FLAG, MAIN_REPO_FLAG, REPOS_DIR_FLAG) and env vars (ORG, ...).
#
# Sets (caller should treat as exported): ORG FORK_OWNER MAIN_REPO REPOS_DIR REMAP
# Fails loud: missing profile file -> return 1; unresolvable ORG -> hard error.
load_org_profile() {
local lib_dir profile_file name
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if [ -n "${ORG_PROFILE_FILE:-}" ]; then
profile_file="$ORG_PROFILE_FILE"
else
name="${PROFILE_FLAG:-${ORG_PROFILE:-}}"
if [ -n "$name" ]; then
profile_file="$lib_dir/../config/org.$name.env"
else
profile_file="$lib_dir/../config/org.env"
fi
fi

if [ ! -f "$profile_file" ]; then
echo "ERROR: org profile not found: $profile_file" >&2
return 1
fi

# Safe to source: file sets only PROFILE_* names.
# shellcheck source=/dev/null
. "$profile_file"

ORG="${ORG_FLAG:-${ORG:-${PROFILE_ORG:-}}}"
if [ -z "$ORG" ]; then
echo "ERROR: ORG could not be resolved (flag/env/profile all empty)" >&2
return 1
fi
FORK_OWNER="${FORK_OWNER_FLAG:-${FORK_OWNER:-${PROFILE_FORK_OWNER:-clawgenti}}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The fallback clawgenti is org-specific. For full portability the profile should be required to set PROFILE_FORK_OWNER, or the default should be documented as rossoctl-deployment-only. Not a blocker — just worth a comment in the function doc or org.env.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 1423ad9 — documented the clawgenti fallback in the load_org_profile doc header as the rossoctl deployment default, and noted that other orgs should set PROFILE_FORK_OWNER (config/org.env already does). Kept it as a documented default rather than a hard requirement, per the approved design's four-fact model where only ORG has no built-in default.

MAIN_REPO="${MAIN_REPO_FLAG:-${MAIN_REPO:-${PROFILE_MAIN_REPO:-$ORG/$ORG}}}"
REPOS_DIR="${REPOS_DIR_FLAG:-${REPOS_DIR:-${PROFILE_REPOS_DIR:-$HOME/$ORG}}}"
REMAP="${PROFILE_REMAP:-}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: REMAP resolves from the profile only (${PROFILE_REMAP:-}), with no --flag/env tier, unlike ORG/FORK_OWNER/MAIN_REPO/REPOS_DIR, which all honor the full flag > env > profile > default precedence. The function's doc comment lists REMAP among the facts it "Sets," so the asymmetry is easy to miss: a caller who exports REMAP will have it silently overwritten (or blanked, if the profile omits PROFILE_REMAP). If profile-only is intentional for this transitional field, a one-line note here ("REMAP is profile-only by design, no flag/env override") would prevent a future surprise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the asymmetry. Addressed in 0f8734a: added a note at the REMAP assignment that it is profile-only by design (transitional field, self-retires with #37, so it never earns a durable flag/env knob; an exported REMAP is ignored), and amended the doc header's "Sets" line to call out that the first four facts honor the full precedence chain while REMAP does not.


export ORG FORK_OWNER MAIN_REPO REPOS_DIR REMAP
}

# Print the core repo allowlist, one "owner/name" per line.
#
# Reads config/core-repos.txt (comments starting with "#" and blank lines are
# stripped). The file path is resolved relative to THIS library's location, not
# the caller's, so it works no matter which script sources program-lib.sh.
# Reads config/core-repos.txt, which holds BARE repo names (comments starting
# with "#" and blank lines are stripped). The owner is derived by prepending
# the loaded $ORG, so the same list works for any org the suite targets. Call
# load_org_profile (or otherwise set ORG) before this function.
#
# The file path is resolved relative to THIS library's location, not the
# caller's, so it works no matter which script sources program-lib.sh.
# Override with $CORE_REPOS_FILE (used by tests).
#
# Fails loud: if the file is missing or yields zero repos, prints an error to
# stderr and returns 1 -- callers must never silently scan an empty repo set.
# Fails loud: if ORG is unset, or the file is missing or yields zero repos,
# prints an error to stderr and returns 1 -- callers must never silently scan
# an empty repo set or emit ownerless refs.
#
# Usage (portable; mapfile is bash 4+ and absent on macOS bash 3.2):
# REPOS=(); while IFS= read -r r; do [ -n "$r" ] && REPOS+=("$r"); done \
# < <(get_core_repos)
get_core_repos() {
if [ -z "${ORG:-}" ]; then
echo "ERROR: ORG is unset; call load_org_profile before get_core_repos" >&2
return 1
fi

local lib_dir
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local repos_file="${CORE_REPOS_FILE:-$lib_dir/../config/core-repos.txt}"
Expand All @@ -829,7 +890,13 @@ get_core_repos() {
return 1
fi

printf '%s\n' "$repos"
# Prepend the loaded org to each bare name.
local line
while IFS= read -r line; do
[ -n "$line" ] && printf '%s/%s\n' "$ORG" "$line"
done <<EOF
$repos
EOF
}

# Print just the bare repo names (owner stripped) from the core allowlist.
Expand Down Expand Up @@ -859,28 +926,42 @@ is_core_repo() {

# Map a local clone directory basename to its canonical bare repo name.
#
# Clone dirs may still use pre-rename names; this encapsulates the rename
# remap table in one place so every script agrees. Unknown names pass through
# Clone dirs may still use pre-rename names; the remap table lives in the org
# profile's $REMAP (format: space-separated "basename:canonical" pairs, set by
# load_org_profile), so every script agrees and the mapping is data, not code.
# An empty $REMAP makes this pure identity; unknown names pass through
# unchanged (identity), so non-remapped repos need no special handling.
#
# TRANSITIONAL: the non-identity entries below are a temporary bridge for the
# kagenti->rossoctl rename while stale-named clone dirs still exist on disk.
# Once clone dirs are renamed to canonical names, this function becomes pure
# identity and the entries should be deleted. See rossoctl/automation#37.
# It is a lookup table, not a rename detector -- do not treat it as protection
# against future renames.
# TRANSITIONAL: $REMAP is a temporary bridge for the kagenti->rossoctl rename
# while stale-named clone dirs still exist on disk. Once clone dirs are renamed
# to canonical names, the PROFILE_REMAP line is deleted and this becomes pure
# identity. See rossoctl/automation#37. It is a lookup table, not a rename
# detector -- do not treat it as protection against future renames.
#
# A malformed entry (no colon) is skipped with a warning, non-fatal.
#
# Returns: the bare repo name only (e.g. "rossoctl"), NOT an owner/name pair.
# Prepend the owner to build a full API reference, e.g. "rossoctl/$canon".
# Prepend the owner to build a full API reference, e.g. "$ORG/$canon".
#
# Usage: canon=$(canonical_repo_for_dir "$repo_dir_basename")
# Args:
# $1 - clone directory basename (e.g. "kagenti", "cortex")
canonical_repo_for_dir() {
local dir_name="$1"
case "$dir_name" in
kagenti) echo "rossoctl" ;;
kagenti-extensions) echo "cortex" ;;
*) echo "$dir_name" ;;
esac
local pair basename_part canon_part
for pair in ${REMAP:-}; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for pair in ${REMAP:-} relies on intentional word-splitting of the space-separated pairs, which is correct, but shellcheck will flag it as SC2086. A # shellcheck disable=SC2086 -- intentional split on space-separated pairs keeps a clean lint run and documents the intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0f8734a — added # shellcheck disable=SC2086 -- intentional word-split on space-separated pairs above the loop.

basename_part="${pair%%:*}"
canon_part="${pair#*:}"
if [ -z "$basename_part" ] || [ "$basename_part" = "$pair" ]; then
# Malformed entry (no colon) -- skip with a warning, non-fatal.
echo "WARN: ignoring malformed REMAP entry: $pair" >&2
continue
fi
if [ "$dir_name" = "$basename_part" ]; then
echo "$canon_part"
return 0
fi
done
# No match (or empty REMAP): identity.
echo "$dir_name"
}
46 changes: 31 additions & 15 deletions tests/test-core-repos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,37 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT

fail=0

# --- Fixture: a well-formed allowlist with comments, blanks, and trailing ws ---
# --- Fixture: bare names with comments, blanks, and trailing whitespace ---
# The owner is no longer stored in the file; get_core_repos prepends $ORG.
cat > "$TEST_TMPDIR/good.txt" <<'EOF'
# a comment
rossoctl/rossoctl
rossoctl

rossoctl/cortex
rossoctl/agent-skills
cortex
agent-skills
EOF

# --- get_core_repos: parses to exactly the three entries, in order ---
got=$(CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos)
# --- get_core_repos: prepends $ORG to each bare name, in order ---
got=$(ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos)
want=$'rossoctl/rossoctl\nrossoctl/cortex\nrossoctl/agent-skills'
[ "$got" = "$want" ] || { echo "FAIL get_core_repos parse: got [$got]"; fail=1; }

# --- get_core_repos: the owner is derived, not baked in (different ORG) ---
got2=$(ORG=acme CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos)
want2=$'acme/rossoctl\nacme/cortex\nacme/agent-skills'
[ "$got2" = "$want2" ] || { echo "FAIL get_core_repos ORG-derived: got [$got2]"; fail=1; }

# --- get_core_repos: fails loud when ORG is unset (no owner to derive) ---
if CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos >/dev/null 2>&1; then
echo "FAIL get_core_repos should error when ORG unset"; fail=1
fi

# --- get_core_repos: comments and blank lines are excluded ---
count=$(CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos | grep -c .)
count=$(ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" get_core_repos | grep -c .)
[ "$count" = "3" ] || { echo "FAIL get_core_repos count: got $count want 3"; fail=1; }

# --- core_repo_names: owner prefix stripped ---
names=$(CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" core_repo_names)
# --- core_repo_names: bare names (owner prefix stripped) ---
names=$(ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" core_repo_names)
want_names=$'rossoctl\ncortex\nagent-skills'
[ "$names" = "$want_names" ] || { echo "FAIL core_repo_names: got [$names]"; fail=1; }

Expand All @@ -53,24 +64,29 @@ if CORE_REPOS_FILE="$TEST_TMPDIR/empty.txt" get_core_repos >/dev/null 2>&1; then
fi

# --- is_core_repo: membership test against the allowlist (exact, whole-line) ---
CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "rossoctl" \
ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "rossoctl" \
|| { echo "FAIL is_core_repo: rossoctl should be in allowlist"; fail=1; }
if CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "not-a-repo"; then
if ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "not-a-repo"; then
echo "FAIL is_core_repo: not-a-repo should NOT match"; fail=1
fi
# Guard against substring false positives (rosso is a prefix of rossoctl).
if CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "rosso"; then
if ORG=rossoctl CORE_REPOS_FILE="$TEST_TMPDIR/good.txt" is_core_repo "rosso"; then
echo "FAIL is_core_repo: partial 'rosso' must not match 'rossoctl'"; fail=1
fi

# --- canonical_repo_for_dir: remaps the two renamed dirs, identity otherwise ---
# --- canonical_repo_for_dir: reads $REMAP; identity when unset or no match ---
REMAP="kagenti:rossoctl kagenti-extensions:cortex"
[ "$(canonical_repo_for_dir kagenti)" = "rossoctl" ] \
|| { echo "FAIL canonical: kagenti -> rossoctl"; fail=1; }
[ "$(canonical_repo_for_dir kagenti-extensions)" = "cortex" ] \
|| { echo "FAIL canonical: kagenti-extensions -> cortex"; fail=1; }
[ "$(canonical_repo_for_dir automation)" = "automation" ] \
|| { echo "FAIL canonical: automation identity"; fail=1; }
|| { echo "FAIL canonical: automation identity (no match)"; fail=1; }
[ "$(canonical_repo_for_dir operator)" = "operator" ] \
|| { echo "FAIL canonical: operator identity"; fail=1; }
|| { echo "FAIL canonical: operator identity (no match)"; fail=1; }
# Empty REMAP => pure identity (proves the map is data, not a hardcoded case).
REMAP="" ; [ "$(canonical_repo_for_dir kagenti)" = "kagenti" ] \
|| { echo "FAIL canonical: empty REMAP is identity"; fail=1; }
unset REMAP

[ "$fail" -eq 0 ] && echo "PASS: core-repos helpers (parse, names, fail-loud, canonical remap)" || exit 1
85 changes: 85 additions & 0 deletions tests/test-org-profile.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail

# Verifies load_org_profile() in program-lib.sh:
# - resolves each fact by precedence: flag > env > profile > default
# - PROFILE_-prefixed profile files cannot clobber env-provided values
# - fails loud on missing profile and unresolvable ORG
# Hermetic: each test drives load_org_profile via $ORG_PROFILE_FILE pointing
# at a temp fixture, so the real config/ files are never read.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../scripts/program-lib.sh"

TEST_TMPDIR=$(mktemp -d)
trap 'rm -rf "$TEST_TMPDIR"' EXIT
fail=0

# Fixture profile with PROFILE_-prefixed keys.
cat > "$TEST_TMPDIR/org.fix.env" <<'EOF'
PROFILE_ORG=profileorg
PROFILE_FORK_OWNER=profilefork
PROFILE_MAIN_REPO=profileorg/mainrepo
PROFILE_REPOS_DIR=/tmp/profiledir
PROFILE_REMAP="oldname:newname"
EOF

# Helper: run the loader in a clean subshell with a given environment,
# then echo the four resolved facts. $PROFILE_PATH points directly at a file.
run_loader() {
# args are VAR=VAL assignments applied before the call
(
for kv in "$@"; do export "$kv"; done
# Point loader at the fixture by absolute path override.
ORG_PROFILE_FILE="$TEST_TMPDIR/org.fix.env"
load_org_profile >/dev/null 2>&1 || { echo "LOADER_FAILED"; exit 0; }
echo "$ORG|$FORK_OWNER|$MAIN_REPO|$REPOS_DIR|$REMAP"
)
}

# --- profile tier: with nothing else set, profile values win ---
got=$(run_loader)
want='profileorg|profilefork|profileorg/mainrepo|/tmp/profiledir|oldname:newname'
[ "$got" = "$want" ] || { echo "FAIL profile tier: got [$got]"; fail=1; }

# --- env beats profile (the clobber-safety guarantee) ---
got=$(run_loader "ORG=envorg")
case "$got" in
envorg\|*) ;;
*) echo "FAIL env>profile for ORG: got [$got]"; fail=1 ;;
esac

# --- flag beats env beats profile ---
got=$(run_loader "ORG=envorg" "ORG_FLAG=flagorg")
case "$got" in
flagorg\|*) ;;
*) echo "FAIL flag>env for ORG: got [$got]"; fail=1 ;;
esac

# --- MAIN_REPO defaults to $ORG/$ORG when profile omits it ---
cat > "$TEST_TMPDIR/org.nomain.env" <<'EOF'
PROFILE_ORG=solo
EOF
got=$(
ORG_PROFILE_FILE="$TEST_TMPDIR/org.nomain.env"
load_org_profile >/dev/null 2>&1
echo "$MAIN_REPO|$REPOS_DIR|$FORK_OWNER"
)
[ "$got" = "solo/solo|$HOME/solo|clawgenti" ] \
|| { echo "FAIL defaults: got [$got]"; fail=1; }

# --- fail loud: missing profile file ---
if ( ORG_PROFILE_FILE="$TEST_TMPDIR/nope.env"; load_org_profile ) >/dev/null 2>&1; then
echo "FAIL should error on missing profile"; fail=1
fi

# --- fail loud: profile present but ORG unresolvable ---
cat > "$TEST_TMPDIR/org.noorg.env" <<'EOF'
PROFILE_FORK_OWNER=x
EOF
if ( ORG_PROFILE_FILE="$TEST_TMPDIR/org.noorg.env"; load_org_profile ) >/dev/null 2>&1; then
echo "FAIL should error when ORG cannot be resolved"; fail=1
fi

[ "$fail" -eq 0 ] && echo "PASS: load_org_profile (precedence, clobber-safety, defaults, fail-loud)" || exit 1