Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,15 @@ Skills inherit Claude Code's auth; no API key prompt.

## Commands

Four orthogonal commands. Each works standalone — no `init`, no persistent state.
Five orthogonal commands. Each works standalone — no `init`, no persistent state.

| Command | Description |
|---|---|
| `gw commit [intent]` | Generates a Conventional Commits message for the staged diff. Detects whether the diff is a single logical change or **multiple contexts**; if multiple, offers an interactive commit-split plan. `--push` commits and pushes in one step. |
| `gw review [intent]` | AI review of the current branch vs. base. Findings categorized as **Critical / Suggestions / Nitpicks**. `--json` for scripting. |
| `gw pr [intent]` | Drafts a PR title + body from the branch commits. Opens the PR via `gh` if installed; otherwise prints title + body for manual creation. `--update` refreshes an existing PR. |
| `gw release` | Inspects commits since the last tag, recommends a semver bump, updates `CHANGELOG.md`, writes release notes (English default; PT / ES / FR available), bumps `package.json`, tags, pushes, and creates a GitHub release via `gh` when available. |
| `gw issue "<description>"` | Drafts a structured issue title + body from a free-text description, detecting bug report vs. feature request. Creates the issue via `gh` with `--apply`; `--label` and `--assignee` attach metadata. |

Every LLM call prints input/output token counts after the operation. The model tier (`fast` / `balanced` / `powerful`) is routed per-command and configurable per repo.

Expand Down
57 changes: 57 additions & 0 deletions docs/src/content/docs/commands/issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: gw issue
description: AI-drafted GitHub issue — file a bug or feature request from a description
---

```bash
gw issue "<description>" [options]
```

Drafts a structured GitHub issue (title + body) from a free-text description using AI,
then creates it via the `gh` CLI. The drafter decides whether the description is a bug
report or a feature request and shapes the body accordingly (steps to reproduce vs.
acceptance criteria).

## Arguments & Options

| Argument/Option | Description |
|-----------------|-------------|
| `description` | Free-text description of the bug or feature (required) |
| `--label <a,b>` | Comma-separated labels to attach to the issue |
| `--assignee <user>` | Assign the issue — repeatable or comma-separated |
| `--prompt <text>` | Additional focus instructions for the drafter |
| `--apply` | Create the issue immediately (omit to only preview the draft) |

## Prerequisites

- GitHub CLI (`gh`) installed and authenticated
- An API key or Claude Code provider configured (same as the other commands)

## What it does

1. Takes your description (plus the current branch as context, when available).
2. Generates a clear issue title and a structured body via the LLM.
3. Shows a preview (title, labels, assignees, body) and asks for confirmation.
4. On `--apply`, creates the issue with `gh issue create` and returns its URL.

## Examples

```bash
# Preview a bug-report draft (nothing is created)
gw issue "Logout button does nothing on mobile Safari"

# Create the issue with labels and an assignee
gw issue "Add dark mode toggle to settings" --label "enhancement,ui" --assignee me --apply

# Steer the drafter with extra context
gw issue "Crash on import" --prompt "Focus on the CSV parser; include the stack trace location" --apply
```

## Exit codes

| Code | Meaning |
|------|---------|
| `INVALID_INTENT` (11) | No description was provided |
| `GH_FAILED` (21) | `gh` is unavailable or returned empty output — check `gh auth status` |

