diff --git a/README.md b/README.md index 65bd579..4d4b7b9 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,14 @@ Stacky doesn't use any git or Github APIs. It expects `git` and `gh` cli command ## Usage `stacky` stores all information locally, within your git repository Syntax is as follows: -- `stacky info`: show all stacks , add `-pr` if you want to see GitHub PR numbers (slows things down a bit) +- `stacky info`: show all stacks , add `-pr` if you want to see GitHub PR numbers (slows things down a bit) +- `stacky inbox [--compact]`: show all active GitHub pull requests for the current user, organized by status (waiting on you, waiting on review, approved, and PRs awaiting your review). Use `--compact` or `-c` for a condensed one-line-per-PR view with clickable PR numbers. - `stacky branch`: per branch commands (shortcut: `stacky b`) - `stacky branch up` (`stacky b u`): move down the stack (towards `master`) - - `stacky branch down` (`stacky b d`): move down the stack (towards `master`) + - `stacky branch down` (`stacky b d`): move down the stack (towards `master`) - `stacky branch new `: create a new branch on top of the current one -- `stacky commit [-m ] [--amend] [--allow-empty]`: wrapper around `git commit` that syncs everything upstack + - `stacky branch commit [-m ] [-a]`: create a new branch and commit changes in one command +- `stacky commit [-m ] [--amend] [--allow-empty] [-a]`: wrapper around `git commit` that syncs everything upstack - `stacky amend`: will amend currently tracked changes to top commit - Based on the first argument (`stack` vs `upstack` vs `downstack`), the following commands operate on the entire current stack, everything upstack from the current PR (inclusive), or everything downstack from the current PR: - `stacky stack info [--pr]` @@ -56,12 +58,12 @@ The indicators (`*`, `~`, `!`) mean: ``` $ stacky --help usage: stacky [-h] [--color {always,auto,never}] - {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco} ... + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox} ... Handle git stacks positional arguments: - {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco} + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox} continue Continue previously interrupted command info Stack info commit Commit @@ -71,12 +73,14 @@ positional arguments: upstack (us) Operations on the current upstack downstack (ds) Operations on the current downstack update Update repo + import Import Graphite stack adopt Adopt one branch land Land bottom-most PR on current stack push Alias for downstack push sync Alias for stack sync checkout (co) Checkout a branch sco Checkout a branch in this stack + inbox List all active GitHub pull requests for the current user optional arguments: -h, --help show this help message and exit diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index efe4c6b..cad709d 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -763,6 +763,34 @@ def cmd_branch_new(stack: StackBranchSet, args): run(CmdArgs(["git", "update-ref", "refs/stack-parent/{}".format(name), b.commit, ""])) +def cmd_branch_commit(stack: StackBranchSet, args): + """Create a new branch and commit all changes with the provided message""" + global CURRENT_BRANCH + + # First create the new branch (same logic as cmd_branch_new) + b = stack.stack[CURRENT_BRANCH] + assert b.commit + name = args.name + create_branch(name) + run(CmdArgs(["git", "update-ref", "refs/stack-parent/{}".format(name), b.commit, ""])) + + # Update global CURRENT_BRANCH since we just checked out the new branch + CURRENT_BRANCH = BranchName(name) + + # Reload the stack to include the new branch + load_stack_for_given_branch(stack, CURRENT_BRANCH) + + # Now commit all changes with the provided message (or open editor if no message) + do_commit( + stack, + message=args.message, + amend=False, + allow_empty=False, + edit=True, + add_all=args.add_all, + ) + + def cmd_branch_checkout(stack: StackBranchSet, args): branch_name = args.name if branch_name is None: @@ -1660,6 +1688,157 @@ def cmd_land(stack: StackBranchSet, args): cout("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") +def cmd_inbox(stack: StackBranchSet, args): + """List all active GitHub pull requests for the current user""" + fields = [ + "number", + "title", + "headRefName", + "baseRefName", + "state", + "url", + "createdAt", + "updatedAt", + "author", + "reviewDecision", + "reviewRequests" + ] + + # Get all open PRs authored by the current user + my_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--author", + "@me" + ] + ) + ) + ) + + # Get all open PRs where current user is requested as reviewer + review_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--search", + "review-requested:@me" + ] + ) + ) + ) + + # Categorize my PRs based on review status + waiting_on_me = [] + waiting_on_review = [] + approved = [] + + for pr in my_prs_data: + if pr["reviewDecision"] == "APPROVED": + approved.append(pr) + elif pr["reviewRequests"] and len(pr["reviewRequests"]) > 0: + waiting_on_review.append(pr) + else: + # No pending review requests, likely needs changes or author action + waiting_on_me.append(pr) + + # Sort all lists by updatedAt in descending order (most recent first) + waiting_on_me.sort(key=lambda pr: pr["updatedAt"], reverse=True) + waiting_on_review.sort(key=lambda pr: pr["updatedAt"], reverse=True) + approved.sort(key=lambda pr: pr["updatedAt"], reverse=True) + review_prs_data.sort(key=lambda pr: pr["updatedAt"], reverse=True) + + def display_pr_list(prs, color="white"): + for pr in prs: + if args.compact: + # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" + # Create clickable link for just the PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{} ", pr["title"], fg="white") + cout("({}) ", pr["headRefName"], fg="gray") + cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + else: + # Full format with clickable PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{}\n", pr["title"], fg=color) + cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") + cout(" {}\n", pr["url"], fg="blue") + cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") + + # Display categorized authored PRs + if waiting_on_me: + cout("Your PRs - Waiting on You:\n", fg="red") + display_pr_list(waiting_on_me, "white") + if args.compact: + cout("\n") + else: + cout("\n") + + if waiting_on_review: + cout("Your PRs - Waiting on Review:\n", fg="yellow") + display_pr_list(waiting_on_review, "white") + if args.compact: + cout("\n") + else: + cout("\n") + + if approved: + cout("Your PRs - Approved:\n", fg="green") + display_pr_list(approved, "white") + if args.compact: + cout("\n") + else: + cout("\n") + + if not my_prs_data: + cout("No active pull requests authored by you.\n", fg="green") + + # Display PRs waiting for review + if review_prs_data: + cout("Pull Requests Awaiting Your Review:\n", fg="yellow") + for pr in review_prs_data: + if args.compact: + # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" + # Create clickable link for just the PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{} ", pr["title"], fg="white") + cout("({}) ", pr["headRefName"], fg="gray") + cout("by {} ", pr["author"]["login"], fg="gray") + cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + else: + # Full format with clickable PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{}\n", pr["title"], fg="white") + cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") + cout(" Author: {}\n", pr["author"]["login"], fg="gray") + cout(" {}\n", pr["url"], fg="blue") + cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") + else: + cout("No pull requests awaiting your review.\n", fg="yellow") + + def main(): logging.basicConfig(format=_LOGGING_FORMAT, level=logging.INFO) try: @@ -1726,6 +1905,12 @@ def main(): branch_new_parser.add_argument("name", help="Branch name") branch_new_parser.set_defaults(func=cmd_branch_new) + branch_commit_parser = branch_subparsers.add_parser("commit", help="Create a new branch and commit all changes") + branch_commit_parser.add_argument("name", help="Branch name") + branch_commit_parser.add_argument("-m", help="Commit message", dest="message") + branch_commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") + branch_commit_parser.set_defaults(func=cmd_branch_commit) + branch_checkout_parser = branch_subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch") branch_checkout_parser.add_argument("name", help="Branch name", nargs="?") branch_checkout_parser.set_defaults(func=cmd_branch_checkout) @@ -1838,6 +2023,11 @@ def main(): checkout_parser = subparsers.add_parser("sco", help="Checkout a branch in this stack") checkout_parser.set_defaults(func=cmd_stack_checkout) + # inbox + inbox_parser = subparsers.add_parser("inbox", help="List all active GitHub pull requests for the current user") + inbox_parser.add_argument("--compact", "-c", action="store_true", help="Show compact view") + inbox_parser.set_defaults(func=cmd_inbox) + args = parser.parse_args() logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True)