gh-interdiff 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. #!/usr/bin/env python3
  2. '''show the diff between two pushes of a PR'''
  3. import argparse
  4. import datetime
  5. import json
  6. import shlex
  7. import subprocess
  8. import sys
  9. import termios
  10. import tty
  11. import typing
  12. QUERY = '''
  13. query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
  14. repository(owner: $owner, name: $name) {
  15. pullRequest(number: $number) {
  16. createdAt
  17. baseRefOid
  18. timelineItems(first: 100, after: $cursor, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) {
  19. nodes {
  20. ... on HeadRefForcePushedEvent {
  21. afterCommit { oid }
  22. beforeCommit { oid }
  23. createdAt
  24. }
  25. }
  26. pageInfo {
  27. endCursor
  28. hasNextPage
  29. }
  30. }
  31. }
  32. }
  33. }
  34. '''
  35. def main() -> None:
  36. parser = argparse.ArgumentParser(description=__doc__)
  37. parser.add_argument('pr', type=int, help='pull request number')
  38. args = parser.parse_args()
  39. name_with_owner = run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
  40. capture_output=True, text=True).stdout.strip()
  41. owner, name = name_with_owner.split('/', maxsplit=1)
  42. events = []
  43. cursor = None
  44. while True:
  45. command = ['gh', 'api', 'graphql',
  46. '-f', f'query={QUERY}', '-f', f'owner={owner}', '-f', f'name={name}', '-F', f'number={args.pr}']
  47. if cursor:
  48. command.extend(['-f', f'cursor={cursor}'])
  49. response = json.loads(run(command, capture_output=True, text=True).stdout)
  50. pull_request = response['data']['repository']['pullRequest']
  51. if pull_request is None:
  52. parser.error(f'pull request #{args.pr} was not found in {name_with_owner}')
  53. timeline = pull_request['timelineItems']
  54. events.extend(timeline['nodes'])
  55. if not timeline['pageInfo']['hasNextPage']:
  56. break
  57. cursor = timeline['pageInfo']['endCursor']
  58. if len(events) == 0:
  59. print(f'no pushes for {name_with_owner}#{args.pr}')
  60. return
  61. # add a fake event for base branch → first push
  62. events.insert(0, {
  63. 'createdAt': pull_request['createdAt'],
  64. 'beforeCommit': {'oid': pull_request['baseRefOid']},
  65. 'afterCommit': events[0]['beforeCommit'],
  66. })
  67. print(f'push events for {name_with_owner}#{args.pr}:')
  68. options = []
  69. for index, event in enumerate(events):
  70. before = event['beforeCommit']
  71. after = event['afterCommit']
  72. before_oid = before['oid'][:12]
  73. after_oid = after['oid'][:12]
  74. options.append(f'{index}. {local_time(event['createdAt'])}: {before_oid} → {after_oid}')
  75. while True:
  76. index = menu(options)
  77. if index is None:
  78. break
  79. event = events[index]
  80. before = event['beforeCommit']['oid']
  81. after = event['afterCommit']['oid']
  82. run(['git', 'fetch', 'origin', before, after])
  83. run(['git', 'update-ref', 'refs/heads/gh-interdiff-' + before, before])
  84. run(['git', 'update-ref', 'refs/heads/gh-interdiff-' + after, after])
  85. try:
  86. run(['jj', 'interdiff', '-f', before, '-t', after])
  87. finally:
  88. run(['git', 'update-ref', '-d', 'refs/heads/gh-interdiff-' + before])
  89. run(['git', 'update-ref', '-d', 'refs/heads/gh-interdiff-' + after])
  90. run(['jj', 'git', 'import', '--ignore-working-copy']) # abandon unreachable commits
  91. def run(command: typing.Sequence[str], **kwargs) -> str:
  92. print('\033[90m$ ' + shlex.join(command) + '\033[0m')
  93. return subprocess.run(command, check=True, **kwargs)
  94. def local_time(dt_str: str) -> str:
  95. dt = datetime.datetime.fromisoformat(dt_str.replace('Z', '+00:00')).astimezone()
  96. return dt.strftime('%Y-%m-%d %H:%M:%S %Z')
  97. def menu(options: typing.Sequence[str]) -> int | None:
  98. selected = 0
  99. menu_height = len(options)
  100. original_settings = termios.tcgetattr(sys.stdin)
  101. try:
  102. tty.setraw(sys.stdin.fileno())
  103. print('\n' * menu_height, end='', flush=True)
  104. while True:
  105. # move the cursor up to the beginning of the existing menu
  106. sys.stdout.write(f'\x1b[{menu_height}A')
  107. for index, option in enumerate(options):
  108. if index == selected:
  109. option = f'\x1b[7m{option}\x1b[0m' # invert colors
  110. sys.stdout.write(f'\r\x1b[2K{option}\n')
  111. sys.stdout.write('\r')
  112. sys.stdout.flush()
  113. key = read_key()
  114. if key in ('\x1b[A', 'k'):
  115. if selected > 0:
  116. selected -= 1
  117. elif key in ('\x1b[B', 'j'):
  118. if selected < len(options) - 1:
  119. selected += 1
  120. elif key in ('\r', '\n'):
  121. return selected
  122. elif key == 'q':
  123. return None
  124. finally:
  125. termios.tcsetattr(sys.stdin, termios.TCSADRAIN, original_settings)
  126. def read_key() -> str:
  127. key = sys.stdin.read(1)
  128. if key == '\x1b':
  129. key += sys.stdin.read(2) # arrow keys are 3 bytes: ESC, [, A/B
  130. return key
  131. if __name__ == '__main__':
  132. main()