| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151 |
- #!/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()
|