@towles/tool 0.0.20 → 0.0.48
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.md → LICENSE} +1 -1
- package/README.md +86 -85
- package/bin/run.ts +5 -0
- package/package.json +84 -64
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +27 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +44 -0
- package/src/commands/doctor.ts +136 -0
- package/src/commands/gh/branch-clean.ts +116 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +135 -0
- package/src/commands/gh/pr.ts +175 -0
- package/src/commands/graph-template.html +1214 -0
- package/src/commands/graph.test.ts +176 -0
- package/src/commands/graph.ts +970 -0
- package/src/commands/install.ts +154 -0
- package/src/commands/journal/daily-notes.ts +70 -0
- package/src/commands/journal/meeting.ts +89 -0
- package/src/commands/journal/note.ts +89 -0
- package/src/commands/ralph/plan/add.ts +75 -0
- package/src/commands/ralph/plan/done.ts +82 -0
- package/src/commands/ralph/plan/list.test.ts +48 -0
- package/src/commands/ralph/plan/list.ts +99 -0
- package/src/commands/ralph/plan/remove.ts +71 -0
- package/src/commands/ralph/run.test.ts +521 -0
- package/src/commands/ralph/run.ts +345 -0
- package/src/commands/ralph/show.ts +88 -0
- package/src/config/settings.ts +136 -0
- package/src/lib/journal/utils.ts +399 -0
- package/src/lib/ralph/execution.ts +292 -0
- package/src/lib/ralph/formatter.ts +238 -0
- package/src/lib/ralph/index.ts +4 -0
- package/src/lib/ralph/state.ts +166 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/date-utils.test.ts +97 -0
- package/src/utils/date-utils.ts +54 -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/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -805
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
{ description: "Clean merged branches", command: "<%= config.bin %> <%= command.id %>" },
|
|
16
|
+
{
|
|
17
|
+
description: "Preview without deleting",
|
|
18
|
+
command: "<%= config.bin %> <%= command.id %> --dry-run",
|
|
19
|
+
},
|
|
20
|
+
{ description: "Skip confirmation", command: "<%= config.bin %> <%= command.id %> --force" },
|
|
21
|
+
{
|
|
22
|
+
description: "Check against develop",
|
|
23
|
+
command: "<%= config.bin %> <%= command.id %> --base develop",
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
static override flags = {
|
|
28
|
+
...BaseCommand.baseFlags,
|
|
29
|
+
force: Flags.boolean({
|
|
30
|
+
char: "f",
|
|
31
|
+
description: "Skip confirmation prompt",
|
|
32
|
+
default: false,
|
|
33
|
+
}),
|
|
34
|
+
"dry-run": Flags.boolean({
|
|
35
|
+
char: "d",
|
|
36
|
+
description: "Preview branches without deleting",
|
|
37
|
+
default: false,
|
|
38
|
+
}),
|
|
39
|
+
base: Flags.string({
|
|
40
|
+
char: "b",
|
|
41
|
+
description: "Base branch to check against",
|
|
42
|
+
default: "main",
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async run(): Promise<void> {
|
|
47
|
+
const { flags } = await this.parse(BranchClean);
|
|
48
|
+
const baseBranch = flags.base;
|
|
49
|
+
const dryRun = flags["dry-run"];
|
|
50
|
+
|
|
51
|
+
// Get current branch
|
|
52
|
+
const currentResult = await x("git", ["branch", "--show-current"]);
|
|
53
|
+
const currentBranch = currentResult.stdout.trim();
|
|
54
|
+
|
|
55
|
+
// Get merged branches
|
|
56
|
+
const mergedResult = await x("git", ["branch", "--merged", baseBranch]);
|
|
57
|
+
const allMerged = mergedResult.stdout
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((b) => b.trim().replace(/^\* /, ""))
|
|
60
|
+
.filter((b) => b.length > 0);
|
|
61
|
+
|
|
62
|
+
// Exclude protected branches
|
|
63
|
+
const protectedBranches = ["main", "master", "develop", "dev", baseBranch, currentBranch];
|
|
64
|
+
const toDelete = allMerged.filter((b) => !protectedBranches.includes(b));
|
|
65
|
+
|
|
66
|
+
if (toDelete.length === 0) {
|
|
67
|
+
consola.info(colors.green("No merged branches to clean up"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
consola.log(colors.cyan(`Found ${toDelete.length} merged branch(es):`));
|
|
72
|
+
for (const branch of toDelete) {
|
|
73
|
+
consola.log(` - ${branch}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
consola.info(colors.yellow("Dry run - no branches deleted"));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!flags.force) {
|
|
82
|
+
const answer = await consola.prompt(`Delete ${toDelete.length} branch(es)?`, {
|
|
83
|
+
type: "confirm",
|
|
84
|
+
initial: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!answer) {
|
|
88
|
+
consola.info(colors.dim("Canceled"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Delete branches
|
|
94
|
+
let deleted = 0;
|
|
95
|
+
let failed = 0;
|
|
96
|
+
|
|
97
|
+
for (const branch of toDelete) {
|
|
98
|
+
try {
|
|
99
|
+
await x("git", ["branch", "-d", branch]);
|
|
100
|
+
consola.log(colors.green(`✓ Deleted ${branch}`));
|
|
101
|
+
deleted++;
|
|
102
|
+
} catch {
|
|
103
|
+
consola.log(colors.red(`✗ Failed to delete ${branch}`));
|
|
104
|
+
failed++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
consola.log("");
|
|
109
|
+
if (deleted > 0) {
|
|
110
|
+
consola.info(colors.green(`Deleted ${deleted} branch(es)`));
|
|
111
|
+
}
|
|
112
|
+
if (failed > 0) {
|
|
113
|
+
consola.warn(colors.yellow(`Failed to delete ${failed} branch(es)`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -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,135 @@
|
|
|
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
|
+
{ description: "Browse all open issues", command: "<%= config.bin %> <%= command.id %>" },
|
|
22
|
+
{
|
|
23
|
+
description: "Only issues assigned to me",
|
|
24
|
+
command: "<%= config.bin %> <%= command.id %> --assignedToMe",
|
|
25
|
+
},
|
|
26
|
+
{ description: "Short flag for assigned", command: "<%= config.bin %> <%= command.id %> -a" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
static override flags = {
|
|
30
|
+
...BaseCommand.baseFlags,
|
|
31
|
+
assignedToMe: Flags.boolean({
|
|
32
|
+
char: "a",
|
|
33
|
+
description: "Only show issues assigned to me",
|
|
34
|
+
default: false,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async run(): Promise<void> {
|
|
39
|
+
const { flags } = await this.parse(GhBranch);
|
|
40
|
+
|
|
41
|
+
// Check prerequisites
|
|
42
|
+
const cliInstalled = await isGithubCliInstalled();
|
|
43
|
+
if (!cliInstalled) {
|
|
44
|
+
consola.log("Github CLI not installed");
|
|
45
|
+
this.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
consola.log("Assigned to me:", flags.assignedToMe);
|
|
49
|
+
|
|
50
|
+
const currentIssues = await getIssues({ assignedToMe: flags.assignedToMe, cwd: process.cwd() });
|
|
51
|
+
if (currentIssues.length === 0) {
|
|
52
|
+
consola.log(colors.yellow("No issues found, check assignments"));
|
|
53
|
+
this.exit(1);
|
|
54
|
+
} else {
|
|
55
|
+
consola.log(colors.green(`${currentIssues.length} Issues found assigned to you`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Format table with nice labels
|
|
59
|
+
let lineMaxLength = getTerminalColumns();
|
|
60
|
+
const longestNumber = Math.max(...currentIssues.map((i) => i.number.toString().length));
|
|
61
|
+
const longestLabels = Math.max(
|
|
62
|
+
...currentIssues.map((i) => i.labels.map((x) => x.name).join(", ").length),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
lineMaxLength = lineMaxLength > 130 ? 130 : lineMaxLength;
|
|
66
|
+
const descriptionLength = lineMaxLength - longestNumber - longestLabels - 15;
|
|
67
|
+
|
|
68
|
+
const choices: Choice[] = currentIssues.map((i) => {
|
|
69
|
+
const labelText = i.labels
|
|
70
|
+
.map((l) => printWithHexColor({ msg: l.name, hex: l.color }))
|
|
71
|
+
.join(", ");
|
|
72
|
+
const labelTextNoColor = i.labels.map((l) => l.name).join(", ");
|
|
73
|
+
const labelStartpad = longestLabels - labelTextNoColor.length;
|
|
74
|
+
return {
|
|
75
|
+
title: i.number.toString(),
|
|
76
|
+
value: i.number,
|
|
77
|
+
description: `${limitText(i.title, descriptionLength).padEnd(descriptionLength)} ${"".padStart(labelStartpad)}${labelText}`,
|
|
78
|
+
} as Choice;
|
|
79
|
+
});
|
|
80
|
+
choices.push({ title: "Cancel", value: "cancel" });
|
|
81
|
+
|
|
82
|
+
const fzf = new Fzf(choices, {
|
|
83
|
+
selector: (item) => `${item.value} ${item.description}`,
|
|
84
|
+
casing: "case-insensitive",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await prompts(
|
|
89
|
+
{
|
|
90
|
+
name: "issueNumber",
|
|
91
|
+
message: "Github issue to create branch for:",
|
|
92
|
+
type: "autocomplete",
|
|
93
|
+
choices,
|
|
94
|
+
async suggest(input: string, choices: Choice[]) {
|
|
95
|
+
consola.log(input);
|
|
96
|
+
const results = fzf.find(input);
|
|
97
|
+
return results.map((r) => choices.find((c) => c.value === r.item.value));
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
onCancel: () => {
|
|
102
|
+
consola.info(colors.dim("Canceled"));
|
|
103
|
+
this.exit(0);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (result.issueNumber === "cancel") {
|
|
109
|
+
consola.log(colors.dim("Canceled"));
|
|
110
|
+
this.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const selectedIssue = currentIssues.find((i) => i.number === result.issueNumber)!;
|
|
114
|
+
consola.log(
|
|
115
|
+
`Selected issue ${colors.green(selectedIssue.number)} - ${colors.green(selectedIssue.title)}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const branchName = GhBranch.createBranchNameFromIssue(selectedIssue);
|
|
119
|
+
createBranch({ branchName });
|
|
120
|
+
} catch {
|
|
121
|
+
this.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static createBranchNameFromIssue(selectedIssue: Issue): string {
|
|
126
|
+
let slug = selectedIssue.title.toLowerCase();
|
|
127
|
+
slug = slug.trim();
|
|
128
|
+
slug = slug.replaceAll(" ", "-");
|
|
129
|
+
slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
|
|
130
|
+
slug = slug.replace(/-+/g, "-");
|
|
131
|
+
slug = slug.replace(/-+$/, "");
|
|
132
|
+
|
|
133
|
+
return `feature/${selectedIssue.number}-${slug}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
{
|
|
18
|
+
description: "Create PR from current branch",
|
|
19
|
+
command: "<%= config.bin %> <%= command.id %>",
|
|
20
|
+
},
|
|
21
|
+
{ description: "Create draft PR", command: "<%= config.bin %> <%= command.id %> --draft" },
|
|
22
|
+
{
|
|
23
|
+
description: "PR against develop branch",
|
|
24
|
+
command: "<%= config.bin %> <%= command.id %> --base develop",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
static override flags = {
|
|
29
|
+
...BaseCommand.baseFlags,
|
|
30
|
+
draft: Flags.boolean({
|
|
31
|
+
char: "D",
|
|
32
|
+
description: "Create as draft PR",
|
|
33
|
+
default: false,
|
|
34
|
+
}),
|
|
35
|
+
base: Flags.string({
|
|
36
|
+
char: "b",
|
|
37
|
+
description: "Base branch for the PR",
|
|
38
|
+
default: "main",
|
|
39
|
+
}),
|
|
40
|
+
yes: Flags.boolean({
|
|
41
|
+
char: "y",
|
|
42
|
+
description: "Skip confirmation prompt",
|
|
43
|
+
default: false,
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
async run(): Promise<void> {
|
|
48
|
+
const { flags } = await this.parse(Pr);
|
|
49
|
+
|
|
50
|
+
// Check prerequisites
|
|
51
|
+
const cliInstalled = await isGithubCliInstalled();
|
|
52
|
+
if (!cliInstalled) {
|
|
53
|
+
consola.error("GitHub CLI not installed");
|
|
54
|
+
this.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Get current branch
|
|
58
|
+
const branchResult = await x("git", ["branch", "--show-current"]);
|
|
59
|
+
const currentBranch = branchResult.stdout.trim();
|
|
60
|
+
|
|
61
|
+
if (!currentBranch) {
|
|
62
|
+
consola.error("Not on a branch (detached HEAD?)");
|
|
63
|
+
this.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (currentBranch === flags.base) {
|
|
67
|
+
consola.error(`Already on base branch ${flags.base}`);
|
|
68
|
+
this.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
consola.info(`Current branch: ${colors.cyan(currentBranch)}`);
|
|
72
|
+
consola.info(`Base branch: ${colors.cyan(flags.base)}`);
|
|
73
|
+
|
|
74
|
+
// Get commits between base and current branch
|
|
75
|
+
const logResult = await x("git", ["log", `${flags.base}..HEAD`, "--pretty=format:%s"]);
|
|
76
|
+
|
|
77
|
+
const commits = logResult.stdout.trim().split("\n").filter(Boolean);
|
|
78
|
+
|
|
79
|
+
if (commits.length === 0) {
|
|
80
|
+
consola.error(`No commits between ${flags.base} and ${currentBranch}`);
|
|
81
|
+
this.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
consola.info(`Found ${colors.green(commits.length.toString())} commits`);
|
|
85
|
+
|
|
86
|
+
// Generate PR title and body
|
|
87
|
+
const { title, body } = this.generatePrContent(currentBranch, commits);
|
|
88
|
+
|
|
89
|
+
consola.box({
|
|
90
|
+
title: "PR Preview",
|
|
91
|
+
message: `Title: ${title}\n\n${body}`,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Confirm unless --yes
|
|
95
|
+
if (!flags.yes) {
|
|
96
|
+
const confirmed = await consola.prompt("Create this PR?", {
|
|
97
|
+
type: "confirm",
|
|
98
|
+
initial: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!confirmed) {
|
|
102
|
+
consola.info(colors.dim("Canceled"));
|
|
103
|
+
this.exit(0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Push branch if needed
|
|
108
|
+
const statusResult = await x("git", ["status", "-sb"]);
|
|
109
|
+
const needsPush = !statusResult.stdout.includes("origin/");
|
|
110
|
+
|
|
111
|
+
if (needsPush) {
|
|
112
|
+
consola.info("Pushing branch to remote...");
|
|
113
|
+
await x("git", ["push", "-u", "origin", currentBranch]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Create PR
|
|
117
|
+
const prArgs = ["pr", "create", "--title", title, "--body", body, "--base", flags.base];
|
|
118
|
+
|
|
119
|
+
if (flags.draft) {
|
|
120
|
+
prArgs.push("--draft");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const prResult = await x("gh", prArgs);
|
|
124
|
+
const prUrl = prResult.stdout.trim();
|
|
125
|
+
|
|
126
|
+
consola.success(`PR created: ${colors.cyan(prUrl)}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private generatePrContent(branch: string, commits: string[]): { title: string; body: string } {
|
|
130
|
+
// Extract issue number from branch name if present (e.g., feature/123-some-feature)
|
|
131
|
+
const issueMatch = branch.match(/(\d+)/);
|
|
132
|
+
const issueNumber = issueMatch ? issueMatch[1] : null;
|
|
133
|
+
|
|
134
|
+
// Generate title from first commit or branch name
|
|
135
|
+
let title: string;
|
|
136
|
+
if (commits.length === 1) {
|
|
137
|
+
title = commits[0];
|
|
138
|
+
} else {
|
|
139
|
+
// Use branch name, cleaned up
|
|
140
|
+
title = branch
|
|
141
|
+
.replace(/^(feature|fix|bugfix|hotfix|chore|refactor)\//, "")
|
|
142
|
+
.replace(/^\d+-/, "")
|
|
143
|
+
.replace(/-/g, " ")
|
|
144
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Generate body
|
|
148
|
+
const lines: string[] = ["## Summary", ""];
|
|
149
|
+
|
|
150
|
+
if (commits.length === 1) {
|
|
151
|
+
lines.push(`- ${commits[0]}`);
|
|
152
|
+
} else {
|
|
153
|
+
for (const commit of commits.slice(0, 10)) {
|
|
154
|
+
lines.push(`- ${commit}`);
|
|
155
|
+
}
|
|
156
|
+
if (commits.length > 10) {
|
|
157
|
+
lines.push(`- ... and ${commits.length - 10} more commits`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push("");
|
|
162
|
+
|
|
163
|
+
if (issueNumber) {
|
|
164
|
+
lines.push(`Closes #${issueNumber}`);
|
|
165
|
+
lines.push("");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push("## Test plan");
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push("- [ ] Tests pass");
|
|
171
|
+
lines.push("- [ ] Manual testing");
|
|
172
|
+
|
|
173
|
+
return { title, body: lines.join("\n") };
|
|
174
|
+
}
|
|
175
|
+
}
|