|
|
@@ -0,0 +1,151 @@
|
|
|
+#!/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()
|