unirepo-cli 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -142,6 +142,7 @@ AI coding agents work best when they can see the full change at once.
142
142
  | `status` | Show subtrees, branches, and what changed |
143
143
  | `branch [name]` | Create or show the current push branch |
144
144
  | `push [subtree...]` | Push changed subtrees upstream |
145
+ | `pr [subtree...]` | Open pull requests for changed subtree repos |
145
146
 
146
147
 
147
148
  ### init
@@ -213,6 +214,24 @@ Pushes changed subtrees upstream. Without arguments, auto-detects which subtrees
213
214
  | `--branch <name>` | Override the upstream branch name |
214
215
  | `--dry-run` | Show what would run without executing |
215
216
 
217
+ ### pr
218
+
219
+ ```bash
220
+ unirepo pr [subtree...] --title <title> [--body <text>]
221
+ ```
222
+
223
+ Opens one GitHub pull request per changed subtree repo. Without subtree arguments, `unirepo` auto-detects changed subtrees. Use explicit subtree names when you want PRs for only part of the workspace.
224
+
225
+
226
+ | Flag | Effect |
227
+ | --- | --- |
228
+ | `--title <title>` | Shared PR title for all selected repos |
229
+ | `--body <text>` | Shared PR description |
230
+ | `--base <name>` | Override the base branch for all selected repos |
231
+ | `--head <name>` | Override the head branch name (default: current branch) |
232
+ | `--draft` | Create draft pull requests |
233
+ | `--dry-run` | Show the `gh pr create` commands without executing |
234
+
216
235
 
217
236
  ## How It Works
218
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unirepo-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI tool for creating and managing git-subtree monorepos — run your agents across repos",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,6 +49,6 @@ export async function runBranch({ name }) {
49
49
  ui.info(' 1. Make changes in subtree directories');
50
50
  ui.info(' 2. Commit in the monorepo');
51
51
  ui.info(` 3. unirepo push`);
52
- ui.info(' 4. Open one PR per upstream repo');
52
+ ui.info(' 4. unirepo pr --title "..." --body "..."');
53
53
  ui.blank();
54
54
  }
