Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb8e7cfce | ||
|
|
d789a43cae | ||
|
|
19ad93999f |
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
|
|||||||
|
|
||||||
### `--login` override
|
### `--login` override
|
||||||
|
|
||||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation (as of #1280, `pr-create.sh`, `pr-merge.sh` and `issue-create.sh` accept it too, and it wins over `MOSAIC_GIT_IDENTITY` everywhere). The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||||
|
|
||||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||||
|
|
||||||
@@ -58,6 +58,36 @@ token file present, both tools fall through to the existing shared-account path
|
|||||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||||
tokens.
|
tokens.
|
||||||
|
|
||||||
|
### Identity-first principal resolution in the wrappers (#1280)
|
||||||
|
|
||||||
|
`resolve_gitea_principal()` (detect-platform.sh) gives the write wrappers —
|
||||||
|
`pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `issue-create.sh`, `issue-comment.sh` —
|
||||||
|
ONE precedence for choosing the acting principal:
|
||||||
|
|
||||||
|
1. an explicit `--login <name>` (now accepted by all five; operator intent beats
|
||||||
|
environment), then
|
||||||
|
2. the per-agent identity above (`MOSAIC_GIT_IDENTITY` env / worktree
|
||||||
|
`mosaic.gitIdentity`) when a per-slot token exists — the wrapper then writes via the
|
||||||
|
REST API with that identity's token and never consults `tea`, so the tea login list
|
||||||
|
cannot shadow the requested principal, then
|
||||||
|
3. the tea login list — the LAST resort, never the first, because it enumerates
|
||||||
|
whatever logins the host happens to hold and knows nothing about which seat is
|
||||||
|
calling.
|
||||||
|
|
||||||
|
A requested identity whose per-slot token is absent, or a `--login` whose token cannot
|
||||||
|
resolve host-bound, **fails loud** (nonzero, naming the identity/login and the expected
|
||||||
|
slot) instead of silently writing under whatever account `tea` has configured — that
|
||||||
|
silent fallthrough is defect #1280 (reviews, comments, merges, PRs and issues filed
|
||||||
|
under the wrong account). `pr-merge.sh --dry-run` reports the principal the merge would
|
||||||
|
act as, resolved exactly as the real merge resolves it. ⚠ A **workstation-global**
|
||||||
|
`mosaic.gitIdentity` shadows every seat on that host (a fresh clone with no local value
|
||||||
|
resolves the global one) — set it per-worktree, not with `--global`.
|
||||||
|
|
||||||
|
The resolver is covered by `test-gitea-principal-resolution.sh`; the happy-path
|
||||||
|
ordering (identity arm REACHED, not sitting behind a tea failure) by
|
||||||
|
`test-pr-create-identity-first.sh`; merge credential binding by
|
||||||
|
`test-pr-merge-principal-resolution.sh`.
|
||||||
|
|
||||||
### Enabling it for a clone
|
### Enabling it for a clone
|
||||||
|
|
||||||
The framework installer syncs `git-credential-mosaic` to
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
|||||||
@@ -497,6 +497,32 @@ get_gitea_url_for_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map a Gitea host to the per-agent identity-token slot PREFIX ("gitea-usc" /
|
||||||
|
# "gitea-mosaicstack") used by identity-first principal resolution
|
||||||
|
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity; #1280). Returns 1 for
|
||||||
|
# hosts with no per-slot scheme — callers treat that as "identity does not
|
||||||
|
# bind here" and fall through to existing behavior, never as an error. This is
|
||||||
|
# the single source of truth for the slot layout: get_gitea_token and
|
||||||
|
# resolve_gitea_principal both derive their slot paths from here, so the two
|
||||||
|
# resolutions can never disagree about where an identity's credential lives.
|
||||||
|
gitea_identity_slot_prefix() {
|
||||||
|
case "$1" in
|
||||||
|
git.uscllc.com) echo "gitea-usc" ;;
|
||||||
|
git.mosaicstack.dev) echo "gitea-mosaicstack" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the per-slot token FILE PATH for an identity on a host. Prints the
|
||||||
|
# absolute path on success; returns 1 (no output) when the host has no per-slot
|
||||||
|
# scheme. Prints a PATH only — never a token value.
|
||||||
|
gitea_identity_token_slot() {
|
||||||
|
local identity="$1" host="$2" prefix
|
||||||
|
[[ -n "$identity" ]] || return 1
|
||||||
|
prefix=$(gitea_identity_slot_prefix "$host") || return 1
|
||||||
|
printf '%s\n' "$HOME/.config/mosaic/secrets/gitea-tokens/${prefix}-${identity}.token"
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
@@ -517,13 +543,8 @@ get_gitea_token() {
|
|||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" ]]; then
|
if [[ -n "$_ident" ]]; then
|
||||||
local _idpfx=""
|
local _idtok=""
|
||||||
case "$host" in
|
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
|
||||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
|
||||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
|
||||||
esac
|
|
||||||
if [[ -n "$_idpfx" ]]; then
|
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
@@ -1465,6 +1486,81 @@ raise SystemExit(1)
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# resolve_gitea_principal — identity-first acting-principal resolution shared by
|
||||||
|
# the git wrappers (#1280). The defect this fixes: wrappers resolved their
|
||||||
|
# acting principal from tea's login list FIRST, and that list enumerates
|
||||||
|
# whatever logins happen to be configured on the host — it knows nothing about
|
||||||
|
# which seat is calling — so a wrapper invoked with MOSAIC_GIT_IDENTITY=fargo
|
||||||
|
# still wrote under whichever account tea held (mos-dt-0), and the correct
|
||||||
|
# identity-aware code sat behind arms that only ran when the tea path failed.
|
||||||
|
# Precedence here is the contract:
|
||||||
|
# 1. an explicit login override ($1, the wrapper's --login) — operator intent
|
||||||
|
# beats environment;
|
||||||
|
# 2. MOSAIC_GIT_IDENTITY env, else per-worktree `git config mosaic.gitIdentity`
|
||||||
|
# (mirroring get_gitea_token exactly, so resolver and token resolution can
|
||||||
|
# never disagree) — binds only on hosts with a per-slot token scheme;
|
||||||
|
# 3. the tea login list — LAST resort, never the first.
|
||||||
|
#
|
||||||
|
# Prints exactly one line, three tab-separated fields (machine-readable for
|
||||||
|
# wrapper dispatch and tests):
|
||||||
|
# mode "login" | "identity" | "default"
|
||||||
|
# principal login name (login) | identity name (identity) | tea login or "" (default)
|
||||||
|
# source "tea-login:<name>" | "identity-slot:<path>" | "tea-default" | "host-credential"
|
||||||
|
#
|
||||||
|
# Fails LOUD (nonzero, empty stdout, stderr diagnostic) when an explicit
|
||||||
|
# override cannot be honored — a refusal is a good day; silently falling
|
||||||
|
# through to whoever tea has configured is the exact defect this resolves:
|
||||||
|
# - login mode: no host-bound token for that tea login. The existence check
|
||||||
|
# runs the same tea-config lookup tea itself uses; the token VALUE is
|
||||||
|
# discarded (never printed, never used).
|
||||||
|
# - identity mode: no per-slot token file for that identity on a recognized
|
||||||
|
# host — the diagnostic names the identity, its source, and the expected
|
||||||
|
# slot path. An identity requested on a host with NO per-slot scheme does
|
||||||
|
# not bind (matching get_gitea_token's containment) and falls to default.
|
||||||
|
#
|
||||||
|
# NEVER prints a token value — principal names and slot paths only.
|
||||||
|
# $1 = explicit login override ("" when absent), $2 = host (default: the
|
||||||
|
# origin remote's host).
|
||||||
|
resolve_gitea_principal() {
|
||||||
|
local login_override="${1:-}" host="${2:-}" ident ident_src slot login
|
||||||
|
[[ -n "$host" ]] || { host=$(get_remote_host) || return 1; }
|
||||||
|
|
||||||
|
if [[ -n "$login_override" ]]; then
|
||||||
|
get_gitea_token_for_login "$login_override" "$host" >/dev/null || {
|
||||||
|
echo "Error: --login '$login_override' has no host-matched token on host '$host' (tea config lookup); refusing to fall back to any other principal (#1280 identity-first resolution)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf 'login\t%s\ttea-login:%s\n' "$login_override" "$login_override"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
|
ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
|
if [[ -z "$ident" ]]; then
|
||||||
|
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
|
ident_src="git config mosaic.gitIdentity"
|
||||||
|
fi
|
||||||
|
if [[ -n "$ident" ]] && slot="$(gitea_identity_token_slot "$ident" "$host" 2>/dev/null)"; then
|
||||||
|
if [[ -r "$slot" ]]; then
|
||||||
|
printf 'identity\t%s\tidentity-slot:%s\n' "$ident" "$slot"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no per-slot token at $slot (#1280 identity-first resolution)." >&2
|
||||||
|
echo " Refusing to fall back to the tea login list or shared credentials. Provision the per-slot token, or unset the identity." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No override requested: tea's login list is the LAST resort. Absence is
|
||||||
|
# not an error here — callers fall back to the host credential, exactly as
|
||||||
|
# they did before this resolver existed (preserved behavior).
|
||||||
|
if login=$(get_gitea_login_for_host "$host" 2>/dev/null); then
|
||||||
|
printf 'default\t%s\ttea-default\n' "$login"
|
||||||
|
else
|
||||||
|
printf 'default\t\thost-credential\n'
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
||||||
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
get_gitea_basic_auth() {
|
get_gitea_basic_auth() {
|
||||||
|
|||||||
@@ -76,27 +76,36 @@ fi
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280):
|
||||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config
|
||||||
|
# mosaic.gitIdentity binds the per-slot credential, else the tea login list
|
||||||
|
# (last resort). Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE
|
||||||
|
# (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||||
#
|
#
|
||||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
# The token is resolved for the EFFECTIVE principal so that the single
|
||||||
# given, otherwise the detected default) so that the single credential used for
|
# credential used for the write ALSO drives the /user identity read and the
|
||||||
# the write ALSO drives the /user identity read and the read-back — write token
|
# read-back — write token and read-back token are the same identity by
|
||||||
# and read-back token are the same identity by construction (this is the
|
# construction (this is the credential-ordering fix: a --login override is no
|
||||||
# credential-ordering fix: a --login override is no longer written under one
|
# longer written under one credential and verified under a different default
|
||||||
# credential and verified under a different default one). Falls back to the
|
# one). When $2 is "identity" the principal ($1) is a requested git identity:
|
||||||
# host-scoped credential ONLY when NO --login override was supplied (the
|
# the token MUST resolve from that identity's per-slot token (get_gitea_token's
|
||||||
# best-effort default path). When $2 is "explicit" the login came from a
|
# identity arm), failing closed rather than borrowing the tea default login —
|
||||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
# the tea login list must never shadow a requested identity (#1280). When $2
|
||||||
# CLOSED rather than silently downgrading the write to the host default
|
# is "explicit" the principal came from a caller-supplied --login: that exact
|
||||||
# identity — otherwise a caller relying on a dedicated per-role credential would
|
# login's token MUST resolve, and we FAIL CLOSED rather than silently
|
||||||
# be told the write succeeded as requested while it was attributed to the shared
|
# downgrading the write to the host default identity. Otherwise the best-effort
|
||||||
# default. Returns non-zero (clear stderr) on any resolution failure.
|
# default path applies (per-login token, else the host-scoped credential).
|
||||||
|
# Returns non-zero (clear stderr) on any resolution failure.
|
||||||
gitea_resolve_api_for_login() {
|
gitea_resolve_api_for_login() {
|
||||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||||
|
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
if [[ -n "$override_explicit" ]]; then
|
if [[ "$override_explicit" == "identity" ]]; then
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (comment write/read-back, #1280)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
elif [[ -n "$override_explicit" ]]; then
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -318,23 +327,31 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Resolve the login this comment should be attributed to: the --login
|
# Resolve the acting principal identity-first (#1280): an explicit --login
|
||||||
# override when given, otherwise the detected default for this repo's host.
|
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
# A --login override always wins. Otherwise name this repo host's login only
|
# selects the principal when a per-slot token exists (fail-loud when it
|
||||||
# as a best effort: the login name merely selects a per-login token, and
|
# does not); the tea login list is the LAST resort — it knows nothing about
|
||||||
# gitea_resolve_api_for_login falls back to the host credential
|
# which seat is calling, so resolving from it first wrote under whichever
|
||||||
# (get_gitea_token) when no tea login is named, so the default credential
|
# account tea had configured (the #1280 family).
|
||||||
# still resolves even when the host tea has no matching login entry.
|
principal_host=$(get_remote_host)
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive the
|
# Bind the REST endpoint + token to the resolved principal, then derive the
|
||||||
# acting identity from that SAME credential (GET /user). The write below and
|
# acting identity from that SAME credential (GET /user). The write below and
|
||||||
# its read-back both use this credential, so the write is verified against
|
# its read-back both use this credential, so the write is verified against
|
||||||
# the identity that actually performed it. Passing "explicit" when --login
|
# the identity that actually performed it.
|
||||||
# was supplied forbids the host-default fallback: an unresolvable explicit
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# override fails closed instead of writing under the default identity.
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
|
else
|
||||||
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
|
fi
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
|
||||||
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-create.sh - Create issues on Gitea or GitHub
|
# issue-create.sh - Create issues on Gitea or GitHub
|
||||||
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
|
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
|
||||||
|
# the wrapper then creates the issue through the REST API with that identity's
|
||||||
|
# token — tea is never invoked, so the tea login list cannot shadow the
|
||||||
|
# requested principal); the tea login list is the LAST resort. A requested
|
||||||
|
# identity with no per-slot token fails LOUD rather than writing under
|
||||||
|
# whichever account tea happens to hold.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -16,6 +25,14 @@ INTERACTIVE=false
|
|||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
|
# Acting-principal mode set in the Gitea branch below (from
|
||||||
|
# resolve_gitea_principal): "login" when --login was given, "identity" when a
|
||||||
|
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
|
||||||
|
# arm resolve the --login principal's token too, so an explicit --login keeps
|
||||||
|
# winning even on the tea-FAILURE fallback arm.
|
||||||
|
PRINCIPAL_MODE=""
|
||||||
|
PRINCIPAL_NAME=""
|
||||||
|
|
||||||
gitea_issue_create_api() {
|
gitea_issue_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -26,10 +43,19 @@ gitea_issue_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
|
# is set (per-slot token, fail-loud on absence); shared default otherwise.
|
||||||
token=$(get_gitea_token "$host") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
||||||
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
||||||
@@ -67,6 +93,7 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-m, --milestone NAME Milestone name to assign
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-i, --interactive Prompt for missing issue fields
|
-i, --interactive Prompt for missing issue fields
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
@@ -97,6 +124,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-i|--interactive)
|
-i|--interactive)
|
||||||
INTERACTIVE=true
|
INTERACTIVE=true
|
||||||
shift
|
shift
|
||||||
@@ -134,13 +165,37 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
|
# Resolve the acting principal identity-first (#1280). The tea login
|
||||||
|
# list is the LAST resort: it knows nothing about which seat is calling,
|
||||||
|
# and a login resolved from it first is what attributed issues to the
|
||||||
|
# wrong account even when MOSAIC_GIT_IDENTITY was set.
|
||||||
|
principal_host=$(get_remote_host 2>/dev/null || true)
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
|
# HAPPY PATH for a requested identity: create through the REST API
|
||||||
|
# with the per-slot token and never invoke tea — the identity arm
|
||||||
|
# must be REACHED, not sit behind a tea failure (#1280).
|
||||||
|
gitea_issue_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
||||||
|
else
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-create.sh - Create pull requests on Gitea or GitHub
|
# pr-create.sh - Create pull requests on Gitea or GitHub
|
||||||
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
|
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
|
||||||
|
# the wrapper then creates the PR through the REST API with that identity's
|
||||||
|
# token — tea is never invoked, so the tea login list cannot shadow the
|
||||||
|
# requested principal); the tea login list is the LAST resort. A requested
|
||||||
|
# identity with no per-slot token fails LOUD rather than writing under
|
||||||
|
# whichever account tea happens to hold.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -19,6 +28,15 @@ ISSUE=""
|
|||||||
|
|
||||||
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||||
|
|
||||||
|
# Acting-principal mode set in the Gitea branch below (from
|
||||||
|
# resolve_gitea_principal): "login" when --login was given, "identity" when a
|
||||||
|
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
|
||||||
|
# arm resolve the --login principal's token too, so an explicit --login keeps
|
||||||
|
# winning even on the tea-FAILURE fallback arm (otherwise the fallback would
|
||||||
|
# silently re-resolve to the environment identity or shared credential).
|
||||||
|
PRINCIPAL_MODE=""
|
||||||
|
PRINCIPAL_NAME=""
|
||||||
|
|
||||||
gitea_pr_create_api() {
|
gitea_pr_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -29,10 +47,19 @@ gitea_pr_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
|
# is set (per-slot token, fail-loud on absence); shared default otherwise.
|
||||||
token=$(get_gitea_token "$host") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
||||||
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||||
@@ -76,6 +103,7 @@ Options:
|
|||||||
-H, --head BRANCH Head branch with changes (default: current branch)
|
-H, --head BRANCH Head branch with changes (default: current branch)
|
||||||
-l, --labels LABELS Comma-separated labels
|
-l, --labels LABELS Comma-separated labels
|
||||||
-m, --milestone NAME Milestone name
|
-m, --milestone NAME Milestone name
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
||||||
-d, --draft Create as draft PR
|
-d, --draft Create as draft PR
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -116,6 +144,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -174,15 +206,41 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
|
# Resolve the acting principal identity-first (#1280). The tea login
|
||||||
|
# list is the LAST resort: it knows nothing about which seat is calling,
|
||||||
|
# and a login resolved from it first is what attributed PRs to the wrong
|
||||||
|
# account even when MOSAIC_GIT_IDENTITY was set.
|
||||||
|
principal_host=$(get_remote_host 2>/dev/null || true)
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
|
# HAPPY PATH for a requested identity: the per-slot token IS the
|
||||||
|
# credential, so create through the REST API directly and never
|
||||||
|
# invoke tea — the identity arm must be REACHED, not sit behind a
|
||||||
|
# tea failure (#1280). Fail-loud on a missing slot already happened
|
||||||
|
# in resolve_gitea_principal.
|
||||||
|
gitea_pr_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
# tea pull create syntax. Always pass --repo because tea repo inference
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
# of eval so markdown backticks/body content are not shell-executed.
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
||||||
|
else
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when
|
||||||
|
# absent); the shared host credential is the last resort. The merge is
|
||||||
|
# performed with the resolved credential only — never a cross-principal
|
||||||
|
# fallback (an HTTP 401 from the identity-bound token is a hard stop).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -16,6 +23,7 @@ DRY_RUN=false
|
|||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
CO_AUTHOR_TRAILERS=false
|
CO_AUTHOR_TRAILERS=false
|
||||||
ESCALATE_TO=""
|
ESCALATE_TO=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -31,6 +39,7 @@ Options:
|
|||||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -39,6 +48,7 @@ Examples:
|
|||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
|
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -82,6 +92,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ESCALATE_TO="$2"
|
ESCALATE_TO="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login|-l)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --login requires one tea login name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -572,10 +590,23 @@ PY
|
|||||||
merge_gitea_with_api() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" token attempt_rc
|
local host="$1" token attempt_rc
|
||||||
|
|
||||||
|
# Identity-first principal resolution (#1280): an explicit --login wins
|
||||||
|
# over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise
|
||||||
|
# get_gitea_token resolves the identity's per-slot token when an identity
|
||||||
|
# is requested (fail-loud when absent) and the shared host credential only
|
||||||
|
# when no identity is set. No cross-principal fallback: whatever resolves
|
||||||
|
# here is the ONLY credential the merge is attempted with.
|
||||||
|
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||||
|
if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then
|
||||||
|
echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
if ! token=$(get_gitea_token "$host"); then
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
if [[ -z "$token" ]]; then
|
if [[ -z "$token" ]]; then
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -602,10 +633,25 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
# Report the acting principal the merge WOULD use, resolved the same
|
||||||
|
# way the real merge resolves it (#1280) — a dry run that names a
|
||||||
|
# different principal than the merge would act as is a lie.
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then
|
||||||
|
# Fail-loud diagnostic already printed (unresolvable --login or a
|
||||||
|
# requested identity with no per-slot token).
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
case "$DRY_PRINCIPAL_MODE" in
|
||||||
|
login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;;
|
||||||
|
identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;;
|
||||||
|
*) DRY_PRINCIPAL_DESC="default host credential" ;;
|
||||||
|
esac
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
echo " -c, --comment Review comment (required for request-changes)"
|
||||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
|
||||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
@@ -346,7 +346,14 @@ gitea_resolve_api_for_login() {
|
|||||||
else
|
else
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
fi
|
fi
|
||||||
if [[ -n "$override_explicit" ]]; then
|
if [[ "$override_explicit" == "identity" ]]; then
|
||||||
|
# Requested git identity (#1280): the per-slot token MUST resolve via
|
||||||
|
# get_gitea_token's identity arm; never borrow the tea default login.
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
elif [[ -n "$override_explicit" ]]; then
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -676,29 +683,32 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
|
# Resolve the acting principal ONCE for every action, identity-first
|
||||||
|
# (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY /
|
||||||
|
# per-worktree git config mosaic.gitIdentity selects the principal when a
|
||||||
|
# per-slot token exists (fail-loud when it does not); the tea login list is
|
||||||
|
# the LAST resort — it enumerates whatever logins this host happens to hold
|
||||||
|
# and knows nothing about which seat is calling, so resolving from it first
|
||||||
|
# wrote under whichever account tea had configured (the #1280 family).
|
||||||
|
principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive
|
|
||||||
# the acting identity from that SAME credential so the review submit
|
|
||||||
# and its read-back verify against the identity that performed them.
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
# The review body (if any) travels with the review itself in the REST
|
# The review body (if any) travels with the review itself in the REST
|
||||||
@@ -715,24 +725,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||||
@@ -746,24 +748,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for detect-platform.sh's resolve_gitea_principal() — the
|
||||||
|
# identity-first acting-principal resolution shared by the git wrappers
|
||||||
|
# (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# The contract under test (precedence: --login > MOSAIC_GIT_IDENTITY /
|
||||||
|
# git config mosaic.gitIdentity > tea login list, which is the LAST resort):
|
||||||
|
# 1. identity env + per-slot token present -> mode=identity, principal=
|
||||||
|
# identity name, source names the identity's slot PATH (never a token
|
||||||
|
# value).
|
||||||
|
# 2. identity env + per-slot token ABSENT -> FAIL LOUD: nonzero, empty
|
||||||
|
# stdout, stderr naming the identity and the expected slot path.
|
||||||
|
# 3. identity env + --login -> --login wins (login mode resolves even when
|
||||||
|
# the identity has no slot — operator intent beats environment).
|
||||||
|
# 4. identity unset + no --login -> default mode: the tea login list
|
||||||
|
# resolves the principal exactly as before (preserved behavior).
|
||||||
|
# 5. no identity + no host-matching tea login -> default/host-credential
|
||||||
|
# (preserved behavior; absence is not an error on the default path).
|
||||||
|
# 6. identity on an UNRECOGNIZED host (no per-slot scheme) -> does not bind;
|
||||||
|
# default mode (containment, mirroring get_gitea_token).
|
||||||
|
# 7. --login with no host-bound token for that login -> FAIL LOUD, stderr
|
||||||
|
# naming the login and the host.
|
||||||
|
# 8. git config mosaic.gitIdentity is honored when the env var is unset.
|
||||||
|
# 9. The resolver NEVER emits a token value — stdout/stderr of every
|
||||||
|
# successful resolution must not contain the slot file's contents.
|
||||||
|
#
|
||||||
|
# Uses a stubbed tea binary, stubbed tea config.yml, stubbed credentials.json
|
||||||
|
# and stubbed per-slot token files under a fake HOME. NEVER reads real secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-principal-resolution}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" "$REPO_DIR" "$BIN_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
},
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "shared-usc-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# tea's own config store: the source get_gitea_token_for_login reads. Logins
|
||||||
|
# "alice" (mosaicstack) and "bob-usc" (usc) carry sentinel token values that
|
||||||
|
# the assertions prove are NEVER emitted by the resolver.
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: alice
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-alice-tea-token
|
||||||
|
- name: bob-usc
|
||||||
|
url: https://git.uscllc.com
|
||||||
|
token: SECRET-bob-usc-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
# Stubbed tea: only what login resolution needs (`login list --output json`).
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"alice","url":"https://git.mosaicstack.dev","default":true},
|
||||||
|
{"name":"bob-usc","url":"https://git.uscllc.com"}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Per-slot identity token with a sentinel value the assertions prove is never
|
||||||
|
# emitted (proving "token came from the identity's slot BY PATH, not by value").
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_contains() {
|
||||||
|
local desc="$1" haystack="$2" needle="$3"
|
||||||
|
if [[ "$haystack" != *"$needle"* ]]; then
|
||||||
|
echo "FAIL: $desc — missing '$needle' in: $haystack" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains() {
|
||||||
|
local desc="$1" haystack="$2" needle="$3"
|
||||||
|
if [[ "$haystack" == *"$needle"* ]]; then
|
||||||
|
echo "FAIL: $desc — must not contain '$needle', got: $haystack" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs resolve_gitea_principal for $1=login_override $2=host inside REPO_DIR
|
||||||
|
# (per-worktree git config resolves there) under a fake HOME, stubbed tea, and
|
||||||
|
# stubbed credentials. Extra env (e.g. MOSAIC_GIT_IDENTITY) via $@.
|
||||||
|
call_resolver() {
|
||||||
|
local login="$1" host="$2"; shift 2
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||||
|
bash -c 'source "$DETECT_PLATFORM_SH"; resolve_gitea_principal "$1" "$2"' _ "$login" "$host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
field() { printf '%s' "$1" | cut -f"$2"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Identity env + slot present -> identity mode, slot named BY PATH, and no
|
||||||
|
# token value ever emitted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "identity principal" "agentX" "$(field "$out" 2)"
|
||||||
|
assert_eq "identity slot source" \
|
||||||
|
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" \
|
||||||
|
"$(field "$out" 3)"
|
||||||
|
assert_not_contains "identity stdout leaks token" "$out" "SECRET"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Identity env + slot ABSENT -> fail loud: nonzero, empty stdout, stderr
|
||||||
|
# naming the identity and the expected slot path.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$out" ]]; then
|
||||||
|
echo "FAIL: missing slot — expected empty stdout, got '$out'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
assert_contains "missing slot names identity" "$err" "agentNoSlot"
|
||||||
|
assert_contains "missing slot names slot path" "$err" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"
|
||||||
|
assert_not_contains "missing-slot stderr leaks token" "$err" "SECRET"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Identity + --login -> --login wins. Also wins when the identity has NO
|
||||||
|
# slot (no identity check may veto an explicit login).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "login beats identity (mode)" "login" "$(field "$out" 1)"
|
||||||
|
assert_eq "login beats identity (principal)" "alice" "$(field "$out" 2)"
|
||||||
|
assert_eq "login source" "tea-login:alice" "$(field "$out" 3)"
|
||||||
|
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot)
|
||||||
|
assert_eq "login beats slot-less identity" "login" "$(field "$out" 1)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. No identity, no --login -> default mode via the tea login list
|
||||||
|
# (preserved behavior).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev")
|
||||||
|
assert_eq "default mode" "default" "$(field "$out" 1)"
|
||||||
|
assert_eq "default principal" "alice" "$(field "$out" 2)"
|
||||||
|
assert_eq "default source" "tea-default" "$(field "$out" 3)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. No identity, no --login, no host-matching tea login -> default with the
|
||||||
|
# host credential (absence is not an error on the default path).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "git.unknown.test")
|
||||||
|
assert_eq "no-match default mode" "default" "$(field "$out" 1)"
|
||||||
|
assert_eq "no-match default principal" "" "$(field "$out" 2)"
|
||||||
|
assert_eq "no-match default source" "host-credential" "$(field "$out" 3)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Identity on an UNRECOGNIZED host -> does not bind; default mode
|
||||||
|
# (containment, mirroring get_gitea_token's scope).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "github.com" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "unrecognized host falls to default" "default" "$(field "$out" 1)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. --login with no host-bound token for that login -> fail loud, stderr
|
||||||
|
# naming the login and the host.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "ghost-login" "git.mosaicstack.dev" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: unknown --login — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
assert_contains "unknown login names login" "$err" "ghost-login"
|
||||||
|
assert_contains "unknown login names host" "$err" "git.mosaicstack.dev"
|
||||||
|
# A cross-host login (exists, but for usc) must ALSO fail loud for mosaicstack.
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "bob-usc" "git.mosaicstack.dev" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: cross-host --login — expected nonzero return, got 0" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. git config mosaic.gitIdentity honored when env is unset.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentX
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev")
|
||||||
|
assert_eq "git-config identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "git-config identity principal" "agentX" "$(field "$out" 2)"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Cross-host slot layout: the usc slot path is chosen for the usc host.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "SECRET-agentX-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token"
|
||||||
|
out=$(call_resolver "" "git.uscllc.com" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "usc identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "usc slot source" \
|
||||||
|
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" \
|
||||||
|
"$(field "$out" 3)"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "resolve_gitea_principal identity-first resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -77,12 +77,30 @@ exit 0
|
|||||||
SH
|
SH
|
||||||
chmod +x "$BIN_DIR/tea"
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# TRIPWIRE provider stub: this harness tests argv construction, so ANY curl
|
||||||
|
# call is a failure of that contract (and, before this stub existed, a LIVE
|
||||||
|
# write — the #1282–#1287 incident: the seat's real HOME leaked a global
|
||||||
|
# mosaic.gitIdentity, flipping the wrapper into identity mode whose real
|
||||||
|
# per-slot token created real issues on the forge). Fail loudly instead.
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "FAIL: body-safety harness reached a provider request — this test must never curl" >&2
|
||||||
|
exit 99
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
# Hermetic invocation: fake HOME (no credentials, no tea config, no token
|
||||||
|
# slots) and GIT_CONFIG_GLOBAL severed — `git config --get mosaic.gitIdentity`
|
||||||
|
# otherwise resolves the WORKSTATION's global identity (mos-dt-0 on the seat
|
||||||
|
# that wrote this) and reroutes the wrapper into identity mode (#1280 family).
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
||||||
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
||||||
) >/dev/null
|
) >/dev/null
|
||||||
|
mkdir -p "$WORK_DIR/home"
|
||||||
|
|
||||||
# 1. No command substitution executed anywhere in the pipeline.
|
# 1. No command substitution executed anywhere in the pipeline.
|
||||||
if [[ -e "$SENTINEL" ]]; then
|
if [[ -e "$SENTINEL" ]]; then
|
||||||
|
|||||||
@@ -47,14 +47,31 @@ SH
|
|||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
run_wrapper() {
|
run_wrapper() {
|
||||||
|
# Hermetic: fake HOME (fixture credentials only, no token slots, no tea
|
||||||
|
# config) and GIT_CONFIG_GLOBAL severed — `git config --get
|
||||||
|
# mosaic.gitIdentity` otherwise resolves the WORKSTATION's global identity
|
||||||
|
# and reroutes the wrapper into identity mode before the tea paths this
|
||||||
|
# harness exercises (#1280 family; see test-issue-create-body-safety.sh).
|
||||||
|
# An `env …` prefix (used for MOSAIC_TEA_STALE_USER) is re-wrapped, not
|
||||||
|
# doubled: arguments beginning with "env" are shifted past.
|
||||||
|
local env_pairs=()
|
||||||
|
if [[ "${1:-}" == "env" ]]; then
|
||||||
|
shift
|
||||||
|
while [[ "$#" -gt 0 && "$1" == *=* ]]; do
|
||||||
|
env_pairs+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
fi
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \
|
||||||
"$@"
|
"$@"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
mkdir -p "$WORK_DIR/home"
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load-bearing regression harness for pr-create.sh identity-first principal
|
||||||
|
# resolution (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# The failure this harness is written down to catch: `MOSAIC_GIT_IDENTITY=fargo
|
||||||
|
# pr-create.sh …` produces a PR attributed to `mos-dt-0` (whichever account the
|
||||||
|
# tea login list happens to hold). Before #1280 the identity-aware code existed
|
||||||
|
# but sat on the API arm that only ran when the tea path FAILED — tea succeeded,
|
||||||
|
# so the identity arm never executed, and every test that did not check ORDERING
|
||||||
|
# passed. This harness checks ordering directly:
|
||||||
|
#
|
||||||
|
# 1. identity set + slot present -> the PR is created via the REST API with
|
||||||
|
# the identity's per-slot token (asserted by sentinel value AT the fake
|
||||||
|
# provider), and tea's `pr create` is NEVER invoked.
|
||||||
|
# 2. identity set + slot ABSENT -> nonzero, stderr naming the identity and
|
||||||
|
# the expected slot path; neither tea `pr create` nor any API request
|
||||||
|
# fires. No silent fallback to the tea login list.
|
||||||
|
# 3. identity set + --login -> --login wins: tea runs WITH the explicit
|
||||||
|
# --login, no API request.
|
||||||
|
# 4. nothing set -> preserved behavior: tea path with the tea-list login.
|
||||||
|
#
|
||||||
|
# Uses a stubbed tea, a stubbed curl provider, stubbed credentials.json and
|
||||||
|
# per-slot token under a fake HOME. NEVER reads real secrets or hits a live
|
||||||
|
# forge — all assertions are against the stubs' logs.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-identity-first}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
TOOLS_DIR="$WORK_DIR/tools"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
|
||||||
|
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
|
||||||
|
|
||||||
|
# Fixture: the real scripts under test, copied so sibling stubs (and the
|
||||||
|
# ../_lib credential loader) resolve inside the fixture tree.
|
||||||
|
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS_DIR/git/pr-create.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
|
||||||
|
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-create.sh"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: alice
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-alice-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
|
# Stubbed tea: records every invocation; `login list` feeds login resolution;
|
||||||
|
# `api --login <n> /user` feeds get_gitea_authenticated_user; `pr create` marks
|
||||||
|
# the marker file (its presence fails the identity-mode assertions).
|
||||||
|
cat > "$BIN_DIR/tea" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'TEA: %s\n' "\$*" >> "$LOG_FILE"
|
||||||
|
if [[ "\$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"alice","url":"https://git.mosaicstack.dev","default":true}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"alice"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\$*" == pr\ create* ]]; then
|
||||||
|
echo "TEA-PR-CREATE-INVOKED" >> "$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Stubbed provider: records the URL and the Authorization header VALUE it
|
||||||
|
# received, answers 201 with a created-PR object. The sentinel token values are
|
||||||
|
# synthetic fixtures — asserting them at the provider proves WHICH slot's
|
||||||
|
# credential carried the write.
|
||||||
|
cat > "$BIN_DIR/curl" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
url=""
|
||||||
|
auth=""
|
||||||
|
while [[ \$# -gt 0 ]]; do
|
||||||
|
case "\$1" in
|
||||||
|
-H)
|
||||||
|
case "\$2" in
|
||||||
|
Authorization*) auth="\$2" ;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) [[ -n "\$1" && "\$1" != -* ]] && url="\$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
|
||||||
|
cat <<'JSON'
|
||||||
|
{"number": 1299, "html_url": "https://git.mosaicstack.dev/mosaicstack/stack/pulls/1299"}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_contains() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if ! grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log does not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log must not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTRA_ARGS=""
|
||||||
|
run_pr_create() {
|
||||||
|
# "$@" carries ONLY environment assignments (VAR=value); EXTRA_ARGS (if
|
||||||
|
# set) carries wrapper arguments, so `env` never mistakes a wrapper flag
|
||||||
|
# like --login for one of its own.
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2086 # EXTRA_ARGS is deliberately word-split wrapper args
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
|
||||||
|
bash "$TOOLS_DIR/git/pr-create.sh" -t "Test PR" -B next -H fix/test $EXTRA_ARGS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. HAPPY PATH (the load-bearing ordering test): identity set + slot present
|
||||||
|
# -> REST API with the per-slot token; tea `pr create` NEVER invoked.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-1.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: identity happy path — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-1.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "identity happy path reaches the API" "CURL-URL: https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls"
|
||||||
|
assert_contains "identity happy path carries the slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains "identity happy path must NOT invoke tea pr create" "TEA-PR-CREATE-INVOKED"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Identity set + slot ABSENT -> fail loud BEFORE any write: nonzero, stderr
|
||||||
|
# naming identity + slot path, no tea pr create, no API request.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$WORK_DIR/stderr-2.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$WORK_DIR/stderr-2.tmp")
|
||||||
|
if [[ "$err" != *"agentNoSlot"* ]]; then
|
||||||
|
echo "FAIL: missing slot — stderr does not name the identity:" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
|
||||||
|
echo "FAIL: missing slot — stderr does not name the expected slot path:" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_not_contains "missing slot must not reach tea pr create" "TEA-PR-CREATE-INVOKED"
|
||||||
|
assert_not_contains "missing slot must not reach the API" "CURL-URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Identity set + --login -> --login wins: tea runs WITH the explicit login.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
EXTRA_ARGS="--login alice"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-3.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
EXTRA_ARGS=""
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: login override — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-3.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "login override drives tea with the explicit login" "TEA: pr create --repo mosaicstack/stack --login alice"
|
||||||
|
assert_not_contains "login override must not hit the API" "CURL-URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Nothing set -> preserved behavior: tea path with the tea-list login.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create 2>"$WORK_DIR/stderr-4.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: default path — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-4.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "default path still uses the tea-list login" "TEA: pr create --repo mosaicstack/stack --login alice"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "pr-create identity-first happy-path regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for pr-merge.sh identity-first principal resolution
|
||||||
|
# (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. --dry-run reports the acting principal the merge WOULD use, resolved the
|
||||||
|
# same way the real merge resolves it: --login > MOSAIC_GIT_IDENTITY /
|
||||||
|
# git config mosaic.gitIdentity > shared host credential. (The pre-#1280
|
||||||
|
# deployed copy reported a tea login that the merge would not act as.)
|
||||||
|
# 2. --dry-run fails closed when the requested principal has no credential:
|
||||||
|
# unknown --login, or an identity with no per-slot token (stderr names
|
||||||
|
# the login / the identity and its slot path).
|
||||||
|
# 3. The real merge POST carries the resolved principal's credential and no
|
||||||
|
# other: --login merges with that login's tea-config token; an identity
|
||||||
|
# merges with the per-slot token; an unresolvable --login never reaches
|
||||||
|
# the provider.
|
||||||
|
#
|
||||||
|
# Fixture pattern from test-pr-merge-head-pin.sh: the scripts under test are
|
||||||
|
# copied into a fixture tree with stubbed pr-metadata.sh / ci-queue-wait.sh
|
||||||
|
# siblings; the provider is a stubbed curl that records the credential it
|
||||||
|
# received. NEVER reads real secrets or hits a live forge.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-principal-resolution}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
TOOLS_DIR="$WORK_DIR/tools"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
SHA=0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
|
||||||
|
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$TOOLS_DIR/git/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
|
||||||
|
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-merge.sh"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
# Stubbed siblings pr-merge.sh resolves relative to its own SCRIPT_DIR.
|
||||||
|
cat > "$TOOLS_DIR/git/pr-metadata.sh" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"mosaicstack/stack","title":"Test PR","author":{"login":"contributor"}}'
|
||||||
|
SH
|
||||||
|
cat > "$TOOLS_DIR/git/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-metadata.sh" "$TOOLS_DIR/git/ci-queue-wait.sh"
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: fred-ms
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-fred-ms-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
|
# Stubbed tea for login-list resolution only.
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"fred-ms","url":"https://git.mosaicstack.dev","default":true}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Stubbed provider. pr-merge passes curl config on STDIN with -K -; the stub
|
||||||
|
# reads stdin, records the Authorization header it received, answers 200.
|
||||||
|
cat > "$BIN_DIR/curl" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
url=""
|
||||||
|
out_file=""
|
||||||
|
stdin_config=""
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
stdin_config="\$(cat || true)"
|
||||||
|
fi
|
||||||
|
while [[ \$# -gt 0 ]]; do
|
||||||
|
case "\$1" in
|
||||||
|
-o) out_file="\$2"; shift 2 ;;
|
||||||
|
-K|-w|--max-filesize|--max-time|--connect-timeout|-sS) shift 2 ;;
|
||||||
|
*) [[ -n "\$1" && "\$1" != -* && -z "\$url" ]] && url="\$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
auth="\$(printf '%s' "\$stdin_config" | grep -o 'Authorization: token [^"]*' || true)"
|
||||||
|
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
|
||||||
|
[[ -n "\$out_file" ]] && printf '{}' > "\$out_file"
|
||||||
|
printf '200\n'
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_contains_log() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if ! grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log does not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains_log() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log must not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pr_merge() {
|
||||||
|
local extra_args="$1"; shift
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2086 # extra_args is deliberately word-split wrapper args
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
|
||||||
|
bash "$TOOLS_DIR/git/pr-merge.sh" -n 42 $extra_args
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. --dry-run reports the resolved acting principal truthfully.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
if [[ "$out" != *"as git identity 'agentX' (per-slot credential)"* ]]; then
|
||||||
|
echo "FAIL: dry-run identity — principal not reported: $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
out=$(run_pr_merge "--dry-run --login fred-ms" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
if [[ "$out" != *"as tea login 'fred-ms'"* ]]; then
|
||||||
|
echo "FAIL: dry-run login override — login not reported (must beat env identity): $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
out=$(run_pr_merge "--dry-run")
|
||||||
|
if [[ "$out" != *"as default host credential"* ]]; then
|
||||||
|
echo "FAIL: dry-run default — not reported: $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. --dry-run fails closed when the requested principal has no credential.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--dry-run --login ghost" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]] || [[ "$(cat "$stderr_file")" != *"ghost"* ]]; then
|
||||||
|
echo "FAIL: dry-run unknown --login — expected fail-loud naming 'ghost', rc=$rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
if [[ "$rc" -eq 0 ]] || [[ "$err" != *"agentNoSlot"* ]] \
|
||||||
|
|| [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
|
||||||
|
echo "FAIL: dry-run identity without slot — expected fail-loud naming identity + slot path, rc=$rc" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. The real merge POST carries the resolved principal's credential ONLY.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--login fred-ms" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: merge with --login — expected rc=0, got $rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains_log "merge --login uses the login token" "CURL-AUTH: Authorization: token SECRET-fred-ms-tea-token"
|
||||||
|
assert_not_contains_log "merge --login must not use the identity slot token" "SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains_log "merge --login must not use the shared token" "shared-mosaicstack-token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: merge with identity — expected rc=0, got $rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains_log "merge identity uses the per-slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains_log "merge identity must not use the shared token" "shared-mosaicstack-token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--login ghost" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: merge with unknown --login — expected nonzero, got 0" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_not_contains_log "merge with unknown --login must not reach the provider" "CURL-URL"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "pr-merge identity-first principal resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-gitea-principal-resolution.sh && bash framework/tools/git/test-pr-create-identity-first.sh && bash framework/tools/git/test-pr-merge-principal-resolution.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { parse as parseYaml } from 'yaml';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { registerMissionCommand } from './mission.js';
|
|
||||||
import { PrdService } from '@mosaicstack/prdy';
|
|
||||||
import type { MissionInfo } from '../tui/gateway-api.js';
|
|
||||||
|
|
||||||
// ── Mocks: the gateway is not available in adapter tests ──────────────────────
|
|
||||||
|
|
||||||
// vi.hoisted: the mock factory is hoisted above imports, so the fixture must
|
|
||||||
// be initialized there too.
|
|
||||||
const MISSION = vi.hoisted(
|
|
||||||
(): MissionInfo => ({
|
|
||||||
id: 'mission-plan-1',
|
|
||||||
name: 'Plan Mission Alpha',
|
|
||||||
description: null,
|
|
||||||
status: 'planning',
|
|
||||||
projectId: null,
|
|
||||||
userId: null,
|
|
||||||
phase: null,
|
|
||||||
milestones: null,
|
|
||||||
config: null,
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-03-04T05:06:07.000Z',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock('./with-auth.js', () => ({
|
|
||||||
withAuth: vi.fn().mockResolvedValue({
|
|
||||||
gateway: 'http://localhost:14242',
|
|
||||||
cookie: 'better-auth.session_token=test',
|
|
||||||
session: {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../tui/gateway-api.js', () => ({
|
|
||||||
fetchMissions: vi.fn().mockResolvedValue([MISSION]),
|
|
||||||
fetchMission: vi.fn(),
|
|
||||||
createMission: vi.fn(),
|
|
||||||
updateMission: vi.fn(),
|
|
||||||
fetchMissionTasks: vi.fn().mockResolvedValue([]),
|
|
||||||
createMissionTask: vi.fn(),
|
|
||||||
updateMissionTask: vi.fn(),
|
|
||||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
let projectDir: string;
|
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
|
|
||||||
|
|
||||||
function buildTestProgram(): Command {
|
|
||||||
const program = new Command('mosaic').exitOverride();
|
|
||||||
registerMissionCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-'));
|
|
||||||
process.chdir(projectDir);
|
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
consoleStub.push(logSpy);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore only the per-test spies; module factory mocks keep their
|
|
||||||
// implementations across tests.
|
|
||||||
for (const stub of consoleStub) stub.mockRestore();
|
|
||||||
consoleStub = [];
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('mosaic mission --plan (thin adapter over PrdService)', () => {
|
|
||||||
it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => {
|
|
||||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
|
||||||
from: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
// PRD landed in the same store `mosaic prdy` uses.
|
|
||||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
|
||||||
expect(files).toHaveLength(1);
|
|
||||||
expect(files[0]).toMatch(/\.yaml$/);
|
|
||||||
|
|
||||||
// Fresh service instance (new-process equivalent) reads the linkage back.
|
|
||||||
const service = new PrdService({ projectPath: projectDir });
|
|
||||||
const docs = await service.list();
|
|
||||||
expect(docs).toHaveLength(1);
|
|
||||||
|
|
||||||
const prd = docs[0]!;
|
|
||||||
expect(prd.title).toBe('Plan Mission Alpha');
|
|
||||||
expect(prd.version).toBe(1);
|
|
||||||
|
|
||||||
const links = await service.listMissionLinks(prd.id);
|
|
||||||
expect(links).toHaveLength(1);
|
|
||||||
expect(links[0]).toMatchObject({
|
|
||||||
missionId: MISSION.id,
|
|
||||||
missionVersion: MISSION.updatedAt, // mission version marker
|
|
||||||
prdVersion: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('linkage is persisted in the YAML authority document itself (survives restart)', async () => {
|
|
||||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
|
||||||
from: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
|
||||||
const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8');
|
|
||||||
const persisted = parseYaml(raw) as { missions: Array<Record<string, unknown>> };
|
|
||||||
|
|
||||||
expect(persisted.missions).toHaveLength(1);
|
|
||||||
expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => {
|
|
||||||
// Mission path.
|
|
||||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
|
||||||
from: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
// prdy path (service, non-interactive entry).
|
|
||||||
const service = new PrdService({ projectPath: projectDir });
|
|
||||||
const direct = await service.create({ name: 'Directly Created' });
|
|
||||||
|
|
||||||
const all = await service.list();
|
|
||||||
expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort());
|
|
||||||
expect(all).toHaveLength(2);
|
|
||||||
|
|
||||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
|
||||||
expect(files).toContain(`${direct.id}.yaml`);
|
|
||||||
|
|
||||||
// Both are v1 in the same store with distinct stable ids.
|
|
||||||
for (const doc of all) {
|
|
||||||
expect(doc.version).toBe(1);
|
|
||||||
expect(files).toContain(`${doc.id}.yaml`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -256,41 +256,14 @@ async function planMission(
|
|||||||
console.log(`Planning mission: ${mission.name}\n`);
|
console.log(`Planning mission: ${mission.name}\n`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in
|
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||||
// PrdService — no second writer path. The mission's updatedAt serves as
|
await runPrdWizard({
|
||||||
// its version marker (the gateway exposes no numeric mission version).
|
|
||||||
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
|
|
||||||
const service = new PrdService({ projectPath: process.cwd() });
|
|
||||||
|
|
||||||
if (process.stdout.isTTY) {
|
|
||||||
const created = await runPrdWizard({
|
|
||||||
name: mission.name,
|
name: mission.name,
|
||||||
projectPath: process.cwd(),
|
projectPath: process.cwd(),
|
||||||
interactive: true,
|
interactive: true,
|
||||||
});
|
});
|
||||||
const linked = await service.linkMission({
|
|
||||||
prdId: created.id,
|
|
||||||
missionId: mission.id,
|
|
||||||
missionVersion: mission.updatedAt,
|
|
||||||
requirementIds: [],
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = await service.planForMission({
|
|
||||||
name: mission.name,
|
|
||||||
missionId: mission.id,
|
|
||||||
missionVersion: mission.updatedAt,
|
|
||||||
requirementIds: [],
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
`PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`);
|
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { stringify as stringifyYaml } from 'yaml';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { registerPrdyCommand } from './prdy.js';
|
|
||||||
import { PrdService } from '@mosaicstack/prdy';
|
|
||||||
|
|
||||||
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
|
|
||||||
// outside the tmp project dir) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
vi.mock('./with-auth.js', () => ({
|
|
||||||
withAuth: vi.fn().mockResolvedValue({
|
|
||||||
gateway: 'http://localhost:14242',
|
|
||||||
cookie: 'better-auth.session_token=test',
|
|
||||||
session: {},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../tui/gateway-api.js', () => ({
|
|
||||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ProcessExitError extends Error {
|
|
||||||
constructor(readonly code: number) {
|
|
||||||
super(`process.exit(${code})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubProcessExit() {
|
|
||||||
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
||||||
throw new ProcessExitError(code ?? 0);
|
|
||||||
}) as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
let projectDir: string;
|
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let exitStub: ReturnType<typeof stubProcessExit>;
|
|
||||||
|
|
||||||
function buildTestProgram(): Command {
|
|
||||||
const program = new Command('mosaic').exitOverride();
|
|
||||||
registerPrdyCommand(program);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runPrdy(args: string[]): Promise<unknown> {
|
|
||||||
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: 'cmd-import-prd',
|
|
||||||
title: 'Command Import PRD',
|
|
||||||
status: 'approved', // must be forced to draft: validity is not approval
|
|
||||||
projectPath: '/tmp/elsewhere',
|
|
||||||
template: 'software',
|
|
||||||
version: 1,
|
|
||||||
sections: [
|
|
||||||
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
|
|
||||||
],
|
|
||||||
missions: [],
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
|
|
||||||
process.chdir(projectDir);
|
|
||||||
exitStub = stubProcessExit();
|
|
||||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore only the per-test spies: module factory mocks must keep their
|
|
||||||
// implementations for the next test.
|
|
||||||
exitStub.mockRestore();
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
logSpy.mockRestore();
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('mosaic prdy (thin adapter over PrdService)', () => {
|
|
||||||
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
|
|
||||||
await runPrdy(['--init', 'Adapter Created']);
|
|
||||||
|
|
||||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
|
||||||
expect(files).toHaveLength(1);
|
|
||||||
expect(files[0]).toMatch(/\.yaml$/);
|
|
||||||
|
|
||||||
const docs = await new PrdService({ projectPath: projectDir }).list();
|
|
||||||
expect(docs).toHaveLength(1);
|
|
||||||
expect(docs[0]?.title).toBe('Adapter Created');
|
|
||||||
expect(docs[0]?.version).toBe(1);
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--import <file> creates a valid import through the service', async () => {
|
|
||||||
const filePath = path.join(projectDir, 'incoming.yaml');
|
|
||||||
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
|
|
||||||
|
|
||||||
await runPrdy(['--import', filePath]);
|
|
||||||
|
|
||||||
const docs = await new PrdService({ projectPath: projectDir }).list();
|
|
||||||
expect(docs).toHaveLength(1);
|
|
||||||
expect(docs[0]?.id).toBe('cmd-import-prd');
|
|
||||||
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
|
|
||||||
const filePath = path.join(projectDir, 'broken.yaml');
|
|
||||||
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
|
|
||||||
|
|
||||||
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
|
|
||||||
|
|
||||||
// Typed refusal surfaced to the user, nothing created.
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
|
|
||||||
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
|
|
||||||
const service = new PrdService({ projectPath: projectDir });
|
|
||||||
const existing = await service.create({ name: 'Conflict Target' });
|
|
||||||
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
|
|
||||||
const beforeBytes = await readFile(storeFile, 'utf8');
|
|
||||||
|
|
||||||
const filePath = path.join(projectDir, 'divergent.yaml');
|
|
||||||
await writeFile(
|
|
||||||
filePath,
|
|
||||||
stringifyYaml(
|
|
||||||
importableDocument({
|
|
||||||
...existing,
|
|
||||||
title: 'Divergent Command Import',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
|
|
||||||
|
|
||||||
// Original authority document is byte-identical on disk.
|
|
||||||
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--import --accept-successor persists the successor version explicitly', async () => {
|
|
||||||
const service = new PrdService({ projectPath: projectDir });
|
|
||||||
const existing = await service.create({ name: 'Successor Target' });
|
|
||||||
|
|
||||||
const filePath = path.join(projectDir, 'divergent2.yaml');
|
|
||||||
await writeFile(
|
|
||||||
filePath,
|
|
||||||
stringifyYaml(
|
|
||||||
importableDocument({
|
|
||||||
...existing,
|
|
||||||
title: 'Accepted Via CLI',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
await runPrdy(['--import', filePath, '--accept-successor']);
|
|
||||||
|
|
||||||
const doc = await service.get(existing.id);
|
|
||||||
expect(doc.version).toBe(2);
|
|
||||||
expect(doc.title).toBe('Accepted Via CLI');
|
|
||||||
expect(doc.status).toBe('draft');
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--export writes a labeled generated view and never touches authority', async () => {
|
|
||||||
const service = new PrdService({ projectPath: projectDir });
|
|
||||||
const created = await service.create({ name: 'Export Via CLI' });
|
|
||||||
const before = await service.get(created.id);
|
|
||||||
|
|
||||||
await runPrdy(['--export', created.id]);
|
|
||||||
|
|
||||||
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
|
|
||||||
const md = await readFile(mdPath, 'utf8');
|
|
||||||
expect(md).toContain('generated view — do not edit');
|
|
||||||
expect(md).toContain(`prd-id: ${created.id}`);
|
|
||||||
expect(md).toContain('prd-version: 1');
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(`Generated view written: ${mdPath}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Authority unchanged by the export.
|
|
||||||
expect(await service.get(created.id)).toEqual(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,10 +2,6 @@ import type { Command } from 'commander';
|
|||||||
import { withAuth } from './with-auth.js';
|
import { withAuth } from './with-auth.js';
|
||||||
import { fetchProjects } from '../tui/gateway-api.js';
|
import { fetchProjects } from '../tui/gateway-api.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* `mosaic prdy` — thin adapter over PrdService (@mosaicstack/prdy).
|
|
||||||
* All reads/writes go through the service; there is no local writer path.
|
|
||||||
*/
|
|
||||||
export function registerPrdyCommand(program: Command) {
|
export function registerPrdyCommand(program: Command) {
|
||||||
const cmd = program
|
const cmd = program
|
||||||
.command('prdy')
|
.command('prdy')
|
||||||
@@ -13,18 +9,12 @@ export function registerPrdyCommand(program: Command) {
|
|||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||||
.option('--init [name]', 'Create a new PRD')
|
.option('--init [name]', 'Create a new PRD')
|
||||||
.option('--update [name]', 'Update an existing PRD')
|
.option('--update [name]', 'Update an existing PRD')
|
||||||
.option('--import <file>', 'Import a YAML PRD document (validated, conflict-aware)')
|
|
||||||
.option('--accept-successor', 'With --import: accept a conflicted import as next version')
|
|
||||||
.option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file')
|
|
||||||
.option('--project <idOrName>', 'Scope to project')
|
.option('--project <idOrName>', 'Scope to project')
|
||||||
.action(
|
.action(
|
||||||
async (opts: {
|
async (opts: {
|
||||||
gateway: string;
|
gateway: string;
|
||||||
init?: string | boolean;
|
init?: string | boolean;
|
||||||
update?: string | boolean;
|
update?: string | boolean;
|
||||||
import?: string;
|
|
||||||
acceptSuccessor?: boolean;
|
|
||||||
export?: string | boolean;
|
|
||||||
project?: string;
|
project?: string;
|
||||||
}) => {
|
}) => {
|
||||||
// Detect project context when --project flag is provided
|
// Detect project context when --project flag is provided
|
||||||
@@ -41,69 +31,20 @@ export function registerPrdyCommand(program: Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
|
|
||||||
const service = new PrdService({ projectPath: process.cwd() });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (opts.import !== undefined) {
|
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||||
const input = { filePath: opts.import };
|
|
||||||
|
|
||||||
if (opts.acceptSuccessor) {
|
|
||||||
const successor = await service.acceptSuccessor(input);
|
|
||||||
console.log(
|
|
||||||
`Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await service.importDocument(input);
|
|
||||||
console.log(
|
|
||||||
result.kind === 'created'
|
|
||||||
? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})`
|
|
||||||
: `PRD ${result.document.id} already present with identical content — nothing to do.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.export !== undefined) {
|
|
||||||
const id =
|
|
||||||
typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined;
|
|
||||||
const result = await service.exportMarkdown({ id });
|
|
||||||
console.log(
|
|
||||||
`Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
typeof opts.init === 'string'
|
typeof opts.init === 'string'
|
||||||
? opts.init
|
? opts.init
|
||||||
: typeof opts.update === 'string'
|
: typeof opts.update === 'string'
|
||||||
? opts.update
|
? opts.update
|
||||||
: 'untitled';
|
: 'untitled';
|
||||||
|
|
||||||
if (process.stdout.isTTY) {
|
|
||||||
await runPrdWizard({
|
await runPrdWizard({
|
||||||
name,
|
name,
|
||||||
projectPath: process.cwd(),
|
projectPath: process.cwd(),
|
||||||
interactive: true,
|
interactive: true,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-interactive fallback routes through the service directly.
|
|
||||||
const doc = await service.create({ name });
|
|
||||||
console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === 'PrdImportConflictError') {
|
|
||||||
const conflict = err as { proposal?: { version?: number } };
|
|
||||||
console.error(`${err.message}`);
|
|
||||||
console.error(
|
|
||||||
`Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-74
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
|
|
||||||
import { PrdService } from './service.js';
|
import { createPrd, listPrds, loadPrd } from './prd.js';
|
||||||
import { runPrdWizard } from './wizard.js';
|
import { runPrdWizard } from './wizard.js';
|
||||||
|
|
||||||
interface InitCommandOptions {
|
interface InitCommandOptions {
|
||||||
@@ -18,22 +18,6 @@ interface ShowCommandOptions {
|
|||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportCommandOptions {
|
|
||||||
readonly project: string;
|
|
||||||
readonly file: string;
|
|
||||||
readonly acceptSuccessor?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportCommandOptions {
|
|
||||||
readonly project: string;
|
|
||||||
readonly id?: string;
|
|
||||||
readonly out?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceFor(project: string): PrdService {
|
|
||||||
return new PrdService({ projectPath: project });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPrdyCli(): Command {
|
export function buildPrdyCli(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name('mosaic').description('Mosaic CLI').exitOverride();
|
program.name('mosaic').description('Mosaic CLI').exitOverride();
|
||||||
@@ -54,9 +38,11 @@ export function buildPrdyCli(): Command {
|
|||||||
template: options.template,
|
template: options.template,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
})
|
})
|
||||||
: await serviceFor(options.project).create({
|
: await createPrd({
|
||||||
name: options.name,
|
name: options.name,
|
||||||
|
projectPath: options.project,
|
||||||
template: options.template,
|
template: options.template,
|
||||||
|
interactive: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -66,7 +52,6 @@ export function buildPrdyCli(): Command {
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
status: doc.status,
|
status: doc.status,
|
||||||
version: doc.version,
|
|
||||||
projectPath: doc.projectPath,
|
projectPath: doc.projectPath,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -80,7 +65,7 @@ export function buildPrdyCli(): Command {
|
|||||||
.description('List PRD documents for a project')
|
.description('List PRD documents for a project')
|
||||||
.requiredOption('--project <path>', 'Project path')
|
.requiredOption('--project <path>', 'Project path')
|
||||||
.action(async (options: ListCommandOptions) => {
|
.action(async (options: ListCommandOptions) => {
|
||||||
const docs = await serviceFor(options.project).list();
|
const docs = await listPrds(options.project);
|
||||||
console.log(JSON.stringify(docs, null, 2));
|
console.log(JSON.stringify(docs, null, 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,65 +75,20 @@ export function buildPrdyCli(): Command {
|
|||||||
.requiredOption('--project <path>', 'Project path')
|
.requiredOption('--project <path>', 'Project path')
|
||||||
.option('--id <id>', 'PRD document id')
|
.option('--id <id>', 'PRD document id')
|
||||||
.action(async (options: ShowCommandOptions) => {
|
.action(async (options: ShowCommandOptions) => {
|
||||||
const doc = await serviceFor(options.project).get(options.id);
|
if (options.id !== undefined) {
|
||||||
console.log(JSON.stringify(doc, null, 2));
|
const docs = await listPrds(options.project);
|
||||||
});
|
const match = docs.find((doc) => doc.id === options.id);
|
||||||
|
|
||||||
prdy
|
if (match === undefined) {
|
||||||
.command('import')
|
throw new Error(`PRD id not found: ${options.id}`);
|
||||||
.description('Import a YAML PRD document (validated; conflicts propose a successor)')
|
}
|
||||||
.requiredOption('--project <path>', 'Project path')
|
|
||||||
.requiredOption('--file <file>', 'Path to YAML PRD document')
|
|
||||||
.option('--accept-successor', 'Accept a conflicted import as the next version')
|
|
||||||
.action(async (options: ImportCommandOptions) => {
|
|
||||||
const service = serviceFor(options.project);
|
|
||||||
const input = { filePath: options.file };
|
|
||||||
|
|
||||||
if (options.acceptSuccessor) {
|
console.log(JSON.stringify(match, null, 2));
|
||||||
const successor = await service.acceptSuccessor(input);
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
outcome: 'successor-accepted',
|
|
||||||
id: successor.id,
|
|
||||||
version: successor.version,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await service.importDocument(input);
|
const doc = await loadPrd(options.project);
|
||||||
console.log(
|
console.log(JSON.stringify(doc, null, 2));
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
outcome: result.kind,
|
|
||||||
id: result.document.id,
|
|
||||||
version: result.document.version,
|
|
||||||
status: result.document.status,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
prdy
|
|
||||||
.command('export')
|
|
||||||
.description('Render a PRD to a labeled generated-view Markdown file')
|
|
||||||
.requiredOption('--project <path>', 'Project path')
|
|
||||||
.option('--id <id>', 'PRD document id')
|
|
||||||
.option('--out <path>', 'Output path (default docs/prdy/<id>.md)')
|
|
||||||
.action(async (options: ExportCommandOptions) => {
|
|
||||||
const result = await serviceFor(options.project).exportMarkdown({
|
|
||||||
id: options.id,
|
|
||||||
outPath: options.out,
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
|
|||||||
@@ -1,35 +1,12 @@
|
|||||||
// PrdService is the single authority surface for PRD documents. The raw store
|
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
|
||||||
// writers (createPrd/savePrd) are deliberately NOT exported: every mutation
|
|
||||||
// goes through the service so there is no second writer path.
|
|
||||||
export { loadPrd, listPrds, parsePrdDocument } from './prd.js';
|
|
||||||
export { runPrdWizard } from './wizard.js';
|
export { runPrdWizard } from './wizard.js';
|
||||||
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
||||||
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
|
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
|
||||||
export {
|
|
||||||
PrdService,
|
|
||||||
PRD_GENERATED_VIEW_LABEL,
|
|
||||||
PrdError,
|
|
||||||
PrdNotFoundError,
|
|
||||||
PrdUpdateError,
|
|
||||||
PrdImportInvalidError,
|
|
||||||
PrdImportConflictError,
|
|
||||||
} from './service.js';
|
|
||||||
export type {
|
export type {
|
||||||
PrdStatus,
|
PrdStatus,
|
||||||
PrdTemplate,
|
PrdTemplate,
|
||||||
PrdTemplateSection,
|
PrdTemplateSection,
|
||||||
PrdSection,
|
PrdSection,
|
||||||
PrdMissionLinkage,
|
|
||||||
PrdDocument,
|
PrdDocument,
|
||||||
CreatePrdOptions,
|
CreatePrdOptions,
|
||||||
PrdServiceOptions,
|
|
||||||
PrdCreateInput,
|
|
||||||
PrdSectionPatch,
|
|
||||||
PrdUpdateInput,
|
|
||||||
PrdLinkMissionInput,
|
|
||||||
PrdPlanForMissionInput,
|
|
||||||
PrdExportInput,
|
|
||||||
PrdExportResult,
|
|
||||||
PrdImportInput,
|
|
||||||
PrdImportResult,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|||||||
@@ -17,49 +17,17 @@ const prdSectionSchema = z.object({
|
|||||||
fields: z.record(z.string(), z.string()),
|
fields: z.record(z.string(), z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const prdMissionLinkageSchema = z.object({
|
|
||||||
missionId: z.string().min(1),
|
|
||||||
missionVersion: z.string().min(1),
|
|
||||||
prdVersion: z.number().int().min(1),
|
|
||||||
requirementIds: z.array(z.string()),
|
|
||||||
linkedAt: z.string().datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const prdDocumentSchema = z.object({
|
const prdDocumentSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
status: z.enum(['draft', 'review', 'approved', 'archived']),
|
status: z.enum(['draft', 'review', 'approved', 'archived']),
|
||||||
projectPath: z.string().min(1),
|
projectPath: z.string().min(1),
|
||||||
template: z.string().min(1),
|
template: z.string().min(1),
|
||||||
// Defaults keep documents written by older prdy versions loadable.
|
|
||||||
version: z.number().int().min(1).default(1),
|
|
||||||
sections: z.array(prdSectionSchema),
|
sections: z.array(prdSectionSchema),
|
||||||
missions: z.array(prdMissionLinkageSchema).default([]),
|
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
updatedAt: z.string().datetime(),
|
updatedAt: z.string().datetime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */
|
|
||||||
function coerceTimestamps(value: unknown): unknown {
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return value.toISOString();
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map(coerceTimestamps);
|
|
||||||
}
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate an unknown value as a PRD document (throws zod errors on failure). */
|
|
||||||
export function parsePrdDocument(value: unknown): PrdDocument {
|
|
||||||
return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandHome(projectPath: string): string {
|
function expandHome(projectPath: string): string {
|
||||||
if (!projectPath.startsWith('~')) {
|
if (!projectPath.startsWith('~')) {
|
||||||
return projectPath;
|
return projectPath;
|
||||||
@@ -106,8 +74,6 @@ function prdDirectory(projectPath: string): string {
|
|||||||
return path.join(projectPath, PRD_DIRECTORY);
|
return path.join(projectPath, PRD_DIRECTORY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { prdDirectory };
|
|
||||||
|
|
||||||
function prdFilePath(projectPath: string, id: string): string {
|
function prdFilePath(projectPath: string, id: string): string {
|
||||||
return path.join(prdDirectory(projectPath), `${id}.yaml`);
|
return path.join(prdDirectory(projectPath), `${id}.yaml`);
|
||||||
}
|
}
|
||||||
@@ -147,13 +113,11 @@ export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument>
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
projectPath: resolvedProjectPath,
|
projectPath: resolvedProjectPath,
|
||||||
template: template.id,
|
template: template.id,
|
||||||
version: 1,
|
|
||||||
sections: template.sections.map((section) => ({
|
sections: template.sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
title: section.title,
|
title: section.title,
|
||||||
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
|
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
|
||||||
})),
|
})),
|
||||||
missions: [],
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -226,7 +190,7 @@ export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
|
|||||||
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
|
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = parsePrdDocument(parsed);
|
const document = prdDocumentSchema.parse(parsed);
|
||||||
documents.push(document);
|
documents.push(document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,433 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import yaml from 'js-yaml';
|
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PRD_GENERATED_VIEW_LABEL,
|
|
||||||
PrdImportConflictError,
|
|
||||||
PrdImportInvalidError,
|
|
||||||
PrdNotFoundError,
|
|
||||||
PrdService,
|
|
||||||
PrdUpdateError,
|
|
||||||
} from './index.js';
|
|
||||||
import type { PrdDocument } from './index.js';
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let projectDir: string;
|
|
||||||
|
|
||||||
async function makeProject(): Promise<string> {
|
|
||||||
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function service(): PrdService {
|
|
||||||
return new PrdService({ projectPath: projectDir });
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeDir(): string {
|
|
||||||
return path.join(projectDir, 'docs', 'prdy');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handcraft a full, schema-valid PRD document for import scenarios. */
|
|
||||||
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
|
|
||||||
return {
|
|
||||||
id: 'imported-prd-20260101-000000',
|
|
||||||
title: 'Imported PRD',
|
|
||||||
status: 'draft',
|
|
||||||
projectPath: '/tmp/elsewhere',
|
|
||||||
template: 'software',
|
|
||||||
version: 1,
|
|
||||||
sections: [
|
|
||||||
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
|
|
||||||
{
|
|
||||||
id: 'scope-non-goals',
|
|
||||||
title: 'Scope / Non-Goals',
|
|
||||||
fields: { inScope: '', outOfScope: '' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
missions: [],
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeImportFile(doc: PrdDocument): Promise<string> {
|
|
||||||
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
|
|
||||||
await writeFile(filePath, yaml.dump(doc), 'utf8');
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
projectDir = await makeProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Single authority store (AC: prdy path and mission path resolve to the
|
|
||||||
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
|
|
||||||
|
|
||||||
describe('PrdService single authority store', () => {
|
|
||||||
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
|
|
||||||
const direct = await service().create({ name: 'Direct PRD' });
|
|
||||||
const viaMission = await service().planForMission({
|
|
||||||
name: 'Mission PRD',
|
|
||||||
missionId: 'mission-1',
|
|
||||||
missionVersion: '2026-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = await readdir(storeDir());
|
|
||||||
expect(files).toContain(`${direct.id}.yaml`);
|
|
||||||
expect(files).toContain(`${viaMission.id}.yaml`);
|
|
||||||
|
|
||||||
// A fresh service instance (new process equivalent) resolves both.
|
|
||||||
const all = await service().list();
|
|
||||||
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
|
|
||||||
|
|
||||||
// Stable versions: creation is v1; linkage writes do not bump content version.
|
|
||||||
expect((await service().get(direct.id)).version).toBe(1);
|
|
||||||
expect((await service().get(viaMission.id)).version).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trips documents through the store with identity intact', async () => {
|
|
||||||
const created = await service().create({ name: 'Round Trip', template: 'feature' });
|
|
||||||
const fresh = await service().get(created.id);
|
|
||||||
|
|
||||||
expect(fresh).toEqual(created);
|
|
||||||
expect(fresh.id).toBe(created.id);
|
|
||||||
expect(fresh.template).toBe('feature');
|
|
||||||
expect(fresh.status).toBe('draft');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a typed error for unknown ids and empty stores', async () => {
|
|
||||||
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
||||||
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mission linkage persistence (AC: linkage survives restart via fresh
|
|
||||||
// service instances) ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PrdService mission linkage', () => {
|
|
||||||
it('persists linkage and reads it back from a fresh service instance', async () => {
|
|
||||||
const created = await service().planForMission({
|
|
||||||
name: 'Linked PRD',
|
|
||||||
missionId: 'mission-42',
|
|
||||||
missionVersion: '2026-02-03T04:05:06.000Z',
|
|
||||||
requirementIds: ['FR-1', 'FR-2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fresh instance — nothing in memory from the creating call.
|
|
||||||
const links = await service().listMissionLinks(created.id);
|
|
||||||
expect(links).toHaveLength(1);
|
|
||||||
expect(links[0]).toMatchObject({
|
|
||||||
missionId: 'mission-42',
|
|
||||||
missionVersion: '2026-02-03T04:05:06.000Z',
|
|
||||||
prdVersion: 1,
|
|
||||||
requirementIds: ['FR-1', 'FR-2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Linkage is carried in the YAML authority file itself.
|
|
||||||
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
||||||
const persisted = yaml.load(raw) as PrdDocument;
|
|
||||||
expect(persisted.missions[0]?.missionId).toBe('mission-42');
|
|
||||||
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
|
|
||||||
const created = await service().planForMission({
|
|
||||||
name: 'Relink PRD',
|
|
||||||
missionId: 'mission-7',
|
|
||||||
missionVersion: 'v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
await service().update({
|
|
||||||
id: created.id,
|
|
||||||
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const relinked = await service().linkMission({
|
|
||||||
prdId: created.id,
|
|
||||||
missionId: 'mission-7',
|
|
||||||
missionVersion: 'v2',
|
|
||||||
requirementIds: ['NFR-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(relinked.missions).toHaveLength(1);
|
|
||||||
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not bump the content version when writing linkage', async () => {
|
|
||||||
const created = await service().create({ name: 'Stable Version' });
|
|
||||||
const linked = await service().linkMission({
|
|
||||||
prdId: created.id,
|
|
||||||
missionId: 'm',
|
|
||||||
missionVersion: 'v1',
|
|
||||||
});
|
|
||||||
expect(linked.version).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Update semantics ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PrdService update', () => {
|
|
||||||
it('applies section patches and bumps the content version', async () => {
|
|
||||||
const created = await service().create({ name: 'Updatable' });
|
|
||||||
const updated = await service().update({
|
|
||||||
id: created.id,
|
|
||||||
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updated.version).toBe(2);
|
|
||||||
expect(updated.sections[0]?.fields).toMatchObject({
|
|
||||||
context: 'Some context',
|
|
||||||
objective: 'Goal',
|
|
||||||
});
|
|
||||||
expect((await service().get(created.id)).version).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses unknown section ids with a typed error', async () => {
|
|
||||||
const created = await service().create({ name: 'Strict' });
|
|
||||||
await expect(
|
|
||||||
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
|
|
||||||
).rejects.toBeInstanceOf(PrdUpdateError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Markdown export is a labeled generated view, never authority ──────────────
|
|
||||||
|
|
||||||
describe('PrdService exportMarkdown', () => {
|
|
||||||
it('writes a generated view carrying the label and source identity', async () => {
|
|
||||||
const created = await service().create({ name: 'Exported PRD' });
|
|
||||||
const result = await service().exportMarkdown({ id: created.id });
|
|
||||||
|
|
||||||
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
|
|
||||||
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
|
|
||||||
expect(result.content).toContain(`prd-id: ${created.id}`);
|
|
||||||
expect(result.content).toContain('prd-version: 1');
|
|
||||||
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reflects the current version after updates', async () => {
|
|
||||||
const created = await service().create({ name: 'Versioned Export' });
|
|
||||||
await service().update({
|
|
||||||
id: created.id,
|
|
||||||
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
|
|
||||||
});
|
|
||||||
const result = await service().exportMarkdown({ id: created.id });
|
|
||||||
expect(result.content).toContain('prd-version: 2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
|
|
||||||
const created = await service().create({ name: 'Guarded PRD' });
|
|
||||||
const before = structuredClone(await service().get(created.id));
|
|
||||||
|
|
||||||
const result = await service().exportMarkdown({ id: created.id });
|
|
||||||
await writeFile(
|
|
||||||
result.filePath,
|
|
||||||
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const after = await service().get(created.id);
|
|
||||||
expect(after).toEqual(before);
|
|
||||||
expect(after.version).toBe(1);
|
|
||||||
expect(after.title).toBe(before.title);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never parses Markdown files that sit in the store directory', async () => {
|
|
||||||
const created = await service().create({ name: 'Decoy Guard' });
|
|
||||||
|
|
||||||
// A decoy .md file with invalid YAML must be invisible to the store.
|
|
||||||
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
|
|
||||||
// And a decoy .yaml-named Markdown body must not silently validate either.
|
|
||||||
await service().exportMarkdown({ id: created.id });
|
|
||||||
|
|
||||||
const listed = await service().list();
|
|
||||||
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
|
|
||||||
await expect(service().get(created.id)).resolves.toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Import: validated, conflict-aware, never silently merging ─────────────────
|
|
||||||
|
|
||||||
describe('PrdService importDocument', () => {
|
|
||||||
it('creates a valid import through the service, as draft — validity is not approval', async () => {
|
|
||||||
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
|
|
||||||
|
|
||||||
const result = await service().importDocument({ filePath });
|
|
||||||
|
|
||||||
expect(result.kind).toBe('created');
|
|
||||||
expect(result.document.id).toBe('imported-prd-20260101-000000');
|
|
||||||
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
|
|
||||||
expect(result.document.version).toBe(1);
|
|
||||||
|
|
||||||
const persisted = await service().get('imported-prd-20260101-000000');
|
|
||||||
expect(persisted.status).toBe('draft');
|
|
||||||
|
|
||||||
const files = await readdir(storeDir());
|
|
||||||
expect(files).toContain('imported-prd-20260101-000000.yaml');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports identical content as a no-op without writing', async () => {
|
|
||||||
const created = await service().create({ name: 'Existing PRD' });
|
|
||||||
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
||||||
|
|
||||||
const filePath = await writeImportFile(importFixture({ ...created }));
|
|
||||||
const result = await service().importDocument({ filePath });
|
|
||||||
|
|
||||||
expect(result.kind).toBe('identical');
|
|
||||||
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
||||||
expect(after).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
|
|
||||||
const existing = await service().create({ name: 'Authority PRD' });
|
|
||||||
await service().linkMission({
|
|
||||||
prdId: existing.id,
|
|
||||||
missionId: 'mission-keep',
|
|
||||||
missionVersion: 'v1',
|
|
||||||
requirementIds: ['FR-0'],
|
|
||||||
});
|
|
||||||
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
|
||||||
|
|
||||||
const divergent = importFixture({
|
|
||||||
...existing,
|
|
||||||
title: 'Divergent Title',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'introduction',
|
|
||||||
title: 'Introduction',
|
|
||||||
fields: { context: 'changed', objective: '' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const filePath = await writeImportFile(divergent);
|
|
||||||
|
|
||||||
const attempt = service().importDocument({ filePath });
|
|
||||||
let caught: unknown;
|
|
||||||
try {
|
|
||||||
await attempt;
|
|
||||||
} catch (error) {
|
|
||||||
caught = error;
|
|
||||||
}
|
|
||||||
expect(caught).toBeInstanceOf(PrdImportConflictError);
|
|
||||||
|
|
||||||
const error = caught as PrdImportConflictError;
|
|
||||||
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
|
|
||||||
expect(error.existing.id).toBe(existing.id);
|
|
||||||
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
|
|
||||||
expect(error.proposal.status).toBe('draft');
|
|
||||||
|
|
||||||
// Original authority content untouched on disk.
|
|
||||||
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
|
||||||
expect(afterBytes).toBe(beforeBytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
|
|
||||||
const existing = await service().create({ name: 'Successor Base' });
|
|
||||||
await service().linkMission({
|
|
||||||
prdId: existing.id,
|
|
||||||
missionId: 'mission-keep',
|
|
||||||
missionVersion: 'v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const divergent = importFixture({
|
|
||||||
...existing,
|
|
||||||
title: 'Accepted Successor Title',
|
|
||||||
});
|
|
||||||
const filePath = await writeImportFile(divergent);
|
|
||||||
|
|
||||||
const successor = await service().acceptSuccessor({ filePath });
|
|
||||||
expect(successor.id).toBe(existing.id);
|
|
||||||
expect(successor.version).toBe(existing.version + 1);
|
|
||||||
expect(successor.title).toBe('Accepted Successor Title');
|
|
||||||
expect(successor.status).toBe('draft');
|
|
||||||
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
|
|
||||||
|
|
||||||
// Persisted for a fresh reader.
|
|
||||||
const fresh = await service().get(existing.id);
|
|
||||||
expect(fresh.version).toBe(2);
|
|
||||||
expect(fresh.title).toBe('Accepted Successor Title');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
|
|
||||||
const cases: Array<{ name: string; body: string }> = [
|
|
||||||
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
|
|
||||||
{
|
|
||||||
name: 'bad-status.yaml',
|
|
||||||
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'bad-version.yaml',
|
|
||||||
body: yaml.dump(importFixture({ version: 0 })),
|
|
||||||
},
|
|
||||||
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const fixture of cases) {
|
|
||||||
const filePath = path.join(projectDir, fixture.name);
|
|
||||||
await writeFile(filePath, fixture.body, 'utf8');
|
|
||||||
|
|
||||||
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
|
|
||||||
PrdImportInvalidError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing was created: the authority store does not even exist yet.
|
|
||||||
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
|
|
||||||
const filePath = await writeImportFile(importFixture());
|
|
||||||
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── No second writer: no code path reads exported Markdown back into authority ─
|
|
||||||
|
|
||||||
describe('no-second-writer invariant (source-level)', () => {
|
|
||||||
// Resolve the package source dir whether vitest runs from the package root
|
|
||||||
// (turbo/pnpm test) or from the worktree root.
|
|
||||||
function resolveSrcDir(): string {
|
|
||||||
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
|
|
||||||
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcDir = resolveSrcDir();
|
|
||||||
const sourceFiles = [
|
|
||||||
'cli.ts',
|
|
||||||
'index.ts',
|
|
||||||
'prd.ts',
|
|
||||||
'service.ts',
|
|
||||||
'templates.ts',
|
|
||||||
'types.ts',
|
|
||||||
'wizard.ts',
|
|
||||||
];
|
|
||||||
|
|
||||||
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
|
|
||||||
for (const file of sourceFiles) {
|
|
||||||
const text = await readFile(path.join(srcDir, file), 'utf8');
|
|
||||||
const readLines = text
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
|
|
||||||
|
|
||||||
for (const line of readLines) {
|
|
||||||
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the mosaic prdy/mission adapters never read a .md file', async () => {
|
|
||||||
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
|
|
||||||
for (const file of ['prdy.ts', 'mission.ts']) {
|
|
||||||
const text = await readFile(path.join(adapterDir, file), 'utf8');
|
|
||||||
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import yaml from 'js-yaml';
|
|
||||||
|
|
||||||
import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
|
|
||||||
import type {
|
|
||||||
PrdCreateInput,
|
|
||||||
PrdDocument,
|
|
||||||
PrdExportInput,
|
|
||||||
PrdExportResult,
|
|
||||||
PrdImportInput,
|
|
||||||
PrdImportResult,
|
|
||||||
PrdLinkMissionInput,
|
|
||||||
PrdMissionLinkage,
|
|
||||||
PrdPlanForMissionInput,
|
|
||||||
PrdServiceOptions,
|
|
||||||
PrdUpdateInput,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PrdService is the SINGLE authority surface for PRD documents.
|
|
||||||
*
|
|
||||||
* Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
|
|
||||||
* through this service; the YAML store under `docs/prdy/` is the authority and
|
|
||||||
* exported Markdown is a generated view that no code path reads back.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Typed errors ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class PrdError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
readonly code: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'PrdError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrdNotFoundError extends PrdError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message, 'PRD_NOT_FOUND');
|
|
||||||
this.name = 'PrdNotFoundError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PrdUpdateError extends PrdError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message, 'PRD_UPDATE_INVALID');
|
|
||||||
this.name = 'PrdUpdateError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Structural refusal: the import payload failed schema validation. Nothing is written. */
|
|
||||||
export class PrdImportInvalidError extends PrdError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
readonly issues?: string,
|
|
||||||
) {
|
|
||||||
super(message, 'PRD_IMPORT_INVALID');
|
|
||||||
this.name = 'PrdImportInvalidError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conflict refusal: an existing PRD shares the imported id but the content
|
|
||||||
* diverges. Carries a PROPOSED successor (existing version + 1) that is only
|
|
||||||
* persisted via an explicit {@link PrdService.acceptSuccessor} call — import
|
|
||||||
* never overwrites and never merges.
|
|
||||||
*/
|
|
||||||
export class PrdImportConflictError extends PrdError {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
readonly existing: PrdDocument,
|
|
||||||
readonly proposal: PrdDocument,
|
|
||||||
) {
|
|
||||||
super(message, 'PRD_IMPORT_CONFLICT');
|
|
||||||
this.name = 'PrdImportConflictError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Service ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** The generated-view label carried by every Markdown export. */
|
|
||||||
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';
|
|
||||||
|
|
||||||
export class PrdService {
|
|
||||||
private readonly projectPath: string;
|
|
||||||
|
|
||||||
constructor(options: PrdServiceOptions) {
|
|
||||||
this.projectPath = options.projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a new PRD (version 1, draft) in the authority store. */
|
|
||||||
async create(input: PrdCreateInput): Promise<PrdDocument> {
|
|
||||||
return createPrd({
|
|
||||||
name: input.name,
|
|
||||||
projectPath: this.projectPath,
|
|
||||||
template: input.template,
|
|
||||||
interactive: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a PRD by id, or the most recently updated one. */
|
|
||||||
async get(id?: string): Promise<PrdDocument> {
|
|
||||||
const documents = await listPrds(this.projectPath);
|
|
||||||
|
|
||||||
if (id === undefined) {
|
|
||||||
const latest = documents[0];
|
|
||||||
if (latest === undefined) {
|
|
||||||
throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = documents.find((doc) => doc.id === id);
|
|
||||||
if (match === undefined) {
|
|
||||||
throw new PrdNotFoundError(`PRD id not found: ${id}`);
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List all PRDs in the authority store (most recently updated first). */
|
|
||||||
async list(): Promise<PrdDocument[]> {
|
|
||||||
return listPrds(this.projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply section field patches and bump the content version.
|
|
||||||
* Linkage entries are preserved; linkage writes do NOT bump the version.
|
|
||||||
*/
|
|
||||||
async update(input: PrdUpdateInput): Promise<PrdDocument> {
|
|
||||||
const doc = await this.get(input.id);
|
|
||||||
|
|
||||||
for (const patch of input.sections) {
|
|
||||||
const section = doc.sections.find((candidate) => candidate.id === patch.id);
|
|
||||||
if (section === undefined) {
|
|
||||||
throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
|
|
||||||
}
|
|
||||||
for (const [field, value] of Object.entries(patch.fields)) {
|
|
||||||
if (!(field in section.fields)) {
|
|
||||||
throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
|
|
||||||
}
|
|
||||||
section.fields[field] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.version += 1;
|
|
||||||
doc.updatedAt = new Date().toISOString();
|
|
||||||
await savePrd(doc);
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record (or refresh) a mission ↔ PRD linkage on the PRD document.
|
|
||||||
* Persisted in the YAML authority, so it survives restarts.
|
|
||||||
*/
|
|
||||||
async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
|
|
||||||
const doc = await this.get(input.prdId);
|
|
||||||
return this.applyLinkage(doc, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read back the mission linkages recorded on a PRD. */
|
|
||||||
async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
|
|
||||||
const doc = await this.get(prdId);
|
|
||||||
return doc.missions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mission planning path: create a PRD for a mission AND persist the
|
|
||||||
* mission↔PRD linkage in a single authority write.
|
|
||||||
*/
|
|
||||||
async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
|
|
||||||
const doc = await this.create({ name: input.name, template: input.template });
|
|
||||||
return this.applyLinkage(doc, {
|
|
||||||
prdId: doc.id,
|
|
||||||
missionId: input.missionId,
|
|
||||||
missionVersion: input.missionVersion,
|
|
||||||
requirementIds: input.requirementIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the PRD to a Markdown GENERATED VIEW.
|
|
||||||
*
|
|
||||||
* The output carries source identity (PRD id + version + generated-view
|
|
||||||
* label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
|
|
||||||
* the authority store only loads `.yaml`/`.yml` files, and no code path in
|
|
||||||
* this package parses the exported Markdown.
|
|
||||||
*/
|
|
||||||
async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
|
|
||||||
const doc = await this.get(input?.id);
|
|
||||||
const content = renderMarkdown(doc);
|
|
||||||
const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
||||||
await fs.writeFile(filePath, content, 'utf8');
|
|
||||||
return { filePath, content };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import a YAML PRD document.
|
|
||||||
*
|
|
||||||
* Structural validation (zod) happens BEFORE anything is proposed or
|
|
||||||
* written. A structurally-valid import is persisted as `draft` — validity is
|
|
||||||
* NOT approval. If an existing PRD shares the id with divergent content, a
|
|
||||||
* typed {@link PrdImportConflictError} is thrown carrying a proposed
|
|
||||||
* successor; the original authority document is left byte-identical on disk.
|
|
||||||
*/
|
|
||||||
async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
|
|
||||||
const incoming = await this.readImportFile(input.filePath);
|
|
||||||
|
|
||||||
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
|
|
||||||
if (existing === undefined) {
|
|
||||||
const document = this.buildImportedDocument(incoming);
|
|
||||||
await savePrd(document);
|
|
||||||
return { kind: 'created', document };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canonicalCore(existing) === canonicalCore(incoming)) {
|
|
||||||
return { kind: 'identical', document: existing };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new PrdImportConflictError(
|
|
||||||
`PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
|
|
||||||
`Proposed successor: version ${existing.version + 1} (draft). ` +
|
|
||||||
`Accept explicitly with acceptSuccessor().`,
|
|
||||||
existing,
|
|
||||||
this.buildSuccessor(existing, incoming),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicitly accept a conflicted import as a successor version of the
|
|
||||||
* existing PRD. Re-validates the source file before writing; the successor
|
|
||||||
* is persisted with status `draft` (acceptance of the import is not approval
|
|
||||||
* of the PRD) and the existing mission linkages are carried forward.
|
|
||||||
*/
|
|
||||||
async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
|
|
||||||
const incoming = await this.readImportFile(input.filePath);
|
|
||||||
|
|
||||||
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
|
|
||||||
if (existing === undefined) {
|
|
||||||
throw new PrdNotFoundError(
|
|
||||||
`No existing PRD with id "${incoming.id}" — use importDocument to create it`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const successor = this.buildSuccessor(existing, incoming);
|
|
||||||
await savePrd(successor);
|
|
||||||
return successor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── internals ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
|
|
||||||
const entry: PrdMissionLinkage = {
|
|
||||||
missionId: input.missionId,
|
|
||||||
missionVersion: input.missionVersion,
|
|
||||||
prdVersion: doc.version,
|
|
||||||
requirementIds: input.requirementIds ?? [],
|
|
||||||
linkedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// One entry per mission: refresh in place if the mission is already linked.
|
|
||||||
const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
|
|
||||||
if (index === -1) {
|
|
||||||
doc.missions.push(entry);
|
|
||||||
} else {
|
|
||||||
doc.missions[index] = entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linkage is mission-side metadata, not a content revision: bump the
|
|
||||||
// timestamp only so ids/versions stay stable for consumers.
|
|
||||||
doc.updatedAt = new Date().toISOString();
|
|
||||||
await savePrd(doc);
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readImportFile(filePath: string): Promise<PrdDocument> {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await fs.readFile(filePath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = yaml.load(raw);
|
|
||||||
} catch (error) {
|
|
||||||
throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return parsePrdDocument(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
throw new PrdImportInvalidError(
|
|
||||||
`Import file failed PRD schema validation: ${filePath}`,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildImportedDocument(incoming: PrdDocument): PrdDocument {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
...incoming,
|
|
||||||
// The import lands in THIS project's authority store.
|
|
||||||
projectPath: this.projectPath,
|
|
||||||
// A structurally-valid import is not thereby approved.
|
|
||||||
status: 'draft',
|
|
||||||
version: 1,
|
|
||||||
missions: [],
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
|
|
||||||
return {
|
|
||||||
...incoming,
|
|
||||||
id: existing.id,
|
|
||||||
projectPath: existing.projectPath,
|
|
||||||
status: 'draft',
|
|
||||||
version: existing.version + 1,
|
|
||||||
missions: existing.missions,
|
|
||||||
createdAt: existing.createdAt,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown rendering (generated view) ───────────────────────────────────────
|
|
||||||
|
|
||||||
function canonicalCore(doc: PrdDocument): string {
|
|
||||||
return JSON.stringify([doc.title, doc.template, doc.sections]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarkdown(doc: PrdDocument): string {
|
|
||||||
const lines: string[] = [
|
|
||||||
'<!--',
|
|
||||||
`${PRD_GENERATED_VIEW_LABEL}`,
|
|
||||||
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
|
|
||||||
`prd-id: ${doc.id}`,
|
|
||||||
`prd-version: ${doc.version}`,
|
|
||||||
`generated-at: ${new Date().toISOString()}`,
|
|
||||||
'-->',
|
|
||||||
'',
|
|
||||||
`# ${doc.title}`,
|
|
||||||
'',
|
|
||||||
`**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (doc.missions.length > 0) {
|
|
||||||
lines.push('## Mission Linkage', '');
|
|
||||||
for (const mission of doc.missions) {
|
|
||||||
const requirements =
|
|
||||||
mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
|
|
||||||
lines.push(
|
|
||||||
`- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
|
|
||||||
` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const section of doc.sections) {
|
|
||||||
lines.push(`## ${section.title}`, '');
|
|
||||||
for (const [field, value] of Object.entries(section.fields)) {
|
|
||||||
lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -19,31 +19,13 @@ export interface PrdSection {
|
|||||||
fields: Record<string, string>;
|
fields: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mission ↔ PRD linkage recorded on the PRD document (the YAML authority).
|
|
||||||
*
|
|
||||||
* `missionVersion` is the mission-side revision marker available to the CLI
|
|
||||||
* (the gateway exposes `updatedAt` for missions — there is no numeric mission
|
|
||||||
* version yet). `prdVersion` snapshots the PRD content version at link time.
|
|
||||||
*/
|
|
||||||
export interface PrdMissionLinkage {
|
|
||||||
missionId: string;
|
|
||||||
missionVersion: string;
|
|
||||||
prdVersion: number;
|
|
||||||
requirementIds: string[];
|
|
||||||
linkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdDocument {
|
export interface PrdDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: PrdStatus;
|
status: PrdStatus;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
template: string;
|
template: string;
|
||||||
/** Content revision counter. Bumped by updates and accepted imports. */
|
|
||||||
version: number;
|
|
||||||
sections: PrdSection[];
|
sections: PrdSection[];
|
||||||
missions: PrdMissionLinkage[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -54,60 +36,3 @@ export interface CreatePrdOptions {
|
|||||||
template?: string;
|
template?: string;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PrdService surface (single authority entry point) ─────────────────────────
|
|
||||||
|
|
||||||
export interface PrdServiceOptions {
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdCreateInput {
|
|
||||||
name: string;
|
|
||||||
template?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdSectionPatch {
|
|
||||||
id: string;
|
|
||||||
fields: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdUpdateInput {
|
|
||||||
/** Defaults to the most recently updated PRD. */
|
|
||||||
id?: string;
|
|
||||||
sections: PrdSectionPatch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdLinkMissionInput {
|
|
||||||
/** Defaults to the most recently updated PRD. */
|
|
||||||
prdId?: string;
|
|
||||||
missionId: string;
|
|
||||||
missionVersion: string;
|
|
||||||
requirementIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdPlanForMissionInput extends PrdLinkMissionInput {
|
|
||||||
name: string;
|
|
||||||
template?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdExportInput {
|
|
||||||
/** Defaults to the most recently updated PRD. */
|
|
||||||
id?: string;
|
|
||||||
/** Override the generated-view output path. */
|
|
||||||
outPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrdExportResult {
|
|
||||||
filePath: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discriminated result of a non-conflicting import. */
|
|
||||||
export type PrdImportResult =
|
|
||||||
| { kind: 'created'; document: PrdDocument }
|
|
||||||
| { kind: 'identical'; document: PrdDocument };
|
|
||||||
|
|
||||||
export interface PrdImportInput {
|
|
||||||
/** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */
|
|
||||||
filePath: string;
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-37
@@ -2,8 +2,8 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
|
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
|
||||||
|
|
||||||
import { PrdService } from './service.js';
|
import { createPrd, savePrd } from './prd.js';
|
||||||
import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js';
|
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||||
|
|
||||||
interface WizardAnswers {
|
interface WizardAnswers {
|
||||||
goals: string;
|
goals: string;
|
||||||
@@ -11,41 +11,20 @@ interface WizardAnswers {
|
|||||||
milestones: string;
|
milestones: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
|
||||||
* Translate wizard answers into section patches using the same keyword
|
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
|
||||||
* matching the wizard always used (first section whose id contains the
|
|
||||||
* keyword, then first field whose name contains it, else first field).
|
|
||||||
*/
|
|
||||||
function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] {
|
|
||||||
const bySection = new Map<string, PrdSectionPatch>();
|
|
||||||
|
|
||||||
const add = (keyword: string, value: string): void => {
|
|
||||||
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
|
|
||||||
if (section === undefined) {
|
if (section === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldName =
|
const fieldName =
|
||||||
Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ??
|
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
|
||||||
Object.keys(section.fields)[0];
|
Object.keys(section.fields)[0];
|
||||||
|
|
||||||
if (fieldName === undefined || section.fields[fieldName] === value) {
|
if (fieldName !== undefined) {
|
||||||
return;
|
section.fields[fieldName] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = bySection.get(section.id);
|
|
||||||
if (existing === undefined) {
|
|
||||||
bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } });
|
|
||||||
} else {
|
|
||||||
existing.fields[fieldName] = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
add('goal', answers.goals);
|
|
||||||
add('constraint', answers.constraints);
|
|
||||||
add('milestone', answers.milestones);
|
|
||||||
|
|
||||||
return [...bySection.values()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptText(message: string, initialValue = ''): Promise<string> {
|
async function promptText(message: string, initialValue = ''): Promise<string> {
|
||||||
@@ -84,10 +63,15 @@ async function promptTemplate(template?: string): Promise<string> {
|
|||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
|
||||||
* Interactive PRD wizard. All writes go through PrdService — the wizard is a
|
updateSectionField(doc, 'goal', answers.goals);
|
||||||
* prompt layer, never a second writer path.
|
updateSectionField(doc, 'constraint', answers.constraints);
|
||||||
*/
|
updateSectionField(doc, 'milestone', answers.milestones);
|
||||||
|
|
||||||
|
doc.updatedAt = new Date().toISOString();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||||
intro('Mosaic PRD wizard');
|
intro('Mosaic PRD wizard');
|
||||||
|
|
||||||
@@ -98,15 +82,20 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocume
|
|||||||
const constraints = await promptText('Key constraints');
|
const constraints = await promptText('Key constraints');
|
||||||
const milestones = await promptText('Planned milestones');
|
const milestones = await promptText('Planned milestones');
|
||||||
|
|
||||||
const service = new PrdService({ projectPath: options.projectPath });
|
const doc = await createPrd({
|
||||||
const doc = await service.create({
|
...options,
|
||||||
name,
|
name,
|
||||||
template,
|
template,
|
||||||
|
interactive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const patches = buildWizardPatches(doc, { goals, constraints, milestones });
|
const updated = applyWizardAnswers(doc, {
|
||||||
const updated =
|
goals,
|
||||||
patches.length > 0 ? await service.update({ id: doc.id, sections: patches }) : doc;
|
constraints,
|
||||||
|
milestones,
|
||||||
|
});
|
||||||
|
|
||||||
|
await savePrd(updated);
|
||||||
|
|
||||||
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
|
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Scratchpad — RI-4-001 One transitional PRD authority (RI-N3, #1275)
|
|
||||||
|
|
||||||
- Objective: single PrdService authority in `@mosaicstack/prdy`; `mosaic prdy` and
|
|
||||||
`mission --plan` become thin adapters; mission↔PRD linkage persisted on disk;
|
|
||||||
Markdown export is a labeled generated view (never read back); import is
|
|
||||||
validated/conflict-aware with typed refusals.
|
|
||||||
- Budget: ~35K tokens (card cap). Baselines: prdy build/lint rc=0, 0 tests;
|
|
||||||
mosaic build rc=0 (after root turbo build), lint rc=0, 1548 tests pass;
|
|
||||||
root build rc=0.
|
|
||||||
- Plan: (1) extend store schema (version, missions linkage) (2) PrdService +
|
|
||||||
typed errors (3) wizard/cli route through service (4) mosaic adapters
|
|
||||||
(5) contract specs both packages (6) gates (7) sabotage control (8) report
|
|
||||||
to /var/tmp/ri-050/ri-4-001-report.md.
|
|
||||||
- Decisions:
|
|
||||||
- Linkage lives ON the PRD document (`missions` array) — one authority file,
|
|
||||||
survives restart, no sidecar sync problems.
|
|
||||||
- `version` = content revision of sections/status (bumped by update/import
|
|
||||||
accept). Linkage writes bump `updatedAt` only, so ids/versions stay stable
|
|
||||||
for the card's "stable ids/versions" contract.
|
|
||||||
- Mission version marker = `mission.updatedAt` (gateway MissionInfo has no
|
|
||||||
numeric version field).
|
|
||||||
- Import reads YAML documents only — never the exported Markdown (keeps the
|
|
||||||
"no code path reads exported Markdown" invariant).
|
|
||||||
- Import of an existing id with identical core content → `identical` no-op;
|
|
||||||
divergent → typed `PrdImportConflictError` carrying proposed successor
|
|
||||||
(existing.version + 1, status draft, linkages preserved). Original bytes
|
|
||||||
untouched until explicit `acceptSuccessor`.
|
|
||||||
- `requirementIds` default `[]` at the mission command (no requirement
|
|
||||||
selection UI yet) — service accepts ids when a caller has them.
|
|
||||||
- Progress log:
|
|
||||||
- [16:35] baselines captured (prdy 0 tests; mosaic 1548 after root build; root build rc=0)
|
|
||||||
- [16:38] store schema v2 + PrdService + wizard/cli rerouted; prdy build/lint green
|
|
||||||
- [16:40] mosaic adapters done; prdy spec 20/20 (found+fixed: import project-path leak, empty-store typed error, YAML timestamp coercion)
|
|
||||||
- [16:44] mosaic specs 9/9 (fixed commander from:'user' argv, vi.mock hoisting, restoreAllMocks wiping factory mocks)
|
|
||||||
- [16:45] all gates green; 4 commits (e291bfb, 2c5d208, a23826c, 540d6f1)
|
|
||||||
- [16:46] sabotage: linkage write removed → prdy 3 fail / mosaic 2 fail, 1548/1548 pre-existing pass; restored byte-identically; re-green 20/20 + 1557/1557
|
|
||||||
- [16:47] report written to /var/tmp/ri-050/ri-4-001-report.md — card complete
|
|
||||||
Reference in New Issue
Block a user