See [Exit Codes](/exit-codes/) for the full list.
2 changes: 2 additions & 0 deletions packages/cli/__tests__/run-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jest.unstable_mockModule("@denisvieiradev/gitwise-core", async () => {
review: jest.fn(),
pr: jest.fn(),
applyPr: jest.fn(),
issue: jest.fn(),
applyIssue: jest.fn(),
prepareRelease: jest.fn(),
finishRelease: jest.fn(),
abortRelease: jest.fn(),
Expand Down
125 changes: 125 additions & 0 deletions packages/cli/src/commands/issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Command } from "commander";
import chalk from "chalk";
import * as p from "@clack/prompts";
import {
getMergedConfig,
getApiKey,
createProvider,
issue,
applyIssue,
} from "@denisvieiradev/gitwise-core";
import os from "node:os";

function parseList(value: string, previous: string[] = []): string[] {
const items = value
.split(",")
.map((v) => v.trim())
.filter(Boolean);
return [...previous, ...items];
}

export function makeIssueCommand(): Command {
return new Command("issue")
.description("AI-drafted GitHub issue — file a bug or feature request from a description")
.argument("[description...]", "Free-text description of the bug or feature")
.option("--label <a,b>", "Comma-separated labels to attach", parseList, [])
.option("--assignee <user>", "Assign the issue (repeatable or comma-separated)", parseList, [])
.option("--prompt <text>", "Additional focus instructions for the issue drafter")
.option("--apply", "Skip confirmation and create the issue immediately")
.action(
async (
descriptionParts: string[],
opts: { label: string[]; assignee: string[]; prompt?: string; apply: boolean },
) => {
const cwd = process.cwd();
const homeDir = os.homedir();
const description = descriptionParts.join(" ").trim();

let config;
try {
config = await getMergedConfig({ cwd, homeDir });
} catch {
console.error(chalk.red("Error: Could not load gitwise config."));
process.exit(1);
}

const apiKey = await getApiKey(homeDir);
const provider = createProvider({ kind: config.provider, models: config.models, apiKey, claudeCliPath: config.claudeCliPath });

p.intro(chalk.bold("gitwise issue"));

const spinner = p.spinner();
spinner.start("Drafting issue…");

let draft;
try {
draft = await issue({
description,
prompt: opts.prompt,
labels: opts.label.length ? opts.label : undefined,
assignees: opts.assignee.length ? opts.assignee : undefined,
provider,
cwd,
});
} catch (err: unknown) {
spinner.stop("Failed");
const msg = err instanceof Error ? err.message : String(err);
p.cancel(`Error: ${msg}`);
process.exit(1);
}

spinner.stop("Issue drafted");

// Display draft
console.log(chalk.bold("\nTitle:"), chalk.cyan(draft.title));
if (draft.labels?.length) {
console.log(chalk.bold("Labels:"), draft.labels.join(", "));
}
if (draft.assignees?.length) {
console.log(chalk.bold("Assignees:"), draft.assignees.join(", "));
}
console.log(chalk.bold("\nBody:"));
console.log(chalk.dim("─".repeat(60)));
console.log(draft.body);
console.log(chalk.dim("─".repeat(60)));
console.log(chalk.dim(`\n Tokens: ${draft.tokens.input} in / ${draft.tokens.output} out`));

let confirmed = opts.apply;
if (!confirmed) {
const answer = await p.confirm({ message: "Create this issue?" });
if (p.isCancel(answer) || !answer) {
p.cancel("Cancelled.");
process.exit(0);
}
confirmed = true;
}

const applySpinner = p.spinner();
applySpinner.start("Creating issue…");

let result;
try {
result = await applyIssue(draft, { cwd });
} catch (err: unknown) {
const code = (err as { code?: unknown })?.code;
if (code === "GH_UNAVAILABLE") {
applySpinner.stop("gh CLI not found");
console.log(chalk.bold("\nTitle:"), chalk.cyan(draft.title));
console.log(chalk.bold("\nBody:"));
console.log(chalk.dim("─".repeat(60)));
console.log(draft.body);
console.log(chalk.dim("─".repeat(60)));
p.outro(chalk.yellow("Install the gh CLI (https://cli.github.com) to create issues."));
return;
}
applySpinner.stop("Failed");
const msg = err instanceof Error ? err.message : String(err);
p.cancel(`Issue creation failed: ${msg}`);
process.exit(1);
}

applySpinner.stop("Done");
p.outro(chalk.green(`Issue: ${result.url}`));
},
);
}
2 changes: 2 additions & 0 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeCommitCommand } from "./commands/commit.js";
import { makeReviewCommand } from "./commands/review.js";
import { makePrCommand } from "./commands/pr.js";
import { makeReleaseCommand } from "./commands/release.js";
import { makeIssueCommand } from "./commands/issue.js";

