brintos

brintos / llvm-project-archived public Read only

0
0
Text · 19.1 KiB · 66cc100 Raw
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