#!/usr/bin/env python3 '''show the diff between two pushes of a PR''' import argparse import datetime import json import shlex import subprocess import sys import termios import tty import typing QUERY = ''' query($owner: String!, $name: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { createdAt baseRefOid timelineItems(first: 100, after: $cursor, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent { afterCommit { oid } beforeCommit { oid } createdAt } } pageInfo { endCursor hasNextPage } } } } } ''' def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('pr', type=int, help='pull request number') args = parser.parse_args() name_with_owner = run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], capture_output=True, text=True).stdout.strip() owner, name = name_with_owner.split('/', maxsplit=1) events = [] cursor = None while True: command = ['gh', 'api', 'graphql', '-f', f'query={QUERY}', '-f', f'owner={owner}', '-f', f'name={name}', '-F', f'number={args.pr}'] if cursor: command.extend(['-f', f'cursor={cursor}']) response = json.loads(run(command, capture_output=True, text=True).stdout) pull_request = response['data']['repository']['pullRequest'] if pull_request is None: parser.error(f'pull request #{args.pr} was not found in {name_with_owner}') timeline = pull_request['timelineItems'] events.extend(timeline['nodes']) if not timeline['pageInfo']['hasNextPage']: break cursor = timeline['pageInfo']['endCursor'] if len(events) == 0: print(f'no pushes for {name_with_owner}#{args.pr}') return # add a fake event for base branch → first push events.insert(0, { 'createdAt': pull_request['createdAt'], 'beforeCommit': {'oid': pull_request['baseRefOid']}, 'afterCommit': events[0]['beforeCommit'], }) print(f'push events for {name_with_owner}#{args.pr}:') options = [] for index, event in enumerate(events): before = event['beforeCommit'] after = event['afterCommit'] before_oid = before['oid'][:12] after_oid = after['oid'][:12] options.append(f'{index}. {local_time(event['createdAt'])}: {before_oid} → {after_oid}') while True: index = menu(options) if index is None: break event = events[index] before = event['beforeCommit']['oid'] after = event['afterCommit']['oid'] run(['git', 'fetch', 'origin', before, after]) run(['git', 'update-ref', 'refs/heads/gh-interdiff-' + before, before]) run(['git', 'update-ref', 'refs/heads/gh-interdiff-' + after, after]) try: run(['jj', 'interdiff', '-f', before, '-t', after]) finally: run(['git', 'update-ref', '-d', 'refs/heads/gh-interdiff-' + before]) run(['git', 'update-ref', '-d', 'refs/heads/gh-interdiff-' + after]) run(['jj', 'git', 'import', '--ignore-working-copy']) # abandon unreachable commits def run(command: typing.Sequence[str], **kwargs) -> str: print('\033[90m$ ' + shlex.join(command) + '\033[0m') return subprocess.run(command, check=True, **kwargs) def local_time(dt_str: str) -> str: dt = datetime.datetime.fromisoformat(dt_str.replace('Z', '+00:00')).astimezone() return dt.strftime('%Y-%m-%d %H:%M:%S %Z') def menu(options: typing.Sequence[str]) -> int | None: selected = 0 menu_height = len(options) original_settings = termios.tcgetattr(sys.stdin) try: tty.setraw(sys.stdin.fileno()) print('\n' * menu_height, end='', flush=True) while True: # move the cursor up to the beginning of the existing menu sys.stdout.write(f'\x1b[{menu_height}A') for index, option in enumerate(options): if index == selected: option = f'\x1b[7m{option}\x1b[0m' # invert colors sys.stdout.write(f'\r\x1b[2K{option}\n') sys.stdout.write('\r') sys.stdout.flush() key = read_key() if key in ('\x1b[A', 'k'): if selected > 0: selected -= 1 elif key in ('\x1b[B', 'j'): if selected < len(options) - 1: selected += 1 elif key in ('\r', '\n'): return selected elif key == 'q': return None finally: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, original_settings) def read_key() -> str: key = sys.stdin.read(1) if key == '\x1b': key += sys.stdin.read(2) # arrow keys are 3 bytes: ESC, [, A/B return key if __name__ == '__main__': main()