@smoothbricks/cli 0.4.3 → 0.5.0

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/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAKA,wBAAsB,MAAM,CAAC,IAAI,WAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAexE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAMA,wBAAsB,MAAM,CAAC,IAAI,WAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAexE"}
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@ import { Command, CommanderError } from 'commander';
2
2
  import { variants } from './generate/index.js';
3
3
  import { cliPackageVersion } from './lib/cli-package.js';
4
4
  import { findRepoRoot } from './lib/run.js';
5
+ import { resolvePrConflicts } from './pr/index.js';
5
6
  export async function runCli(argv = process.argv.slice(2)) {
6
7
  const program = buildProgram();
7
8
  try {
@@ -278,6 +279,17 @@ function buildProgram() {
278
279
  const { githubCiNxDeploy } = await import('./github-ci/index.js');
279
280
  await githubCiNxDeploy(await findRepoRoot(), options);
280
281
  });
282
+ const pr = program.command('pr').description('Work with GitHub pull requests');
283
+ pr.command('resolve [pr]')
284
+ .description('Resolve conflict markers in a PR (agent-first, two-phase)')
285
+ .option('--remote <name>', 'git remote hosting the PR branch (auto-inferred when omitted)')
286
+ .option('--abort', 'discard an in-progress resolution and return to the original branch')
287
+ .action(async (prArg, options) => {
288
+ const exitCode = await resolvePrConflicts(await findRepoRoot(), prArg, options);
289
+ if (exitCode !== 0) {
290
+ process.exitCode = exitCode;
291
+ }
292
+ });
281
293
  return program;
282
294
  }
283
295
  function booleanOption(value) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared detection of Git merge-conflict markers.
