prq-cli 0.3.0 → 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.
Files changed (3) hide show
  1. package/README.md +42 -8
  2. package/dist/bin/prq.js +264 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,11 +17,11 @@ Requires [GitHub CLI](https://cli.github.com/) (`gh`) to be authenticated.
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- # Set up config for the current project
21
- prq init
22
-
23
20
  # See your review queue
24
21
  prq
22
+
23
+ # Interactive mode — navigate with arrow keys, act with shortcuts
24
+ prq -i
25
25
  ```
26
26
 
27
27
  ## Commands
@@ -40,11 +40,23 @@ prq # all repos you have access to
40
40
  prq status --repos org/repo1 org/repo2 # specific repos
41
41
  prq status --stale-days 7 # custom stale threshold
42
42
  prq status --json # machine-readable output
43
+ prq status -i # interactive mode
43
44
  ```
44
45
 
46
+ ### Interactive Mode
47
+
48
+ Run `prq -i` to navigate your queue with keyboard shortcuts:
49
+
50
+ - **↑↓** navigate between PRs
51
+ - **r** review — open files changed tab
52
+ - **o** open — open PR in browser
53
+ - **n** nudge — post a comment
54
+ - **c** copy URL to clipboard
55
+ - **q** quit
56
+
45
57
  ### `prq open <identifier>`
46
58
 
47
- Open a PR in the browser. Accepts a PR number, `org/repo#number`, or a full GitHub URL.
59
+ Open a PR in the browser.
48
60
 
49
61
  ```bash
50
62
  prq open 482 # searches your queue for PR #482
@@ -71,14 +83,18 @@ prq nudge 482 --yes # skip confirmation
71
83
  prq nudge 482 --message "Any updates?" # custom message
72
84
  ```
73
85
 
74
- ### `prq init`
86
+ ### `prq run <action> <identifier>`
75
87
 
76
- Creates a `.prqrc.json` config file in the current directory.
88
+ Run a custom action defined in your config.
77
89
 
78
90
  ```bash
79
- prq init
91
+ prq run checkout 482
80
92
  ```
81
93
 
94
+ ### `prq init`
95
+
96
+ Creates a `.prqrc.json` config file in the current directory.
97
+
82
98
  ## Configuration
83
99
 
84
100
  Config is loaded in this order (later overrides earlier):
@@ -92,10 +108,28 @@ Example `.prqrc.json`:
92
108
  ```json
93
109
  {
94
110
  "repos": ["org/repo1", "org/repo2"],
95
- "staleDays": 5
111
+ "staleDays": 5,
112
+ "actions": {
113
+ "review": "claude -p '/review {url}'",
114
+ "checkout": "gh pr checkout {number} --repo {owner}/{repo}"
115
+ }
96
116
  }
97
117
  ```
98
118
 
119
+ ### Custom Actions
120
+
121
+ Actions are shell command templates with variables:
122
+
123
+ - `{url}` — full PR URL
124
+ - `{number}` — PR number
125
+ - `{owner}` — repo owner
126
+ - `{repo}` — repo name
127
+ - `{title}` — PR title
128
+ - `{author}` — PR author
129
+ - `{days}` — days since last activity
130
+
131
+ Default actions (`open`, `review`, `nudge`) can be overridden. Custom actions are available via `prq run <action>` and in interactive mode.
132
+
99
133
  ## License
100
134
 
101
135
  MIT
package/dist/bin/prq.js CHANGED
@@ -6411,40 +6411,71 @@ async function nudgeCommand(identifier, config, options) {
6411
6411
  console.log(source_default.green(` Comment posted on ${label}`));
6412
6412
  }
6413
6413
 
6414
- // src/platform.ts
6414
+ // src/actions.ts
6415
6415
  import { spawn } from "node:child_process";
6416
- function openUrl(url) {
6417
- return new Promise((resolve, reject) => {
6418
- const cmd = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : process.platform === "win32" ? "cmd" : null;
6419
- if (!cmd) {
6420
- reject(new Error(`Unsupported platform: ${process.platform}`));
6421
- return;
6416
+ var DEFAULT_ACTIONS = {
6417
+ open: "open {url}",
6418
+ review: "open {url}/files",
6419
+ nudge: "gh pr comment {number} --repo {owner}/{repo} --body 'Hey @{author}, is this PR still active? No activity for {days} days.'"
6420
+ };
6421
+ function getAction(name, config) {
6422
+ return config.actions[name] ?? DEFAULT_ACTIONS[name];
6423
+ }
6424
+ function listActions(config) {
6425
+ return { ...DEFAULT_ACTIONS, ...config.actions };
6426
+ }
6427
+ function buildContext(pr) {
6428
+ const days = Math.floor((Date.now() - new Date(pr.updatedAt || Date.now()).getTime()) / 86400000);
6429
+ return {
6430
+ url: pr.url,
6431
+ number: pr.number,
6432
+ owner: pr.owner,
6433
+ repo: pr.repo,
6434
+ fullRepo: `${pr.owner}/${pr.repo}`,
6435
+ title: pr.title,
6436
+ author: pr.author,
6437
+ days
6438
+ };
6439
+ }
6440
+ function interpolate(template, context) {
6441
+ return template.replace(/\{(\w+)\}/g, (match, key) => {
6442
+ if (key in context) {
6443
+ return String(context[key]);
6422
6444
  }
6423
- const args = process.platform === "win32" ? ["/c", "start", url] : [url];
6424
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
6425
- child.unref();
6426
- child.on("error", reject);
6427
- child.on("close", () => resolve());
6445
+ return match;
6428
6446
  });
6429
6447
  }
6430
-
6431
- // src/commands/open.ts
6432
- async function openCommand(identifier, config) {
6433
- const pr = await resolveIdentifier(identifier, config);
6434
- const label = `${pr.owner}/${pr.repo}#${pr.number}`;
6435
- process.stderr.write(source_default.dim(`Opening ${label}...
6436
- `));
6437
- await openUrl(pr.url);
6448
+ function executeCommand(command) {
6449
+ return new Promise((resolve, reject) => {
6450
+ const child = spawn(command, {
6451
+ shell: true,
6452
+ stdio: "inherit"
6453
+ });
6454
+ child.on("error", reject);
6455
+ child.on("close", (code) => {
6456
+ if (code === 0)
6457
+ resolve();
6458
+ else
6459
+ reject(new Error(`Command exited with code ${code}`));
6460
+ });
6461
+ });
6438
6462
  }
6439
6463
 
6440
- // src/commands/review.ts
6441
- async function reviewCommand(identifier, config) {
6464
+ // src/commands/run.ts
6465
+ async function runCommand(action, identifier, config) {
6466
+ const template = getAction(action, config);
6467
+ if (!template) {
6468
+ const available = Object.keys(listActions(config)).join(", ");
6469
+ throw new Error(`Unknown action: "${action}"
6470
+ Available actions: ${available}`);
6471
+ }
6442
6472
  const pr = await resolveIdentifier(identifier, config);
6473
+ const context = buildContext(pr);
6474
+ const command = interpolate(template, context);
6443
6475
  const label = `${pr.owner}/${pr.repo}#${pr.number}`;
6444
- const filesUrl = `${pr.url}/files`;
6445
- process.stderr.write(source_default.dim(`Opening review for ${label}...
6476
+ process.stderr.write(source_default.dim(`${label} ${action}: ${command}
6446
6477
  `));
6447
- await openUrl(filesUrl);
6478
+ await executeCommand(command);
6448
6479
  }
6449
6480
 
6450
6481
  // src/categorize.ts
@@ -6552,7 +6583,7 @@ function categorize(reviewedPRs, requestedPRs, authoredPRs, staleDays) {
6552
6583
  return results;
6553
6584
  }
6554
6585
 
6555
- // src/output.ts
6586
+ // src/interactive.ts
6556
6587
  var CATEGORY_CONFIG = {
6557
6588
  "needs-re-review": {
6558
6589
  icon: "◆",
@@ -6573,6 +6604,198 @@ var CATEGORY_ORDER = [
6573
6604
  "stale",
6574
6605
  "waiting-on-others"
6575
6606
  ];
6607
+ function toResolvedPR(pr) {
6608
+ const [owner, repo] = pr.repo.split("/");
6609
+ return {
6610
+ owner,
6611
+ repo,
6612
+ number: pr.number,
6613
+ url: pr.url,
6614
+ title: pr.title,
6615
+ author: pr.author,
6616
+ updatedAt: pr.updatedAt
6617
+ };
6618
+ }
6619
+ function render(result, selectedIndex, message) {
6620
+ process.stdout.write("\x1B[2J\x1B[H");
6621
+ const lines = [];
6622
+ lines.push(source_default.bold(` PRQ Status for ${result.user}`));
6623
+ lines.push(source_default.dim(` ${"─".repeat(50)}`));
6624
+ const grouped = new Map;
6625
+ for (const pr of result.prs) {
6626
+ const list = grouped.get(pr.category) ?? [];
6627
+ list.push(pr);
6628
+ grouped.set(pr.category, list);
6629
+ }
6630
+ let flatIndex = 0;
6631
+ for (const category of CATEGORY_ORDER) {
6632
+ const prs = grouped.get(category);
6633
+ if (!prs || prs.length === 0)
6634
+ continue;
6635
+ const config = CATEGORY_CONFIG[category];
6636
+ lines.push("");
6637
+ lines.push(` ${config.color(`${config.icon} ${config.label}`)} ${source_default.dim(`(${prs.length})`)}`);
6638
+ for (const pr of prs) {
6639
+ const isSelected = flatIndex === selectedIndex;
6640
+ const arrow = isSelected ? source_default.yellow("›") : " ";
6641
+ const draft = pr.isDraft ? source_default.dim(" [draft]") : "";
6642
+ const maxTitle = 50;
6643
+ const title = pr.title.length > maxTitle ? `${pr.title.slice(0, maxTitle - 3)}...` : pr.title;
6644
+ if (isSelected) {
6645
+ const ref = source_default.white(`#${pr.number}`);
6646
+ lines.push(` ${arrow} ${ref} ${source_default.white(title)}${draft}`);
6647
+ lines.push(` ${source_default.dim("↳")} ${source_default.dim(pr.detail)}`);
6648
+ } else {
6649
+ const ref = source_default.dim(`#${pr.number}`);
6650
+ lines.push(` ${arrow} ${ref} ${source_default.dim(title)}${draft}`);
6651
+ lines.push(` ${source_default.dim("↳")} ${source_default.dim(pr.detail)}`);
6652
+ }
6653
+ flatIndex++;
6654
+ }
6655
+ }
6656
+ if (result.prs.length === 0) {
6657
+ lines.push("");
6658
+ lines.push(source_default.green(" All clear! No PRs need your attention."));
6659
+ }
6660
+ lines.push("");
6661
+ lines.push(source_default.dim(` ${"─".repeat(50)}`));
6662
+ lines.push("");
6663
+ lines.push(` ${source_default.dim("↑↓")} navigate ${source_default.white("r")} review ${source_default.white("o")} open ${source_default.white("n")} nudge ${source_default.white("c")} copy url ${source_default.white("q")} quit`);
6664
+ if (message) {
6665
+ lines.push("");
6666
+ lines.push(` ${message}`);
6667
+ }
6668
+ process.stdout.write(lines.join(`
6669
+ `));
6670
+ }
6671
+ async function interactiveMode(result, config) {
6672
+ if (result.prs.length === 0) {
6673
+ console.log(source_default.green(`
6674
+ All clear! No PRs need your attention.
6675
+ `));
6676
+ return;
6677
+ }
6678
+ let selectedIndex = 0;
6679
+ let message = "";
6680
+ const total = result.prs.length;
6681
+ if (!process.stdin.isTTY) {
6682
+ process.stdout.write(`Interactive mode requires a terminal. Use prq status instead.
6683
+ `);
6684
+ return;
6685
+ }
6686
+ process.stdin.setRawMode(true);
6687
+ process.stdin.resume();
6688
+ process.stdin.setEncoding("utf8");
6689
+ process.stdout.write("\x1B[?25l");
6690
+ render(result, selectedIndex, message);
6691
+ return new Promise((resolve) => {
6692
+ const cleanup = () => {
6693
+ process.stdin.setRawMode(false);
6694
+ process.stdin.pause();
6695
+ process.stdin.removeAllListeners("data");
6696
+ process.stdout.write("\x1B[?25h\x1B[2J\x1B[H");
6697
+ };
6698
+ process.stdin.on("data", async (key) => {
6699
+ const pr = result.prs[selectedIndex];
6700
+ switch (key) {
6701
+ case "q":
6702
+ case "\x03":
6703
+ cleanup();
6704
+ resolve();
6705
+ return;
6706
+ case "\x1B[A":
6707
+ selectedIndex = Math.max(0, selectedIndex - 1);
6708
+ message = "";
6709
+ break;
6710
+ case "\x1B[B":
6711
+ selectedIndex = Math.min(total - 1, selectedIndex + 1);
6712
+ message = "";
6713
+ break;
6714
+ case "o": {
6715
+ const template = getAction("open", config);
6716
+ if (template) {
6717
+ const cmd = interpolate(template, buildContext(toResolvedPR(pr)));
6718
+ message = source_default.dim(`opening ${pr.repo}#${pr.number}...`);
6719
+ render(result, selectedIndex, message);
6720
+ try {
6721
+ await executeCommand(cmd);
6722
+ message = source_default.green(`opened ${pr.repo}#${pr.number}`);
6723
+ } catch {
6724
+ message = source_default.red("failed to open");
6725
+ }
6726
+ }
6727
+ break;
6728
+ }
6729
+ case "r": {
6730
+ const template = getAction("review", config);
6731
+ if (template) {
6732
+ const cmd = interpolate(template, buildContext(toResolvedPR(pr)));
6733
+ message = source_default.dim(`opening review for ${pr.repo}#${pr.number}...`);
6734
+ render(result, selectedIndex, message);
6735
+ try {
6736
+ await executeCommand(cmd);
6737
+ message = source_default.green(`opened review for ${pr.repo}#${pr.number}`);
6738
+ } catch {
6739
+ message = source_default.red("failed to open review");
6740
+ }
6741
+ }
6742
+ break;
6743
+ }
6744
+ case "n": {
6745
+ const template = getAction("nudge", config);
6746
+ if (template) {
6747
+ const cmd = interpolate(template, buildContext(toResolvedPR(pr)));
6748
+ message = source_default.dim(`nudging ${pr.repo}#${pr.number}...`);
6749
+ render(result, selectedIndex, message);
6750
+ try {
6751
+ await executeCommand(cmd);
6752
+ message = source_default.green(`nudged ${pr.repo}#${pr.number}`);
6753
+ } catch {
6754
+ message = source_default.red("failed to nudge");
6755
+ }
6756
+ }
6757
+ break;
6758
+ }
6759
+ case "c": {
6760
+ const url = pr.url;
6761
+ try {
6762
+ const proc = process.platform === "darwin" ? "pbcopy" : process.platform === "linux" ? "xclip -selection clipboard" : "clip";
6763
+ await executeCommand(`echo "${url}" | ${proc}`);
6764
+ message = source_default.green("url copied");
6765
+ } catch {
6766
+ message = source_default.dim(url);
6767
+ }
6768
+ break;
6769
+ }
6770
+ default:
6771
+ break;
6772
+ }
6773
+ render(result, selectedIndex, message);
6774
+ });
6775
+ });
6776
+ }
6777
+
6778
+ // src/output.ts
6779
+ var CATEGORY_CONFIG2 = {
6780
+ "needs-re-review": {
6781
+ icon: "◆",
6782
+ label: "Needs Re-review",
6783
+ color: source_default.yellow
6784
+ },
6785
+ requested: { icon: "●", label: "Requested Reviews", color: source_default.green },
6786
+ stale: { icon: "○", label: "Stale", color: source_default.red },
6787
+ "waiting-on-others": {
6788
+ icon: "◇",
6789
+ label: "Your PRs Waiting",
6790
+ color: source_default.dim
6791
+ }
6792
+ };
6793
+ var CATEGORY_ORDER2 = [
6794
+ "needs-re-review",
6795
+ "requested",
6796
+ "stale",
6797
+ "waiting-on-others"
6798
+ ];
6576
6799
  function formatPR(pr) {
6577
6800
  const draft = pr.isDraft ? source_default.dim(" [draft]") : "";
6578
6801
  const prRef = source_default.cyan(`${pr.repo}#${pr.number}`);
@@ -6596,12 +6819,12 @@ function formatStatus(result) {
6596
6819
  grouped.set(pr.category, list);
6597
6820
  }
6598
6821
  let hasContent = false;
6599
- for (const category of CATEGORY_ORDER) {
6822
+ for (const category of CATEGORY_ORDER2) {
6600
6823
  const prs = grouped.get(category);
6601
6824
  if (!prs || prs.length === 0)
6602
6825
  continue;
6603
6826
  hasContent = true;
6604
- const config = CATEGORY_CONFIG[category];
6827
+ const config = CATEGORY_CONFIG2[category];
6605
6828
  lines.push("");
6606
6829
  lines.push(` ${config.color(`${config.icon} ${config.label}`)} ${source_default.dim(`(${prs.length})`)}`);
6607
6830
  for (const pr of prs) {
@@ -6625,7 +6848,7 @@ function formatStatus(result) {
6625
6848
  }
6626
6849
 
6627
6850
  // src/commands/status.ts
6628
- async function statusCommand(config, json) {
6851
+ async function statusCommand(config, json, interactive) {
6629
6852
  const user = config.user ?? await getAuthenticatedUser();
6630
6853
  process.stderr.write(`Fetching PRs for ${user}...
6631
6854
  `);
@@ -6646,6 +6869,8 @@ async function statusCommand(config, json) {
6646
6869
  if (json) {
6647
6870
  process.stdout.write(`${JSON.stringify(result, null, 2)}
6648
6871
  `);
6872
+ } else if (interactive && process.stdin.isTTY) {
6873
+ await interactiveMode(result, config);
6649
6874
  } else {
6650
6875
  process.stdout.write(formatStatus(result));
6651
6876
  }
@@ -10632,7 +10857,8 @@ var NEVER = INVALID;
10632
10857
  var configSchema = exports_external.object({
10633
10858
  repos: exports_external.array(exports_external.string()).default([]),
10634
10859
  staleDays: exports_external.number().default(3),
10635
- user: exports_external.string().optional()
10860
+ user: exports_external.string().optional(),
10861
+ actions: exports_external.record(exports_external.string()).default({})
10636
10862
  });
10637
10863
  function loadConfig(cliOverrides) {
10638
10864
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
@@ -10667,20 +10893,20 @@ function getVersion() {
10667
10893
  function createCLI() {
10668
10894
  const program2 = new Command;
10669
10895
  program2.name("prq").description("PR Queue — see what code reviews need your attention").version(getVersion());
10670
- program2.command("status", { isDefault: true }).description("Show PRs needing your attention").option("-r, --repos <repos...>", "Filter to specific repos (owner/name)").option("-s, --stale-days <days>", "Days of inactivity to consider stale", "3").option("--json", "Output as JSON").action(async (opts) => {
10896
+ program2.command("status", { isDefault: true }).description("Show PRs needing your attention").option("-r, --repos <repos...>", "Filter to specific repos (owner/name)").option("-s, --stale-days <days>", "Days of inactivity to consider stale", "3").option("--json", "Output as JSON").option("-i, --interactive", "Interactive mode with keyboard shortcuts").action(async (opts) => {
10671
10897
  const config = loadConfig({
10672
10898
  repos: opts.repos,
10673
10899
  staleDays: opts.staleDays ? parseInt(opts.staleDays, 10) : undefined
10674
10900
  });
10675
- await statusCommand(config, opts.json ?? false);
10901
+ await statusCommand(config, opts.json ?? false, opts.interactive ?? false);
10676
10902
  });
10677
10903
  program2.command("open <identifier>").description("Open a PR in the browser").action(async (identifier) => {
10678
10904
  const config = loadConfig({});
10679
- await openCommand(identifier, config);
10905
+ await runCommand("open", identifier, config);
10680
10906
  });
10681
10907
  program2.command("review <identifier>").description("Open PR files changed tab for review").action(async (identifier) => {
10682
10908
  const config = loadConfig({});
10683
- await reviewCommand(identifier, config);
10909
+ await runCommand("review", identifier, config);
10684
10910
  });
10685
10911
  program2.command("nudge <identifier>").description("Post a nudge comment on a PR").option("-m, --message <msg>", "Custom nudge message").option("-y, --yes", "Skip confirmation").action(async (identifier, opts) => {
10686
10912
  const config = loadConfig({});
@@ -10689,6 +10915,10 @@ function createCLI() {
10689
10915
  yes: opts.yes ?? false
10690
10916
  });
10691
10917
  });
10918
+ program2.command("run <action> <identifier>").description("Run a custom action on a PR").action(async (action, identifier) => {
10919
+ const config = loadConfig({});
10920
+ await runCommand(action, identifier, config);
10921
+ });
10692
10922
  program2.command("init").description("Create config file interactively").action(async () => {
10693
10923
  await initCommand();
10694
10924
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prq-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "PR Queue — see what code reviews need your attention",
5
5
  "type": "module",
6
6
  "bin": {