@towles/tool 0.0.18 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE.md +9 -10
- package/README.md +121 -78
- package/bin/run.ts +5 -0
- package/package.json +63 -53
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +42 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +43 -0
- package/src/commands/doctor.ts +133 -0
- package/src/commands/gh/branch-clean.ts +110 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +132 -0
- package/src/commands/gh/pr.ts +168 -0
- package/src/commands/index.ts +55 -0
- package/src/commands/install.ts +148 -0
- package/src/commands/journal/daily-notes.ts +66 -0
- package/src/commands/journal/meeting.ts +83 -0
- package/src/commands/journal/note.ts +83 -0
- package/src/commands/journal/utils.ts +399 -0
- package/src/commands/observe/graph.test.ts +89 -0
- package/src/commands/observe/graph.ts +1640 -0
- package/src/commands/observe/report.ts +166 -0
- package/src/commands/observe/session.ts +385 -0
- package/src/commands/observe/setup.ts +180 -0
- package/src/commands/observe/status.ts +146 -0
- package/src/commands/ralph/lib/execution.ts +302 -0
- package/src/commands/ralph/lib/formatter.ts +298 -0
- package/src/commands/ralph/lib/index.ts +4 -0
- package/src/commands/ralph/lib/marker.ts +108 -0
- package/src/commands/ralph/lib/state.ts +191 -0
- package/src/commands/ralph/marker/create.ts +23 -0
- package/src/commands/ralph/plan.ts +73 -0
- package/src/commands/ralph/progress.ts +44 -0
- package/src/commands/ralph/ralph.test.ts +673 -0
- package/src/commands/ralph/run.ts +408 -0
- package/src/commands/ralph/task/add.ts +105 -0
- package/src/commands/ralph/task/done.ts +73 -0
- package/src/commands/ralph/task/list.test.ts +48 -0
- package/src/commands/ralph/task/list.ts +110 -0
- package/src/commands/ralph/task/remove.ts +62 -0
- package/src/config/context.ts +7 -0
- package/src/config/settings.ts +155 -0
- package/src/constants.ts +3 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/anthropic/types.ts +158 -0
- package/src/utils/date-utils.test.ts +96 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/exec.ts +8 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
- package/src/utils/git/gh-cli-wrapper.ts +54 -0
- package/src/utils/git/git-wrapper.test.ts +26 -0
- package/src/utils/git/git-wrapper.ts +15 -0
- package/src/utils/git/git.ts +25 -0
- package/src/utils/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -794
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { x } from "tinyexec";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { BaseCommand } from "./base.js";
|
|
6
|
+
|
|
7
|
+
interface CheckResult {
|
|
8
|
+
name: string;
|
|
9
|
+
version: string | null;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
warning?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check system dependencies and environment
|
|
16
|
+
*/
|
|
17
|
+
export default class Doctor extends BaseCommand {
|
|
18
|
+
static override description = "Check system dependencies and environment";
|
|
19
|
+
|
|
20
|
+
static override examples = ["<%= config.bin %> <%= command.id %>"];
|
|
21
|
+
|
|
22
|
+
async run(): Promise<void> {
|
|
23
|
+
await this.parse(Doctor);
|
|
24
|
+
|
|
25
|
+
this.log("Checking dependencies...\n");
|
|
26
|
+
|
|
27
|
+
const checks: CheckResult[] = await Promise.all([
|
|
28
|
+
this.checkCommand("git", ["--version"], /git version ([\d.]+)/),
|
|
29
|
+
this.checkCommand("gh", ["--version"], /gh version ([\d.]+)/),
|
|
30
|
+
this.checkCommand("node", ["--version"], /v?([\d.]+)/),
|
|
31
|
+
this.checkCommand("bun", ["--version"], /([\d.]+)/),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// Display results
|
|
35
|
+
for (const check of checks) {
|
|
36
|
+
const icon = check.ok ? pc.green("✓") : pc.red("✗");
|
|
37
|
+
const version = check.version ?? "not found";
|
|
38
|
+
this.log(`${icon} ${check.name}: ${version}`);
|
|
39
|
+
if (check.warning) {
|
|
40
|
+
this.log(` ${pc.yellow("⚠")} ${check.warning}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check gh auth
|
|
45
|
+
this.log("");
|
|
46
|
+
const ghAuth = await this.checkGhAuth();
|
|
47
|
+
const authIcon = ghAuth.ok ? pc.green("✓") : pc.yellow("⚠");
|
|
48
|
+
this.log(`${authIcon} gh auth: ${ghAuth.ok ? "authenticated" : "not authenticated"}`);
|
|
49
|
+
if (!ghAuth.ok) {
|
|
50
|
+
this.log(` ${pc.dim("Run: gh auth login")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Node version check
|
|
54
|
+
const nodeCheck = checks.find((c) => c.name === "node");
|
|
55
|
+
if (nodeCheck?.version) {
|
|
56
|
+
const major = Number.parseInt(nodeCheck.version.split(".")[0], 10);
|
|
57
|
+
if (major < 18) {
|
|
58
|
+
this.log("");
|
|
59
|
+
this.log(`${pc.yellow("⚠")} Node.js 18+ recommended (found ${nodeCheck.version})`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check ralph files in .gitignore
|
|
64
|
+
this.log("");
|
|
65
|
+
const gitignoreCheck = this.checkRalphGitignore();
|
|
66
|
+
const gitignoreIcon = gitignoreCheck.ok ? pc.green("✓") : pc.yellow("⚠");
|
|
67
|
+
this.log(
|
|
68
|
+
`${gitignoreIcon} .gitignore: ${gitignoreCheck.ok ? "ralph-* excluded" : "ralph-* NOT excluded"}`,
|
|
69
|
+
);
|
|
70
|
+
if (!gitignoreCheck.ok) {
|
|
71
|
+
this.log(` ${pc.dim('Add "ralph-*" to .gitignore to exclude local ralph state files')}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Summary
|
|
75
|
+
const allOk = checks.every((c) => c.ok) && ghAuth.ok && gitignoreCheck.ok;
|
|
76
|
+
this.log("");
|
|
77
|
+
if (allOk) {
|
|
78
|
+
this.log(pc.green("All checks passed!"));
|
|
79
|
+
} else {
|
|
80
|
+
this.log(pc.yellow("Some checks failed. See above for details."));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async checkCommand(
|
|
85
|
+
name: string,
|
|
86
|
+
args: string[],
|
|
87
|
+
versionPattern: RegExp,
|
|
88
|
+
): Promise<CheckResult> {
|
|
89
|
+
try {
|
|
90
|
+
// tinyexec is safe - uses execFile internally, no shell injection risk
|
|
91
|
+
const result = await x(name, args);
|
|
92
|
+
const output = result.stdout + result.stderr;
|
|
93
|
+
const match = output.match(versionPattern);
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
version: match?.[1] ?? output.trim().slice(0, 20),
|
|
97
|
+
ok: true,
|
|
98
|
+
};
|
|
99
|
+
} catch {
|
|
100
|
+
return { name, version: null, ok: false };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async checkGhAuth(): Promise<{ ok: boolean }> {
|
|
105
|
+
try {
|
|
106
|
+
// tinyexec is safe - uses execFile internally, no shell injection risk
|
|
107
|
+
const result = await x("gh", ["auth", "status"]);
|
|
108
|
+
return { ok: result.exitCode === 0 };
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private checkRalphGitignore(): { ok: boolean } {
|
|
115
|
+
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
116
|
+
try {
|
|
117
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
118
|
+
return { ok: false };
|
|
119
|
+
}
|
|
120
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
121
|
+
// Check for ralph-* pattern or specific ralph files
|
|
122
|
+
const hasRalphPattern = content.split("\n").some((line) => {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
return (
|
|
125
|
+
trimmed === "ralph-*" || trimmed === "ralph-*.json" || trimmed === "ralph-state.json"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
return { ok: hasRalphPattern };
|
|
129
|
+
} catch {
|
|
130
|
+
return { ok: false };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import { colors } from "consola/utils";
|
|
3
|
+
import consola from "consola";
|
|
4
|
+
import { x } from "tinyexec";
|
|
5
|
+
|
|
6
|
+
import { BaseCommand } from "../base.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clean up merged branches
|
|
10
|
+
*/
|
|
11
|
+
export default class BranchClean extends BaseCommand {
|
|
12
|
+
static override description = "Delete local branches that have been merged into main";
|
|
13
|
+
|
|
14
|
+
static override examples = [
|
|
15
|
+
"<%= config.bin %> gh branch-clean",
|
|
16
|
+
"<%= config.bin %> gh branch-clean --dry-run",
|
|
17
|
+
"<%= config.bin %> gh branch-clean --force",
|
|
18
|
+
"<%= config.bin %> gh branch-clean --base develop",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
static override flags = {
|
|
22
|
+
...BaseCommand.baseFlags,
|
|
23
|
+
force: Flags.boolean({
|
|
24
|
+
char: "f",
|
|
25
|
+
description: "Skip confirmation prompt",
|
|
26
|
+
default: false,
|
|
27
|
+
}),
|
|
28
|
+
"dry-run": Flags.boolean({
|
|
29
|
+
char: "d",
|
|
30
|
+
description: "Preview branches without deleting",
|
|
31
|
+
default: false,
|
|
32
|
+
}),
|
|
33
|
+
base: Flags.string({
|
|
34
|
+
char: "b",
|
|
35
|
+
description: "Base branch to check against",
|
|
36
|
+
default: "main",
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async run(): Promise<void> {
|
|
41
|
+
const { flags } = await this.parse(BranchClean);
|
|
42
|
+
const baseBranch = flags.base;
|
|
43
|
+
const dryRun = flags["dry-run"];
|
|
44
|
+
|
|
45
|
+
// Get current branch
|
|
46
|
+
const currentResult = await x("git", ["branch", "--show-current"]);
|
|
47
|
+
const currentBranch = currentResult.stdout.trim();
|
|
48
|
+
|
|
49
|
+
// Get merged branches
|
|
50
|
+
const mergedResult = await x("git", ["branch", "--merged", baseBranch]);
|
|
51
|
+
const allMerged = mergedResult.stdout
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((b) => b.trim().replace(/^\* /, ""))
|
|
54
|
+
.filter((b) => b.length > 0);
|
|
55
|
+
|
|
56
|
+
// Exclude protected branches
|
|
57
|
+
const protectedBranches = ["main", "master", "develop", "dev", baseBranch, currentBranch];
|
|
58
|
+
const toDelete = allMerged.filter((b) => !protectedBranches.includes(b));
|
|
59
|
+
|
|
60
|
+
if (toDelete.length === 0) {
|
|
61
|
+
consola.info(colors.green("No merged branches to clean up"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
consola.log(colors.cyan(`Found ${toDelete.length} merged branch(es):`));
|
|
66
|
+
for (const branch of toDelete) {
|
|
67
|
+
consola.log(` - ${branch}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (dryRun) {
|
|
71
|
+
consola.info(colors.yellow("Dry run - no branches deleted"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!flags.force) {
|
|
76
|
+
const answer = await consola.prompt(`Delete ${toDelete.length} branch(es)?`, {
|
|
77
|
+
type: "confirm",
|
|
78
|
+
initial: false,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!answer) {
|
|
82
|
+
consola.info(colors.dim("Canceled"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Delete branches
|
|
88
|
+
let deleted = 0;
|
|
89
|
+
let failed = 0;
|
|
90
|
+
|
|
91
|
+
for (const branch of toDelete) {
|
|
92
|
+
try {
|
|
93
|
+
await x("git", ["branch", "-d", branch]);
|
|
94
|
+
consola.log(colors.green(`✓ Deleted ${branch}`));
|
|
95
|
+
deleted++;
|
|
96
|
+
} catch {
|
|
97
|
+
consola.log(colors.red(`✗ Failed to delete ${branch}`));
|
|
98
|
+
failed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
consola.log("");
|
|
103
|
+
if (deleted > 0) {
|
|
104
|
+
consola.info(colors.green(`Deleted ${deleted} branch(es)`));
|
|
105
|
+
}
|
|
106
|
+
if (failed > 0) {
|
|
107
|
+
consola.warn(colors.yellow(`Failed to delete ${failed} branch(es)`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Issue } from "../../utils/git/gh-cli-wrapper";
|
|
3
|
+
import GhBranch from "./branch";
|
|
4
|
+
|
|
5
|
+
const createBranchNameFromIssue = GhBranch.createBranchNameFromIssue;
|
|
6
|
+
|
|
7
|
+
describe("gh-branch", () => {
|
|
8
|
+
describe("createBranchNameFromIssue", () => {
|
|
9
|
+
it("creates branch name from issue with basic title", () => {
|
|
10
|
+
const issue: Issue = {
|
|
11
|
+
number: 4,
|
|
12
|
+
title: "Long Issue Title - with a lot of words and stuff ",
|
|
13
|
+
state: "open",
|
|
14
|
+
labels: [],
|
|
15
|
+
};
|
|
16
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
17
|
+
expect(branchName).toBe("feature/4-long-issue-title-with-a-lot-of-words-and-stuff");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("handles special characters in title", () => {
|
|
21
|
+
const issue: Issue = {
|
|
22
|
+
number: 123,
|
|
23
|
+
title: "Fix bug: @user reported $100 issue!",
|
|
24
|
+
state: "open",
|
|
25
|
+
labels: [],
|
|
26
|
+
};
|
|
27
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
28
|
+
expect(branchName).toBe("feature/123-fix-bug-user-reported-100-issue");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles title with only numbers", () => {
|
|
32
|
+
const issue: Issue = {
|
|
33
|
+
number: 42,
|
|
34
|
+
title: "123 456",
|
|
35
|
+
state: "open",
|
|
36
|
+
labels: [],
|
|
37
|
+
};
|
|
38
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
39
|
+
expect(branchName).toBe("feature/42-123-456");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("trims trailing dashes", () => {
|
|
43
|
+
const issue: Issue = {
|
|
44
|
+
number: 7,
|
|
45
|
+
title: "Update docs ---",
|
|
46
|
+
state: "open",
|
|
47
|
+
labels: [],
|
|
48
|
+
};
|
|
49
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
50
|
+
expect(branchName).toBe("feature/7-update-docs");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles unicode characters", () => {
|
|
54
|
+
const issue: Issue = {
|
|
55
|
+
number: 99,
|
|
56
|
+
title: "Fix für Übersetzung",
|
|
57
|
+
state: "open",
|
|
58
|
+
labels: [],
|
|
59
|
+
};
|
|
60
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
61
|
+
expect(branchName).toBe("feature/99-fix-f-r-bersetzung");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles empty-ish title", () => {
|
|
65
|
+
const issue: Issue = {
|
|
66
|
+
number: 1,
|
|
67
|
+
title: " ",
|
|
68
|
+
state: "open",
|
|
69
|
+
labels: [],
|
|
70
|
+
};
|
|
71
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
72
|
+
expect(branchName).toBe("feature/1-");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles title with underscores", () => {
|
|
76
|
+
const issue: Issue = {
|
|
77
|
+
number: 50,
|
|
78
|
+
title: "snake_case_title",
|
|
79
|
+
state: "open",
|
|
80
|
+
labels: [],
|
|
81
|
+
};
|
|
82
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
83
|
+
expect(branchName).toBe("feature/50-snake_case_title");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("handles very long titles", () => {
|
|
87
|
+
const issue: Issue = {
|
|
88
|
+
number: 200,
|
|
89
|
+
title: "This is a very long issue title that goes on and on with many words",
|
|
90
|
+
state: "open",
|
|
91
|
+
labels: [],
|
|
92
|
+
};
|
|
93
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
94
|
+
expect(branchName).toBe(
|
|
95
|
+
"feature/200-this-is-a-very-long-issue-title-that-goes-on-and-on-with-many-words",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("collapses multiple consecutive dashes", () => {
|
|
100
|
+
const issue: Issue = {
|
|
101
|
+
number: 15,
|
|
102
|
+
title: "Fix multiple spaces",
|
|
103
|
+
state: "open",
|
|
104
|
+
labels: [],
|
|
105
|
+
};
|
|
106
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
107
|
+
expect(branchName).toBe("feature/15-fix-multiple-spaces");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles title with brackets and parentheses", () => {
|
|
111
|
+
const issue: Issue = {
|
|
112
|
+
number: 33,
|
|
113
|
+
title: "[Bug] Fix (critical) issue",
|
|
114
|
+
state: "open",
|
|
115
|
+
labels: [],
|
|
116
|
+
};
|
|
117
|
+
const branchName = createBranchNameFromIssue(issue);
|
|
118
|
+
expect(branchName).toBe("feature/33--bug-fix-critical-issue");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// TODO: Integration tests for githubBranchCommand require module mocking
|
|
123
|
+
// which works differently in bun:test vs vitest
|
|
124
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import prompts from "prompts";
|
|
3
|
+
import type { Choice } from "prompts";
|
|
4
|
+
import { colors } from "consola/utils";
|
|
5
|
+
import { Fzf } from "fzf";
|
|
6
|
+
import consola from "consola";
|
|
7
|
+
|
|
8
|
+
import { BaseCommand } from "../base.js";
|
|
9
|
+
import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
|
|
10
|
+
import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
|
|
11
|
+
import { createBranch } from "../../utils/git/git-wrapper.js";
|
|
12
|
+
import { getTerminalColumns, limitText, printWithHexColor } from "../../utils/render.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a git branch from a GitHub issue
|
|
16
|
+
*/
|
|
17
|
+
export default class GhBranch extends BaseCommand {
|
|
18
|
+
static override description = "Create a git branch from a GitHub issue";
|
|
19
|
+
|
|
20
|
+
static override examples = [
|
|
21
|
+
"<%= config.bin %> gh branch",
|
|
22
|
+
"<%= config.bin %> gh branch --assignedToMe",
|
|
23
|
+
"<%= config.bin %> gh branch -a",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
static override flags = {
|
|
27
|
+
...BaseCommand.baseFlags,
|
|
28
|
+
assignedToMe: Flags.boolean({
|
|
29
|
+
char: "a",
|
|
30
|
+
description: "Only show issues assigned to me",
|
|
31
|
+
default: false,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async run(): Promise<void> {
|
|
36
|
+
const { flags } = await this.parse(GhBranch);
|
|
37
|
+
|
|
38
|
+
// Check prerequisites
|
|
39
|
+
const cliInstalled = await isGithubCliInstalled();
|
|
40
|
+
if (!cliInstalled) {
|
|
41
|
+
consola.log("Github CLI not installed");
|
|
42
|
+
this.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
consola.log("Assigned to me:", flags.assignedToMe);
|
|
46
|
+
|
|
47
|
+
const currentIssues = await getIssues({ assignedToMe: flags.assignedToMe, cwd: process.cwd() });
|
|
48
|
+
if (currentIssues.length === 0) {
|
|
49
|
+
consola.log(colors.yellow("No issues found, check assignments"));
|
|
50
|
+
this.exit(1);
|
|
51
|
+
} else {
|
|
52
|
+
consola.log(colors.green(`${currentIssues.length} Issues found assigned to you`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Format table with nice labels
|
|
56
|
+
let lineMaxLength = getTerminalColumns();
|
|
57
|
+
const longestNumber = Math.max(...currentIssues.map((i) => i.number.toString().length));
|
|
58
|
+
const longestLabels = Math.max(
|
|
59
|
+
...currentIssues.map((i) => i.labels.map((x) => x.name).join(", ").length),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
lineMaxLength = lineMaxLength > 130 ? 130 : lineMaxLength;
|
|
63
|
+
const descriptionLength = lineMaxLength - longestNumber - longestLabels - 15;
|
|
64
|
+
|
|
65
|
+
const choices: Choice[] = currentIssues.map((i) => {
|
|
66
|
+
const labelText = i.labels
|
|
67
|
+
.map((l) => printWithHexColor({ msg: l.name, hex: l.color }))
|
|
68
|
+
.join(", ");
|
|
69
|
+
const labelTextNoColor = i.labels.map((l) => l.name).join(", ");
|
|
70
|
+
const labelStartpad = longestLabels - labelTextNoColor.length;
|
|
71
|
+
return {
|
|
72
|
+
title: i.number.toString(),
|
|
73
|
+
value: i.number,
|
|
74
|
+
description: `${limitText(i.title, descriptionLength).padEnd(descriptionLength)} ${"".padStart(labelStartpad)}${labelText}`,
|
|
75
|
+
} as Choice;
|
|
76
|
+
});
|
|
77
|
+
choices.push({ title: "Cancel", value: "cancel" });
|
|
78
|
+
|
|
79
|
+
const fzf = new Fzf(choices, {
|
|
80
|
+
selector: (item) => `${item.value} ${item.description}`,
|
|
81
|
+
casing: "case-insensitive",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = await prompts(
|
|
86
|
+
{
|
|
87
|
+
name: "issueNumber",
|
|
88
|
+
message: "Github issue to create branch for:",
|
|
89
|
+
type: "autocomplete",
|
|
90
|
+
choices,
|
|
91
|
+
async suggest(input: string, choices: Choice[]) {
|
|
92
|
+
consola.log(input);
|
|
93
|
+
const results = fzf.find(input);
|
|
94
|
+
return results.map((r) => choices.find((c) => c.value === r.item.value));
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
onCancel: () => {
|
|
99
|
+
consola.info(colors.dim("Canceled"));
|
|
100
|
+
this.exit(0);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (result.issueNumber === "cancel") {
|
|
106
|
+
consola.log(colors.dim("Canceled"));
|
|
107
|
+
this.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const selectedIssue = currentIssues.find((i) => i.number === result.issueNumber)!;
|
|
111
|
+
consola.log(
|
|
112
|
+
`Selected issue ${colors.green(selectedIssue.number)} - ${colors.green(selectedIssue.title)}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const branchName = GhBranch.createBranchNameFromIssue(selectedIssue);
|
|
116
|
+
createBranch({ branchName });
|
|
117
|
+
} catch {
|
|
118
|
+
this.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static createBranchNameFromIssue(selectedIssue: Issue): string {
|
|
123
|
+
let slug = selectedIssue.title.toLowerCase();
|
|
124
|
+
slug = slug.trim();
|
|
125
|
+
slug = slug.replaceAll(" ", "-");
|
|
126
|
+
slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
|
|
127
|
+
slug = slug.replace(/-+/g, "-");
|
|
128
|
+
slug = slug.replace(/-+$/, "");
|
|
129
|
+
|
|
130
|
+
return `feature/${selectedIssue.number}-${slug}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import { x } from "tinyexec";
|
|
3
|
+
import consola from "consola";
|
|
4
|
+
import { colors } from "consola/utils";
|
|
5
|
+
|
|
6
|
+
import { BaseCommand } from "../base.js";
|
|
7
|
+
import { isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a pull request from the current branch
|
|
11
|
+
* Note: Uses tinyexec which is safe (execFile-based, no shell injection)
|
|
12
|
+
*/
|
|
13
|
+
export default class Pr extends BaseCommand {
|
|
14
|
+
static override description = "Create a pull request from the current branch";
|
|
15
|
+
|
|
16
|
+
static override examples = [
|
|
17
|
+
"<%= config.bin %> gh pr",
|
|
18
|
+
"<%= config.bin %> gh pr --draft",
|
|
19
|
+
"<%= config.bin %> gh pr --base develop",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
static override flags = {
|
|
23
|
+
...BaseCommand.baseFlags,
|
|
24
|
+
draft: Flags.boolean({
|
|
25
|
+
description: "Create as draft PR",
|
|
26
|
+
default: false,
|
|
27
|
+
}),
|
|
28
|
+
base: Flags.string({
|
|
29
|
+
char: "b",
|
|
30
|
+
description: "Base branch for the PR",
|
|
31
|
+
default: "main",
|
|
32
|
+
}),
|
|
33
|
+
yes: Flags.boolean({
|
|
34
|
+
char: "y",
|
|
35
|
+
description: "Skip confirmation prompt",
|
|
36
|
+
default: false,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async run(): Promise<void> {
|
|
41
|
+
const { flags } = await this.parse(Pr);
|
|
42
|
+
|
|
43
|
+
// Check prerequisites
|
|
44
|
+
const cliInstalled = await isGithubCliInstalled();
|
|
45
|
+
if (!cliInstalled) {
|
|
46
|
+
consola.error("GitHub CLI not installed");
|
|
47
|
+
this.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get current branch
|
|
51
|
+
const branchResult = await x("git", ["branch", "--show-current"]);
|
|
52
|
+
const currentBranch = branchResult.stdout.trim();
|
|
53
|
+
|
|
54
|
+
if (!currentBranch) {
|
|
55
|
+
consola.error("Not on a branch (detached HEAD?)");
|
|
56
|
+
this.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (currentBranch === flags.base) {
|
|
60
|
+
consola.error(`Already on base branch ${flags.base}`);
|
|
61
|
+
this.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
consola.info(`Current branch: ${colors.cyan(currentBranch)}`);
|
|
65
|
+
consola.info(`Base branch: ${colors.cyan(flags.base)}`);
|
|
66
|
+
|
|
67
|
+
// Get commits between base and current branch
|
|
68
|
+
const logResult = await x("git", ["log", `${flags.base}..HEAD`, "--pretty=format:%s"]);
|
|
69
|
+
|
|
70
|
+
const commits = logResult.stdout.trim().split("\n").filter(Boolean);
|
|
71
|
+
|
|
72
|
+
if (commits.length === 0) {
|
|
73
|
+
consola.error(`No commits between ${flags.base} and ${currentBranch}`);
|
|
74
|
+
this.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
consola.info(`Found ${colors.green(commits.length.toString())} commits`);
|
|
78
|
+
|
|
79
|
+
// Generate PR title and body
|
|
80
|
+
const { title, body } = this.generatePrContent(currentBranch, commits);
|
|
81
|
+
|
|
82
|
+
consola.box({
|
|
83
|
+
title: "PR Preview",
|
|
84
|
+
message: `Title: ${title}\n\n${body}`,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Confirm unless --yes
|
|
88
|
+
if (!flags.yes) {
|
|
89
|
+
const confirmed = await consola.prompt("Create this PR?", {
|
|
90
|
+
type: "confirm",
|
|
91
|
+
initial: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!confirmed) {
|
|
95
|
+
consola.info(colors.dim("Canceled"));
|
|
96
|
+
this.exit(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Push branch if needed
|
|
101
|
+
const statusResult = await x("git", ["status", "-sb"]);
|
|
102
|
+
const needsPush = !statusResult.stdout.includes("origin/");
|
|
103
|
+
|
|
104
|
+
if (needsPush) {
|
|
105
|
+
consola.info("Pushing branch to remote...");
|
|
106
|
+
await x("git", ["push", "-u", "origin", currentBranch]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Create PR
|
|
110
|
+
const prArgs = ["pr", "create", "--title", title, "--body", body, "--base", flags.base];
|
|
111
|
+
|
|
112
|
+
if (flags.draft) {
|
|
113
|
+
prArgs.push("--draft");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prResult = await x("gh", prArgs);
|
|
117
|
+
const prUrl = prResult.stdout.trim();
|
|
118
|
+
|
|
119
|
+
consola.success(`PR created: ${colors.cyan(prUrl)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private generatePrContent(branch: string, commits: string[]): { title: string; body: string } {
|
|
123
|
+
// Extract issue number from branch name if present (e.g., feature/123-some-feature)
|
|
124
|
+
const issueMatch = branch.match(/(\d+)/);
|
|
125
|
+
const issueNumber = issueMatch ? issueMatch[1] : null;
|
|
126
|
+
|
|
127
|
+
// Generate title from first commit or branch name
|
|
128
|
+
let title: string;
|
|
129
|
+
if (commits.length === 1) {
|
|
130
|
+
title = commits[0];
|
|
131
|
+
} else {
|
|
132
|
+
// Use branch name, cleaned up
|
|
133
|
+
title = branch
|
|
134
|
+
.replace(/^(feature|fix|bugfix|hotfix|chore|refactor)\//, "")
|
|
135
|
+
.replace(/^\d+-/, "")
|
|
136
|
+
.replace(/-/g, " ")
|
|
137
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Generate body
|
|
141
|
+
const lines: string[] = ["## Summary", ""];
|
|
142
|
+
|
|
143
|
+
if (commits.length === 1) {
|
|
144
|
+
lines.push(`- ${commits[0]}`);
|
|
145
|
+
} else {
|
|
146
|
+
for (const commit of commits.slice(0, 10)) {
|
|
147
|
+
lines.push(`- ${commit}`);
|
|
148
|
+
}
|
|
149
|
+
if (commits.length > 10) {
|
|
150
|
+
lines.push(`- ... and ${commits.length - 10} more commits`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push("");
|
|
155
|
+
|
|
156
|
+
if (issueNumber) {
|
|
157
|
+
lines.push(`Closes #${issueNumber}`);
|
|
158
|
+
lines.push("");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push("## Test plan");
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push("- [ ] Tests pass");
|
|
164
|
+
lines.push("- [ ] Manual testing");
|
|
165
|
+
|
|
166
|
+
return { title, body: lines.join("\n") };
|
|
167
|
+
}
|
|
168
|
+
}
|