mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: /retro global — cross-project AI coding retrospective (v0.10.2.0) (#316)
* feat: gstack-global-discover — cross-tool AI session discovery Standalone script that scans Claude Code, Codex CLI, and Gemini CLI session directories, resolves each session's working directory to a git repo, deduplicates by normalized remote URL, and outputs structured JSON. - Reads only first 4-8KB of session files (avoids OOM on large transcripts) - Only counts JSONL files modified within the time window (accurate counts) - Week windows midnight-aligned like day windows for consistency - 16 tests covering URL normalization, CLI behavior, and output structure * feat: /retro global — cross-project retro using discovery engine Adds Global Retrospective Mode to the /retro skill. When invoked as `/retro global`, skips the repo-scoped retro and instead uses gstack-global-discover to find all AI coding sessions across all tools, then runs git log on each discovered repo for a unified cross-project retrospective with global shipping streak and context-switching metrics. * chore: bump version and changelog (v0.9.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: sync documentation with shipped changes Update README /retro description to mention global mode. Add bin/ directory to CLAUDE.md project structure. * feat: /retro global adds per-project personal contributions breakdown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files after main merge * chore: bump version and changelog (v0.10.2.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: test coverage catalog — shared audit across plan/ship/review (v0.10.1.0) (#259) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /retro global shareable personal card — screenshot-ready stats Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate Codex/agents SKILL.md for retro shareable card Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: widen retro global card — never truncate repo names Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: retro global card — left border only, drop unreliable right border Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
// Import normalizeRemoteUrl for unit testing
|
||||
// We test the script end-to-end via CLI and normalizeRemoteUrl via import
|
||||
const scriptPath = join(import.meta.dir, "..", "bin", "gstack-global-discover.ts");
|
||||
|
||||
describe("gstack-global-discover", () => {
|
||||
describe("normalizeRemoteUrl", () => {
|
||||
// Dynamically import to test the exported function
|
||||
let normalizeRemoteUrl: (url: string) => string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import("../bin/gstack-global-discover.ts");
|
||||
normalizeRemoteUrl = mod.normalizeRemoteUrl;
|
||||
});
|
||||
|
||||
test("strips .git suffix", () => {
|
||||
expect(normalizeRemoteUrl("https://github.com/user/repo.git")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts SSH to HTTPS", () => {
|
||||
expect(normalizeRemoteUrl("git@github.com:user/repo.git")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts SSH without .git to HTTPS", () => {
|
||||
expect(normalizeRemoteUrl("git@github.com:user/repo")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("lowercases host", () => {
|
||||
expect(normalizeRemoteUrl("https://GitHub.COM/user/repo")).toBe(
|
||||
"https://github.com/user/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("SSH and HTTPS for same repo normalize to same URL", () => {
|
||||
const ssh = normalizeRemoteUrl("git@github.com:garrytan/gstack.git");
|
||||
const https = normalizeRemoteUrl("https://github.com/garrytan/gstack.git");
|
||||
const httpsNoDotGit = normalizeRemoteUrl("https://github.com/garrytan/gstack");
|
||||
expect(ssh).toBe(https);
|
||||
expect(https).toBe(httpsNoDotGit);
|
||||
});
|
||||
|
||||
test("handles local: URLs consistently", () => {
|
||||
const result = normalizeRemoteUrl("local:/tmp/my-repo");
|
||||
// local: gets parsed as a URL scheme — the important thing is consistency
|
||||
expect(result).toContain("/tmp/my-repo");
|
||||
});
|
||||
|
||||
test("handles GitLab SSH URLs", () => {
|
||||
expect(normalizeRemoteUrl("git@gitlab.com:org/project.git")).toBe(
|
||||
"https://gitlab.com/org/project"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI", () => {
|
||||
test("--help exits 0 and prints usage", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath, "--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("--since");
|
||||
});
|
||||
|
||||
test("no args exits 1 with error", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("--since is required");
|
||||
});
|
||||
|
||||
test("invalid window format exits 1", () => {
|
||||
const result = spawnSync("bun", ["run", scriptPath, "--since", "abc"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Invalid window format");
|
||||
});
|
||||
|
||||
test("--since 7d produces valid JSON", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "7d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json).toHaveProperty("window", "7d");
|
||||
expect(json).toHaveProperty("repos");
|
||||
expect(json).toHaveProperty("total_sessions");
|
||||
expect(json).toHaveProperty("total_repos");
|
||||
expect(json).toHaveProperty("tools");
|
||||
expect(Array.isArray(json.repos)).toBe(true);
|
||||
});
|
||||
|
||||
test("--since 7d --format summary produces readable output", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "7d", "--format", "summary"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Window: 7d");
|
||||
expect(result.stdout).toContain("Sessions:");
|
||||
expect(result.stdout).toContain("Repos:");
|
||||
});
|
||||
|
||||
test("--since 1h returns results (may be empty)", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "1h", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.total_sessions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery output structure", () => {
|
||||
test("repos have required fields", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
for (const repo of json.repos) {
|
||||
expect(repo).toHaveProperty("name");
|
||||
expect(repo).toHaveProperty("remote");
|
||||
expect(repo).toHaveProperty("paths");
|
||||
expect(repo).toHaveProperty("sessions");
|
||||
expect(Array.isArray(repo.paths)).toBe(true);
|
||||
expect(repo.paths.length).toBeGreaterThan(0);
|
||||
expect(repo.sessions).toHaveProperty("claude_code");
|
||||
expect(repo.sessions).toHaveProperty("codex");
|
||||
expect(repo.sessions).toHaveProperty("gemini");
|
||||
}
|
||||
});
|
||||
|
||||
test("tools summary matches repo data", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
// Total sessions should equal sum across tools
|
||||
const toolTotal =
|
||||
json.tools.claude_code.total_sessions +
|
||||
json.tools.codex.total_sessions +
|
||||
json.tools.gemini.total_sessions;
|
||||
expect(json.total_sessions).toBe(toolTotal);
|
||||
});
|
||||
|
||||
test("deduplicates Conductor workspaces by remote", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
const json = JSON.parse(result.stdout);
|
||||
|
||||
// Check that no two repos share the same normalized remote
|
||||
const remotes = json.repos.map((r: any) => r.remote);
|
||||
const uniqueRemotes = new Set(remotes);
|
||||
expect(remotes.length).toBe(uniqueRemotes.size);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,9 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'retro': ['retro/**'],
|
||||
'retro-base-branch': ['retro/**'],
|
||||
|
||||
// Global discover
|
||||
'global-discover': ['bin/gstack-global-discover.ts', 'test/global-discover.test.ts'],
|
||||
|
||||
// Document-release
|
||||
'document-release': ['document-release/**'],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user