3
+ *
4
+ * Real conflict markers always sit at column 0 and are a 7-character run of
5
+ * `<`, `|`, or `>` followed by a space (the diff3 base marker `|||||||` and the
6
+ * ours/theirs markers `<<<<<<< ` / `>>>>>>> `). We deliberately do NOT match a
7
+ * bare `=======` separator: it is ambiguous with Markdown h1 underlines and adds
8
+ * no signal (a real conflict always carries the angle/pipe markers too). The
9
+ * column-0 anchor also means quoted marker strings inside source (which are
10
+ * indented) never false-positive.
11
+ */
12
+ export declare const CONFLICT_MARKER_PATTERN = "^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |>>>>>>> )";
13
+ export interface ConflictMarkerHit {
14
+ /** Repo-relative path of the offending file. */
15
+ readonly file: string;
16
+ /** 1-based line numbers carrying a conflict marker. */
17
+ readonly lines: number[];
18
+ }
19
+ /** 1-based line numbers of every conflict-marker line in `text`. */
20
+ export declare function findMarkerLines(text: string): number[];
21
+ export interface MarkerScanShell {
22
+ runResult(command: string, args: string[], cwd: string): Promise<{
23
+ exitCode: number;
24
+ stdout: string;
25
+ stderr: string;
26
+ }>;
27
+ }
28
+ /**
29
+ * Scan tracked files in the working tree for conflict markers via `git grep`.
30
+ * `pathspecs` optionally restricts the scan (e.g. release package roots).
31
+ */
32
+ export declare function scanTrackedForMarkers(shell: MarkerScanShell, root: string, pathspecs?: string[]): Promise<ConflictMarkerHit[]>;
33
+ /** Thrown by {@link assertNoConflictMarkers} so callers/tests can inspect the hits. */
34
+ export declare class ConflictMarkersError extends Error {
35
+ readonly hits: ConflictMarkerHit[];
36
+ constructor(hits: ConflictMarkerHit[], context: string);
37
+ }
38
+ /**
39
+ * Reusable publish/merge guard: throw {@link ConflictMarkersError} when any
40
+ * tracked file carries conflict markers. `context` names the blocked operation
41
+ * (e.g. `'publish'`) for the message.
42
+ */
43
+ export declare function assertNoConflictMarkers(shell: MarkerScanShell, root: string, context: string): Promise<void>;
44
+ /**
45
+ * Scan the files changed between `baseRef` and `headRef` (three-dot, i.e. since
46
+ * their merge base) for conflict markers, reading blobs at `headRef` — no
47
+ * checkout or working-tree mutation required.
48
+ */
49
+ export declare function scanRefChangedForMarkers(shell: MarkerScanShell, root: string, baseRef: string, headRef: string): Promise<ConflictMarkerHit[]>;
50
+ /** Parse `path:line:content` rows from `git grep -n` into per-file hits. */
51
+ export declare function parseGitGrep(stdout: string): ConflictMarkerHit[];
52
+ /** Human-readable one-liner summary of marker hits, e.g. for CLI output. */
53
+ export declare function formatMarkerHits(hits: ConflictMarkerHit[]): string;
54
+ //# sourceMappingURL=conflict-markers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conflict-markers.d.ts","sourceRoot":"","sources":["../../src/lib/conflict-markers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,gDAAgD,CAAC;AAIrF,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAStD;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,CACP,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClE;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,eAAe,EACtB,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAM,EAAO,GACvB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAW9B;AAED,uFAAuF;AACvF,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,IAAI,EAAE,iBAAiB,EAAE;gBAAzB,IAAI,EAAE,iBAAiB,EAAE,EAClC,OAAO,EAAE,MAAM;CAKlB;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,KAAK,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKlH;AAED;;;;GAIG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,eAAe,EACtB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAkB9B;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,EAAE,CA2BhE;AAED,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAElE"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared detection of Git merge-conflict markers.
3
+ *
4
+ * Real conflict markers always sit at column 0 and are a 7-character run of
5
+ * `<`, `|`, or `>` followed by a space (the diff3 base marker `|||||||` and the
6
+ * ours/theirs markers `<<<<<<< ` / `>>>>>>> `). We deliberately do NOT match a
7
+ * bare `=======` separator: it is ambiguous with Markdown h1 underlines and adds
8
+ * no signal (a real conflict always carries the angle/pipe markers too). The
9
+ * column-0 anchor also means quoted marker strings inside source (which are
10
+ * indented) never false-positive.
11
+ */
12
+ export const CONFLICT_MARKER_PATTERN = '^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |>>>>>>> )';
13
+ const conflictMarkerRe = new RegExp(CONFLICT_MARKER_PATTERN);
14
+ /** 1-based line numbers of every conflict-marker line in `text`. */
15
+ export function findMarkerLines(text) {
16
+ const hits = [];
17
+ const lines = text.split('\n');
18
+ for (let i = 0; i < lines.length; i++) {
19
+ if (conflictMarkerRe.test(lines[i])) {
20
+ hits.push(i + 1);
21
+ }
22
+ }
23
+ return hits;
24
+ }
25
+ /**
26
+ * Scan tracked files in the working tree for conflict markers via `git grep`.
27
+ * `pathspecs` optionally restricts the scan (e.g. release package roots).
28
+ */
29
+ export async function scanTrackedForMarkers(shell, root, pathspecs = []) {
30
+ const args = ['grep', '-nI', '-E', CONFLICT_MARKER_PATTERN];
31
+ if (pathspecs.length > 0) {
32
+ args.push('--', ...pathspecs);
33
+ }
34
+ const { exitCode, stdout, stderr } = await shell.runResult('git', args, root);
35
+ // git grep: 0 = matches found, 1 = no matches, >1 = error.
36
+ if (exitCode > 1) {
37
+ throw new Error(`git grep for conflict markers failed: ${stderr.trim() || `exit ${exitCode}`}`);
38
+ }
39
+ return parseGitGrep(stdout);
40
+ }
41
+ /** Thrown by {@link assertNoConflictMarkers} so callers/tests can inspect the hits. */
42
+ export class ConflictMarkersError extends Error {
43
+ hits;
44
+ constructor(hits, context) {
45
+ super(`Refusing to ${context}: conflict markers found in ${hits.length} file(s):\n${formatMarkerHits(hits)}`);
46
+ this.hits = hits;
47
+ this.name = 'ConflictMarkersError';
48
+ }
49
+ }
50
+ /**
51
+ * Reusable publish/merge guard: throw {@link ConflictMarkersError} when any
52
+ * tracked file carries conflict markers. `context` names the blocked operation
53
+ * (e.g. `'publish'`) for the message.
54
+ */
55
+ export async function assertNoConflictMarkers(shell, root, context) {
56
+ const hits = await scanTrackedForMarkers(shell, root);
57
+ if (hits.length > 0) {
58
+ throw new ConflictMarkersError(hits, context);
59
+ }
60
+ }
61
+ /**
62
+ * Scan the files changed between `baseRef` and `headRef` (three-dot, i.e. since
63
+ * their merge base) for conflict markers, reading blobs at `headRef` — no
64
+ * checkout or working-tree mutation required.
65
+ */
66
+ export async function scanRefChangedForMarkers(shell, root, baseRef, headRef) {
67
+ const diff = await shell.runResult('git', ['diff', '--name-only', `${baseRef}...${headRef}`], root);
68
+ if (diff.exitCode !== 0) {
69
+ throw new Error(`git diff ${baseRef}...${headRef} failed: ${diff.stderr.trim() || `exit ${diff.exitCode}`}`);
70
+ }
71
+ const files = diff.stdout.split('\n').filter((line) => line.length > 0);
72
+ const hits = [];
73
+ for (const file of files) {
74
+ const show = await shell.runResult('git', ['show', `${headRef}:${file}`, '--textconv'], root);
75
+ if (show.exitCode !== 0) {
76
+ continue; // deleted at head / binary / unreadable — not a marker source
77
+ }
78
+ const lines = findMarkerLines(show.stdout);
79
+ if (lines.length > 0) {
80
+ hits.push({ file, lines });
81
+ }
82
+ }
83
+ return hits;
84
+ }
85
+ /** Parse `path:line:content` rows from `git grep -n` into per-file hits. */
86
+ export function parseGitGrep(stdout) {
87
+ const byFile = new Map();
88
+ for (const row of stdout.split('\n')) {
89
+ if (row.length === 0) {
90
+ continue;
91
+ }
92
+ const firstColon = row.indexOf(':');
93
+ if (firstColon < 0) {
94
+ continue;
95
+ }
96
+ const secondColon = row.indexOf(':', firstColon + 1);
97
+ if (secondColon < 0) {
98
+ continue;
99
+ }
100
+ const file = row.slice(0, firstColon);
101
+ const line = Number.parseInt(row.slice(firstColon + 1, secondColon), 10);
102
+ if (!Number.isFinite(line)) {
103
+ continue;
104
+ }
105
+ const existing = byFile.get(file);
106
+ if (existing) {
107
+ existing.push(line);
108
+ }
109
+ else {
110
+ byFile.set(file, [line]);
111
+ }
112
+ }
113
+ return [...byFile.entries()].map(([file, lines]) => ({ file, lines }));
114
+ }
115
+ /** Human-readable one-liner summary of marker hits, e.g. for CLI output. */
116
+ export function formatMarkerHits(hits) {
117
+ return hits.map((hit) => ` ${hit.file} (lines ${hit.lines.join(', ')})`).join('\n');
118
+ }
@@ -0,0 +1,27 @@
1
+ /** 0 = clean/done, 2 = action required (conflicts to resolve), 1 = usage/error. */
2
+ export type PrResolveExit = 0 | 1 | 2;
3
+ export interface PrResolveOptions {
4
+ /** Git remote hosting the PR branch. Auto-inferred from the PR repo when omitted. */
5
+ remote?: string;
6
+ /** Abort an in-progress resolution: restore the original branch, drop state. */
7
+ abort?: boolean;
8
+ }
9
+ export interface PrResolveShell {
10
+ runResult(command: string, args: string[], cwd: string): Promise<{
11
+ exitCode: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ }>;
15
+ run(command: string, args: string[], cwd: string): Promise<void>;
16
+ }
17
+ /**
18
+ * Agent-first conflict resolution for a GitHub PR.
19
+ *
20
+ * First run (pointed at a PR): reports the conflict markers and, if any, checks
21
+ * out the PR branch and instructs the next step. Second run (on that branch,
22
+ * after the human/agent has resolved + committed): verifies no markers remain,
23
+ * pushes, and returns to the original branch. Idempotent via a state file under
24
+ * the git dir.
25
+ */
26
+ export declare function resolvePrConflicts(root: string, prArg: string | undefined, options: PrResolveOptions, shell?: PrResolveShell): Promise<PrResolveExit>;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/pr/index.ts"],"names":[],"mappings":"AAMA,mFAAmF;AACnF,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAEtC,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,CACP,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE;AA6BD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,OAAO,EAAE,gBAAgB,EACzB,KAAK,GAAE,cAA6B,GACnC,OAAO,CAAC,aAAa,CAAC,CAWxB"}
@@ -0,0 +1,256 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { formatMarkerHits, scanRefChangedForMarkers } from '../lib/conflict-markers.js';
4
+ import { isRecord, readJson } from '../lib/json.js';
5
+ import { run, runResult } from '../lib/run.js';
6
+ const defaultShell = {
7
+ runResult: (command, args, cwd) => runResult(command, args, cwd),
8
+ run: (command, args, cwd) => run(command, args, cwd),
9
+ };
10
+ /**
11
+ * Agent-first conflict resolution for a GitHub PR.
12
+ *
13
+ * First run (pointed at a PR): reports the conflict markers and, if any, checks
14
+ * out the PR branch and instructs the next step. Second run (on that branch,
15
+ * after the human/agent has resolved + committed): verifies no markers remain,
16
+ * pushes, and returns to the original branch. Idempotent via a state file under
17
+ * the git dir.
18
+ */
19
+ export async function resolvePrConflicts(root, prArg, options, shell = defaultShell) {
20
+ const statePath = join(await absoluteGitDir(shell, root), 'smoo', 'pr-resolve.json');
21
+ const state = readState(statePath);
22
+ if (options.abort === true) {
23
+ return abortResolve(shell, root, statePath, state);
24
+ }
25
+ if (state) {
26
+ return finishResolve(shell, root, statePath, state, prArg);
27
+ }
28
+ return startResolve(shell, root, statePath, prArg, options.remote);
29
+ }
30
+ async function startResolve(shell, root, statePath, prArg, remoteOption) {
31
+ if (!prArg) {
32
+ console.error('Point smoo at a PR: smoo pr resolve <number|url|branch> [--remote <name>]');
33
+ return 1;
34
+ }
35
+ if (!(await isWorkingTreeClean(shell, root))) {
36
+ console.error('Working tree is dirty. Commit or stash your changes before resolving a PR.');
37
+ return 1;
38
+ }
39
+ const meta = await prMeta(shell, root, prArg);
40
+ const remote = remoteOption ?? (await inferRemote(shell, root, meta.nameWithOwner));
41
+ await fetchBranches(shell, root, remote, [meta.baseBranch, meta.headBranch]);
42
+ const baseRef = `${remote}/${meta.baseBranch}`;
43
+ const headRef = `${remote}/${meta.headBranch}`;
44
+ const hits = await scanRefChangedForMarkers(shell, root, baseRef, headRef);
45
+ if (hits.length === 0) {
46
+ console.log(`✅ PR #${meta.number} (${meta.headBranch} → ${meta.baseBranch}) has no conflict markers — nothing to resolve.`);
47
+ console.log('Next: it is safe to Rebase-and-merge.');
48
+ return 0;
49
+ }
50
+ const originalBranch = await currentBranch(shell, root);
51
+ const originalSha = (await mustRun(shell, root, ['rev-parse', 'HEAD'])).trim();
52
+ await checkoutPrBranch(shell, root, remote, meta);
53
+ writeState(statePath, {
54
+ pr: meta.number,
55
+ url: meta.url,
56
+ headBranch: meta.headBranch,
57
+ baseBranch: meta.baseBranch,
58
+ remote,
59
+ crossRepo: meta.crossRepo,
60
+ originalBranch,
61
+ originalSha,
62
+ startedAt: new Date().toISOString(),
63
+ });
64
+ const fileCount = hits.length;
65
+ console.log(`⚠️ PR #${meta.number} has conflict markers in ${fileCount} file${fileCount === 1 ? '' : 's'}:`);
66
+ console.log(formatMarkerHits(hits));
67
+ console.log('');
68
+ console.log(`Checked out '${meta.headBranch}' (was on '${originalBranch || originalSha.slice(0, 8)}').`);
69
+ console.log('Next:');
70
+ console.log(' 1. Resolve each <<<<<<< / ======= / >>>>>>> block (keep the right content, delete the markers).');
71
+ console.log(' 2. Commit the resolution: git add -A && git commit');
72
+ console.log(' 3. Run the SAME command again to verify, push, and return: smoo pr resolve');
73
+ return 2;
74
+ }
75
+ async function finishResolve(shell, root, statePath, state, prArg) {
76
+ if (prArg && !prArgMatchesState(prArg, state)) {
77
+ console.error(`A resolution for PR #${state.pr} (${state.headBranch}) is already in progress.\n` +
78
+ 'Finish it with `smoo pr resolve` (no argument), or discard it with `smoo pr resolve --abort`.');
79
+ return 1;
80
+ }
81
+ const branch = await currentBranch(shell, root);
82
+ if (branch !== state.headBranch) {
83
+ console.error(`Expected to be on '${state.headBranch}' to finish resolving PR #${state.pr}, but HEAD is '${branch || 'detached'}'.\n` +
84
+ `Check out the branch (git checkout ${state.headBranch}) or discard with \`smoo pr resolve --abort\`.`);
85
+ return 1;
86
+ }
87
+ if (!(await isWorkingTreeClean(shell, root))) {
88
+ console.error('Your resolution is not committed yet.');
89
+ console.error('Next: git add -A && git commit — then run `smoo pr resolve` again.');
90
+ return 2;
91
+ }
92
+ await fetchBranches(shell, root, state.remote, [state.baseBranch]);
93
+ const hits = await scanRefChangedForMarkers(shell, root, `${state.remote}/${state.baseBranch}`, 'HEAD');
94
+ if (hits.length > 0) {
95
+ console.error(`Conflict markers still present in ${hits.length} file(s):`);
96
+ console.error(formatMarkerHits(hits));
97
+ console.error('Next: resolve the remaining markers, commit, then run `smoo pr resolve` again.');
98
+ return 2;
99
+ }
100
+ await pushResolvedBranch(shell, root, state);
101
+ await restoreOriginalBranch(shell, root, state);
102
+ clearState(statePath);
103
+ const back = state.originalBranch || state.originalSha.slice(0, 8);
104
+ console.log(`✅ Resolved PR #${state.pr}: pushed '${state.headBranch}' to '${state.remote}' and returned to '${back}'.`);
105
+ console.log(`Next: PR #${state.pr} is clean now — mark it Ready / Rebase-and-merge.`);
106
+ return 0;
107
+ }
108
+ async function abortResolve(shell, root, statePath, state) {
109
+ if (!state) {
110
+ console.log('No conflict resolution in progress.');
111
+ return 0;
112
+ }
113
+ await restoreOriginalBranch(shell, root, state);
114
+ clearState(statePath);
115
+ const back = state.originalBranch || state.originalSha.slice(0, 8);
116
+ console.log(`Aborted resolution of PR #${state.pr}; returned to '${back}'. The PR branch is unchanged.`);
117
+ return 0;
118
+ }
119
+ async function prMeta(shell, root, prArg) {
120
+ const fields = 'number,url,headRefName,baseRefName,isCrossRepository';
121
+ const result = await shell.runResult('gh', ['pr', 'view', prArg, '--json', fields], root);
122
+ if (result.exitCode !== 0) {
123
+ throw new Error(`Could not resolve PR '${prArg}' via gh: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
124
+ }
125
+ const raw = JSON.parse(result.stdout);
126
+ if (!isRecord(raw)) {
127
+ throw new Error(`gh pr view returned unexpected JSON for '${prArg}'.`);
128
+ }
129
+ const { number, url, headRefName, baseRefName } = raw;
130
+ if (typeof number !== 'number' ||
131
+ typeof url !== 'string' ||
132
+ typeof headRefName !== 'string' ||
133
+ typeof baseRefName !== 'string') {
134
+ throw new Error(`gh pr view JSON for '${prArg}' is missing expected fields.`);
135
+ }
136
+ const match = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(url);
137
+ return {
138
+ number,
139
+ url,
140
+ headBranch: headRefName,
141
+ baseBranch: baseRefName,
142
+ crossRepo: raw.isCrossRepository === true,
143
+ nameWithOwner: match ? match[1] : '',
144
+ };
145
+ }
146
+ /** Pick the git remote whose URL points at `nameWithOwner`; fall back to origin. */
147
+ async function inferRemote(shell, root, nameWithOwner) {
148
+ if (nameWithOwner.length === 0) {
149
+ return 'origin';
150
+ }
151
+ const result = await shell.runResult('git', ['remote', '-v'], root);
152
+ const needle = nameWithOwner.toLowerCase();
153
+ for (const line of result.stdout.split('\n')) {
154
+ const [name, url] = line.split(/\s+/);
155
+ if (name && url?.toLowerCase().includes(needle)) {
156
+ return name;
157
+ }
158
+ }
159
+ return 'origin';
160
+ }
161
+ async function checkoutPrBranch(shell, root, remote, meta) {
162
+ if (meta.crossRepo) {
163
+ // Fork PR: let gh set up the fork remote + tracking branch correctly.
164
+ await shell.run('gh', ['pr', 'checkout', String(meta.number)], root);
165
+ return;
166
+ }
167
+ await shell.run('git', ['checkout', '-B', meta.headBranch, `${remote}/${meta.headBranch}`], root);
168
+ }
169
+ async function pushResolvedBranch(shell, root, state) {
170
+ const refspec = `HEAD:refs/heads/${state.headBranch}`;
171
+ const ff = await shell.runResult('git', ['push', state.remote, refspec], root);
172
+ if (ff.exitCode === 0) {
173
+ return;
174
+ }
175
+ // The resolution may have been rebased/amended onto a moved head; the branch is
176
+ // the review branch we own, so force-with-lease is the safe way to update it.
177
+ console.log('Fast-forward push rejected; retrying with --force-with-lease (review branch).');
178
+ const forced = await shell.runResult('git', ['push', '--force-with-lease', state.remote, refspec], root);
179
+ if (forced.exitCode !== 0) {
180
+ throw new Error(`Failed to push '${state.headBranch}' to '${state.remote}': ${forced.stderr.trim() || ff.stderr.trim()}`);
181
+ }
182
+ }
183
+ async function restoreOriginalBranch(shell, root, state) {
184
+ const target = state.originalBranch.length > 0 ? state.originalBranch : state.originalSha;
185
+ await shell.run('git', ['checkout', target], root);
186
+ }
187
+ async function fetchBranches(shell, root, remote, branches) {
188
+ const refspecs = branches.map((branch) => `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`);
189
+ await shell.run('git', ['fetch', remote, ...refspecs], root);
190
+ }
191
+ async function currentBranch(shell, root) {
192
+ const result = await shell.runResult('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
193
+ return result.exitCode === 0 ? result.stdout.trim() : '';
194
+ }
195
+ async function isWorkingTreeClean(shell, root) {
196
+ const result = await shell.runResult('git', ['status', '--porcelain'], root);
197
+ return result.exitCode === 0 && result.stdout.trim().length === 0;
198
+ }
199
+ async function absoluteGitDir(shell, root) {
200
+ const result = await shell.runResult('git', ['rev-parse', '--absolute-git-dir'], root);
201
+ if (result.exitCode !== 0) {
202
+ throw new Error(`Not a git repository at ${root}: ${result.stderr.trim()}`);
203
+ }
204
+ return result.stdout.trim();
205
+ }
206
+ async function mustRun(shell, root, args) {
207
+ const result = await shell.runResult('git', args, root);
208
+ if (result.exitCode !== 0) {
209
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
210
+ }
211
+ return result.stdout;
212
+ }
213
+ function prArgMatchesState(prArg, state) {
214
+ if (prArg === state.headBranch || prArg === state.url) {
215
+ return true;
216
+ }
217
+ const numeric = prArg.startsWith('#') ? prArg.slice(1) : prArg;
218
+ return numeric === String(state.pr);
219
+ }
220
+ function readState(statePath) {
221
+ let raw;
222
+ try {
223
+ raw = readJson(statePath);
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ if (!isRecord(raw)) {
229
+ return null;
230
+ }
231
+ const { pr, url, headBranch, baseBranch, remote, crossRepo, originalBranch, originalSha, startedAt } = raw;
232
+ if (typeof pr !== 'number' ||
233
+ typeof headBranch !== 'string' ||
234
+ typeof baseBranch !== 'string' ||
235
+ typeof remote !== 'string') {
236
+ return null;
237
+ }
238
+ return {
239
+ pr,
240
+ url: typeof url === 'string' ? url : '',
241
+ headBranch,
242
+ baseBranch,
243
+ remote,
244
+ crossRepo: crossRepo === true,
245
+ originalBranch: typeof originalBranch === 'string' ? originalBranch : '',
246
+ originalSha: typeof originalSha === 'string' ? originalSha : '',
247
+ startedAt: typeof startedAt === 'string' ? startedAt : '',
248
+ };
249
+ }
250
+ function writeState(statePath, state) {
251
+ mkdirSync(dirname(statePath), { recursive: true });
252
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
253
+ }
254
+ function clearState(statePath) {
255
+ rmSync(statePath, { force: true });
256
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/release/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/release/index.ts"],"names":[],"mappings":"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"}
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { Writable } from 'node:stream';
6
6
  import { $ } from 'bun';
7
+ import { assertNoConflictMarkers } from '../lib/conflict-markers.js';
7
8
  import { withDevenvEnv } from '../lib/devenv.js';
8
9
  import { isRecord, readJsonObject, stringProperty } from '../lib/json.js';
9
10
  import { decode, run, runInteractiveStatus, runResult, runStatus } from '../lib/run.js';
@@ -39,6 +40,10 @@ export async function releaseVersion(root, options) {
39
40
  await writeReleaseGithubOutput(options.githubOutput, result.packages, result.mode);
40
41
  }
41
42
  export async function releasePublish(root, options) {
43
+ // Never publish a tree carrying unresolved conflict markers (e.g. a merged
44
+ // review branch that still had markers). Enforced here so every smoo-managed
45
+ // repo's template publish step inherits it. See `smoo pr resolve`.
46
+ await assertNoConflictMarkers({ runResult }, root, 'publish');
42
47
  const bump = releaseBumpArg(options.bump);
43
48
  const packages = await releasePackagesAtHead(root, releasePackages(root));
44
49
  if (packages.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@ import { Command, CommanderError } from 'commander';
2
2
  import { variants } from './generate/index.js';
3
3
  import { cliPackageVersion } from './lib/cli-package.js';
4
4
  import { findRepoRoot } from './lib/run.js';
5
+ import { resolvePrConflicts } from './pr/index.js';
5
6
 
6
7
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
7
8
  const program = buildProgram();
@@ -341,6 +342,18 @@ function buildProgram(): Command {
341
342
  },
342
343
  );
343
344
 
345
+ const pr = program.command('pr').description('Work with GitHub pull requests');
346
+ pr.command('resolve [pr]')
347
+ .description('Resolve conflict markers in a PR (agent-first, two-phase)')
348
+ .option('--remote <name>', 'git remote hosting the PR branch (auto-inferred when omitted)')
349
+ .option('--abort', 'discard an in-progress resolution and return to the original branch')
350
+ .action(async (prArg: string | undefined, options: { remote?: string; abort?: boolean }) => {
351
+ const exitCode = await resolvePrConflicts(await findRepoRoot(), prArg, options);
352
+ if (exitCode !== 0) {
353
+ process.exitCode = exitCode;
354
+ }
355
+ });
356
+
344
357
  return program;
345
358
  }
346
359