xref: /aosp_15_r20/external/toolchain-utils/cros_utils/bugs.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1#!/usr/bin/env python3
2# Copyright 2021 The ChromiumOS Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Utilities to file bugs."""
6
7import datetime
8import enum
9import json
10import os
11import threading
12from typing import Any, Dict, List, Optional, Union
13
14
15X20_PATH = "/google/data/rw/teams/c-compiler-chrome/prod_bugs"
16
17# List of 'well-known' bug numbers to tag as parents.
18RUST_MAINTENANCE_METABUG = 322195383
19RUST_SECURITY_METABUG = 322195192
20
21
22# These constants are sourced from
23# //google3/googleclient/chrome/chromeos_toolchain/bug_manager/bugs.go
24class WellKnownComponents(enum.IntEnum):
25    """A listing of "well-known" components recognized by our infra."""
26
27    CrOSToolchainPublic = -1
28    CrOSToolchainPrivate = -2
29    AndroidRustToolchain = -3
30
31
32class _FileNameGenerator:
33    """Generates unique file names. This container is thread-safe.
34
35    The names generated have the following properties:
36        - successive, sequenced calls to `get_json_file_name()` will produce
37          names that sort later in lists over time (e.g.,
38          [generator.generate_json_file_name() for _ in range(10)] will be in
39          sorted order).
40        - file names cannot collide with file names generated on the same
41          machine (ignoring machines with unreasonable PID reuse).
42        - file names are incredibly unlikely to collide when generated on
43          multiple machines, as they have 8 bytes of entropy in them.
44    """
45
46    _RANDOM_BYTES = 8
47    _MAX_OS_ENTROPY_VALUE = 1 << _RANDOM_BYTES * 8
48    # The intent of this is "the maximum possible size of our entropy string,
49    # so we can zfill properly below." Double the value the OS hands us, since
50    # we add to it in `generate_json_file_name`.
51    _ENTROPY_STR_SIZE = len(str(2 * _MAX_OS_ENTROPY_VALUE))
52
53    def __init__(self):
54        self._lock = threading.Lock()
55        self._entropy = int.from_bytes(
56            os.getrandom(self._RANDOM_BYTES), byteorder="little", signed=False
57        )
58
59    def generate_json_file_name(self, now: datetime.datetime):
60        with self._lock:
61            my_entropy = self._entropy
62            self._entropy += 1
63
64        now_str = now.isoformat("T", "seconds") + "Z"
65        entropy_str = str(my_entropy).zfill(self._ENTROPY_STR_SIZE)
66        pid = os.getpid()
67        return f"{now_str}_{entropy_str}_{pid}.json"
68
69
70_GLOBAL_NAME_GENERATOR = _FileNameGenerator()
71
72
73def _WriteBugJSONFile(
74    object_type: str,
75    json_object: Dict[str, Any],
76    directory: Optional[Union[os.PathLike, str]],
77):
78    """Writes a JSON file to `directory` with the given bug-ish object.
79
80    Args:
81        object_type: name of the object we're writing.
82        json_object: object to write.
83        directory: the directory to write to. Uses X20_PATH if None.
84    """
85    final_object = {
86        "type": object_type,
87        "value": json_object,
88    }
89
90    if directory is None:
91        directory = X20_PATH
92
93    now = datetime.datetime.now(tz=datetime.timezone.utc)
94    file_path = os.path.join(
95        directory, _GLOBAL_NAME_GENERATOR.generate_json_file_name(now)
96    )
97    temp_path = file_path + ".in_progress"
98    try:
99        with open(temp_path, "w", encoding="utf-8") as f:
100            json.dump(final_object, f)
101        os.rename(temp_path, file_path)
102    except:
103        os.remove(temp_path)
104        raise
105    return file_path
106
107
108def AppendToExistingBug(
109    bug_id: int, body: str, directory: Optional[os.PathLike] = None
110):
111    """Sends a reply to an existing bug."""
112    _WriteBugJSONFile(
113        "AppendToExistingBugRequest",
114        {
115            "body": body,
116            "bug_id": bug_id,
117        },
118        directory,
119    )
120
121
122def CreateNewBug(
123    component_id: int,
124    title: str,
125    body: str,
126    assignee: Optional[str] = None,
127    cc: Optional[List[str]] = None,
128    directory: Optional[os.PathLike] = None,
129    parent_bug: int = 0,
130):
131    """Sends a request to create a new bug.
132
133    Args:
134        component_id: The component ID to add. Anything from WellKnownComponents
135            also works.
136        title: Title of the bug. Must be nonempty.
137        body: Body of the bug. Must be nonempty.
138        assignee: Assignee of the bug. Must be either an email address, or a
139            "well-known" assignee (detective, mage).
140        cc: A list of emails to add to the CC list. Must either be an email
141            address, or a "well-known" individual (detective, mage).
142        directory: The directory to write the report to. Defaults to our x20
143            bugs directory.
144        parent_bug: The parent bug number for this bug. If none should be
145            specified, pass the value 0.
146    """
147    obj = {
148        "component_id": component_id,
149        "subject": title,
150        "body": body,
151    }
152
153    if assignee:
154        obj["assignee"] = assignee
155
156    if cc:
157        obj["cc"] = cc
158
159    if parent_bug:
160        obj["parent_bug"] = parent_bug
161
162    _WriteBugJSONFile("FileNewBugRequest", obj, directory)
163
164
165def SendCronjobLog(
166    cronjob_name: str,
167    failed: bool,
168    message: str,
169    turndown_time_hours: int = 0,
170    directory: Optional[os.PathLike] = None,
171    parent_bug: int = 0,
172):
173    """Sends the record of a cronjob to our bug infra.
174
175    Args:
176        cronjob_name: The name of the cronjob. Expected to remain consistent
177            over time.
178        failed: Whether the job failed or not.
179        message: Any seemingly relevant context. This is pasted verbatim in a
180            bug, if the cronjob infra deems it worthy.
181        turndown_time_hours: If nonzero, this cronjob will be considered turned
182            down if more than `turndown_time_hours` pass without a report of
183            success or failure. If zero, this job will not automatically be
184            turned down.
185        directory: The directory to write the report to. Defaults to our x20
186            bugs directory.
187        parent_bug: The parent bug number for the bug filed for this cronjob,
188            if any. If none should be specified, pass the value 0.
189    """
190    json_object = {
191        "name": cronjob_name,
192        "message": message,
193        "failed": failed,
194    }
195
196    if turndown_time_hours:
197        json_object["cronjob_turndown_time_hours"] = turndown_time_hours
198
199    if parent_bug:
200        json_object["parent_bug"] = parent_bug
201
202    _WriteBugJSONFile("CronjobUpdate", json_object, directory)
203