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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ storm continue 42 --dry-run

# Update storm-agent to the latest version
storm update

# Register a project for global operations
storm global add .

# List all registered projects with issue counts
storm global list

# Run storm across all registered projects
storm global run

# Preview issues across all projects without executing
storm global run --dry-run

# Run all projects concurrently
storm global run --parallel

# Show branches and PRs across all projects
storm global status

# Unregister a project
storm global remove .
```

## Generating issues
Expand Down Expand Up @@ -156,6 +177,36 @@ The continue template supports all standard placeholders plus PR-specific ones:
| `{{ pr.diff }}` | Diff stat summary |
| `{{ pr.reviews }}` | Formatted review comments with file paths and diff hunks |

## Global mode

The `storm global` command lets you manage and run storm across multiple projects from a single place. Project paths are stored in `~/.storm/global.json`.

```bash
# Register projects
storm global add /path/to/project-a
storm global add .

# See all registered projects and their issue counts
storm global list

# Run storm across all projects sequentially
storm global run

# Run in parallel
storm global run --parallel

# Preview without executing
storm global run --dry-run

# Check status across all projects
storm global status

# Unregister a project
storm global remove /path/to/project-a
```

Projects must have a valid `.storm/storm.json` to be registered. Invalid or missing projects are skipped with a warning during `global run` and `global status`.

## Updating storm-agent

If you installed via the quick install script, update to the latest version with:
Expand Down
48 changes: 48 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { statusCommand } from "./src/commands/status.js";
import { generateCommand } from "./src/commands/generate.js";
import { updateCommand } from "./src/commands/update.js";
import { continueCommand } from "./src/commands/continue.js";
import {
globalAddCommand,
globalRemoveCommand,
globalListCommand,
globalRunCommand,
globalStatusCommand,
} from "./src/commands/global.js";

const program = new Command();

Expand Down Expand Up @@ -75,4 +82,45 @@ program
await updateCommand();
});

const globalCmd = program
.command("global")
.description("Manage and run storm across multiple projects");

globalCmd
.command("add <path>")
.description("Register a project path for global operations")
.action(async (path: string) => {
await globalAddCommand(path);
});

globalCmd
.command("remove <path>")
.description("Unregister a project path")
.action(async (path: string) => {
await globalRemoveCommand(path);
});

globalCmd
.command("list")
.description("Show registered projects with issue counts")
.action(async () => {
await globalListCommand();
});

globalCmd
.command("run")
.description("Run storm across all registered projects")
.option("--dry-run", "Preview issues without executing")
.option("--parallel", "Run projects concurrently instead of sequentially")
.action(async (options) => {
await globalRunCommand({ dryRun: options.dryRun, parallel: options.parallel });
});

globalCmd
.command("status")
.description("Show storm branches and PRs across all projects")
.action(async () => {
await globalStatusCommand();
});

program.parse();
182 changes: 182 additions & 0 deletions src/__tests__/global-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { join, resolve } from "path";
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs";
import { tmpdir } from "os";
import {
loadGlobalConfig,
saveGlobalConfig,
addProject,
removeProject,
GLOBAL_CONFIG_DIR,
GLOBAL_CONFIG_FILE,
} from "../core/global-config.js";

function createTempDir(): string {
const dir = join(tmpdir(), `storm-global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}

function createStormProject(projectPath: string): void {
const stormDir = join(projectPath, ".storm");
mkdirSync(stormDir, { recursive: true });
writeFileSync(
join(stormDir, "storm.json"),
JSON.stringify({
github: { repo: "owner/repo", label: "storm", baseBranch: "main" },
})
);
}

describe("global-config module exports", () => {
it("exports correct global config path constants", () => {
expect(GLOBAL_CONFIG_DIR).toContain(".storm");
expect(GLOBAL_CONFIG_FILE).toContain("global.json");
});
});

describe("addProject", () => {
// We need to back up and restore the real global config
let originalContent: string | null = null;

beforeEach(() => {
try {
originalContent = readFileSync(GLOBAL_CONFIG_FILE, "utf-8");
} catch {
originalContent = null;
}
// Start with clean config
if (existsSync(GLOBAL_CONFIG_FILE)) {
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify({ projects: [] }));
}
});

afterEach(() => {
if (originalContent !== null) {
writeFileSync(GLOBAL_CONFIG_FILE, originalContent);
} else if (existsSync(GLOBAL_CONFIG_FILE)) {
rmSync(GLOBAL_CONFIG_FILE);
}
});

it("adds a valid project", async () => {
const projectPath = createTempDir();
createStormProject(projectPath);

const result = await addProject(projectPath);
expect(result.added).toBe(true);
expect(result.resolved).toBe(resolve(projectPath));

const config = await loadGlobalConfig();
expect(config.projects.some((p) => p.path === resolve(projectPath))).toBe(true);

rmSync(projectPath, { recursive: true, force: true });
});

it("rejects project without storm config", async () => {
const projectPath = createTempDir();
// No .storm/storm.json created

const result = await addProject(projectPath);
expect(result.added).toBe(false);
expect(result.error).toContain("No .storm/storm.json found");

rmSync(projectPath, { recursive: true, force: true });
});

it("rejects duplicate project", async () => {
const projectPath = createTempDir();
createStormProject(projectPath);

const first = await addProject(projectPath);
expect(first.added).toBe(true);

const second = await addProject(projectPath);
expect(second.added).toBe(false);
expect(second.error).toContain("already registered");

rmSync(projectPath, { recursive: true, force: true });
});

it("resolves relative path to absolute", async () => {
// Create a project in a known temp location
const projectPath = createTempDir();
createStormProject(projectPath);

const result = await addProject(projectPath);
expect(result.resolved).toBe(resolve(projectPath));
// The resolved path should be absolute
expect(result.resolved.startsWith("/")).toBe(true);

rmSync(projectPath, { recursive: true, force: true });
});
});

describe("removeProject", () => {
let originalContent: string | null = null;

beforeEach(() => {
try {
originalContent = readFileSync(GLOBAL_CONFIG_FILE, "utf-8");
} catch {
originalContent = null;
}
});

afterEach(() => {
if (originalContent !== null) {
writeFileSync(GLOBAL_CONFIG_FILE, originalContent);
} else if (existsSync(GLOBAL_CONFIG_FILE)) {
rmSync(GLOBAL_CONFIG_FILE);
}
});

it("removes an existing project", async () => {
const projectPath = createTempDir();
createStormProject(projectPath);

// Add first
await addProject(projectPath);
const resolved = resolve(projectPath);

// Verify it's there
let config = await loadGlobalConfig();
expect(config.projects.some((p) => p.path === resolved)).toBe(true);

// Remove
const result = await removeProject(projectPath);
expect(result.removed).toBe(true);

// Verify it's gone
config = await loadGlobalConfig();
expect(config.projects.some((p) => p.path === resolved)).toBe(false);

rmSync(projectPath, { recursive: true, force: true });
});

it("returns false for non-registered project", async () => {
await saveGlobalConfig({ projects: [] });

const result = await removeProject("/nonexistent/path");
expect(result.removed).toBe(false);
});

it("only removes the specified project", async () => {
const projectA = createTempDir();
const projectB = createTempDir();
createStormProject(projectA);
createStormProject(projectB);

await addProject(projectA);
await addProject(projectB);

await removeProject(projectA);

const config = await loadGlobalConfig();
expect(config.projects).toHaveLength(1);
expect(config.projects[0].path).toBe(resolve(projectB));

rmSync(projectA, { recursive: true, force: true });
rmSync(projectB, { recursive: true, force: true });
});
});
Loading
Loading