mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-31 07:19:31 +02:00
64f9aafa1e
* fix(office-hours): #1671 — session writer was writing to the legacy file User-visible symptom: returning /office-hours users get the same closing pitch every visit, no matter how many times they've run the skill. The welcome_back tier (which exists specifically to skip the pitch for returning users) was unreachable. Live since 2026-04-18 / v1.0.0.0 on every fresh-$HOME user. Root cause: the v1.0.0.0 migration moved the read path to ~/.gstack/developer-profile.json but left the writer in office-hours/SKILL.md.tmpl writing to the legacy ~/.gstack/builder-profile.jsonl. Reader and writer disagreed on storage, so SESSION_COUNT never incremented and /office-hours always treated the user as a first-timer. Fix: - bin/gstack-developer-profile: new --log-session subcommand that read-modify-writes developer-profile.json's sessions[] array (atomic mktemp+mv, signals/resources/topics aggregation, gbrain-enqueue mirror of gstack-timeline-log:40). Naming matches the gstack-*-log family verb. - bin/gstack-developer-profile: do_read filters mode:"resources" entries when picking LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE so the Phase 6 resources auto-append doesn't clobber real-session state. Latent bug that was masked by the broken writer; activated by the fix. - office-hours/SKILL.md.tmpl: lines 490 + 893 swap echo >> for --log-session. - test/gstack-developer-profile.test.ts: +8 tests covering --log-session contract (regression, aggregation, dedup, validation, ts handling) plus the mode-filter regression. All 8 fail on main, all 8 pass with this fix. - test/static-no-legacy-writes.test.ts: new static-grep invariant walking every skill dir to prevent future regressions onto the legacy file. Affected users: stranded builder-profile.jsonl entries are not recovered automatically by this PR. On their next /office-hours run, the first new session lands in welcome_back; past data stays in the legacy file (still readable by other tools during deprecation). Most pre-existing users have only a handful of stranded sessions. See docs/designs/FIX_1671_PROFILE_MIGRATION.md for scope decisions (RC2/RC3 follow-ups, what was intentionally left out, and why). Issue: #1671 * test(office-hours): refine #1671 invariant regex comment for literal-path scope Clarifies that the WRITE_PATTERN regex catches literal-path writes only; variable-indirected writes (FILE=...; echo >> "$FILE") are not detected. The SKILL.md.tmpl assertions in the same suite pin the exact #1671 regression class directly; this regex is a backstop, not a flow analyzer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(timeline): pass read filters as data * feat(next-version): support monorepo VERSION paths via --version-path + .gstack/version-path The workspace-aware ship queue hardcoded the VERSION file at the repo root. In monorepos where versioning is subproject-scoped (one app inside a larger repo), every PR's VERSION lookup 404s, the queue silently empties, and parallel /ship sessions all bump from "current main + 1" — producing a cascade of slot collisions. Repro: tinas-second-brain repo. Root VERSION is absent; the real VERSION lives at "Tinas Second Brain/health-tracker/VERSION". In one day, four sequential collisions: 0.4.0.1 -> 0.5.0.0 -> 0.5.0.1 -> 0.5.0.2 -> 0.5.0.3. Fix: add a --version-path flag and a repo-local .gstack/version-path config file. Resolution priority: CLI flag > .gstack/version-path > "VERSION". The resolved path threads through all four call sites — git show origin/<base>:<path>, the GitHub Contents API, the GitLab files API, and the local sibling-worktree scan — and shows up in the JSON output as version_path so /ship and operators can see what got picked. The previous warning "could not fetch VERSION (fork or private)" was misleading whenever the real cause was wrong path. The new wording names the path that 404'd and hints at the two knobs. Backward-compatible: no flag, no config, no change in behavior. Tests: 6 unit tests for resolveVersionPath (priority, parsing, blank / missing / empty edge cases) + a second integration smoke that drives --version-path end-to-end and asserts it surfaces in JSON output. * fix(investigate): support standalone freeze hook path * fix(browse): clarify localhost bind failures * fix(migration): defer v1.40.0.0 done-marker until every repair succeeds (#1581) The v1.40.0.0 migration unconditionally `touch`ed its done-marker, even when the jq-gated `.brain-privacy-map.json` patch was skipped because jq was missing on the user's machine. On subsequent runs, the script short-circuited on the marker so the privacy-map repair never landed. Federation sync then silently dropped `/plan-eng-review` test plans. Track every failure mode via a single `incomplete` flag: jq missing, malformed JSON, jq mutation failure, tempfile creation failure, `mv` failure, allowlist append failure, gitattributes append failure. The marker is written only when `incomplete=0`, so the migration runner retries on the next /gstack-upgrade once the prerequisites are met. * test(migration): unit tests for v1.40.0.0 deferred done-marker fix (#1581) 8 cases pinning the fix: - Case 1 (happy path): jq present, fresh privacy-map → all three files patched, marker written. - Case 2 (regression for #1581): jq missing, privacy-map present → marker must NOT be written. Fails against the buggy script, passes against the fix. - Case 3 (recovery): jq missing, then jq restored → patch lands on second run. - Case 4 (idempotency): privacy-map already has correct entry → no mutation, marker written. - Case 5 (fresh-init): privacy-map file absent → allowlist + gitattrs patched, marker written. - Case 6 (malformed JSON): broken privacy-map JSON → no marker, no mutation. - Case 7 (jq mutation failure): fake jq returning 1 → no marker, tempfile cleaned up. - Case 8 (allowlist append failure): read-only allowlist → no marker. Tests use spawnSync('bash', [MIGRATION], …) with isolated tmpHomes. "jq missing" sets PATH to a curated dir of symlinks to standard utils, omitting jq; "jq mutation fails" uses an `exit 1` shim. Avoids blanket-clearing PATH (which would hide bash/grep/etc). * fix(brain-sync): make artifact sync work on Windows (discover-new + drain) Automatic artifact sync was fully non-functional on Windows (Git Bash): --discover-new enqueued nothing and the --once drain staged nothing, so artifacts_sync_mode looked active but no artifacts ever reached the repo. Three independent Windows-only causes in bin/gstack-brain-sync: 1. discover-new matched os.path.relpath (backslash separators on Windows) against the forward-slash allowlist globs, so no nested file ever matched. Normalized the relpath to "/". 2. discover-new enqueued via subprocess.run([gstack-brain-enqueue, rel]), but Windows Python cannot exec a bash-shebang script, so nothing was enqueued even once matched. Now appends to the queue in-process. 3. compute_paths_to_stage ends in print(p); Windows Python emits CRLF, the bash `read -r` keeps the trailing CR, and `git add -- "path<CR>"` matches nothing under `2>/dev/null || true`. Now strips the CR before staging. The in-process enqueue mirrors gstack-brain-enqueue's contract: one atomic O_APPEND write per record (each line < PIPE_BUF) so a parallel writer-shim append can't interleave mid-record, and the discover cursor advances only after the write succeeds, so a failed write retries instead of silently recording the file as synced. Skip-list entries are separator-normalized on both the discover and drain (compute_paths_to_stage) sides, so a backslash .brain-skip.txt entry can't be honored at discovery yet bypassed at commit. Adds test/brain-sync-windows-paths.test.ts (static invariants -- behavioral spawn tests cannot run on the Windows lane, since Node/Bun cannot exec the bin/ shebang scripts there) and wires it into windows-free-tests.yml. Verified red->green and end-to-end on Windows 11 / Git Bash; macOS/Linux behavior unchanged (os.sep is already "/", no CRLF, compute path logic unchanged besides the shared skip normalization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: detect bun.lock (Bun v1.2+ text lockfile) in diff-scope CONFIG gstack-diff-scope only matched the legacy binary lockfile `bun.lockb` but not the newer text-based `bun.lock` introduced in Bun v1.2+. Projects using current Bun versions were silently missing the SCOPE_CONFIG signal when only the lockfile changed. 🤖 Generated with [Qoder][https://qoder.com] * fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive The daemon's tunnel bootstrap used `dns.resolve6` to look up `<device>.coredevice.local`, which fails with ESERVFAIL on macOS 26.x (Darwin 25.x) because Node's resolve6 path goes through libresolv and does NOT consult mDNSResponder. `dns.lookup` (getaddrinfo) does. Even when resolution works, CoreDevice in Xcode 26 only holds the USB tunnel up while a devicectl command is in-flight, so the IPv6 ULA becomes unroutable within ~10-15s of idle and subsequent proxy requests time out. Two-part fix: 1. Resolution order is now (a) `xcrun devicectl device info details --json-output` to read `result.connectionProperties.tunnelIPAddress` directly, (b) mDNS via `dns.lookup`, (c) legacy `dns.resolve6` as a last-ditch fallback. 2. After a successful bootstrap the daemon spawns a periodic `devicectl device info details` (~5s) to keep the tunnel session alive. Cleaned up on SIGINT/SIGTERM/exit. Adds tests for `getDeviceTunnelIPv6FromDevicectl`, the `resolveTunnelIPv6` fallback chain, and `startTunnelKeepalive`. Existing bootstrap tests updated to include the new `device info details` spawn step. Tested against: iPhone 12 Pro on iOS 26.x via Mac Mini M-series running macOS Sequoia 15.x / Darwin 25.3.0. * chore(release): v1.44.1.0 — 9-PR community fix wave (post-windhoek paper-cut) Bump VERSION + CHANGELOG entry. Wave covers /office-hours session counter, iOS QA macOS 26 tunnels, Windows brain-sync, browse server bind diagnostics, monorepo VERSION layouts, /investigate freeze hook on standalone installs, gstack-timeline-read quote injection, v1.40.0.0 migration on jq-less machines, bun.lock detection. 9 community PRs: #1676 #1635 #1627 #1648 #1664 #1589 #1672 #1649 #1673 9 contributors credited: @pryow @jbetala7 @cfeddersen @Gujiassh @spacegeologist @stedfn @daveowenatl @hiSandog @sternryan 4 issues closed: #1671 #1677 #1634 #1647 #1581 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Rook <rook@robomovers.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Christoph <astaran@herr-der-ringe-film.de> Co-authored-by: gujishh <baiaoshh@163.com> Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com> Co-authored-by: Stefan Neamtu <stefan.neamtu@nearone.org> Co-authored-by: Dave Owen <daveowen66@gmail.com> Co-authored-by: 陈家名 <chenjiaming@kezaihui.com> Co-authored-by: Ryan Stern <206953196+sternryan@users.noreply.github.com>
519 lines
18 KiB
TypeScript
Executable File
519 lines
18 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
// gstack-next-version — host-aware VERSION allocator for /ship.
|
|
//
|
|
// Queries the PR queue (GitHub or GitLab), fetches each open PR's VERSION,
|
|
// scans configurable Conductor sibling worktrees, picks the next free version
|
|
// slot at the requested bump level, and emits the whole picture as JSON.
|
|
//
|
|
// Contract: util NEVER writes files or mutates state. Pure reader + reporter.
|
|
// /ship consumes the JSON and decides what to do.
|
|
//
|
|
// Usage:
|
|
// gstack-next-version --base <branch> --bump <major|minor|patch|micro> \
|
|
// --current-version <X.Y.Z.W> [--workspace-root <path>|null] \
|
|
// [--version-path <path>] [--json]
|
|
//
|
|
// VERSION path resolution (monorepo support):
|
|
// 1. --version-path <path> CLI flag (highest priority)
|
|
// 2. .gstack/version-path file at the repo root (single-line relative path,
|
|
// committed so all collaborators benefit)
|
|
// 3. "VERSION" at the repo root (default, backward-compatible)
|
|
//
|
|
// Exit codes:
|
|
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
|
|
// 2 — invalid arguments
|
|
// 3 — util bug (unexpected exception)
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
type Bump = "major" | "minor" | "patch" | "micro";
|
|
type Version = [number, number, number, number];
|
|
|
|
type ClaimedPR = {
|
|
pr: number;
|
|
branch: string;
|
|
version: string;
|
|
url?: string;
|
|
};
|
|
|
|
type Sibling = {
|
|
path: string;
|
|
branch: string;
|
|
version: string;
|
|
last_commit_ts: number;
|
|
has_open_pr: boolean;
|
|
is_active: boolean;
|
|
};
|
|
|
|
type Output = {
|
|
version: string;
|
|
current_version: string;
|
|
base_version: string;
|
|
version_path: string;
|
|
bump: Bump;
|
|
host: "github" | "gitlab" | "unknown";
|
|
offline: boolean;
|
|
claimed: ClaimedPR[];
|
|
siblings: Sibling[];
|
|
active_siblings: Sibling[];
|
|
reason: string;
|
|
warnings: string[];
|
|
};
|
|
|
|
const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60;
|
|
const GH_API_CONCURRENCY = 10;
|
|
|
|
function parseVersion(s: string): Version | null {
|
|
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
if (!m) return null;
|
|
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
}
|
|
|
|
function fmtVersion(v: Version): string {
|
|
return v.join(".");
|
|
}
|
|
|
|
function bumpVersion(v: Version, level: Bump): Version {
|
|
switch (level) {
|
|
case "major":
|
|
return [v[0] + 1, 0, 0, 0];
|
|
case "minor":
|
|
return [v[0], v[1] + 1, 0, 0];
|
|
case "patch":
|
|
return [v[0], v[1], v[2] + 1, 0];
|
|
case "micro":
|
|
return [v[0], v[1], v[2], v[3] + 1];
|
|
}
|
|
}
|
|
|
|
function cmpVersion(a: Version, b: Version): number {
|
|
for (let i = 0; i < 4; i++) {
|
|
if (a[i] !== b[i]) return a[i] - b[i];
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Collision resolution: bump past the highest claimed within the same level.
|
|
// Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to
|
|
// 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent.
|
|
function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } {
|
|
let candidate = bumpVersion(base, level);
|
|
const sortedClaimed = [...claimed].sort(cmpVersion);
|
|
const highest = sortedClaimed[sortedClaimed.length - 1];
|
|
if (highest && cmpVersion(highest, base) > 0) {
|
|
// Queue already advanced past base; bump past the highest claim.
|
|
const bumpedPastHighest = bumpVersion(highest, level);
|
|
if (cmpVersion(bumpedPastHighest, candidate) > 0) {
|
|
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` };
|
|
}
|
|
}
|
|
return { version: candidate, reason: "no collision; clean bump from base" };
|
|
}
|
|
|
|
function runCommand(cmd: string, args: string[], timeoutMs = 15000): { ok: boolean; stdout: string; stderr: string } {
|
|
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
|
|
return {
|
|
ok: r.status === 0 && !r.error,
|
|
stdout: r.stdout ?? "",
|
|
stderr: r.stderr ?? (r.error ? String(r.error) : ""),
|
|
};
|
|
}
|
|
|
|
// VERSION-path resolution for monorepos. Priority: CLI flag > .gstack/version-path
|
|
// at repo root > "VERSION". Pure function; takes the repo root as an argument so
|
|
// tests can drive it with a fixture dir without mocking git.
|
|
function resolveVersionPath(override: string | undefined, repoRoot: string): string {
|
|
if (override) return override.trim();
|
|
const configFile = join(repoRoot, ".gstack", "version-path");
|
|
if (existsSync(configFile)) {
|
|
try {
|
|
const firstLine = readFileSync(configFile, "utf8").split("\n")[0]?.trim() ?? "";
|
|
if (firstLine) return firstLine;
|
|
} catch {
|
|
// fall through to default
|
|
}
|
|
}
|
|
return "VERSION";
|
|
}
|
|
|
|
function repoToplevel(): string {
|
|
const r = runCommand("git", ["rev-parse", "--show-toplevel"]);
|
|
return r.ok ? r.stdout.trim() : process.cwd();
|
|
}
|
|
|
|
function detectHost(): "github" | "gitlab" | "unknown" {
|
|
const remote = runCommand("git", ["remote", "get-url", "origin"]);
|
|
if (remote.ok) {
|
|
const url = remote.stdout.trim();
|
|
if (url.includes("github.com")) return "github";
|
|
if (url.includes("gitlab")) return "gitlab";
|
|
}
|
|
const gh = runCommand("gh", ["auth", "status"]);
|
|
if (gh.ok) return "github";
|
|
const glab = runCommand("glab", ["auth", "status"]);
|
|
if (glab.ok) return "gitlab";
|
|
return "unknown";
|
|
}
|
|
|
|
function readBaseVersion(base: string, versionPath: string, warnings: string[]): string {
|
|
// git fetch is best-effort; we tolerate failure and fall back to whatever
|
|
// origin/<base> currently points at.
|
|
runCommand("git", ["fetch", "origin", base, "--quiet"], 10000);
|
|
const r = runCommand("git", ["show", `origin/${base}:${versionPath}`]);
|
|
if (!r.ok) {
|
|
warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`);
|
|
return "0.0.0.0";
|
|
}
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
|
|
const list = runCommand("gh", [
|
|
"pr",
|
|
"list",
|
|
"--state",
|
|
"open",
|
|
"--base",
|
|
base,
|
|
"--limit",
|
|
"200",
|
|
"--json",
|
|
"number,headRefName,headRepositoryOwner,url,isDraft",
|
|
]);
|
|
if (!list.ok) {
|
|
warnings.push(`gh pr list failed: ${list.stderr.trim().slice(0, 200)}`);
|
|
return { claimed: [], offline: true };
|
|
}
|
|
let prs: {
|
|
number: number;
|
|
headRefName: string;
|
|
headRepositoryOwner?: { login: string };
|
|
url: string;
|
|
isDraft: boolean;
|
|
}[];
|
|
try {
|
|
prs = JSON.parse(list.stdout);
|
|
} catch (e) {
|
|
warnings.push(`gh pr list returned invalid JSON`);
|
|
return { claimed: [], offline: true };
|
|
}
|
|
// Determine our repo owner to filter out fork PRs. `gh api contents?ref=<branch>`
|
|
// resolves to OUR repo regardless of where the PR originated, so fork PRs would
|
|
// otherwise return our main's VERSION as a phantom claim.
|
|
const viewer = runCommand("gh", ["repo", "view", "--json", "owner", "-q", ".owner.login"]);
|
|
const myOwner = viewer.ok ? viewer.stdout.trim() : "";
|
|
const sameRepoPRs = (myOwner
|
|
? prs.filter((p) => (p.headRepositoryOwner?.login ?? "") === myOwner)
|
|
: prs
|
|
).filter((p) => excludePR === null || p.number !== excludePR);
|
|
// Fetch each PR's VERSION at its head in parallel (bounded concurrency).
|
|
const results: ClaimedPR[] = [];
|
|
const queue = [...sameRepoPRs];
|
|
const workers = Array.from({ length: Math.min(GH_API_CONCURRENCY, sameRepoPRs.length) }, async () => {
|
|
while (queue.length) {
|
|
const pr = queue.shift();
|
|
if (!pr) return;
|
|
// gh passes branch name via argv, not shell — safe.
|
|
// encodeURI handles spaces in subproject paths (e.g. "Tinas Second Brain/...")
|
|
// while leaving "/" untouched so the GitHub Contents API gets the path intact.
|
|
const content = runCommand("gh", [
|
|
"api",
|
|
`repos/{owner}/{repo}/contents/${encodeURI(versionPath)}?ref=${encodeURIComponent(pr.headRefName)}`,
|
|
"-q",
|
|
".content",
|
|
]);
|
|
if (!content.ok) {
|
|
warnings.push(
|
|
`PR #${pr.number}: could not fetch ${versionPath} (fork, private, or wrong path — try --version-path or .gstack/version-path)`,
|
|
);
|
|
continue;
|
|
}
|
|
let versionStr: string;
|
|
try {
|
|
versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim();
|
|
} catch {
|
|
warnings.push(`PR #${pr.number}: VERSION is not valid base64`);
|
|
continue;
|
|
}
|
|
if (!parseVersion(versionStr)) {
|
|
warnings.push(`PR #${pr.number}: VERSION is malformed (${versionStr})`);
|
|
continue;
|
|
}
|
|
results.push({ pr: pr.number, branch: pr.headRefName, version: versionStr, url: pr.url });
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return { claimed: results, offline: false };
|
|
}
|
|
|
|
async function fetchGitlabClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
|
|
const list = runCommand("glab", [
|
|
"mr",
|
|
"list",
|
|
"--opened",
|
|
"--target-branch",
|
|
base,
|
|
"--output",
|
|
"json",
|
|
"--per-page",
|
|
"200",
|
|
]);
|
|
if (!list.ok) {
|
|
warnings.push(`glab mr list failed: ${list.stderr.trim().slice(0, 200)}`);
|
|
return { claimed: [], offline: true };
|
|
}
|
|
let mrs: { iid: number; source_branch: string; web_url: string }[];
|
|
try {
|
|
mrs = JSON.parse(list.stdout);
|
|
} catch {
|
|
warnings.push(`glab mr list returned invalid JSON`);
|
|
return { claimed: [], offline: true };
|
|
}
|
|
if (excludePR !== null) {
|
|
mrs = mrs.filter((mr) => mr.iid !== excludePR);
|
|
}
|
|
const results: ClaimedPR[] = [];
|
|
for (const mr of mrs) {
|
|
// GitLab files API takes the full path URL-encoded (slashes become %2F).
|
|
const content = runCommand("glab", [
|
|
"api",
|
|
`projects/:id/repository/files/${encodeURIComponent(versionPath)}?ref=${encodeURIComponent(mr.source_branch)}`,
|
|
]);
|
|
if (!content.ok) {
|
|
warnings.push(
|
|
`MR !${mr.iid}: could not fetch ${versionPath} (wrong path? — try --version-path or .gstack/version-path)`,
|
|
);
|
|
continue;
|
|
}
|
|
try {
|
|
const j = JSON.parse(content.stdout);
|
|
const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim();
|
|
if (!parseVersion(versionStr)) {
|
|
warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`);
|
|
continue;
|
|
}
|
|
results.push({ pr: mr.iid, branch: mr.source_branch, version: versionStr, url: mr.web_url });
|
|
} catch {
|
|
warnings.push(`MR !${mr.iid}: unexpected glab api response`);
|
|
}
|
|
}
|
|
return { claimed: results, offline: false };
|
|
}
|
|
|
|
function resolveWorkspaceRoot(override?: string): string | null {
|
|
if (override === "null") return null;
|
|
if (override) return override;
|
|
const r = runCommand(join(__dirname, "gstack-config"), ["get", "workspace_root"]);
|
|
const configured = r.ok ? r.stdout.trim() : "";
|
|
if (configured === "null") return null;
|
|
if (configured) return configured;
|
|
// Default: $HOME/conductor/workspaces/
|
|
return join(homedir(), "conductor", "workspaces");
|
|
}
|
|
|
|
function currentRepoSlug(): string {
|
|
const r = runCommand("git", ["remote", "get-url", "origin"]);
|
|
if (!r.ok) return "";
|
|
// Extract "owner/repo" from URL like git@github.com:owner/repo.git
|
|
const m = r.stdout.trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
return m ? m[1] : "";
|
|
}
|
|
|
|
function scanSiblings(root: string | null, versionPath: string, claimed: ClaimedPR[], warnings: string[]): Sibling[] {
|
|
if (!root || !existsSync(root)) return [];
|
|
const mySlug = currentRepoSlug();
|
|
if (!mySlug) {
|
|
warnings.push("could not determine current repo slug; skipping sibling scan");
|
|
return [];
|
|
}
|
|
const repoName = mySlug.split("/").pop() ?? "";
|
|
// Conductor layout: <root>/<repo>/<workspace>/
|
|
const repoDir = join(root, repoName);
|
|
if (!existsSync(repoDir)) return [];
|
|
const myAbsPath = resolve(process.cwd());
|
|
const results: Sibling[] = [];
|
|
for (const name of readdirSync(repoDir)) {
|
|
const p = join(repoDir, name);
|
|
if (resolve(p) === myAbsPath) continue;
|
|
try {
|
|
const s = statSync(p);
|
|
if (!s.isDirectory()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!existsSync(join(p, ".git")) && !existsSync(join(p, ".git/HEAD"))) continue;
|
|
const versionFile = join(p, versionPath);
|
|
if (!existsSync(versionFile)) continue;
|
|
let version: string;
|
|
try {
|
|
version = readFileSync(versionFile, "utf8").trim();
|
|
if (!parseVersion(version)) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const branchR = runCommand("git", ["-C", p, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
if (!branchR.ok) continue;
|
|
const branch = branchR.stdout.trim();
|
|
const commitTsR = runCommand("git", ["-C", p, "log", "-1", "--format=%ct"]);
|
|
const last_commit_ts = commitTsR.ok ? Number(commitTsR.stdout.trim()) : 0;
|
|
const has_open_pr = claimed.some((c) => c.branch === branch);
|
|
results.push({
|
|
path: p,
|
|
branch,
|
|
version,
|
|
last_commit_ts,
|
|
has_open_pr,
|
|
is_active: false,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function markActiveSiblings(siblings: Sibling[], baseVersion: Version): Sibling[] {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return siblings.map((s) => {
|
|
const v = parseVersion(s.version);
|
|
const isAhead = v ? cmpVersion(v, baseVersion) > 0 : false;
|
|
const isFresh = s.last_commit_ts > 0 && now - s.last_commit_ts < ACTIVE_SIBLING_MAX_AGE_S;
|
|
const is_active = isAhead && isFresh && !s.has_open_pr;
|
|
return { ...s, is_active };
|
|
});
|
|
}
|
|
|
|
function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; versionPath?: string; help: boolean } {
|
|
let base = "";
|
|
let bump: Bump | "" = "";
|
|
let current = "";
|
|
let workspaceRoot: string | undefined;
|
|
let excludePR: number | null = null;
|
|
let versionPath: string | undefined;
|
|
let help = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--base") base = argv[++i] ?? "";
|
|
else if (a === "--bump") bump = (argv[++i] ?? "") as Bump;
|
|
else if (a === "--current-version") current = argv[++i] ?? "";
|
|
else if (a === "--workspace-root") workspaceRoot = argv[++i];
|
|
else if (a === "--version-path") versionPath = argv[++i];
|
|
else if (a === "--exclude-pr") {
|
|
const n = Number(argv[++i]);
|
|
excludePR = Number.isFinite(n) && n > 0 ? n : null;
|
|
}
|
|
else if (a === "-h" || a === "--help") help = true;
|
|
}
|
|
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
|
|
if (!base) base = "main";
|
|
if (!bump) {
|
|
console.error("Error: --bump is required (major|minor|patch|micro)");
|
|
process.exit(2);
|
|
}
|
|
if (!["major", "minor", "patch", "micro"].includes(bump)) {
|
|
console.error(`Error: --bump must be major|minor|patch|micro (got ${bump})`);
|
|
process.exit(2);
|
|
}
|
|
return { base, bump: bump as Bump, current, workspaceRoot, excludePR, versionPath, help: false };
|
|
}
|
|
|
|
// Auto-detect: if --exclude-pr wasn't passed, check whether the current branch
|
|
// already has an open PR and exclude it by default. This prevents the self-
|
|
// reference bug where /ship's own PR inflates the queue on rerun.
|
|
function autoDetectExcludePR(): number | null {
|
|
const r = runCommand("gh", ["pr", "view", "--json", "number", "-q", ".number"]);
|
|
if (!r.ok) return null;
|
|
const n = Number(r.stdout.trim());
|
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
console.log(
|
|
"Usage: gstack-next-version --base <branch> --bump <level> --current-version <X.Y.Z.W> [--workspace-root <path|null>] [--version-path <path>]",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
const warnings: string[] = [];
|
|
const host = detectHost();
|
|
const versionPath = resolveVersionPath(args.versionPath, repoToplevel());
|
|
const baseVersion = args.current || readBaseVersion(args.base, versionPath, warnings);
|
|
const baseParsed = parseVersion(baseVersion);
|
|
if (!baseParsed) {
|
|
console.error(`Error: could not parse base version '${baseVersion}'`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const excludePR = args.excludePR ?? autoDetectExcludePR();
|
|
if (excludePR !== null && args.excludePR === null) {
|
|
warnings.push(`auto-excluded PR #${excludePR} (current branch's own PR)`);
|
|
}
|
|
|
|
let claimed: ClaimedPR[] = [];
|
|
let offline = false;
|
|
if (host === "github") {
|
|
({ claimed, offline } = await fetchGithubClaimed(args.base, versionPath, excludePR, warnings));
|
|
} else if (host === "gitlab") {
|
|
({ claimed, offline } = await fetchGitlabClaimed(args.base, versionPath, excludePR, warnings));
|
|
} else {
|
|
warnings.push("host unknown; queue-awareness unavailable");
|
|
}
|
|
|
|
// Only count PRs that actually bumped VERSION past base as real "claims".
|
|
// A PR whose VERSION equals base's VERSION hasn't claimed anything.
|
|
const realClaims = claimed.filter((c) => {
|
|
const v = parseVersion(c.version);
|
|
return v !== null && cmpVersion(v, baseParsed) > 0;
|
|
});
|
|
const claimedVersions = realClaims
|
|
.map((c) => parseVersion(c.version))
|
|
.filter((v): v is Version => v !== null);
|
|
|
|
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump);
|
|
|
|
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
|
|
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed);
|
|
const activeSiblings = siblings.filter((s) => s.is_active);
|
|
|
|
// If an active sibling outranks our pick, bump past it (same bump level).
|
|
let finalVersion = picked;
|
|
let finalReason = reason;
|
|
const activeAhead = activeSiblings
|
|
.map((s) => parseVersion(s.version))
|
|
.filter((v): v is Version => v !== null)
|
|
.filter((v) => cmpVersion(v, finalVersion) >= 0);
|
|
if (activeAhead.length) {
|
|
const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1];
|
|
finalVersion = bumpVersion(highest, args.bump);
|
|
finalReason = `bumped past active sibling ${fmtVersion(highest)}`;
|
|
}
|
|
|
|
const out: Output = {
|
|
version: fmtVersion(finalVersion),
|
|
current_version: args.current || baseVersion,
|
|
base_version: baseVersion,
|
|
version_path: versionPath,
|
|
bump: args.bump,
|
|
host,
|
|
offline,
|
|
claimed: realClaims,
|
|
siblings,
|
|
active_siblings: activeSiblings,
|
|
reason: finalReason,
|
|
warnings,
|
|
};
|
|
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
}
|
|
|
|
// Pure-function exports for testing
|
|
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath };
|
|
|
|
// Only run main() when invoked as a script, not when imported by tests.
|
|
if (import.meta.main) {
|
|
main().catch((e) => {
|
|
console.error("Unexpected error:", e?.stack ?? e);
|
|
process.exit(3);
|
|
});
|
|
}
|