const requireFromHere = createRequire(import.meta.url);
const pkg = requireFromHere("../package.json") as { version: string };
Expand Down Expand Up @@ -43,6 +44,7 @@ export function createProgram(): Command {
program.addCommand(makeReviewCommand());
program.addCommand(makePrCommand());
program.addCommand(makeReleaseCommand());
program.addCommand(makeIssueCommand());
program.addCommand(makeConfigCommand());

return program;
Expand Down
63 changes: 63 additions & 0 deletions packages/core/__tests__/unit/infra/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,69 @@ describe("github infra (core)", () => {
expect(err).toMatchObject({ code: "GH_FAILED" });
});

it("createIssue throws GitwiseError code GH_FAILED on empty stdout", async () => {
jest.resetModules();
jest.unstable_mockModule("node:child_process", () => ({
execFile: (
_cmd: string,
_args: string[],
_opts: unknown,
cb: (err: Error | null, result: { stdout: string; stderr: string }) => void,
): void => {
cb(null, { stdout: "", stderr: "" });
},
}));
const { createIssue } = await import("../../../src/infra/github.js");
const { GitwiseError: GE } = await import("../../../src/errors.js");
const err = await createIssue({ title: "t", body: "b", cwd: "/tmp" }).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(GE);
expect(err).toMatchObject({ code: "GH_FAILED" });
});

it("createIssue calls gh with an args array (never a shell string) including labels and assignees", async () => {
jest.resetModules();
const captured: { cmd?: string; args?: string[] } = {};
jest.unstable_mockModule("node:child_process", () => ({
execFile: (
cmd: string,
cmdArgs: string[],
_opts: unknown,
cb: (err: Error | null, result: { stdout: string; stderr: string }) => void,
): void => {
captured.cmd = cmd;
captured.args = cmdArgs;
cb(null, { stdout: "https://github.com/o/r/issues/1\n", stderr: "" });
},
}));
const { createIssue } = await import("../../../src/infra/github.js");
const result = await createIssue({
title: "t",
body: "b",
cwd: "/tmp",
labels: ["bug", "ui"],
assignees: ["alice"],
});
expect(result.url).toBe("https://github.com/o/r/issues/1");
expect(captured.cmd).toBe("gh");
expect(Array.isArray(captured.args)).toBe(true);
expect(captured.args).toEqual([
"issue",
"create",
"--title",
"t",
"--body",
"b",
"--label",
"bug",
"--label",
"ui",
"--assignee",
"alice",
]);
});

it("createGitHubRelease throws GitwiseError code GH_FAILED on empty stdout", async () => {
jest.resetModules();
jest.unstable_mockModule("node:child_process", () => ({
Expand Down
8 changes: 6 additions & 2 deletions packages/core/__tests__/unit/providers/model-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect } from "@jest/globals";
import { resolveModelTier, SUPPORTED_COMMANDS } from "../../../src/providers/model-router.js";

describe("model-router (core)", () => {
it("exposes exactly the four supported command keys", () => {
expect(SUPPORTED_COMMANDS.sort()).toEqual(["commit", "pr", "release", "review"].sort());
it("exposes exactly the supported command keys", () => {
expect(SUPPORTED_COMMANDS.sort()).toEqual(["commit", "issue", "pr", "release", "review"].sort());
});

it("commit defaults to fast tier", () => {
Expand All @@ -22,6 +22,10 @@ describe("model-router (core)", () => {
expect(resolveModelTier("release")).toBe("fast");
});

it("issue defaults to fast tier", () => {
expect(resolveModelTier("issue")).toBe("fast");
});

it("unknown command defaults to balanced", () => {
expect(resolveModelTier("unknown")).toBe("balanced");
});
Expand Down
Loading