@@ -0,0 +1,175 @@
1
+ import {
2
+ getCurrentBranch,
3
+ getSubtreePrefixes,
4
+ getChangedSubtrees,
5
+ getTrackedSubtreeBranch,
6
+ hasRemoteBranch,
7
+ } from '../git.js';
8
+ import { isGhAvailable, getGitHubRepoSlugFromUrl, createPullRequest } from '../github.js';
9
+ import { validateInsideMonorepo } from '../validate.js';
10
+ import * as ui from '../ui.js';
11
+
12
+ function quoteShellArg(value) {
13
+ return JSON.stringify(value);
14
+ }
15
+
16
+ export function selectPrSubtrees(allPrefixes, changedPrefixes, requestedSubtrees) {
17
+ if (!requestedSubtrees || requestedSubtrees.length === 0) {
18
+ return changedPrefixes;
19
+ }
20
+
21
+ const prefixNames = new Set(allPrefixes.map((prefix) => prefix.name));
22
+ for (const name of requestedSubtrees) {
23
+ if (!prefixNames.has(name)) {
24
+ throw new Error(`"${name}" is not a tracked subtree. Run "unirepo status" to see available subtrees.`);
25
+ }
26
+ }
27
+
28
+ return allPrefixes.filter((prefix) => requestedSubtrees.includes(prefix.name));
29
+ }
30
+
31
+ export function resolvePrBaseBranch({ requestedBase, trackedBranch }) {
32
+ return requestedBase || trackedBranch || 'main';
33
+ }
34
+
35
+ export function planPrTargets({
36
+ allPrefixes,
37
+ changedPrefixes,
38
+ requestedSubtrees,
39
+ requestedBase,
40
+ headBranch,
41
+ getTrackedBranchFn = () => null,
42
+ getRepoSlugFn = () => null,
43
+ }) {
44
+ return selectPrSubtrees(allPrefixes, changedPrefixes, requestedSubtrees).map((prefix) => ({
45
+ name: prefix.name,
46
+ repo: getRepoSlugFn(prefix.url),
47
+ url: prefix.url,
48
+ base: resolvePrBaseBranch({
49
+ requestedBase,
50
+ trackedBranch: getTrackedBranchFn(prefix.name),
51
+ }),
52
+ head: headBranch,
53
+ }));
54
+ }
55
+
56
+ export async function runPr({ subtrees: requestedSubtrees, title, body, base, head, draft, dryRun }) {
57
+ const cwd = process.cwd();
58
+
59
+ ui.header('Creating pull requests');
60
+ ui.blank();
61
+
62
+ validateInsideMonorepo(cwd);
63
+
64
+ if (!isGhAvailable()) {
65
+ throw new Error('GitHub CLI is not available or not authenticated. Install gh and run "gh auth login".');
66
+ }
67
+
68
+ const currentBranch = getCurrentBranch(cwd);
69
+ const headBranch = head || currentBranch;
70
+ const allPrefixes = getSubtreePrefixes(cwd);
71
+ const changedPrefixes = getChangedSubtrees(cwd);
72
+ const targets = planPrTargets({
73
+ allPrefixes,
74
+ changedPrefixes,
75
+ requestedSubtrees,
76
+ requestedBase: base,
77
+ headBranch,
78
+ getTrackedBranchFn: (prefixName) => getTrackedSubtreeBranch(cwd, prefixName),
79
+ getRepoSlugFn: getGitHubRepoSlugFromUrl,
80
+ });
81
+
82
+ if (targets.length === 0) {
83
+ ui.info('No changed subtrees detected. Nothing to open PRs for.');
84
+ ui.info('Push a changed subtree first or pass explicit subtree names.');
85
+ ui.blank();
86
+ return;
87
+ }
88
+
89
+ ui.info(`Head branch: ${headBranch}`);
90
+ ui.info(`Subtrees: ${targets.map((target) => target.name).join(', ')}`);
91
+ ui.blank();
92
+
93
+ const dryRunCommands = [];
94
+ let succeeded = 0;
95
+ let failed = 0;
96
+
97
+ for (let i = 0; i < targets.length; i++) {
98
+ const target = targets[i];
99
+
100
+ ui.repoStep(i + 1, targets.length, target.name, dryRun ? 'Planning PR for' : 'Opening PR for');
101
+ ui.repoDetail('Repo', target.repo || target.url);
102
+ ui.repoDetail('Base', target.base);
103
+ ui.repoDetail('Head', target.head);
104
+
105
+ if (!target.repo) {
106
+ ui.error(`Failed to open PR for ${target.name}: remote URL is not a GitHub repository.`);
107
+ failed++;
108
+ ui.blank();
109
+ continue;
110
+ }
111
+
112
+ if (!hasRemoteBranch(cwd, target.name, target.head)) {
113
+ ui.error(`Failed to open PR for ${target.name}: upstream branch "${target.head}" does not exist. Run "unirepo push ${target.name}" first.`);
114
+ failed++;
115
+ ui.blank();
116
+ continue;
117
+ }
118
+
119
+ if (!hasRemoteBranch(cwd, target.name, target.base)) {
120
+ ui.error(`Failed to open PR for ${target.name}: base branch "${target.base}" does not exist upstream.`);
121
+ failed++;
122
+ ui.blank();
123
+ continue;
124
+ }
125
+
126
+ const ghCommand = [
127
+ 'gh pr create',
128
+ `--repo ${quoteShellArg(target.repo)}`,
129
+ `--base ${quoteShellArg(target.base)}`,
130
+ `--head ${quoteShellArg(target.head)}`,
131
+ `--title ${quoteShellArg(title)}`,
132
+ `--body ${quoteShellArg(body)}`,
133
+ draft ? '--draft' : '',
134
+ ]
135
+ .filter(Boolean)
136
+ .join(' ');
137
+
138
+ if (dryRun) {
139
+ dryRunCommands.push(ghCommand);
140
+ succeeded++;
141
+ ui.success(`${target.name} PR planned`);
142
+ ui.blank();
143
+ continue;
144
+ }
145
+
146
+ try {
147
+ const url = createPullRequest({
148
+ repo: target.repo,
149
+ base: target.base,
150
+ head: target.head,
151
+ title,
152
+ body,
153
+ draft,
154
+ });
155
+ ui.success(`${target.name} PR opened${url ? `: ${url}` : ''}`);
156
+ succeeded++;
157
+ } catch (err) {
158
+ ui.error(`Failed to open PR for ${target.name}: ${err.message}`);
159
+ failed++;
160
+ }
161
+
162
+ ui.blank();
163
+ }
164
+
165
+ if (dryRunCommands.length > 0) {
166
+ ui.dryRun(dryRunCommands);
167
+ }
168
+
169
+ if (failed === 0) {
170
+ ui.success(`${dryRun ? 'Planned' : 'Opened'} ${succeeded} pull request(s)`);
171
+ } else {
172
+ ui.warning(`${dryRun ? 'Planned' : 'Opened'} ${succeeded}, ${failed} failed`);
173
+ }
174
+ ui.blank();
175
+ }
package/src/github.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync, execSync } from 'node:child_process';
2
2
 
