3 # If you modify this file, please pass it through:
5 # flake8 --ignore=E501,W503 generate-comment-diff.py
6 # black generate-comment-diff.py
10 # ./generate-comment-diff-test.sh
17 from collections import OrderedDict
21 def __init__(self, name):
29 def from_raw(cls, name, raw):
33 return "<Project {}>".format(self.name)
53 def from_raw(cls, raw):
56 change._number = raw["_number"]
57 change._subject = raw["subject"]
58 change._project = raw["project"]
63 return "<Change {}>".format(self.number)
79 def from_raw(cls, raw):
82 account._name = raw["name"]
83 account._id = raw["_account_id"]
102 def from_raw(cls, raw):
105 message._author = Account.from_raw(raw["author"])
106 message._date = raw["date"]
107 message._message = raw["message"]
112 return "<Message by {} at {}>".format(self.author.name, self.date)
120 def start_line(self):
121 return self._start_line
125 return self._end_line
128 def from_raw(cls, raw):
131 rng._start_line = raw["start_line"]
132 rng._end_line = raw["end_line"]
167 def from_raw(cls, raw, path=None):
170 comment._author = Account.from_raw(raw["author"])
171 comment._date = raw["updated"]
172 comment._message = raw["message"]
177 comment._path = raw["path"]
179 comment._side = raw.get("side", "REVISION")
181 comment._line = raw.get("line", None)
184 comment._range = Range.from_raw(raw["range"])
186 comment._range = None
191 return "<Comment by {} at {}>".format(self.author.name, self.date)
211 def from_raw(cls, raw):
214 diff._content = raw["content"]
217 diff._path_a = raw["meta_a"]["name"]
222 diff._path_b = raw["meta_b"]["name"]
230 def __init__(self, base_addr):
231 self._base_addr = base_addr
233 def _json_query(self, path):
234 url = "{}/{}".format(self._base_addr, path)
235 # print("Getting {}".format(url))
236 text = requests.get(url).text
238 return json.loads(text)
242 return self._base_addr
244 def get_projects(self):
245 raw = self._json_query("projects/")
248 for (name, proj_raw) in raw.items():
249 projects.append(Project.from_raw(name, proj_raw))
253 def get_change(self, change_number):
254 raw = self._json_query("changes/?q=change:{}".format(change_number))
258 return Change.from_raw(raw[0])
260 def get_changes(self):
261 raw = self._json_query("changes/")
264 for change_raw in raw:
265 changes.append(Change.from_raw(change_raw))
269 def get_change_messages(self, change):
270 raw = self._json_query(
271 "changes/{}~{}/messages".format(change.project, change.number,)
276 for message_raw in raw:
277 messages.append(Message.from_raw(message_raw))
281 def get_change_message_comments(self, change, message_filter):
282 raw = self._json_query(
283 "changes/{}~{}/comments".format(change.project, change.number,)
286 # dict with revision as key -> dict with path as key -> list of comments on that rev/path.
287 comments_by_revision = {}
289 for (path, comment_raw_list) in raw.items():
290 for comment_raw in comment_raw_list:
292 message_filter.author.id == comment_raw["author"]["_account_id"]
293 and message_filter.date == comment_raw["updated"]
295 rev = comment_raw["patch_set"]
297 if rev not in comments_by_revision:
298 comments_by_revision[rev] = OrderedDict()
300 comments_for_that_revision = comments_by_revision[rev]
302 if path not in comments_for_that_revision:
303 comments_for_that_revision[path] = []
305 comments_for_that_revision[path].append(
306 Comment.from_raw(comment_raw, path=path)
309 return comments_by_revision
311 def get_diff(self, change, revision, path):
312 raw = self._json_query(
313 "changes/{}~{}/revisions/{}/files/{}/diff?context=ALL&intraline&whitespace=IGNORE_NONE".format(
317 urllib.parse.quote(path, safe=""),
321 return Diff.from_raw(raw)
324 def print_comment(comment, revision):
325 if comment.line is None:
326 print("PS{}:".format(revision))
328 print("PS{}, Line {}:".format(revision, comment.line))
332 comment_lines = comment.message.splitlines()
334 for line in comment_lines:
335 # Don't wrap lines that are quotes or code blocks (which start with a space)
336 if line.startswith(">") or line.startswith(" "):
339 print(textwrap.fill(line))
342 def is_interesting_line_c(line):
346 if line[0].isspace():
349 if line in ("{", "}"):
354 # - skip things that look like labels, /^[a-zA-Z0-9_]+:$/
355 # - skip preprocessor directives
359 def render_diff(diff):
362 # Maps line numbers of files A/B (1-based) to the corresponding index
363 # (0-based) in diff_lines.
365 # The index 0 in these list is unused (there is no line number 0), so set
366 # it to -1 to ensure it's not used as an index.
367 line_mapping_a_to_diff = [-1]
368 line_mapping_b_to_diff = [-1]
370 # Last line we've seen that is worthy of being used as context in range
372 last_interesting_line = ""
374 for chunk in diff.content:
376 for line in chunk["ab"]:
379 "line": " {}".format(line),
380 "a": len(line_mapping_a_to_diff),
381 "b": len(line_mapping_b_to_diff),
382 # If a range were to start at this line, what would be
383 # the line number we would write in the header for each
384 # of the files. And what would be the context line
385 # included in the header.
386 "line-num-a": len(line_mapping_a_to_diff),
387 "line-num-b": len(line_mapping_b_to_diff),
388 "context": last_interesting_line,
392 line_mapping_a_to_diff.append(len(diff_lines) - 1)
393 line_mapping_b_to_diff.append(len(diff_lines) - 1)
395 if is_interesting_line_c(line):
396 last_interesting_line = line
399 for line in chunk["a"]:
402 "line": "-{}".format(line),
403 "a": len(line_mapping_a_to_diff),
404 "line-num-a": len(line_mapping_a_to_diff),
405 "line-num-b": len(line_mapping_b_to_diff),
406 "context": last_interesting_line,
410 line_mapping_a_to_diff.append(len(diff_lines) - 1)
412 if is_interesting_line_c(line):
413 last_interesting_line = line
416 for line in chunk["b"]:
419 "line": "+{}".format(line),
420 "b": len(line_mapping_b_to_diff),
421 "line-num-a": len(line_mapping_a_to_diff),
422 "line-num-b": len(line_mapping_b_to_diff),
423 "context": last_interesting_line,
427 line_mapping_b_to_diff.append(len(diff_lines) - 1)
429 if is_interesting_line_c(line):
430 last_interesting_line = line
432 return diff_lines, line_mapping_a_to_diff, line_mapping_b_to_diff
435 def print_one_diff_line(diff, diff_line, num_width_a, num_width_b):
436 # Keep this around because it's useful for debugging.
437 print_line_number_prefix = False
438 if print_line_number_prefix:
439 if diff.path_a is None:
442 "{b:{num_width_b}} ".format(b=diff_line["b"], num_width_b=num_width_b),
445 elif diff.path_b is None:
448 "{a:{num_width_a}} ".format(a=diff_line["a"], num_width_a=num_width_a),
454 "{a:{num_width_a}} {b:{num_width_b}} ".format(
455 a=diff_line.get("a", ""),
456 b=diff_line.get("b", ""),
457 num_width_a=num_width_a,
458 num_width_b=num_width_b,
463 print("| {}".format(diff_line["line"]))
466 def print_comments_matching_diff_line(comments, diff_line, revision):
467 for comment in comments:
469 comment.side == "PARENT"
471 and diff_line["a"] == comment.line
474 print_comment(comment, revision)
478 comment.side == "REVISION"
480 and diff_line["b"] == comment.line
483 print_comment(comment, revision)
487 def print_range_header(diff_slice):
488 # Print a diff hunk-like header that indicates where the following lines
489 # come from in a and b versions of the file.
491 line_start_a = diff_slice[0]["line-num-a"]
492 line_start_b = diff_slice[0]["line-num-b"]
493 context = diff_slice[0]["context"]
497 for diff_line in diff_slice:
505 "| @@ -{},{} +{},{} @@ {}".format(
506 line_start_a, num_lines_a, line_start_b, num_lines_b, context
511 def print_diff_with_comments(server, diff, comments, revision):
512 assert type(comments) is list
514 if diff.path_a is not None:
515 print("| --- {}".format(diff.path_a))
517 print("| --- /dev/null")
519 if diff.path_b is not None:
520 print("| +++ {}".format(diff.path_b))
522 print("| +++ /dev/null")
524 diff_lines, line_mapping_a_to_diff, line_mapping_b_to_diff = render_diff(diff)
526 def comment_to_diff_range_idx(comment):
528 line_mapping_a_to_diff
529 if comment.side == "PARENT"
530 else line_mapping_b_to_diff
533 if comment.range is not None:
535 start = mapping[comment.range.start_line]
536 end = mapping[comment.range.end_line]
540 idx = mapping[comment.line]
543 diff_line_ranges_to_print = []
545 # Compute a list of ranges of `diff_lines` we want to print, based on
546 # where the comments are.
548 # FIXME: This is broken for overlapping range comments, we should use a
549 # proper rangeset to merge ranges.
550 for comment in comments:
551 if comment.line is None:
552 # It's a file comment, doesn't matter for ranges.
555 start_idx_in_diff, end_idx_in_diff = comment_to_diff_range_idx(comment)
557 # We want to print from this point.
558 low = max(0, start_idx_in_diff - 9)
560 # And up to this point (exclusive).
561 high = min(len(diff_lines) - 1, end_idx_in_diff + 10)
563 if len(diff_line_ranges_to_print) == 0:
564 # This is the first range we insert.
565 diff_line_ranges_to_print.append((low, high))
567 prev_range = diff_line_ranges_to_print[-1]
568 if prev_range[1] >= low:
569 # Overlap (or contiguous) with prev range, merge.
570 diff_line_ranges_to_print[-1] = (prev_range[0], high)
572 # Disjoint from prev range.
573 diff_line_ranges_to_print.append((low, high))
575 # First, print any file-level comments.
576 for comment in comments:
577 if comment.line is None:
578 print_comment(comment, revision)
582 # Print all diff ranges we want to print, with comments matching those lines.
583 for i, (low, high) in enumerate(diff_line_ranges_to_print):
584 diff_slice = diff_lines[low:high]
586 print_range_header(diff_slice)
588 # Figure out the maximal line number for a and b we'll need to display
593 for diff_line in diff_slice:
595 max_a_line = max(max_a_line, diff_line["a"])
598 max_b_line = max(max_b_line, diff_line["b"])
600 num_width_a = len(str(max_a_line))
601 num_width_b = len(str(max_b_line))
603 for diff_line in diff_slice:
604 print_one_diff_line(diff, diff_line, num_width_a, num_width_b)
605 print_comments_matching_diff_line(comments, diff_line, revision)
607 if i != len(diff_line_ranges_to_print) - 1:
617 answer = sys.stdin.readline().strip()
622 print("Can't parse {} as an integer.".format(answer))
625 def choose(items, key_func, render_func):
630 text = render_func(item)
631 assert key not in by_key
634 print("[{}] {}".format(key, text))
639 return by_key[answer]
641 print("Invalid choice.")
645 if len(sys.argv) not in (3, 5):
646 print("Invalid number of parameters.")
648 print("Interactive usage: ./generate.py [server base address] [change number]")
650 "Unattended usage: ./generate.py [server base address] [change number] [author id] [comment timestamp]"
654 print(" ./generate.py 'https://gnutoolchain-gerrit.osci.io/r' 483")
656 " ./generate.py 'https://gnutoolchain-gerrit.osci.io/r' 483 1000025 \"2019-11-05 23:52:21.000000000\""
660 interactive = len(sys.argv) == 3
662 server_address = sys.argv[1]
663 change_number = int(sys.argv[2])
665 if server_address.endswith('/'):
666 server_address = server_address[:-1]
668 server = Server(server_address)
670 change = server.get_change(change_number)
672 raise Exception("Change {} does not exist.".format(change_number))
674 messages = server.get_change_messages(change)
675 messages = sorted(messages, key=lambda m: m.date)
683 def __call__(self, item):
690 lambda m: "By {} ({}) at '{}'".format(m.author.name, m.author.id, m.date),
693 author_id = int(sys.argv[3])
694 timestamp = sys.argv[4]
696 for message in messages:
697 if message.author.id == author_id and message.date == timestamp:
701 "Could not find message corresponding to author {} and timestamp {}".format(
706 # Look for code comments that were posted along this message.
707 comments_by_revision = server.get_change_message_comments(change, message)
709 # It is possible to comment on multiple revisions of a change at the same
710 # time... so generate different diffs (each relative to the base) for each
711 # revision that was commented on.
712 for (revision, comments_by_path) in comments_by_revision.items():
713 for (path, comment_for_path) in comments_by_path.items():
714 # Get the information required to build the diff
715 diff_from_base_to_rev = server.get_diff(change, revision, path)
717 # Print it with interleaved comments.
718 print_diff_with_comments(
719 server, diff_from_base_to_rev, comment_for_path, revision
723 if __name__ == "__main__":