1052 lines
50 KiB
Diff
1052 lines
50 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
||
index 404dd9391..cd7f145c9 100644
|
||
--- a/docs/TOOLS.md
|
||
+++ b/docs/TOOLS.md
|
||
@@ -118,6 +118,30 @@ succeeded run → clean target tree → path allowlist → syntax gates →
|
||
apply → policy suites → attribution commit. Any failure reverts.
|
||
Push is never automatic.
|
||
|
||
+## Conductor (landing approved work)
|
||
+
|
||
+```bash
|
||
+scripts/land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT]
|
||
+```
|
||
+
|
||
+Turns a merge decision the human resolved into one gated local commit on
|
||
+`refactor` (#1610). It reads `<dataRoot>/bus/bus.sqlite` read-only through
|
||
+`scripts/land-work/read.mjs` and checks that the human picked the raise's
|
||
+approval option, that the question names one `work/vikunja-<p>-<t>`, and
|
||
+that the task's latest `review.verdict` approves a commit C that the work
|
||
+ref in the working repository still points at. It then applies
|
||
+`git diff $(merge-base refactor C) C` in a detached worktree, checks the
|
||
+patch-id, runs every package suite except `queue` and every
|
||
+`scripts/test-*.sh`, and commits. The subject is the approval option's
|
||
+text unless `--subject` overrides it. The tool prints the line for the PM
|
||
+and doesn't send it. `--dry-run` runs every check and the gate and writes
|
||
+no ref. A refusal names its reason and exits 2; the reasons are listed at
|
||
+the top of the script. The worktree, its `node_modules` link and its Docker
|
||
+network are removed on every exit. The gate log directory stays and the
|
||
+tool names it. Keep `TMPDIR` short: the gate inherits it, the cli suite
|
||
+puts unix sockets under it, and a 46-byte `TMPDIR` failed 8 of those tests.
|
||
+Push is a separate act.
|
||
+
|
||
## Maintenance
|
||
|
||
| Command | Purpose | Notes |
|
||
@@ -128,6 +152,7 @@ Push is never automatic.
|
||
| `scripts/test-release.sh` | Release selftests | 14 cases |
|
||
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
|
||
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
|
||
+| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 78 cases |
|
||
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
|
||
|
||
## Seat launch with registration (`scripts/mosaic`)
|
||
diff --git a/docs/plans/CONDUCTOR.md b/docs/plans/CONDUCTOR.md
|
||
index 0d5deff5c..bab3525a6 100644
|
||
--- a/docs/plans/CONDUCTOR.md
|
||
+++ b/docs/plans/CONDUCTOR.md
|
||
@@ -43,6 +43,33 @@ tracked, versioned files whose changes arrive as reviewed commits.
|
||
- Verification is mechanical: suites + `node --check` / `bash -n` gates.
|
||
- Recursive decomposition = "fail → smaller task", never "hope."
|
||
|
||
+## Landing approved work
|
||
+
|
||
+A stack coder pushes `work/vikunja-<p>-<t>` to the working repository, the
|
||
+reviewer approves a commit on it, and the PM raises a `git.merge.protected`
|
||
+decision for the human. After the human resolves it with the merge option,
|
||
+the conductor runs:
|
||
+
|
||
+```bash
|
||
+scripts/land-work.sh <decision-id> --dry-run
|
||
+scripts/land-work.sh <decision-id>
|
||
+```
|
||
+
|
||
+The tool binds the commit to what was reviewed and decided. It reads the
|
||
+decision, its raise and the latest verdict from the bus database
|
||
+read-only, requires the work ref to be at the approved commit, squashes
|
||
+the reviewed diff onto `refactor` in a detached worktree, checks the
|
||
+patch-id, runs the full gate and commits once, locally. It refuses work
|
||
+that adds a file at the repository root, in a new top-level directory, or
|
||
+under `src/` or `v1/`. Each
|
||
+refusal names its reason (`docs/TOOLS.md`). Don't work around one; fix the
|
||
+cause or go back to the PM.
|
||
+
|
||
+The conductor then reviews the commit, pushes it as a separate act and
|
||
+sends the PM the line the tool printed. Only `refactor` lands this way.
|
||
+Merges to `next` or `main`, re-pins and restarts keep their own authority,
|
||
+and no stack role runs this tool.
|
||
+
|
||
## Worker task template
|
||
|
||
```json
|
||
diff --git a/scripts/land-work.sh b/scripts/land-work.sh
|
||
new file mode 100644
|
||
index 000000000..8c8ff6e80
|
||
--- /dev/null
|
||
+++ b/scripts/land-work.sh
|
||
@@ -0,0 +1,280 @@
|
||
+#!/usr/bin/env bash
|
||
+# Land approved work: turn a resolved merge decision into one gated local
|
||
+# commit on the base branch (row 116, #1610).
|
||
+#
|
||
+# Usage: scripts/land-work.sh <decision-id> [--base refactor] [--dry-run]
|
||
+# [--subject TEXT] [--attribution LINE] [--business ID]
|
||
+# [--project ID]
|
||
+#
|
||
+# Checks, in order; each refusal names its reason on stderr and exits 2:
|
||
+# bus-schema the bus database's schema isn't the Store's
|
||
+# decision-not-found no decision with that id and one raise in the business
|
||
+# not-a-merge the decision's action isn't git.merge.protected
|
||
+# not-resolved the decision is open, withdrawn or expired
|
||
+# not-human not resolved by the business's human through the CLI
|
||
+# not-merge-option the choice isn't the raise's approval option
|
||
+# work-ref the question doesn't name exactly one
|
||
+# work/vikunja-<p>-<t>, or a task_ref disagrees with it
|
||
+# target-branch the raise's target or --base isn't refactor (next and
|
||
+# main always refuse)
|
||
+# superseded a later merge decision on the same work ref or task,
|
||
+# or one that supersedes this one
|
||
+# no-verdict no review.verdict for the task
|
||
+# not-approved the latest verdict isn't approve on a full commit id
|
||
+# ref-moved the work ref in the working repository isn't at C
|
||
+# root-file the reviewed diff adds a file at the repository root
|
||
+# checkout-dirty the base is checked out with changes on a reviewed path
|
||
+# base-conflict the reviewed diff doesn't apply cleanly to the base
|
||
+# patch-drift the applied diff's patch-id isn't the reviewed one
|
||
+# gate-failed a gate step failed; the log is named
|
||
+# base-moved the base branch moved while the gate ran
|
||
+#
|
||
+# Reads: <dataRoot>/bus/bus.sqlite, opened readOnly by
|
||
+# scripts/land-work/read.mjs, which holds every query and runs SELECTs only.
|
||
+# The live bus can stay up: it runs in WAL mode and a reader doesn't block it.
|
||
+#
|
||
+# Writes, on a pass and without --dry-run: one commit on refs/heads/<base>,
|
||
+# made by `git commit` in a detached worktree, then moved onto the base with
|
||
+# a compare-and-swap update-ref. If the base is checked out, the reviewed
|
||
+# paths there are brought up to the new commit. Nothing is pushed.
|
||
+#
|
||
+# The worktree goes under $TMPDIR and is removed on every exit. The gate logs
|
||
+# stay in $TMPDIR/land-work-logs.* and the tool names the directory.
|
||
+set -euo pipefail
|
||
+
|
||
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
+REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
+
|
||
+usage() { echo "usage: land-work.sh <decision-id> [--base refactor] [--dry-run] [--subject TEXT] [--attribution LINE] [--business ID] [--project ID]" >&2; exit 4; }
|
||
+fail() { echo "land-work: $1" >&2; exit "${2:-1}"; }
|
||
+refuse() { echo "land-work: refused: $1${2:+: $2}" >&2; exit 2; }
|
||
+
|
||
+DECISION=""; BASE="refactor"; DRY_RUN="no"; SUBJECT=""; ATTRIBUTION=""
|
||
+BUSINESS=""; PROJECT=""
|
||
+while [ $# -gt 0 ]; do
|
||
+ case "$1" in
|
||
+ --base) [ $# -ge 2 ] || usage; BASE="$2"; shift 2 ;;
|
||
+ --dry-run) DRY_RUN="yes"; shift ;;
|
||
+ --subject) [ $# -ge 2 ] || usage; SUBJECT="$2"; shift 2 ;;
|
||
+ --attribution) [ $# -ge 2 ] || usage; ATTRIBUTION="$2"; shift 2 ;;
|
||
+ --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
|
||
+ --project) [ $# -ge 2 ] || usage; PROJECT="$2"; shift 2 ;;
|
||
+ -*) usage ;;
|
||
+ *) [ -z "$DECISION" ] || usage; DECISION="$1"; shift ;;
|
||
+ esac
|
||
+done
|
||
+[ -n "$DECISION" ] || usage
|
||
+[[ "$DECISION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || fail "not a decision id: $DECISION" 4
|
||
+case "$SUBJECT$ATTRIBUTION" in *$'\n'*) fail "--subject and --attribution take one line" 4 ;; esac
|
||
+# Exactly refactor (#1610): next and main always refuse, as does anything else.
|
||
+[ "$BASE" = "refactor" ] || refuse target-branch "--base must be refactor, not $BASE"
|
||
+
|
||
+# ---- temporary space: the worktree goes, the gate logs stay ----
|
||
+TMP_ROOT="${TMPDIR:-/tmp}"
|
||
+LOGS="$(mktemp -d "$TMP_ROOT/land-work-logs.XXXXXX")"
|
||
+SCRATCH="$(mktemp -d "$TMP_ROOT/land-work.XXXXXX")"
|
||
+# Lowercase digits and a dash: Compose names a network <dir>_default as is.
|
||
+WT_NAME="lw-$$-$(date -u +%s)"
|
||
+WT=""
|
||
+TARGET=""
|
||
+cleanup() {
|
||
+ local rc=$?
|
||
+ if [ -n "$WT" ]; then
|
||
+ [ -L "$WT/node_modules" ] && rm -f "$WT/node_modules"
|
||
+ git -C "$TARGET" worktree remove --force "$WT" >/dev/null 2>&1 || rm -rf "$WT"
|
||
+ git -C "$TARGET" worktree prune >/dev/null 2>&1 || true
|
||
+ fi
|
||
+ if command -v docker >/dev/null 2>&1 && docker network inspect "${WT_NAME}_default" >/dev/null 2>&1; then
|
||
+ docker network rm "${WT_NAME}_default" >/dev/null 2>&1 || echo "land-work: could not remove Docker network ${WT_NAME}_default" >&2
|
||
+ fi
|
||
+ rm -rf "$SCRATCH"
|
||
+ rmdir "$LOGS" 2>/dev/null || true
|
||
+ exit "$rc"
|
||
+}
|
||
+trap cleanup EXIT
|
||
+
|
||
+# ---- configuration: data root, business, project ----
|
||
+DATA_ROOT="$(node "$REPO/scripts/mosaic-config.mjs" validate | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).dataRoot))')" \
|
||
+ || fail "system config is missing or invalid" 3
|
||
+CONFIG_DIR="$(dirname "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||
+if [ -z "$BUSINESS" ]; then
|
||
+ shopt -s nullglob
|
||
+ files=("$CONFIG_DIR"/businesses/*.json)
|
||
+ shopt -u nullglob
|
||
+ [ "${#files[@]}" -eq 1 ] || fail "name the business with --business (${#files[@]} business files)" 4
|
||
+ BUSINESS="$(basename "${files[0]}" .json)"
|
||
+fi
|
||
+BUSINESS_FILE="$CONFIG_DIR/businesses/$BUSINESS.json"
|
||
+[ -f "$BUSINESS_FILE" ] || fail "no business file: $BUSINESS_FILE" 3
|
||
+
|
||
+# ---- the decision and the verdict, read-only from the bus ----
|
||
+READ_RC=0
|
||
+node "$SCRIPT_DIR/land-work/read.mjs" "$DATA_ROOT" "$BUSINESS_FILE" "$DECISION" "$BASE" > "$SCRATCH/read.txt" || READ_RC=$?
|
||
+value() { awk -F'\t' -v k="$1" '$1 == k { sub(/^[^\t]*\t/, ""); print; exit }' "$SCRATCH/read.txt"; }
|
||
+if [ "$READ_RC" -eq 2 ] && grep -q '^refuse' "$SCRATCH/read.txt"; then
|
||
+ line="$(value refuse)"
|
||
+ refuse "${line%% *}" "${line#* }"
|
||
+fi
|
||
+[ "$READ_RC" -eq 0 ] || fail "could not read the decision from the bus (exit $READ_RC)" "$READ_RC"
|
||
+TASK_REF="$(value task_ref)"
|
||
+WORK_REF="$(value work_ref)"
|
||
+C="$(value commit)"
|
||
+VERDICT="$(value verdict)"
|
||
+CHOICE="$(value choice)"
|
||
+RESOLVED_BY="$(value by)"
|
||
+RESOLVED_AT="$(value resolved_at)"
|
||
+[ -n "$SUBJECT" ] || SUBJECT="$(value subject)"
|
||
+[ -n "$SUBJECT" ] || fail "the decision gives no subject; pass --subject" 4
|
||
+[ -n "$PROJECT" ] || PROJECT="$(value project)"
|
||
+
|
||
+# ---- the project's repository and the working repository ----
|
||
+PROJECT_ROOT="$(node -e '
|
||
+const b = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||
+const ids = Object.keys(b.projects ?? {});
|
||
+const p = process.argv[2] || (ids.length === 1 ? ids[0] : "");
|
||
+if (!p || !b.projects?.[p]?.root) process.exit(3);
|
||
+console.log(`${p}\t${b.projects[p].root}`);
|
||
+' "$BUSINESS_FILE" "$PROJECT")" || fail "name the project with --project; the decision names none that $BUSINESS has" 4
|
||
+PROJECT="${PROJECT_ROOT%% *}"
|
||
+TARGET="${PROJECT_ROOT#* }"
|
||
+[ -d "$TARGET/.git" ] || [ -f "$TARGET/.git" ] || fail "project root is not a git checkout: $TARGET" 4
|
||
+WORKING="$DATA_ROOT/working/$BUSINESS/$PROJECT.git"
|
||
+[ -d "$WORKING" ] || fail "no working repository: $WORKING" 4
|
||
+
|
||
+# The working repository holds what sessions sent: no hooks, no fsmonitor,
|
||
+# file transport only, as packages/seat/src/working.mjs runs it.
|
||
+wgit() { git -c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.allow=never -c protocol.file.allow=always -C "$WORKING" "$@"; }
|
||
+# A diff in one fixed format, whatever the user's diff config says.
|
||
+pdiff() { git -c diff.noprefix=false -c diff.mnemonicPrefix=false -C "$1" diff --no-ext-diff --no-textconv --no-color --binary --no-renames "${@:2}"; }
|
||
+
|
||
+AT="$(wgit rev-parse --verify -q "refs/heads/$WORK_REF^{commit}" || true)"
|
||
+[ "$AT" = "$C" ] || refuse ref-moved "$WORK_REF is at ${AT:-nothing}, the verdict approved $C"
|
||
+
|
||
+OLD="$(git -C "$TARGET" rev-parse --verify -q "refs/heads/$BASE^{commit}")" || fail "no branch $BASE in $TARGET" 4
|
||
+# Objects only: no refspec destination and no FETCH_HEAD, so no ref moves.
|
||
+git -c protocol.allow=never -c protocol.file.allow=always -c transfer.fsckObjects=true \
|
||
+ -c maintenance.auto=false -c gc.auto=0 -C "$TARGET" \
|
||
+ fetch -q --no-tags --no-write-fetch-head "$WORKING" "refs/heads/$WORK_REF" \
|
||
+ || fail "could not fetch $WORK_REF from $WORKING"
|
||
+git -C "$TARGET" cat-file -e "$C^{commit}" 2>/dev/null || fail "the fetch did not bring $C"
|
||
+
|
||
+# ---- the reviewed diff ----
|
||
+MB="$(git -C "$TARGET" merge-base "$OLD" "$C")" || refuse base-conflict "$C shares no history with $BASE"
|
||
+pdiff "$TARGET" "$MB" "$C" > "$SCRATCH/reviewed.patch"
|
||
+[ -s "$SCRATCH/reviewed.patch" ] || refuse base-conflict "the reviewed diff $MB..$C is empty"
|
||
+REVIEWED_ID="$(git -C "$TARGET" patch-id --stable < "$SCRATCH/reviewed.patch" | cut -d' ' -f1)"
|
||
+
|
||
+# Invariant 1: nothing new at the root. A top-level file, a top-level
|
||
+# directory the base doesn't have, and the legacy src/ and archived v1/
|
||
+# trees all count as the root.
|
||
+mapfile -d '' -t ADDED < <(git -C "$TARGET" diff -z --no-renames --name-only --diff-filter=A "$MB" "$C")
|
||
+mapfile -d '' -t TOP < <(git -C "$TARGET" ls-tree -z --name-only "$OLD")
|
||
+ROOT_HITS=""
|
||
+for f in "${ADDED[@]}"; do
|
||
+ case "$f" in
|
||
+ */*) top="${f%%/*}" ;;
|
||
+ *) ROOT_HITS="$ROOT_HITS $f"; continue ;;
|
||
+ esac
|
||
+ known="no"
|
||
+ for t in "${TOP[@]}"; do [ "$t" = "$top" ] && known="yes" && break; done
|
||
+ if [ "$known" = "no" ] || [ "$top" = "src" ] || [ "$top" = "v1" ]; then ROOT_HITS="$ROOT_HITS $f"; fi
|
||
+done
|
||
+[ -z "$ROOT_HITS" ] || refuse root-file "the reviewed diff adds$ROOT_HITS"
|
||
+
|
||
+mapfile -d '' -t PATHS < <(git -C "$TARGET" diff -z --no-renames --name-only "$MB" "$C")
|
||
+# Paths go to git as they are, never as globs or pathspec magic.
|
||
+lgit() { git --literal-pathspecs -C "$@"; }
|
||
+
|
||
+# Where the base is checked out, the reviewed paths there must be clean: the
|
||
+# landing rewrites them to the new commit.
|
||
+CHECKOUT="$(git -C "$TARGET" worktree list --porcelain | awk -v b="branch refs/heads/$BASE" '/^worktree /{w=substr($0,10)} $0==b{print w; exit}')"
|
||
+checkout_clean() {
|
||
+ [ -n "$CHECKOUT" ] || return 0
|
||
+ local dirty=""
|
||
+ lgit "$CHECKOUT" diff --quiet HEAD -- "${PATHS[@]}" || dirty="changed"
|
||
+ lgit "$CHECKOUT" diff --cached --quiet HEAD -- "${PATHS[@]}" || dirty="staged"
|
||
+ for f in "${PATHS[@]}"; do
|
||
+ if ! git -C "$CHECKOUT" cat-file -e "HEAD:$f" 2>/dev/null && [ -e "$CHECKOUT/$f" ]; then dirty="untracked $f"; fi
|
||
+ done
|
||
+ [ -z "$dirty" ] || refuse checkout-dirty "$BASE is checked out at $CHECKOUT with a $dirty reviewed path"
|
||
+}
|
||
+checkout_clean
|
||
+
|
||
+# ---- the squash, in a detached worktree on the base ----
|
||
+WT="$SCRATCH/$WT_NAME"
|
||
+git -C "$TARGET" worktree add -q --detach "$WT" "$OLD" || fail "could not add a worktree at $WT"
|
||
+git -C "$WT" apply --index "$SCRATCH/reviewed.patch" 2> "$SCRATCH/apply.err" \
|
||
+ || refuse base-conflict "$(tail -n1 "$SCRATCH/apply.err")"
|
||
+APPLIED_ID="$(pdiff "$WT" --cached "$OLD" | git -C "$WT" patch-id --stable | cut -d' ' -f1)"
|
||
+[ "$APPLIED_ID" = "$REVIEWED_ID" ] || refuse patch-drift "applied ${APPLIED_ID:-none}, reviewed $REVIEWED_ID"
|
||
+
|
||
+# ---- the gate ----
|
||
+echo "land-work: gate logs in $LOGS" >&2
|
||
+# The full gate, as Sage's landing gate template runs it. There is no switch
|
||
+# to shorten or skip it.
|
||
+[ -d "$TARGET/node_modules" ] || fail "no node_modules in $TARGET for the gate" 4
|
||
+ln -s "$TARGET/node_modules" "$WT/node_modules"
|
||
+gate_step() { # name dir command
|
||
+ local rc=0
|
||
+ (cd "$WT/$2" && bash -c "set -o pipefail; $3") > "$LOGS/$1.log" 2>&1 || rc=$?
|
||
+ echo "$1 rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$LOGS/$1.log" | tr '\n' ' ')" | tee -a "$LOGS/gate.log" >&2
|
||
+ [ "$rc" -eq 0 ] || refuse gate-failed "$1, see $LOGS/$1.log"
|
||
+}
|
||
+# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself.
|
||
+for d in "$WT"/packages/*/; do
|
||
+ p="$(basename "$d")"
|
||
+ [ -d "$d/tests" ] || continue
|
||
+ [ "$p" = queue ] && continue
|
||
+ gate_step "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'
|
||
+done
|
||
+for s in "$WT"/scripts/test-*.sh; do
|
||
+ [ -f "$s" ] || continue
|
||
+ n="$(basename "$s" .sh)"
|
||
+ gate_step "script-${n#test-}" . "bash scripts/$n.sh"
|
||
+done
|
||
+echo "GATE_PASSED" | tee -a "$LOGS/gate.log" >&2
|
||
+
|
||
+# ---- the commit ----
|
||
+SHORT_ID="${REVIEWED_ID:0:12}"
|
||
+cat > "$SCRATCH/message" <<EOF
|
||
+$SUBJECT
|
||
+
|
||
+Squash of $WORK_REF ($TASK_REF) at $C onto $BASE $OLD,
|
||
+the reviewed diff $MB..$C. Resolved $CHOICE by $RESOLVED_BY via the CLI at $RESOLVED_AT.
|
||
+
|
||
+Decision: $DECISION
|
||
+Verdict: $VERDICT
|
||
+Work-ref: $WORK_REF
|
||
+Reviewed-commit: $C
|
||
+Base: $BASE $OLD
|
||
+Patch-id: $REVIEWED_ID
|
||
+Gate: full, every package suite and every scripts/test-*.sh
|
||
+${ATTRIBUTION:-Applied-under: scripts/land-work.sh (conductor)}
|
||
+EOF
|
||
+
|
||
+if [ "$DRY_RUN" = "yes" ]; then
|
||
+ TREE="$(git -C "$WT" write-tree)"
|
||
+ echo "land-work (dry-run): would commit tree $TREE on $BASE at $OLD, patch-id $SHORT_ID, with this message:"
|
||
+ sed 's/^/ /' "$SCRATCH/message"
|
||
+ echo "land-work (dry-run): no ref was written."
|
||
+ exit 0
|
||
+fi
|
||
+
|
||
+checkout_clean
|
||
+git -C "$WT" commit -q -F "$SCRATCH/message" || fail "git commit failed in the worktree"
|
||
+NEW="$(git -C "$WT" rev-parse HEAD)"
|
||
+git -C "$TARGET" update-ref -m "land-work: decision $DECISION" "refs/heads/$BASE" "$NEW" "$OLD" 2>/dev/null \
|
||
+ || refuse base-moved "$BASE is no longer at $OLD; nothing was written"
|
||
+if [ -n "$CHECKOUT" ]; then
|
||
+ # HEAD is now NEW there: take the reviewed paths' index entries and files from it.
|
||
+ lgit "$CHECKOUT" reset -q -- "${PATHS[@]}"
|
||
+ for f in "${PATHS[@]}"; do
|
||
+ if git -C "$CHECKOUT" cat-file -e "HEAD:$f" 2>/dev/null; then
|
||
+ lgit "$CHECKOUT" checkout -q -- "$f"
|
||
+ else
|
||
+ rm -f "$CHECKOUT/$f"
|
||
+ fi
|
||
+ done
|
||
+fi
|
||
+echo "land-work: committed $NEW on $BASE (parent $OLD), patch-id $SHORT_ID. NOT pushed - push remains an explicit act."
|
||
+echo "$WORK_REF ($TASK_REF) is merged to $BASE as ${NEW:0:9}; push pending."
|
||
diff --git a/scripts/land-work/fixture.mjs b/scripts/land-work/fixture.mjs
|
||
new file mode 100644
|
||
index 000000000..d7288a343
|
||
--- /dev/null
|
||
+++ b/scripts/land-work/fixture.mjs
|
||
@@ -0,0 +1,57 @@
|
||
+#!/usr/bin/env node
|
||
+// Test fixture for land-work (row 116, #1610): writes rows into a bus
|
||
+// database through the real Store, so the schema and its digest are the
|
||
+// Store's own and land-work's read check passes.
|
||
+//
|
||
+// node scripts/land-work/fixture.mjs <dataRoot> <rows.json>
|
||
+//
|
||
+// rows.json is an array of { "table": "decisions" | "decision_events" |
|
||
+// "events", ...columns }. Rows go in in array order, in one transaction.
|
||
+// Object values (body, options) are stored as JSON text. A missing `at` is
|
||
+// now, a missing events or decisions `id` is a fresh UUID, and decisions
|
||
+// default to the shape a PM's merge raise has. The data root is made if
|
||
+// missing. Tests only: the Store's writer lock refuses a database that
|
||
+// already has a writer.
|
||
+import { readFileSync, mkdirSync } from 'node:fs';
|
||
+import { randomUUID } from 'node:crypto';
|
||
+import { pathToFileURL } from 'node:url';
|
||
+import { Store } from '../../packages/bus/src/store.mjs';
|
||
+
|
||
+const TABLES = {
|
||
+ decisions: ['seq', 'id', 'at', 'business', 'project', 'raised_by_role', 'raised_by_run', 'class', 'action',
|
||
+ 'route_to', 'question', 'options', 'recommendation', 'task_ref', 'requirement_ref', 'blocking', 'supersedes'],
|
||
+ decision_events: ['seq', 'decision', 'at', 'op', 'by', 'choice', 'note', 'via'],
|
||
+ events: ['seq', 'id', 'at', 'business', 'kind', 'actor_role', 'actor_run', 'subject', 'corrects', 'body'],
|
||
+};
|
||
+const DEFAULTS = {
|
||
+ decisions: { raised_by_role: 'pm', raised_by_run: 'rfixture', class: 'gated', route_to: 'human',
|
||
+ recommendation: 'yes', blocking: 0 },
|
||
+ decision_events: {},
|
||
+ events: {},
|
||
+};
|
||
+
|
||
+export function writeRows(dataRoot, rows) {
|
||
+ mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||
+ const store = new Store(dataRoot);
|
||
+ try {
|
||
+ store.transaction(() => {
|
||
+ for (const row of rows) {
|
||
+ const columns = TABLES[row.table];
|
||
+ if (!columns) throw Error(`unknown table ${row.table}`);
|
||
+ const r = { ...DEFAULTS[row.table], ...row };
|
||
+ r.at ??= new Date().toISOString();
|
||
+ if (row.table !== 'decision_events') r.id ??= randomUUID();
|
||
+ const used = columns.filter((c) => r[c] !== undefined);
|
||
+ const values = used.map((c) => (r[c] !== null && typeof r[c] === 'object' ? JSON.stringify(r[c]) : r[c]));
|
||
+ store.run(`INSERT INTO ${row.table} (${used.join(',')}) VALUES (${used.map(() => '?').join(',')})`, ...values);
|
||
+ }
|
||
+ });
|
||
+ } finally {
|
||
+ store.close();
|
||
+ }
|
||
+}
|
||
+
|
||
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
+ const [dataRoot, file] = process.argv.slice(2);
|
||
+ writeRows(dataRoot, JSON.parse(readFileSync(file, 'utf8')));
|
||
+}
|
||
diff --git a/scripts/land-work/read.mjs b/scripts/land-work/read.mjs
|
||
new file mode 100644
|
||
index 000000000..495a069eb
|
||
--- /dev/null
|
||
+++ b/scripts/land-work/read.mjs
|
||
@@ -0,0 +1,224 @@
|
||
+#!/usr/bin/env node
|
||
+// land-work's read of the bus (row 116, #1610). Every query land-work runs
|
||
+// against bus.sqlite is in this file, and every one is a SELECT.
|
||
+//
|
||
+// node scripts/land-work/read.mjs <dataRoot> <business-file> <decision-id> <base>
|
||
+//
|
||
+// It opens <dataRoot>/bus/bus.sqlite with readOnly: true and checks the
|
||
+// schema the way the Store does before reading anything. Output is one
|
||
+// "key<TAB>value" line per fact land-work needs, or one
|
||
+// "refuse<TAB>reason<TAB>detail" line and exit 2. A missing or unsafe
|
||
+// database or business file exits 3.
|
||
+//
|
||
+// Kept in step with packages/bus/src/store.mjs, which this imports from:
|
||
+// the schema_version literal '3b' below is a copy of the Store's. A schema
|
||
+// bump changes it there, and until it changes here every read refuses
|
||
+// bus-schema.
|
||
+import { DatabaseSync } from 'node:sqlite';
|
||
+import { readFileSync, lstatSync } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { pathToFileURL } from 'node:url';
|
||
+import { createHash } from 'node:crypto';
|
||
+import { schemaDigest, SCHEMA_SHA256 } from '../../packages/bus/src/store.mjs';
|
||
+
|
||
+const SCHEMA_VERSION = '3b';
|
||
+const MERGE = 'git.merge.protected';
|
||
+const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/;
|
||
+const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g;
|
||
+const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||
+
|
||
+class Refusal extends Error {
|
||
+ constructor(reason, detail) {
|
||
+ super(detail);
|
||
+ this.reason = reason;
|
||
+ }
|
||
+}
|
||
+const refuse = (reason, detail) => {
|
||
+ throw new Refusal(reason, detail);
|
||
+};
|
||
+const json = (text) => {
|
||
+ try {
|
||
+ return JSON.parse(text);
|
||
+ } catch {
|
||
+ return null;
|
||
+ }
|
||
+};
|
||
+
|
||
+export function openBus(dataRoot) {
|
||
+ const path = join(dataRoot, 'bus', 'bus.sqlite');
|
||
+ let s;
|
||
+ try {
|
||
+ s = lstatSync(path);
|
||
+ } catch {
|
||
+ throw Object.assign(new Error(`no bus database at ${path}`), { exit: 3 });
|
||
+ }
|
||
+ if (s.isSymbolicLink() || !s.isFile() || s.uid !== process.getuid())
|
||
+ throw Object.assign(new Error(`unsafe bus database ${path}`), { exit: 3 });
|
||
+ const db = new DatabaseSync(path, { readOnly: true, timeout: 5000 });
|
||
+ try {
|
||
+ checkSchema(db);
|
||
+ } catch (e) {
|
||
+ db.close();
|
||
+ throw e;
|
||
+ }
|
||
+ return db;
|
||
+}
|
||
+
|
||
+// The Store's own open check (store.mjs), against a reference built from the
|
||
+// same schema.sql the import has already verified against SCHEMA_SHA256.
|
||
+export function checkSchema(db) {
|
||
+ const ddl = readFileSync(new URL('../../packages/bus/schema.sql', import.meta.url), 'utf8');
|
||
+ if (createHash('sha256').update(ddl).digest('hex') !== SCHEMA_SHA256)
|
||
+ refuse('bus-schema', 'packages/bus/schema.sql does not match SCHEMA_SHA256');
|
||
+ const reference = new DatabaseSync(':memory:');
|
||
+ reference.exec(ddl);
|
||
+ const expected = schemaDigest(reference);
|
||
+ reference.close();
|
||
+ const meta = (key) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value;
|
||
+ let digest;
|
||
+ try {
|
||
+ digest = schemaDigest(db);
|
||
+ } catch (e) {
|
||
+ refuse('bus-schema', `cannot read the schema: ${e.message}`);
|
||
+ }
|
||
+ if (digest !== expected) refuse('bus-schema', `schema digest ${digest.slice(0, 12)}, the store expects ${expected.slice(0, 12)}`);
|
||
+ if (meta('schema_version') !== SCHEMA_VERSION || meta('schema_digest') !== expected)
|
||
+ refuse('bus-schema', `meta says version ${meta('schema_version')}, digest ${String(meta('schema_digest')).slice(0, 12)}`);
|
||
+}
|
||
+
|
||
+// The queries. Each takes the business so one bus can't answer for another.
|
||
+const queries = {
|
||
+ decision: (db, b, id) => db.prepare('SELECT * FROM decisions WHERE business = ? AND id = ?').get(b, id),
|
||
+ raise: (db, b, id) =>
|
||
+ db
|
||
+ .prepare(
|
||
+ `SELECT * FROM events WHERE business = ? AND kind = 'action.allowed' AND subject = ?
|
||
+ AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ?
|
||
+ ORDER BY seq`,
|
||
+ )
|
||
+ .all(b, id, id),
|
||
+ closes: (db, id) =>
|
||
+ db
|
||
+ .prepare(
|
||
+ `SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`,
|
||
+ )
|
||
+ .all(id),
|
||
+ answer: (db, b, id) =>
|
||
+ db
|
||
+ .prepare(
|
||
+ `SELECT * FROM events WHERE business = ? AND kind = 'human.input' AND subject = ?
|
||
+ AND json_extract(body, '$.kind') = 'answer' AND json_extract(body, '$.decision') = ?
|
||
+ AND json_extract(body, '$.op') = 'resolved'
|
||
+ ORDER BY seq`,
|
||
+ )
|
||
+ .all(b, id, id),
|
||
+ later: (db, b, d) =>
|
||
+ db
|
||
+ .prepare(
|
||
+ `SELECT * FROM decisions WHERE business = ? AND id <> ?
|
||
+ AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`,
|
||
+ )
|
||
+ .all(b, d.id, d.id, d.seq, MERGE),
|
||
+ verdict: (db, b, task) =>
|
||
+ db
|
||
+ .prepare(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`)
|
||
+ .get(b, task),
|
||
+};
|
||
+
|
||
+export function readLanding(db, business, id, base) {
|
||
+ const b = business.id;
|
||
+ const d = queries.decision(db, b, id);
|
||
+ const raises = queries.raise(db, b, id);
|
||
+ if (!d || raises.length !== 1) refuse('decision-not-found', `no decision ${id} with one raise in business ${b}`);
|
||
+ const raise = json(raises[0].body) ?? {};
|
||
+ if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
|
||
+
|
||
+ const closes = queries.closes(db, id);
|
||
+ if (closes.length !== 1 || closes[0].op !== 'resolved')
|
||
+ refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
|
||
+ const res = closes[0];
|
||
+ const answers = queries.answer(db, b, id);
|
||
+ const answer = answers.length === 1 ? answers[0] : null;
|
||
+ if (
|
||
+ res.by !== business.human ||
|
||
+ res.via !== 'cli' ||
|
||
+ !answer ||
|
||
+ answer.actor_role !== null ||
|
||
+ answer.actor_run !== null ||
|
||
+ json(answer.body)?.choice !== res.choice
|
||
+ )
|
||
+ refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
|
||
+ const options = json(d.options) ?? [];
|
||
+ const merge = options.find((o) => o?.key === raise.approvalChoice);
|
||
+ if (!raise.approvalChoice || !merge) refuse('not-merge-option', 'the raise names no approval option');
|
||
+ if (res.choice !== raise.approvalChoice)
|
||
+ refuse('not-merge-option', `chose ${res.choice}, the merge option is ${raise.approvalChoice}`);
|
||
+
|
||
+ // The work ref comes from the question: decisions don't carry it.
|
||
+ const named = [...new Set(d.question.match(ANY_WORK_REF) ?? [])];
|
||
+ const m = named.length === 1 ? WORK_REF.exec(named[0]) : null;
|
||
+ if (!m) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`);
|
||
+ const workRef = named[0];
|
||
+ const task = `vikunja:${m[1]}/${m[2]}`;
|
||
+ for (const ref of [d.task_ref, raise.task_ref])
|
||
+ if (ref != null && ref !== task) refuse('work-ref', `the decision cites ${ref}, the question ${workRef}`);
|
||
+
|
||
+ // The target is the raise's, and only refactor ever lands.
|
||
+ if (raise.target !== 'refactor' || raise.target !== base)
|
||
+ refuse('target-branch', `the raise targets ${raise.target}, the base is ${base}`);
|
||
+
|
||
+ const later = queries.later(db, b, d).filter(
|
||
+ (r) => r.supersedes === id || r.task_ref === task || (r.question.match(ANY_WORK_REF) ?? []).includes(workRef),
|
||
+ );
|
||
+ if (later.length) refuse('superseded', `later decision ${later.map((r) => r.id).join(', ')} on ${workRef}`);
|
||
+
|
||
+ const v = queries.verdict(db, b, task);
|
||
+ if (!v) refuse('no-verdict', `no review.verdict for ${task}`);
|
||
+ const vb = json(v.body) ?? {};
|
||
+ if (vb.verdict !== 'approve' || vb.task_ref !== task || !OID.test(vb.commit ?? ''))
|
||
+ refuse('not-approved', `latest verdict ${v.id} is ${vb.verdict} on ${vb.commit}`);
|
||
+
|
||
+ return {
|
||
+ task_ref: task,
|
||
+ work_ref: workRef,
|
||
+ commit: vb.commit,
|
||
+ verdict: v.id,
|
||
+ project: d.project ?? '',
|
||
+ choice: res.choice,
|
||
+ by: res.by,
|
||
+ resolved_at: res.at,
|
||
+ subject: String(merge.text ?? '').split('\n')[0].trim(),
|
||
+ };
|
||
+}
|
||
+
|
||
+function main([dataRoot, businessFile, id, base]) {
|
||
+ const out = (k, v) => process.stdout.write(`${k}\t${String(v ?? '').replace(/[\t\n\r]/g, ' ')}\n`);
|
||
+ let business;
|
||
+ try {
|
||
+ business = JSON.parse(readFileSync(businessFile, 'utf8'));
|
||
+ } catch (e) {
|
||
+ process.stderr.write(`land-work: cannot read the business file ${businessFile}: ${e.message}\n`);
|
||
+ return 3;
|
||
+ }
|
||
+ if (typeof business?.id !== 'string' || typeof business?.human !== 'string' || !business.human) {
|
||
+ process.stderr.write(`land-work: ${businessFile} names no business id and human\n`);
|
||
+ return 3;
|
||
+ }
|
||
+ let db;
|
||
+ try {
|
||
+ db = openBus(dataRoot);
|
||
+ for (const [k, v] of Object.entries(readLanding(db, business, id, base))) out(k, v);
|
||
+ return 0;
|
||
+ } catch (e) {
|
||
+ if (e instanceof Refusal) {
|
||
+ out('refuse', `${e.reason}\t${e.message}`);
|
||
+ return 2;
|
||
+ }
|
||
+ process.stderr.write(`land-work: ${e.message}\n`);
|
||
+ return e.exit ?? 1;
|
||
+ } finally {
|
||
+ db?.close();
|
||
+ }
|
||
+}
|
||
+
|
||
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = main(process.argv.slice(2));
|
||
diff --git a/scripts/test-land-work.sh b/scripts/test-land-work.sh
|
||
new file mode 100644
|
||
index 000000000..6e43ed667
|
||
--- /dev/null
|
||
+++ b/scripts/test-land-work.sh
|
||
@@ -0,0 +1,385 @@
|
||
+#!/usr/bin/env bash
|
||
+# Sandboxed selftests for scripts/land-work.sh (row 116, #1610).
|
||
+#
|
||
+# Builds a fixture project repository, a working repository holding the
|
||
+# coder's work refs, and per case a bus database written through the real
|
||
+# Store (scripts/land-work/fixture.mjs). Every refusal is exercised, then a
|
||
+# landing, a dry run, a gate failure and a base that moves during the gate.
|
||
+# Docker is a fake on PATH that logs its calls. Nothing here opens the real
|
||
+# config, data root or bus: MOSAIC_CONFIG always names a sandbox file.
|
||
+set -uo pipefail
|
||
+cd "$(dirname "$0")/.."
|
||
+REPO="$(pwd)"
|
||
+
|
||
+SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
|
||
+trap 'rm -rf "$SANDBOX"' EXIT
|
||
+
|
||
+PASS=0
|
||
+FAIL=0
|
||
+# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||
+if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||
+ C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
|
||
+else
|
||
+ C_OK=""; C_FAIL=""; C_RESET=""
|
||
+fi
|
||
+check() {
|
||
+ if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
|
||
+}
|
||
+
|
||
+# Git sees no user or system config: no hooks path, no signing, a fixed identity.
|
||
+: > "$SANDBOX/gitconfig"
|
||
+export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
|
||
+export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
|
||
+REAL_GIT="$(command -v git)"
|
||
+
|
||
+# ---- fakes: docker logs its calls; a git that tampers with the applied diff ----
|
||
+mkdir -p "$SANDBOX/bin" "$SANDBOX/tamper"
|
||
+cat > "$SANDBOX/bin/docker" <<EOF
|
||
+#!/usr/bin/env bash
|
||
+echo "docker \$*" >> "$SANDBOX/docker.log"
|
||
+[ "\$1 \$2" != "network inspect" ] || [ "\${FAKE_NET_EXISTS:-}" = 1 ]
|
||
+EOF
|
||
+cat > "$SANDBOX/tamper/git" <<EOF
|
||
+#!/usr/bin/env bash
|
||
+"$REAL_GIT" "\$@"; rc=\$?
|
||
+if [ "\${1:-}" = -C ] && [ "\${3:-}" = apply ] && [ "\$rc" -eq 0 ]; then
|
||
+ printf 'tampered\n' >> "\$2/README.md" && "$REAL_GIT" -C "\$2" add README.md
|
||
+fi
|
||
+exit "\$rc"
|
||
+EOF
|
||
+chmod +x "$SANDBOX/bin/docker" "$SANDBOX/tamper/git"
|
||
+
|
||
+# ---- the project repository at the review's merge base ----
|
||
+P="$SANDBOX/proto"
|
||
+T="$P/target"
|
||
+git init -q -b refactor "$T"
|
||
+printf 'line1\nline2\nline3\n' > "$T/README.md"
|
||
+mkdir -p "$T/packages/demo/tests" "$T/packages/queue/tests" "$T/scripts" "$T/src"
|
||
+cat > "$T/packages/demo/tests/demo.test.mjs" <<'EOF'
|
||
+import test from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+test('demo', () => assert.notEqual(process.env.LW_FAIL, 'pkg'));
|
||
+EOF
|
||
+cat > "$T/packages/queue/tests/never.test.mjs" <<'EOF'
|
||
+import test from 'node:test';
|
||
+test('the gate runs no pkg-queue step', () => { throw new Error('pkg-queue ran'); });
|
||
+EOF
|
||
+cat > "$T/scripts/test-demo.sh" <<'EOF'
|
||
+#!/usr/bin/env bash
|
||
+[ "${LW_FAIL:-}" != script ] || exit 1
|
||
+[ -z "${LW_DIRTY:-}" ] || echo "during the gate" >> "$LW_DIRTY"
|
||
+if [ -n "${LW_MOVE_BASE:-}" ]; then
|
||
+ moved="$(git -C "$LW_MOVE_BASE" commit-tree "refactor^{tree}" -p refactor -m moved)" \
|
||
+ && git -C "$LW_MOVE_BASE" update-ref refs/heads/refactor "$moved"
|
||
+fi
|
||
+echo "ℹ pass 1"
|
||
+EOF
|
||
+echo 'echo legacy' > "$T/src/legacy.sh"
|
||
+echo node_modules > "$T/.gitignore"
|
||
+git -C "$T" add -A && git -C "$T" commit -q -m base
|
||
+MB="$(git -C "$T" rev-parse HEAD)"
|
||
+
|
||
+# ---- the coder's commits, pushed to the working repository ----
|
||
+git clone -q "$T" "$P/coder"
|
||
+CO="$P/coder"
|
||
+commit_on() { # message: commit what's changed in the coder clone, print the id
|
||
+ git -C "$CO" add -A && git -C "$CO" commit -q -m "$1" && git -C "$CO" rev-parse HEAD
|
||
+}
|
||
+git -C "$CO" checkout -q -b w42 "$MB"
|
||
+printf 'line1\nline2 changed\nline3\n' > "$CO/README.md"
|
||
+echo feature > "$CO/packages/demo/feature.txt"
|
||
+W_OK="$(commit_on w42)"
|
||
+git -C "$CO" checkout -q -b w43 "$MB"; echo notes > "$CO/notes.txt"
|
||
+W_ROOT="$(commit_on w43)"
|
||
+git -C "$CO" checkout -q -b w44 "$MB"; mkdir "$CO/tools2"; echo x > "$CO/tools2/x.txt"
|
||
+W_NEWDIR="$(commit_on w44)"
|
||
+git -C "$CO" checkout -q -b w45 "$MB"; echo 'export {};' > "$CO/src/parse-requirement-id.mjs"
|
||
+W_SRC="$(commit_on w45)"
|
||
+git -C "$CO" checkout -q --orphan w46; git -C "$CO" rm -rfq .; echo o > "$CO/o.txt"
|
||
+W_ORPHAN="$(commit_on w46)"
|
||
+git init -q --bare "$P/working"
|
||
+for pair in "w42:42" "w43:43" "w44:44" "w45:45" "w46:46"; do
|
||
+ git -C "$CO" push -q "$P/working" "${pair%%:*}:refs/heads/work/vikunja-7-${pair#*:}"
|
||
+done
|
||
+git -C "$CO" push -q "$P/working" "$MB:refs/heads/work/vikunja-7-47"
|
||
+
|
||
+# The base moves on after the review, on a path the work doesn't touch.
|
||
+echo docs > "$T/docs.txt"
|
||
+git -C "$T" add docs.txt && git -C "$T" commit -q -m "base moves"
|
||
+OLD="$(git -C "$T" rev-parse HEAD)"
|
||
+mkdir "$T/node_modules"
|
||
+
|
||
+# ---- bus rows: one decision and its verdicts, in the live rows' shape ----
|
||
+D="d0000000-0000-4000-8000-000000000001"
|
||
+V="e0000000-0000-4000-8000-000000000002"
|
||
+cat > "$SANDBOX/gen.mjs" <<'EOF'
|
||
+const [variant, p, t, commit] = process.argv.slice(2);
|
||
+const D = 'd0000000-0000-4000-8000-000000000001';
|
||
+const L = 'd0000000-0000-4000-8000-000000000009';
|
||
+const MERGE = 'git.merge.protected';
|
||
+const biz = variant === 'other-business' ? 'other' : 'fx';
|
||
+const ref = `work/vikunja-${p}-${t}`;
|
||
+const task = `vikunja:${p}/${t}`;
|
||
+const action = variant === 'not-a-merge' ? 'git.push.protected' : MERGE;
|
||
+const question =
|
||
+ {
|
||
+ 'two-refs': `Squash-merge ${ref} and work/vikunja-7-99 into refactor?`,
|
||
+ 'bad-ref': 'Squash-merge work/feature-x into refactor?',
|
||
+ }[variant] ?? `Squash-merge ${ref} into refactor? It carries task ${p}/${t}, approved by the reviewer.`;
|
||
+const options = [
|
||
+ { key: 'yes', text: `Squash-merge ${ref} into refactor` },
|
||
+ { key: 'no', text: "Don't merge yet" },
|
||
+];
|
||
+const rows = [
|
||
+ {
|
||
+ table: 'decisions', id: D, business: biz, project: null, action, question, options,
|
||
+ ...(variant === 'task-ref-disagrees' ? { task_ref: 'vikunja:7/99' } : {}),
|
||
+ },
|
||
+];
|
||
+if (variant !== 'no-raise')
|
||
+ rows.push({
|
||
+ table: 'events', business: biz, kind: 'action.allowed', actor_role: 'pm', actor_run: 'rfixture', subject: D,
|
||
+ body: { decision: D, action, operation: 'decision.raise', task_ref: null,
|
||
+ target: variant === 'target-main' ? 'main' : 'refactor', approvalChoice: 'yes', holderRun: null },
|
||
+ });
|
||
+const choice = variant === 'chose-no' ? 'no' : 'yes';
|
||
+if (variant === 'withdrawn') rows.push({ table: 'decision_events', decision: D, op: 'withdrawn', by: 'pm', choice: null, via: null });
|
||
+else if (variant !== 'open') {
|
||
+ rows.push({ table: 'decision_events', decision: D, op: 'resolved', by: variant === 'by-agent' ? 'pm' : 'jason', choice,
|
||
+ via: variant === 'via-webui' ? 'webui' : 'cli' });
|
||
+ if (variant !== 'no-answer')
|
||
+ rows.push({ table: 'events', business: biz, kind: 'human.input', subject: D, body: { kind: 'answer', decision: D, op: 'resolved', choice } });
|
||
+}
|
||
+const later = {
|
||
+ 'superseded-question': { action: MERGE, question: `Hold ${ref} back after all?` },
|
||
+ 'superseded-task': { action: MERGE, task_ref: task, question: `Hold task ${p}/${t} back after all?` },
|
||
+ 'superseded-by': { action: 'scope.change', supersedes: D, question: 'Replace the earlier merge decision' },
|
||
+}[variant];
|
||
+if (later) rows.push({ table: 'decisions', id: L, business: biz, project: null, options, ...later });
|
||
+const verdict = (id, v, c, body = {}) => ({
|
||
+ table: 'events', id, business: biz, kind: 'review.verdict', actor_role: 'reviewer', actor_run: 'rrev', subject: task,
|
||
+ body: { task_ref: task, request: 'f0000000-0000-4000-8000-000000000003', commit: c, verdict: v, message: 'fixture', ...body },
|
||
+});
|
||
+// An earlier changes verdict, as 19/265 had, so "latest" is what's read.
|
||
+if (variant !== 'no-verdict') {
|
||
+ rows.push(verdict('e0000000-0000-4000-8000-000000000001', 'changes', '1'.repeat(40)));
|
||
+ rows.push(verdict('e0000000-0000-4000-8000-000000000002', 'approve', variant === 'short-commit' ? commit.slice(0, 12) : commit,
|
||
+ variant === 'wrong-task-body' ? { task_ref: 'vikunja:7/99' } : {}));
|
||
+}
|
||
+if (variant === 'changes-later') rows.push(verdict('e0000000-0000-4000-8000-000000000004', 'changes', commit));
|
||
+process.stdout.write(JSON.stringify(rows));
|
||
+EOF
|
||
+
|
||
+# ---- per case: a copy of the target and the working repository, and a bus ----
|
||
+setup() { # name variant task commit
|
||
+ local c="$SANDBOX/c/$1"
|
||
+ mkdir -p "$c/businesses" "$c/tmp" "$c/data/working/fx"
|
||
+ chmod 700 "$c/data"
|
||
+ cp -a "$T" "$c/target"
|
||
+ cp -a "$P/working" "$c/data/working/fx/stack.git"
|
||
+ printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}\n' \
|
||
+ "$c/data" > "$c/config.json"
|
||
+ printf '{"id":"fx","human":"jason","projects":{"stack":{"root":"%s"}}}\n' "$c/target" > "$c/businesses/fx.json"
|
||
+ node "$SANDBOX/gen.mjs" "$2" 7 "$3" "$4" > "$c/rows.json" \
|
||
+ && node scripts/land-work/fixture.mjs "$c/data" "$c/rows.json" \
|
||
+ || echo "setup failed for $1" >&2
|
||
+}
|
||
+lw() { # name args...
|
||
+ local c="$SANDBOX/c/$1"
|
||
+ shift
|
||
+ MOSAIC_CONFIG="$c/config.json" TMPDIR="$c/tmp" PATH="${LW_PATH:+$LW_PATH:}$SANDBOX/bin:$PATH" \
|
||
+ bash scripts/land-work.sh "$@" > "$c/out" 2> "$c/err"
|
||
+}
|
||
+expect() { # name rc reason args...: run land-work, check the exit and the refusal
|
||
+ local name="$1" want="$2" reason="$3" rc=0
|
||
+ shift 3
|
||
+ lw "$name" "$@" || rc=$?
|
||
+ if [ "$rc" -eq "$want" ] && { [ -z "$reason" ] || grep -q "refused: $reason\b" "$SANDBOX/c/$name/err"; }; then
|
||
+ PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name: exit $rc${reason:+, $reason}"
|
||
+ else
|
||
+ FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name: exit $rc, wanted $want${reason:+ $reason}: $(tail -n2 "$SANDBOX/c/$name/err" | tr '\n' ' ')"
|
||
+ fi
|
||
+}
|
||
+cleaned() { # name: one worktree left in the target, no scratch left in TMPDIR
|
||
+ local c="$SANDBOX/c/$1"
|
||
+ [ "$(git -C "$c/target" worktree list --porcelain | grep -c '^worktree ')" -eq 1 ] \
|
||
+ && [ -z "$(find "$c/tmp" -maxdepth 1 -name 'land-work.*')" ]
|
||
+}
|
||
+
|
||
+# ---- arguments and configuration ----
|
||
+setup args ok 42 "$W_OK"
|
||
+expect args 2 target-branch "$D" --base next
|
||
+expect args 2 target-branch "$D" --base main
|
||
+expect args 4 "" "bad/../id"
|
||
+expect args 4 "" "$D" --unknown
|
||
+expect args 4 "" "$D" --subject $'two\nlines'
|
||
+rc=0; MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" --base next >/dev/null 2>&1 || rc=$?
|
||
+check "--base next refuses before the config is read (got $rc)" "$([ "$rc" -eq 2 ]; echo $?)"
|
||
+rc=0; MOSAIC_CONFIG="$SANDBOX/missing.json" TMPDIR="$SANDBOX/c/args/tmp" bash scripts/land-work.sh "$D" >/dev/null 2>&1 || rc=$?
|
||
+check "a missing system config exits 3 (got $rc)" "$([ "$rc" -eq 3 ]; echo $?)"
|
||
+mkdir -p "$SANDBOX/c/nobus/data/working/fx" "$SANDBOX/c/nobus/businesses" "$SANDBOX/c/nobus/tmp"
|
||
+chmod 700 "$SANDBOX/c/nobus/data"
|
||
+cp "$SANDBOX/c/args/businesses/fx.json" "$SANDBOX/c/nobus/businesses/"
|
||
+sed "s#$SANDBOX/c/args/data#$SANDBOX/c/nobus/data#" "$SANDBOX/c/args/config.json" > "$SANDBOX/c/nobus/config.json"
|
||
+expect nobus 3 "" "$D"
|
||
+
|
||
+# ---- the bus database ----
|
||
+setup schema-table ok 42 "$W_OK"
|
||
+node -e 'const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]); db.exec("CREATE TABLE extra(x)"); db.close()' \
|
||
+ "$SANDBOX/c/schema-table/data/bus/bus.sqlite"
|
||
+expect schema-table 2 bus-schema "$D"
|
||
+setup schema-meta ok 42 "$W_OK"
|
||
+# The same schema, so the digest matches, with meta saying another version.
|
||
+node -e '
|
||
+const {DatabaseSync}=require("node:sqlite"); const db=new DatabaseSync(process.argv[1]);
|
||
+const sql=db.prepare("SELECT sql FROM sqlite_master WHERE name=?").get("meta_no_update").sql;
|
||
+db.exec("DROP TRIGGER meta_no_update"); db.exec("UPDATE meta SET value=\x273a\x27 WHERE key=\x27schema_version\x27"); db.exec(sql); db.close()' \
|
||
+ "$SANDBOX/c/schema-meta/data/bus/bus.sqlite"
|
||
+expect schema-meta 2 bus-schema "$D"
|
||
+rc=0
|
||
+node --input-type=module -e '
|
||
+import { openBus } from "./scripts/land-work/read.mjs";
|
||
+const db = openBus(process.argv[1]);
|
||
+try { db.exec("CREATE TABLE x(y)"); process.exit(1); } catch (e) { process.exit(/readonly/.test(e.message) ? 0 : 1); }' \
|
||
+ "$SANDBOX/c/args/data" || rc=$?
|
||
+check "openBus opens the database read-only" "$rc"
|
||
+
|
||
+# ---- the decision ----
|
||
+for v in "decision-not-found:other-business" "decision-not-found:no-raise" "not-a-merge:not-a-merge" \
|
||
+ "not-resolved:open" "not-resolved:withdrawn" "not-human:by-agent" "not-human:via-webui" \
|
||
+ "not-human:no-answer" "not-merge-option:chose-no" "work-ref:two-refs" "work-ref:bad-ref" \
|
||
+ "work-ref:task-ref-disagrees" "target-branch:target-main" "superseded:superseded-question" \
|
||
+ "superseded:superseded-task" "superseded:superseded-by" "no-verdict:no-verdict" \
|
||
+ "not-approved:changes-later" "not-approved:short-commit" "not-approved:wrong-task-body"; do
|
||
+ setup "${v#*:}" "${v#*:}" 42 "$W_OK"
|
||
+ expect "${v#*:}" 2 "${v%%:*}" "$D"
|
||
+done
|
||
+expect args 2 decision-not-found "d0000000-0000-4000-8000-00000000000f"
|
||
+
|
||
+# ---- the work ref and the reviewed diff ----
|
||
+setup ref-moved ok 42 "$W_OK"
|
||
+git -C "$SANDBOX/c/ref-moved/data/working/fx/stack.git" update-ref refs/heads/work/vikunja-7-42 "$W_ROOT"
|
||
+expect ref-moved 2 ref-moved "$D"
|
||
+setup root-top ok 43 "$W_ROOT"
|
||
+expect root-top 2 root-file "$D"
|
||
+setup root-newdir ok 44 "$W_NEWDIR"
|
||
+expect root-newdir 2 root-file "$D"
|
||
+setup root-src ok 45 "$W_SRC"
|
||
+expect root-src 2 root-file "$D"
|
||
+setup unrelated ok 46 "$W_ORPHAN"
|
||
+expect unrelated 2 base-conflict "$D"
|
||
+setup empty ok 47 "$MB"
|
||
+expect empty 2 base-conflict "$D"
|
||
+grep -q "is empty" "$SANDBOX/c/empty/err"
|
||
+check "an empty reviewed diff says so" "$?"
|
||
+
|
||
+# The base is checked out in the target: a reviewed path there must be clean.
|
||
+setup dirty-changed ok 42 "$W_OK"
|
||
+echo local >> "$SANDBOX/c/dirty-changed/target/README.md"
|
||
+expect dirty-changed 2 checkout-dirty "$D"
|
||
+setup dirty-staged ok 42 "$W_OK"
|
||
+echo local >> "$SANDBOX/c/dirty-staged/target/README.md"
|
||
+git -C "$SANDBOX/c/dirty-staged/target" add README.md
|
||
+printf 'line1\nline2\nline3\n' > "$SANDBOX/c/dirty-staged/target/README.md"
|
||
+expect dirty-staged 2 checkout-dirty "$D"
|
||
+setup dirty-untracked ok 42 "$W_OK"
|
||
+echo local > "$SANDBOX/c/dirty-untracked/target/packages/demo/feature.txt"
|
||
+expect dirty-untracked 2 checkout-dirty "$D"
|
||
+
|
||
+setup conflict ok 42 "$W_OK"
|
||
+printf 'line1\nline2 on the base\nline3\n' > "$SANDBOX/c/conflict/target/README.md"
|
||
+git -C "$SANDBOX/c/conflict/target" commit -q -am "base edits the reviewed line"
|
||
+expect conflict 2 base-conflict "$D"
|
||
+check "a refusal after the worktree is added leaves no worktree or scratch" "$(cleaned conflict; echo $?)"
|
||
+grep -q "network inspect lw-" "$SANDBOX/docker.log" 2>/dev/null
|
||
+check "cleanup looks for the worktree's Docker network" "$?"
|
||
+
|
||
+setup dirty-during ok 42 "$W_OK"
|
||
+LW_DIRTY="$SANDBOX/c/dirty-during/target/README.md" expect dirty-during 2 checkout-dirty "$D"
|
||
+check "a reviewed path dirtied during the gate moves no ref" \
|
||
+ "$([ "$(git -C "$SANDBOX/c/dirty-during/target" rev-parse refactor)" = "$OLD" ]; echo $?)"
|
||
+
|
||
+setup drift ok 42 "$W_OK"
|
||
+LW_PATH="$SANDBOX/tamper" expect drift 2 patch-drift "$D"
|
||
+check "patch-drift leaves no worktree or scratch" "$(cleaned drift; echo $?)"
|
||
+
|
||
+# ---- the gate ----
|
||
+setup gate-pkg ok 42 "$W_OK"
|
||
+LW_FAIL=pkg expect gate-pkg 2 gate-failed "$D"
|
||
+log="$(sed -n 's/.*gate-failed: pkg-demo, see \(.*\)$/\1/p' "$SANDBOX/c/gate-pkg/err")"
|
||
+check "gate-failed names the failing step's log, and it exists" "$([ -n "$log" ] && [ -s "$log" ]; echo $?)"
|
||
+check "gate-failed leaves no worktree or scratch" "$(cleaned gate-pkg; echo $?)"
|
||
+setup gate-script ok 42 "$W_OK"
|
||
+LW_FAIL=script expect gate-script 2 gate-failed "$D"
|
||
+grep -q "gate-failed: script-demo" "$SANDBOX/c/gate-script/err"
|
||
+check "a failing scripts/test-*.sh fails the gate" "$?"
|
||
+check "a failed gate moves no ref" "$([ "$(git -C "$SANDBOX/c/gate-script/target" rev-parse refactor)" = "$OLD" ]; echo $?)"
|
||
+
|
||
+setup nomods ok 42 "$W_OK"
|
||
+rm -rf "$SANDBOX/c/nomods/target/node_modules"
|
||
+expect nomods 4 "" "$D"
|
||
+check "a target without node_modules leaves no worktree or scratch" "$(cleaned nomods; echo $?)"
|
||
+
|
||
+setup moved ok 42 "$W_OK"
|
||
+git -C "$SANDBOX/c/moved/target" checkout -q -b side
|
||
+LW_MOVE_BASE="$SANDBOX/c/moved/target" expect moved 2 base-moved "$D"
|
||
+check "base-moved keeps the base where it moved to" \
|
||
+ "$([ "$(git -C "$SANDBOX/c/moved/target" log -1 --format=%s refactor)" = moved ]; echo $?)"
|
||
+
|
||
+# ---- a dry run ----
|
||
+setup dry ok 42 "$W_OK"
|
||
+before="$(git -C "$SANDBOX/c/dry/target" for-each-ref; git -C "$SANDBOX/c/dry/target" reflog refactor; git -C "$SANDBOX/c/dry/target" status --porcelain)"
|
||
+expect dry 0 "" "$D" --dry-run --subject "Custom subject" --attribution "Applied-under: suite (conductor)"
|
||
+after="$(git -C "$SANDBOX/c/dry/target" for-each-ref; git -C "$SANDBOX/c/dry/target" reflog refactor; git -C "$SANDBOX/c/dry/target" status --porcelain)"
|
||
+check "--dry-run writes no ref" "$([ "$before" = "$after" ]; echo $?)"
|
||
+grep -q "no ref was written" "$SANDBOX/c/dry/out" && grep -qx " Custom subject" "$SANDBOX/c/dry/out" \
|
||
+ && grep -qx " Applied-under: suite (conductor)" "$SANDBOX/c/dry/out"
|
||
+check "--dry-run prints the commit with --subject and --attribution" "$?"
|
||
+grep -q "^GATE_PASSED" "$SANDBOX/c/dry/err"
|
||
+check "--dry-run runs the gate" "$?"
|
||
+check "--dry-run leaves no worktree or scratch" "$(cleaned dry; echo $?)"
|
||
+DRY_TREE="$(sed -n 's/.*would commit tree \([0-9a-f]*\) .*/\1/p' "$SANDBOX/c/dry/out")"
|
||
+
|
||
+# ---- a landing ----
|
||
+setup land ok 42 "$W_OK"
|
||
+L="$SANDBOX/c/land"
|
||
+echo "local edit" >> "$L/target/docs.txt"
|
||
+BUS_BEFORE="$(sha256sum "$L/data/bus/bus.sqlite")"
|
||
+: > "$SANDBOX/docker.log"
|
||
+FAKE_NET_EXISTS=1 expect land 0 "" "$D"
|
||
+NEW="$(git -C "$L/target" rev-parse refactor)"
|
||
+check "the landing is one commit on the old base" "$([ "$(git -C "$L/target" rev-parse "$NEW^")" = "$OLD" ]; echo $?)"
|
||
+pid() { git -C "$L/target" diff "$1" "$2" | git patch-id --stable | cut -d' ' -f1; }
|
||
+check "its diff is the reviewed diff by patch-id" "$([ "$(pid "$OLD" "$NEW")" = "$(pid "$MB" "$W_OK")" ]; echo $?)"
|
||
+check "its tree is the dry run's" "$([ -n "$DRY_TREE" ] && [ "$(git -C "$L/target" rev-parse "$NEW^{tree}")" = "$DRY_TREE" ]; echo $?)"
|
||
+msg="$(git -C "$L/target" log -1 --format=%B "$NEW")"
|
||
+PATCH_ID="$(pid "$MB" "$W_OK")"
|
||
+ok=0
|
||
+[ "$(head -n1 <<<"$msg")" = "Squash-merge work/vikunja-7-42 into refactor" ] || ok=1
|
||
+for line in "Decision: $D" "Verdict: $V" "Work-ref: work/vikunja-7-42" "Reviewed-commit: $W_OK" \
|
||
+ "Base: refactor $OLD" "Patch-id: $PATCH_ID"; do
|
||
+ grep -qxF "$line" <<<"$msg" || ok=1
|
||
+done
|
||
+[ "$(sed '/^$/d' <<<"$msg" | tail -n1)" = "Applied-under: scripts/land-work.sh (conductor)" ] || ok=1
|
||
+check "the message has the option's subject, the citations and the attribution last" "$ok"
|
||
+check "it prints the line for the PM" \
|
||
+ "$([ "$(tail -n1 "$L/out")" = "work/vikunja-7-42 (vikunja:7/42) is merged to refactor as ${NEW:0:9}; push pending." ]; echo $?)"
|
||
+check "the checked-out base has the reviewed paths and keeps the unrelated edit" \
|
||
+ "$([ "$(git -C "$L/target" status --porcelain)" = " M docs.txt" ] && [ -f "$L/target/packages/demo/feature.txt" ]; echo $?)"
|
||
+check "only refs/heads/refactor exists in the target" \
|
||
+ "$([ "$(git -C "$L/target" for-each-ref --format='%(refname)')" = refs/heads/refactor ]; echo $?)"
|
||
+check "the work ref is unchanged" \
|
||
+ "$([ "$(git -C "$L/data/working/fx/stack.git" rev-parse refs/heads/work/vikunja-7-42)" = "$W_OK" ]; echo $?)"
|
||
+check "the bus database is unchanged" "$([ "$(sha256sum "$L/data/bus/bus.sqlite")" = "$BUS_BEFORE" ]; echo $?)"
|
||
+logs="$(sed -n 's/^land-work: gate logs in //p' "$L/err")"
|
||
+grep -q "^pkg-demo rc=0" "$logs/gate.log" && grep -q "^script-demo rc=0" "$logs/gate.log" \
|
||
+ && grep -q "^GATE_PASSED" "$logs/gate.log" && ! grep -q "pkg-queue" "$logs/gate.log"
|
||
+check "the gate ran each package suite but queue, and each scripts/test-*.sh" "$?"
|
||
+check "the landing leaves no worktree or scratch" "$(cleaned land; echo $?)"
|
||
+check "the target's node_modules is still there" "$([ -d "$L/target/node_modules" ] && [ ! -L "$L/target/node_modules" ]; echo $?)"
|
||
+grep -q "^docker network rm lw-[0-9]*-[0-9]*_default$" "$SANDBOX/docker.log"
|
||
+check "the worktree's Docker network is removed" "$?"
|
||
+
|
||
+echo "---"
|
||
+echo "land-work selftests: $PASS passed, $FAIL failed"
|
||
+[ "$FAIL" -eq 0 ]
|