3
3
  /**
4
4
  * Thin wrapper around the `gh` CLI. We shell out rather than hitting the
@@ -23,6 +23,19 @@ function runGh(args, { timeout = 30000 } = {}) {
23
23
  }
24
24
  }
25
25
 
26
+ function runGhArgs(args, { timeout = 30000 } = {}) {
27
+ try {
28
+ return execFileSync('gh', args, {
29
+ encoding: 'utf-8',
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ timeout,
32
+ });
33
+ } catch (err) {
34
+ const stderr = (err.stderr || '').toString().trim();
35
+ throw new Error(stderr || err.message);
36
+ }
37
+ }
38
+
26
39
  function runGhJson(args, opts) {
27
40
  const out = runGh(args, opts);
28
41
  if (!out.trim()) return [];
@@ -111,3 +124,49 @@ export function searchRepos(query, limit = SEARCH_LIMIT) {
111
124
  isPrivate: r.isPrivate,
112
125
  }));
113
126
  }
127
+
128
+ export function getGitHubRepoSlugFromUrl(url) {
129
+ const trimmed = url.replace(/\/$/, '').replace(/\.git$/, '');
130
+ const patterns = [
131
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)$/i,
132
+ /^git@github\.com:([^/]+)\/([^/]+)$/i,
133
+ /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+)$/i,
134
+ ];
135
+
136
+ for (const pattern of patterns) {
137
+ const match = trimmed.match(pattern);
138
+ if (match) {
139
+ return `${match[1]}/${match[2]}`;
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ export function createPullRequest({ repo, base, head, title, body, draft = false }) {
147
+ const args = [
148
+ 'pr',
149
+ 'create',
150
+ '--repo',
151
+ repo,
152
+ '--base',
153
+ base,
154
+ '--head',
155
+ head,
156
+ '--title',
157
+ title,
158
+ '--body',
159
+ body,
160
+ ];
161
+
162
+ if (draft) {
163
+ args.push('--draft');
164
+ }
165
+
166
+ const output = runGhArgs(args);
167
+ const lines = output
168
+ .split('\n')
169
+ .map((line) => line.trim())
170
+ .filter(Boolean);
171
+ return lines.at(-1) || '';
172
+ }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { runPull } from './commands/pull.js';
8
8
  import { runStatus } from './commands/status.js';
9
9
  import { runPush } from './commands/push.js';
10
10
  import { runBranch } from './commands/branch.js';
11
+ import { runPr } from './commands/pr.js';
11
12
  import { pathToFileURL } from 'node:url';
12
13
  import { realpathSync } from 'node:fs';
13
14
 
@@ -37,10 +38,20 @@ export function parseArgs(argv) {
37
38
  flags.json = true;
38
39
  } else if (arg === '--dry-run') {
39
40
  flags.dryRun = true;
41
+ } else if (arg === '--draft') {
42
+ flags.draft = true;
40
43
  } else if (arg === '--prefix' && i + 1 < args.length) {
41
44
  flags.prefix = args[++i];
42
45
  } else if (arg === '--branch' && i + 1 < args.length) {
43
46
  flags.branch = args[++i];
47
+ } else if (arg === '--title' && i + 1 < args.length) {
48
+ flags.title = args[++i];
49
+ } else if (arg === '--body' && i + 1 < args.length) {
50
+ flags.body = args[++i];
51
+ } else if (arg === '--base' && i + 1 < args.length) {
52
+ flags.base = args[++i];
53
+ } else if (arg === '--head' && i + 1 < args.length) {
54
+ flags.head = args[++i];
44
55
  } else if (arg === '--help' || arg === '-h') {
45
56
  flags.help = true;
46
57
  } else if (arg.startsWith('-')) {
@@ -61,6 +72,7 @@ const COMMAND_USAGE = {
61
72
  status: 'Usage: unirepo status [--json]',
62
73
  push: 'Usage: unirepo push [subtree...] [--branch <name>] [--dry-run]',
63
74
  branch: 'Usage: unirepo branch [name]',
75
+ pr: 'Usage: unirepo pr [subtree...] --title <title> [--body <text>] [--base <name>] [--head <name>] [--draft] [--dry-run]',
64
76
  };
65
77
 
66
78
  const COMMAND_FLAGS = {
@@ -71,6 +83,7 @@ const COMMAND_FLAGS = {
71
83
  status: new Set(['json']),
72
84
  push: new Set(['branch', 'dryRun']),
73
85
  branch: new Set(),
86
+ pr: new Set(['title', 'body', 'base', 'head', 'draft', 'dryRun']),
74
87
  };
75
88
 
76
89
  const FLAG_NAMES = {
@@ -79,6 +92,11 @@ const FLAG_NAMES = {
79
92
  dryRun: '--dry-run',
80
93
  prefix: '--prefix',
81
94
  branch: '--branch',
95
+ title: '--title',
96
+ body: '--body',
97
+ base: '--base',
98
+ head: '--head',
99
+ draft: '--draft',
82
100
  };
83
101
 
84
102
  export function validateCommandFlags(command, flags) {
@@ -191,6 +209,24 @@ export async function main() {
191
209
  break;
192
210
  }
193
211
 
212
+ case 'pr': {
213
+ if (!flags.title) {
214
+ ui.error('Usage: unirepo pr [subtree...] --title <title> [--body <text>] [--base <name>] [--head <name>] [--draft] [--dry-run]');
215
+ ui.info('Provide a shared PR title with --title. Use --body to set the description.');
216
+ process.exit(1);
217
+ }
218
+ await runPr({
219
+ subtrees: positional.length > 0 ? positional : undefined,
220
+ title: flags.title,
221
+ body: flags.body || '',
222
+ base: flags.base,
223
+ head: flags.head,
224
+ draft: flags.draft || false,
225
+ dryRun: flags.dryRun || false,
226
+ });
227
+ break;
228
+ }
229
+
194
230
  default:
195
231
  ui.error(`Unknown command: ${command}`);
196
232
  ui.blank();
package/src/templates.js CHANGED
@@ -30,6 +30,7 @@ unirepo branch <branch>
30
30
  unirepo pull
31
31
  unirepo push --dry-run
32
32
  unirepo push
33
+ unirepo pr --title "feat: ..." --body "..."
33
34
  unirepo add <repo-url> --branch <branch>
34
35
  \`\`\`
35
36
 
@@ -38,6 +39,7 @@ unirepo add <repo-url> --branch <branch>
38
39
  - \`pull\` updates one or more tracked subtrees from upstream before or during your work. Use \`--prefix\` when you want a branch-specific pull for just one subtree.
39
40
  - \`push --dry-run\` is the safe first step before a real push.
40
41
  - \`push\` without subtree names auto-detects changed subtrees. \`push <subtree>\` pushes one subtree.
42
+ - \`pr\` opens one PR per changed or explicitly selected subtree repo after those branches have been pushed.
41
43
  - \`add\` imports another repository as a subtree. Use \`--branch\` to import from a non-default upstream branch.
42
44
 
43
45
  ## Workflow
@@ -93,6 +95,13 @@ Git:
93
95
  git subtree push --prefix=<subtree> <remote-or-url> <branch>
94
96
  \`\`\`
95
97
 
98
+ 7. Open one PR per upstream subtree repo after push.
99
+ CLI:
100
+ \`\`\`bash
101
+ unirepo pr --title "feat: ..." --body "..."
102
+ unirepo pr <subtree> --title "feat: ..."
103
+ \`\`\`
104
+
96
105
  ## Raw Git Subtree
97
106
 
98
107
  Use these when operating without the CLI:
@@ -116,6 +125,7 @@ git subtree push --prefix=libfoo https://github.com/example/libfoo.git <branch>
116
125
  - Open one PR per upstream subtree repo.
117
126
  - Use the same branch name in each upstream repo.
118
127
  - Target the default branch of the upstream repo unless that repo's workflow says otherwise.
128
+ - Run \`unirepo pr\` only after the head branch exists upstream for that subtree repo.
119
129
  - Ensure each PR contains only changes from its own subtree.
120
130
  `;
121
131
 
package/src/ui.js CHANGED
@@ -179,6 +179,7 @@ ${chalk.bold('Commands:')}
179
179
  ${chalk.green('pull')} [subtree...] Pull subtree updates from upstream
180
180
  ${chalk.green('status')} Show tracked subtrees and changes
181
181
  ${chalk.green('push')} [subtree...] Push changed subtrees upstream
182
+ ${chalk.green('pr')} [subtree...] Open PRs for changed subtree repos
182
183
  ${chalk.green('branch')} [name] Create a branch on all upstream repos
183
184
  ${chalk.green('version')} Show the installed CLI version
184
185
 
@@ -206,6 +207,14 @@ ${chalk.bold('Push options:')}
206
207
  --branch <name> Branch name for upstream push (default: current)
207
208
  --dry-run Show commands without executing
208
209
 
210
+ ${chalk.bold('PR options:')}
211
+ --title <title> Shared PR title (required)
212
+ --body <text> Shared PR description
213
+ --base <name> Override the base branch for all selected repos
214
+ --head <name> Override the head branch (default: current branch)
215
+ --draft Create draft PRs
216
+ --dry-run Show gh pr create commands without executing
217
+
209
218
  ${chalk.bold('Examples:')}
210
219
  ${chalk.dim('# Create monorepo from multiple repos')}
211
220
  npx unirepo init my-monorepo https://github.com/org/api.git https://github.com/org/web.git
@@ -228,5 +237,8 @@ ${chalk.bold('Examples:')}
228
237
 
229
238
  ${chalk.dim('# Push changes upstream')}
230
239
  npx unirepo push --dry-run
240
+
241
+ ${chalk.dim('# Open PRs for changed subtree repos')}
242
+ npx unirepo pr --title "feat: add auth flow" --body "Cross-repo auth changes"
231
243
  `);
232
244
  }