473 lines · python
1#!/usr/bin/env python32# ===----------------------------------------------------------------------===##3#4# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.5# See https://llvm.org/LICENSE.txt for license information.6# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception7#8# ===----------------------------------------------------------------------===##9 10from typing import List, Dict, Tuple, Optional11import copy12import csv13import itertools14import json15import os16import pathlib17import re18import subprocess19 20# Number of the 'Libc++ Standards Conformance' project on Github21LIBCXX_CONFORMANCE_PROJECT = '31'22 23def extract_between_markers(text: str, begin_marker: str, end_marker: str) -> Optional[str]:24 """25 Given a string containing special markers, extract everything located beetwen these markers.26 27 If the beginning marker is not found, None is returned. If the beginning marker is found but28 there is no end marker, it is an error (this is done to avoid silently accepting inputs that29 are erroneous by mistake).30 """31 start = text.find(begin_marker)32 if start == -1:33 return None34 35 start += len(begin_marker) # skip the marker itself36 end = text.find(end_marker, start)37 if end == -1:38 raise ArgumentError(f"Could not find end marker {end_marker} in: {text[start:]}")39 40 return text[start:end]41 42class PaperStatus:43 TODO = 144 IN_PROGRESS = 245 PARTIAL = 346 DONE = 447 NOTHING_TO_DO = 548 49 _status: int50 51 _original: Optional[str]52 """53 Optional string from which the paper status was created. This is used to carry additional54 information from CSV rows, like any notes associated to the status.55 """56 57 def __init__(self, status: int, original: Optional[str] = None):58 self._status = status59 self._original = original60 61 def __eq__(self, other) -> bool:62 return self._status == other._status63 64 def __lt__(self, other) -> bool:65 relative_order = {66 PaperStatus.TODO: 0,67 PaperStatus.IN_PROGRESS: 1,68 PaperStatus.PARTIAL: 2,69 PaperStatus.DONE: 3,70 PaperStatus.NOTHING_TO_DO: 3,71 }72 return relative_order[self._status] < relative_order[other._status]73 74 @staticmethod75 def from_csv_entry(entry: str):76 """77 Parse a paper status out of a CSV row entry. Entries can look like:78 - '' (an empty string, which means the paper is not done yet)79 - '|In Progress|'80 - '|Partial|'81 - '|Complete|'82 - '|Nothing To Do|'83 """84 if entry == '':85 return PaperStatus(PaperStatus.TODO, entry)86 elif entry == '|In Progress|':87 return PaperStatus(PaperStatus.IN_PROGRESS, entry)88 elif entry == '|Partial|':89 return PaperStatus(PaperStatus.PARTIAL, entry)90 elif entry == '|Complete|':91 return PaperStatus(PaperStatus.DONE, entry)92 elif entry == '|Nothing To Do|':93 return PaperStatus(PaperStatus.NOTHING_TO_DO, entry)94 else:95 raise RuntimeError(f'Unexpected CSV entry for status: {entry}')96 97 @staticmethod98 def from_github_issue(issue: Dict):99 """100 Parse a paper status out of a Github issue obtained from querying a Github project.101 """102 if 'status' not in issue:103 return PaperStatus(PaperStatus.TODO)104 elif issue['status'] == 'Todo':105 return PaperStatus(PaperStatus.TODO)106 elif issue['status'] == 'In Progress':107 return PaperStatus(PaperStatus.IN_PROGRESS)108 elif issue['status'] == 'Partial':109 return PaperStatus(PaperStatus.PARTIAL)110 elif issue['status'] == 'Done':111 return PaperStatus(PaperStatus.DONE)112 elif issue['status'] == 'Nothing To Do':113 return PaperStatus(PaperStatus.NOTHING_TO_DO)114 else:115 raise RuntimeError(f"Received unrecognizable Github issue status: {issue['status']}")116 117 def to_csv_entry(self) -> str:118 """119 Return the issue state formatted for a CSV entry. The status is formatted as '|Complete|',120 '|In Progress|', etc.121 """122 mapping = {123 PaperStatus.TODO: '',124 PaperStatus.IN_PROGRESS: '|In Progress|',125 PaperStatus.PARTIAL: '|Partial|',126 PaperStatus.DONE: '|Complete|',127 PaperStatus.NOTHING_TO_DO: '|Nothing To Do|',128 }129 return self._original if self._original is not None else mapping[self._status]130 131class PaperInfo:132 paper_number: str133 """134 Identifier for the paper or the LWG issue. This must be something like 'PnnnnRx', 'Nxxxxx' or 'LWGxxxxx'.135 """136 137 paper_name: str138 """139 Plain text string representing the name of the paper.140 """141 142 status: PaperStatus143 """144 Status of the paper/issue. This can be complete, in progress, partial, or done.145 """146 147 meeting: Optional[str]148 """149 Plain text string representing the meeting at which the paper/issue was voted.150 """151 152 first_released_version: Optional[str]153 """154 First version of LLVM in which this paper/issue was resolved.155 """156 157 github_issue: Optional[str]158 """159 Optional number of the Github issue tracking the implementation status of this paper.160 This is used to cross-reference rows in the status pages with Github issues.161 """162 163 notes: Optional[str]164 """165 Optional plain text string representing notes to associate to the paper.166 This is used to populate the "Notes" column in the CSV status pages.167 """168 169 original: Optional[object]170 """171 Object from which this PaperInfo originated. This is used to track the CSV row or Github issue that172 was used to generate this PaperInfo and is useful for error reporting purposes.173 """174 175 def __init__(self, paper_number: str, paper_name: str,176 status: PaperStatus,177 meeting: Optional[str] = None,178 first_released_version: Optional[str] = None,179 github_issue: Optional[str] = None,180 notes: Optional[str] = None,181 original: Optional[object] = None):182 self.paper_number = paper_number183 self.paper_name = paper_name184 self.status = status185 self.meeting = meeting186 self.first_released_version = first_released_version187 self.github_issue = github_issue188 self.notes = notes189 self.original = original190 191 def for_printing(self) -> Tuple[str, str, str, str, str, str, str]:192 return (193 f'`{self.paper_number} <https://wg21.link/{self.paper_number}>`__',194 self.paper_name,195 self.meeting if self.meeting is not None else '',196 self.status.to_csv_entry(),197 self.first_released_version if self.first_released_version is not None else '',198 f'`#{self.github_issue} <https://github.com/llvm/llvm-project/issues/{self.github_issue}>`__' if self.github_issue is not None else '',199 self.notes if self.notes is not None else '',200 )201 202 def __repr__(self) -> str:203 return repr(self.original) if self.original is not None else repr(self.for_printing())204 205 @staticmethod206 def from_csv_row(row: Tuple[str, str, str, str, str, str]):# -> PaperInfo:207 """208 Given a row from one of our status-tracking CSV files, create a PaperInfo object representing that row.209 """210 # Extract the paper number from the first column211 match = re.search(r"((P[0-9R]+)|(LWG[0-9]+)|(N[0-9]+))\s+", row[0])212 if match is None:213 raise RuntimeError(f"Can't parse paper/issue number out of row: {row}")214 215 # Match the issue number if present216 github_issue = re.search(r'#([0-9]+)', row[5])217 if github_issue:218 github_issue = github_issue.group(1)219 220 return PaperInfo(221 paper_number=match.group(1),222 paper_name=row[1],223 status=PaperStatus.from_csv_entry(row[3]),224 meeting=row[2] or None,225 first_released_version=row[4] or None,226 github_issue=github_issue,227 notes=row[6] or None,228 original=row,229 )230 231 @staticmethod232 def from_github_issue(issue: Dict):# -> PaperInfo:233 """234 Create a PaperInfo object from the Github issue information obtained from querying a Github Project.235 """236 # Extract the paper number from the issue title237 match = re.search(r"((P[0-9R]+)|(LWG[0-9]+)|(N[0-9]+)):", issue['title'])238 if match is None:239 raise RuntimeError(f"Issue doesn't have a title that we know how to parse: {issue}")240 paper = match.group(1)241 242 # Extract any notes from the Github issue and populate the RST notes with them243 issue_description = issue['content']['body']244 notes = extract_between_markers(issue_description, 'BEGIN-RST-NOTES', 'END-RST-NOTES')245 notes = notes.strip() if notes is not None else notes246 247 return PaperInfo(248 paper_number=paper,249 paper_name=issue['title'].removeprefix(paper + ': '),250 status=PaperStatus.from_github_issue(issue),251 meeting=issue.get('meeting Voted', None),252 first_released_version=None, # TODO253 github_issue=str(issue['content']['number']),254 notes=notes,255 original=issue,256 )257 258def merge(paper: PaperInfo, gh: PaperInfo) -> PaperInfo:259 """260 Merge a paper coming from a CSV row with a corresponding Github-tracked paper.261 262 If the CSV row has a status that is "less advanced" than the Github issue, simply update the CSV263 row with the newer status. Otherwise, report an error if they have a different status because264 something must be wrong.265 266 We don't update issues from 'To Do' to 'In Progress', since that only creates churn and the267 status files aim to document user-facing functionality in releases, for which 'In Progress'268 is not useful.269 270 In case we don't update the CSV row's status, we still take any updated notes coming271 from the Github issue and we add a link to the Github issue if it was previously missing.272 """273 took_gh_in_full = False # Whether we updated the entire PaperInfo from the Github version274 if paper.status == PaperStatus(PaperStatus.TODO) and gh.status == PaperStatus(PaperStatus.IN_PROGRESS):275 result = copy.deepcopy(paper)276 elif paper.status < gh.status:277 result = copy.deepcopy(gh)278 took_gh_in_full = True279 elif paper.status == gh.status:280 result = copy.deepcopy(paper)281 else:282 print(f"We found a CSV row and a Github issue with different statuses:\nrow: {paper}\nGithub issue: {gh}")283 result = copy.deepcopy(paper)284 285 # If we didn't take the Github issue in full, make sure to update the notes, the link and anything else.286 if not took_gh_in_full:287 result.github_issue = gh.github_issue288 result.notes = gh.notes289 return result290 291def load_csv(file: pathlib.Path) -> List[Tuple]:292 rows = []293 with open(file, newline='', encoding='utf-8') as f:294 reader = csv.reader(f, delimiter=',')295 for row in reader:296 rows.append(row)297 return rows298 299def write_csv(output: pathlib.Path, rows: List[Tuple]):300 with open(output, 'w', newline='', encoding='utf-8') as f:301 writer = csv.writer(f, quoting=csv.QUOTE_ALL, lineterminator='\n')302 for row in rows:303 writer.writerow(row)304 305def create_github_issue(paper: PaperInfo, labels: List[str]) -> None:306 """307 Create a new Github issue representing the given PaperInfo.308 """309 assert paper.github_issue is None, "Trying to create a Github issue for a paper that is already tracked"310 311 paper_name = paper.paper_name.replace('``', '`').replace('\\', '')312 313 create_cli = ['gh', 'issue', 'create', '--repo', 'llvm/llvm-project',314 '--title', f'{paper.paper_number}: {paper_name}',315 '--body', f'**Link:** https://wg21.link/{paper.paper_number}',316 '--project', 'libc++ Standards Conformance',317 '--label', 'libc++']318 319 for label in labels:320 create_cli += ['--label', label]321 322 print("Do you want to create the following issue?")323 print(create_cli)324 answer = input("y/n: ")325 if answer == 'n':326 print("Not creating issue")327 return328 elif answer != 'y':329 print(f"Invalid answer {answer}, skipping")330 return331 332 print("Creating issue")333 issue_link = subprocess.check_output(create_cli).decode().strip()334 print(f"Created tracking issue for {paper.paper_number}: {issue_link}")335 336 # Retrieve the "Github project item ID" by re-adding the issue to the project again,337 # even though we created it inside the project in the first place.338 item_add_cli = ['gh', 'project', 'item-add', LIBCXX_CONFORMANCE_PROJECT, '--owner', 'llvm', '--url', issue_link, '--format', 'json']339 item = json.loads(subprocess.check_output(item_add_cli).decode().strip())340 341 # Then, adjust the 'Meeting Voted' field of that item.342 meeting_voted_cli = ['gh', 'project', 'item-edit',343 '--project-id', 'PVT_kwDOAQWwKc4AlOgt',344 '--field-id', 'PVTF_lADOAQWwKc4AlOgtzgdUEXI', '--text', paper.meeting,345 '--id', item['id']]346 subprocess.check_call(meeting_voted_cli)347 348 # And also adjust the 'Status' field of the item to 'To Do'.349 status_cli = ['gh', 'project', 'item-edit',350 '--project-id', 'PVT_kwDOAQWwKc4AlOgt',351 '--field-id', 'PVTSSF_lADOAQWwKc4AlOgtzgdUBak', '--single-select-option-id', 'f75ad846',352 '--id', item['id']]353 subprocess.check_call(status_cli)354 355def sync_csv(rows: List[Tuple], from_github: List[PaperInfo], create_new: bool, labels: List[str] = None) -> List[Tuple]:356 """357 Given a list of CSV rows representing an existing status file and a list of PaperInfos representing358 up-to-date (but potentially incomplete) tracking information from Github, this function returns the359 new CSV rows synchronized with the up-to-date information.360 361 If `create_new` is True and a paper from the CSV file is not tracked on Github yet, this also prompts362 to create a new issue on Github for tracking it. In that case the created issue is tagged with the363 provided labels.364 365 Note that this only tracks changes from 'not implemented' issues to 'implemented'. If an up-to-date366 PaperInfo reports that a paper is not implemented but the existing CSV rows report it as implemented,367 it is an error (i.e. the result is not a CSV row where the paper is *not* implemented).368 """369 results = [rows[0]] # Start with the header370 for row in rows[1:]: # Skip the header371 # If the row contains empty entries, this is a "separator row" between meetings.372 # Preserve it as-is.373 if row[0] == "":374 results.append(row)375 continue376 377 paper = PaperInfo.from_csv_row(row)378 379 # Find any Github issues tracking this paper. Each row must have one and exactly one Github380 # issue tracking it, which we validate below.381 tracking = [gh for gh in from_github if paper.paper_number == gh.paper_number]382 383 # If there's more than one tracking issue, something is weird.384 if len(tracking) > 1:385 print(f"Found a row with more than one tracking issue: {row}\ntracked by: {tracking}")386 results.append(row)387 continue388 389 # Validate the Github issue associated to the CSV row, if any390 if paper.github_issue is not None:391 if len(tracking) == 0:392 print(f"Found row claiming to have a tracking issue, but failed to find a tracking issue on Github: {row}")393 results.append(row)394 continue395 if len(tracking) == 1 and paper.github_issue != tracking[0].github_issue:396 print(f"Found row with incorrect tracking issue: {row}\ntracked by: {tracking[0]}")397 results.append(row)398 continue399 400 # If there is no tracking issue for that row and we are creating new issues, do that.401 # Otherwise just log that we're missing an issue.402 if len(tracking) == 0:403 if create_new:404 assert labels is not None, "Missing labels when creating new Github issues"405 create_github_issue(paper, labels=labels)406 else:407 print(f"Can't find any Github issue for CSV row: {row}")408 results.append(row)409 continue410 411 results.append(merge(paper, tracking[0]).for_printing())412 413 return results414 415CSV_FILES_TO_SYNC = {416 'Cxx17Issues.csv': ['c++17', 'lwg-issue'],417 'Cxx17Papers.csv': ['c++17', 'wg21 paper'],418 'Cxx20Issues.csv': ['c++20', 'lwg-issue'],419 'Cxx20Papers.csv': ['c++20', 'wg21 paper'],420 'Cxx23Issues.csv': ['c++23', 'lwg-issue'],421 'Cxx23Papers.csv': ['c++23', 'wg21 paper'],422 'Cxx2cIssues.csv': ['c++26', 'lwg-issue'],423 'Cxx2cPapers.csv': ['c++26', 'wg21 paper'],424}425 426def main(argv):427 import argparse428 parser = argparse.ArgumentParser(prog='synchronize-status-files',429 description='Synchronize the libc++ conformance status files with Github issues')430 parser.add_argument('--validate-only', action='store_true',431 help="Only perform the data validation of CSV files.")432 parser.add_argument('--create-new', action='store_true',433 help="Create new Github issues for CSV rows that do not correspond to any existing Github issue.")434 parser.add_argument('--load-github-from', type=str,435 help="A json file to load the Github project information from instead of querying the API. This is useful for testing to avoid rate limiting.")436 args = parser.parse_args(argv)437 438 libcxx_root = pathlib.Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))439 440 # Perform data validation for all the CSV files.441 print("Performing data validation of the CSV files")442 for filename in CSV_FILES_TO_SYNC:443 csv = load_csv(libcxx_root / 'docs' / 'Status' / filename)444 for row in csv[1:]: # Skip the header445 if row[0] != "": # Skip separator rows446 PaperInfo.from_csv_row(row)447 448 if args.validate_only:449 return450 451 # Load all the Github issues tracking papers from Github.452 if args.load_github_from:453 print(f"Loading all issues from {args.load_github_from}")454 with open(args.load_github_from, 'r', encoding='utf-8') as f:455 project_info = json.load(f)456 else:457 print("Loading all issues from Github")458 gh_command_line = ['gh', 'project', 'item-list', LIBCXX_CONFORMANCE_PROJECT, '--owner', 'llvm', '--format', 'json', '--limit', '9999999']459 project_info = json.loads(subprocess.check_output(gh_command_line))460 from_github = [PaperInfo.from_github_issue(i) for i in project_info['items']]461 462 # Synchronize CSV files with the Github issues.463 for (filename, labels) in CSV_FILES_TO_SYNC.items():464 print(f"Synchronizing {filename} with Github issues")465 file = libcxx_root / 'docs' / 'Status' / filename466 csv = load_csv(file)467 synced = sync_csv(csv, from_github, create_new=args.create_new, labels=labels)468 write_csv(file, synced)469 470if __name__ == '__main__':471 import sys472 main(sys.argv[1:])473