Source code for spacr.qt.ai.issue_report

"""
Error reports filed as issues on the public spaCR GitHub repository.

A failed run is reported in one of two ways, chosen by the "One-click issue
filing" preference (:func:`spacr.qt.preferences.get_issue_prompt_mode`):

* ``'always'``, the default: :func:`file_without_review` files the report as
  soon as the run has failed. It sends :func:`public_report`, the same
  redaction the preview applies by default, and it never opens a browser.
  Filing needs a GitHub sign-in (the ``gh`` CLI or ``GITHUB_TOKEN``). Without
  one, nothing is sent.
* ``'ask'``: the report opens in an editable preview, and
  :func:`submit_report` sends it only after the Send click. Without a
  sign-in it opens a pre-filled GitHub form in the browser.

Both paths build the report with :func:`build_report`, and both look for an
open issue carrying the same traceback fingerprint before they open a new
one. spaCR never stores a durable GitHub token itself.
"""
from __future__ import annotations

import hashlib
import platform
import re
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional
from ...logging_util import _spacr_home

REPO = "EinarOlafsson/spacr"
ISSUE_LABEL = "auto-filed"
LOG_TAIL_LINES = 50

#: Lines kept in the log file saved beside a report. Larger than
#: :data:`LOG_TAIL_LINES` because this one is not going into a URL.
LOG_BUNDLE_LINES = 2000
MAX_URL_LEN = 7500



#: Placeholder substituted for anything that looks like a credential.
REDACTED = "<REDACTED>"

#: Vendor-specific credential shapes. Matched anywhere in the text —
#: a traceback, a settings value or a log line can all carry one.
_TOKEN_PATTERNS = (
    re.compile(r"github_pat_[A-Za-z0-9_]{16,}"),
    re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"),
    re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{8,}"),
    re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}"),
    re.compile(r"\bAIza[A-Za-z0-9_\-]{20,}"),
    re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{8,}"),
)

#: ``Authorization: Bearer <token>`` — keep the scheme, drop the secret.
_BEARER_RE = re.compile(r"(?i)(\bbearer\s+)[A-Za-z0-9._\-]{8,}")

#: ``api_key = 'xxx'`` / ``GITHUB_TOKEN: xxx`` style assignments.
_ASSIGN_RE = re.compile(
    r"(?i)"
    r"([\"']?\b[A-Za-z0-9_\-]*"
    r"(?:api[_-]?key|secret|passwd|password|token|credential)"
    r"[A-Za-z0-9_\-]*\b[\"']?\s*[=:]\s*)"
    r"([\"']?)"
    r"([^\s,;'\"}\)]{6,})"
    r"\2"
)

#: Settings keys whose *value* is dropped wholesale regardless of shape.
_SECRET_KEY_RE = re.compile(
    r"(?i)(api[_-]?key|secret|passwd|password|token|credential)"
)


[docs] def redact_secrets(s: str) -> str: """Strip anything that looks like an API key / access token. The issue body is posted to a PUBLIC GitHub repo, so a token that survived into a traceback, a settings value or a log line would be leaked to the world (and, for GitHub PATs, instantly revoked). :param s: arbitrary text. :returns: the same text with credential-shaped substrings replaced by :data:`REDACTED`. """ if not s: return s for pat in _TOKEN_PATTERNS: s = pat.sub(REDACTED, s) s = _BEARER_RE.sub(lambda m: m.group(1) + REDACTED, s) s = _ASSIGN_RE.sub( lambda m: f"{m.group(1)}{m.group(2)}{REDACTED}{m.group(2)}", s) return s
#: Placeholder for this computer's login name. USER_PLACEHOLDER = "<USER>" #: Placeholder for this computer's network name. HOST_PLACEHOLDER = "<HOST>" #: Login and host names that identify nobody, and are left in place. #: #: The second group is the one a lab machine is actually called, and it is #: here for a second reason: every one of these words also occurs inside #: ordinary report text. A machine named ``gpu`` turned ``use_gpu = True`` #: in the settings block into ``use_<HOST> = True``, because ``_`` is a word #: boundary to :func:`redact_identity`. Keeping the name in is the honest #: trade — it says nothing about who the user is, and the alternative is a #: report that rewrites words it was never meant to touch. _GENERIC_NAMES = frozenset({ "root", "user", "users", "admin", "administrator", "runner", "ubuntu", "jovyan", "vagrant", "guest", "test", "spacr", "python", "home", "localhost", "localdomain", "local", "default", "docker", "codespace", "codespaces", "gpu", "cpu", "bio", "lab", "data", "server", "desktop", "laptop", "workstation", "node", "host", "main", "dev", "build", "linux", "mac", "windows", "win", "imaging", "microscope", "compute", "cluster", "nas", }) def _identity_words() -> List[tuple]: """This computer's login and host names, each with its placeholder. The login name comes from :func:`getpass.getuser` and from the name of the home folder. The host name comes from :func:`socket.gethostname` and :func:`platform.node`, both in full and as their first dotted label. Both calls read local state and do not touch the network. Names shorter than three characters, names that are all digits (the first label of a dotted-quad address is not a name, and ``192`` would otherwise be replaced wherever it stood), and the generic names in :data:`_GENERIC_NAMES` are left out. A name that is both the login and the host name — which is what a workstation named after its user gives — is listed once, as the login name, rather than twice with the host entry winning. :returns: ``[(name, placeholder)]``, longest name first, so a host name that contains the login name is replaced whole. """ users, hosts = set(), set() try: import getpass users.add(getpass.getuser()) except Exception: # noqa: BLE001 pass try: users.add(Path.home().name) except Exception: # noqa: BLE001 pass try: import socket hosts.add(socket.gethostname()) except Exception: # noqa: BLE001 pass try: hosts.add(platform.node()) except Exception: # noqa: BLE001 pass hosts |= {name.split(".", 1)[0] for name in list(hosts) if name} users = {str(name or "").strip() for name in users} hosts = {str(name or "").strip() for name in hosts} - users words = [] for names, placeholder in ((hosts, HOST_PLACEHOLDER), (users, USER_PLACEHOLDER)): for name in names: if (len(name) < 3 or name.isdigit() or name.lower() in _GENERIC_NAMES): continue words.append((name, placeholder)) words.sort(key=lambda pair: len(pair[0]), reverse=True) return words
[docs] def redact_identity(s: str) -> str: """Replace this computer's login and host names with placeholders. A home folder is already shortened to ``~`` by :func:`sanitize_path`. The login name can still appear elsewhere: another folder named after the user, a permission error, an ``owner`` setting. The host name can appear in a network path or a connection error. Each is replaced wherever it stands as a whole word, in any letter case. :param s: arbitrary text. :returns: the text with :data:`USER_PLACEHOLDER` and :data:`HOST_PLACEHOLDER` in place of those names. """ if not s: return s for name, placeholder in _identity_words(): s = re.sub(r"(?<![A-Za-z0-9])" + re.escape(name) + r"(?![A-Za-z0-9])", placeholder, s, flags=re.IGNORECASE) return s
[docs] def sanitize_path(s: str) -> str: """Replace absolute paths pointing inside ``$HOME`` with ``~/``. Also collapses any string that looks like an on-disk ``*.db`` path down to ``<DB>`` so lab / patient / experiment identifiers embedded in a filename don't leak, replaces this computer's login and host names through :func:`redact_identity`, and redacts credential-shaped substrings via :func:`redact_secrets`. :param s: arbitrary text. :returns: text with home-relative paths abbreviated and DB paths, login and host names and secrets redacted. """ home = str(Path.home()) s = s.replace(home, "~") s = re.sub(r"[/\\][^\s'\"]+\.db\b", "<DB>", s) s = redact_identity(s) return redact_secrets(s)
[docs] def sanitize_settings(settings: Dict[str, Any]) -> Dict[str, Any]: """Return a copy of ``settings`` with paths + DB names sanitized. Values whose *key* names a credential (``api_key``, ``GITHUB_TOKEN``, ``password``, …) are dropped entirely — the key name is enough of a hint that the value must never reach a public issue. :param settings: any pipeline settings dict. :returns: sanitized copy safe to include in a public issue. """ out: Dict[str, Any] = {} for k, v in (settings or {}).items(): if isinstance(k, str) and _SECRET_KEY_RE.search(k): out[k] = REDACTED elif isinstance(v, str): out[k] = sanitize_path(v) elif isinstance(v, list): out[k] = [sanitize_path(x) if isinstance(x, str) else x for x in v] else: out[k] = v return out
[docs] def sanitize_traceback(tb: str) -> str: """Sanitise a full traceback string via :func:`sanitize_path`. :param tb: the traceback text; ``None`` or an empty string gives ``""``. """ return sanitize_path(tb or "")
#: Where a path can begin: a drive letter, a UNC or scheme-relative pair of #: separators, a home-relative ``~/``, or a bare root slash. _PATH_ROOT = r"(?:[A-Za-z]:[\\/]|[\\/]{2}|~[\\/]|/)" #: One path component: anything up to the next separator, quote or space. _PATH_SEG = r"[^\s'\"`\\/]+" #: A traceback's ``File "..."`` field, whatever the file is called. _FILE_FIELD_RE = re.compile(r'(?m)(\bFile\s+)["\'][^"\']+["\']') #: A quoted value that IS a path, to its closing quote or the end of the #: line. Everything that quotes a path quotes the whole of it: ``repr`` of a #: settings value, the file name in an :class:`OSError` message, the log #: copy's backticked path. The end-of-line branch is for the title, which #: :func:`build_report` cuts to 80 characters and so can carry an opening #: quote whose closing one was cut off. _QUOTED_PATH_RE = re.compile( r"(?m)(?P<q>['\"`])" + _PATH_ROOT + r"[^'\"`\n]*(?P<end>(?P=q)|$)" ) #: A word after the LAST component of an unquoted path that is taken as #: part of that component: one that starts with a digit (``plate 1``, #: ``Patient 042``) or a capital (``Lab Drive``, ``plate A``), or that ends #: in a file extension (``plate a.tif``). Prose after a path on the same #: line is lower case -- ``failed``, ``not found``, ``does not exist`` -- so #: those words stay readable. _PATH_TAIL_WORD = (r"(?:[0-9A-Z][^\s'\"`\\/]*" r"|[^\s'\"`\\/]*\.[A-Za-z][A-Za-z0-9]{0,4}(?![\w.]))") #: An unquoted path. A component may contain spaces when a separator #: follows it, which is what makes ``/Volumes/Lab Drive/x`` and #: ``C:\Program Files\spaCR\x`` one token rather than three. The last #: component, which no separator follows, runs on over the words #: :data:`_PATH_TAIL_WORD` accepts. _BARE_PATH_RE = re.compile( r"(?<![\w~<])" + _PATH_ROOT + r"(?=[^\s'\"`\\/])" r"(?:" + _PATH_SEG + r"(?:[ \t]" + _PATH_SEG + r")*[\\/])*" r"(?:" + _PATH_SEG + r"(?:[ \t]" + _PATH_TAIL_WORD + r")*)?" )
[docs] def strip_report_paths(text: str) -> str: """Remove file/folder names from an already sanitised report. The ordinary sanitizer abbreviates the home directory so a traceback is still useful. Public reports default to the stricter form: traceback file fields, quoted path values and remaining absolute path-like tokens become ``<PATH>``. The preview lets the user restore the useful names before sending; with issue reporting set to 'always' there is no preview, so what this leaves behind is what gets published. A PATH CAN CONTAIN A SPACE, and the rule that stopped one at the first whitespace published the rest of it. ``/Volumes/Lab Drive/Patient 042`` came out as ``<PATH> Drive/Patient 042``, and ``C:\\Users\\anna\\OneDrive - Karolinska Institutet\\Screens`` kept the institution and the screen — the two operating systems whose own folders have spaces in them. A UNC path was not a path at all: nothing here started at ``\\\\``. The LAST component of an unquoted path has no separator after it to prove a space is inside the path rather than after it, so it runs on only over words shaped like a name -- a number, a capitalised word, a file name -- and ``\\\\LAB-NAS\\screens\\plate 1`` is replaced whole while ``opening /mnt/data/x.tif failed`` keeps its ``failed``. A lower-case word ending such a component still survives it; a quoted path is taken whole, and the title and every ``repr``-ed settings value are quoted. A slash straight after ``<`` starts a closing tag, not a path. The report's collapsible sections end in ``</summary>`` and ``</details>``, and issue #121 was filed with both turned into ``<<PATH>``, so every section after the first one stayed open. :param text: a report title or body, already through :func:`sanitize_path`. :returns: the same text with path-shaped tokens replaced by ``<PATH>``. """ value = str(text or "") value = _FILE_FIELD_RE.sub(r'\1"<PATH>"', value) value = _QUOTED_PATH_RE.sub( lambda m: f"{m.group('q')}<PATH>{m.group('end')}", value) value = _BARE_PATH_RE.sub("<PATH>", value) return value
[docs] def public_report(report: Dict[str, str]) -> Dict[str, str]: """The report as it is sent when nobody reviews it first. The preview opens with "Remove file and folder names" switched on, so what a reviewer sends by default is the body after :func:`strip_report_paths`. A report filed automatically gets that same body. The title is stripped as well: it quotes the exception line, and that line can carry a file name. :param report: a report from :func:`build_report`. :returns: ``title``, ``body`` and ``fingerprint``, ready to post. """ return { "title": strip_report_paths(str(report.get("title", ""))), "body": strip_report_paths(str(report.get("body", ""))), "fingerprint": str(report.get("fingerprint", "")), }
#: ``, line 123,`` inside a traceback frame — volatile, stripped before hashing. _LINENO_RE = re.compile(r",\s*line\s+\d+\s*,") #: A traceback frame line: the quoted file, then the rest of the line. _FRAME_RE = re.compile(r'^File\s+"(?P<path>[^"]*)"(?P<rest>.*)$') def _frame_key(frame: str) -> str: """One frame line as the fingerprint sees it. The file is cut to its last two path components, which is the module and the package it sits in. Everything above them depends on the machine: the home folder, the Python version, the name of the conda environment, the operating system's separator. With the whole path in the key, the same crash on two computers had two fingerprints, so the open-issue search could never find the other computer's report. :param frame: a stripped ``File "...", line N, in f`` line. :returns: the line with the path shortened and the line number removed. """ match = _FRAME_RE.match(frame) if match is not None: parts = [p for p in re.split(r"[\\/]+", match.group("path")) if p] where = "/".join(parts[-2:]) if parts else match.group("path") frame = f'File "{where}"{match.group("rest")}' return _LINENO_RE.sub(",", frame) def _traceback_hash(tb: str) -> str: """Short deterministic fingerprint of a traceback, for dedup coalescing. The key is built from the call stack (file + function, with the volatile line NUMBERS removed) plus the exception TYPE. That gives the three properties dedup needs: * the same bug still fingerprints the same after an unrelated edit shifts the line numbers above it, * the same bug fingerprints the same on another computer, because each file is named by its last two path components (:func:`_frame_key`), and * two genuinely different exceptions raised from the same frame get different fingerprints instead of being merged into one issue. The exception *message* is deliberately excluded — it routinely embeds a filename or a plate id, which would fork the fingerprint on every run. :returns: first 6 hex chars of sha256 over that key. """ lines: List[str] = [] for ln in tb.splitlines(): stripped = ln.strip() if not stripped: continue if stripped.startswith("File "): lines.append(_frame_key(stripped)) elif not ln.startswith((" ", "\t")): if stripped.startswith("Traceback"): continue lines.append(stripped.split(":", 1)[0]) key = "\n".join(lines) or tb return hashlib.sha256(key.encode()).hexdigest()[:6]
[docs] def fingerprint_of(traceback_text: str) -> str: """The fingerprint :func:`build_report` gives this traceback. :param traceback_text: the raw traceback. :returns: six hex characters, without building the report. The automatic filer checks this against what it has filed before, so a repeated crash costs no log copy and no network call. """ return _traceback_hash(sanitize_traceback(traceback_text))
[docs] def log_tail(n_lines: int = LOG_TAIL_LINES, log_path: Optional[Path] = None) -> str: """Return the last ``n_lines`` of ``~/.spacr/logs/spacr.log`` (or a custom path), sanitized. :param n_lines: how many trailing lines to include. :param log_path: override for the log file path. :returns: sanitised last-N-lines block or ``""`` if the file is absent or unreadable. """ if log_path is None: try: from ..logging_util import log_path as _lp log_path = _lp() except Exception: return "" try: with open(log_path, encoding="utf-8", errors="replace") as f: lines = f.readlines() except Exception: return "" return sanitize_path("".join(lines[-n_lines:]))
[docs] def log_bundle_dir() -> Path: """Where a report's log copy is written.""" return _spacr_home() / "reports"
[docs] def save_log_bundle(fingerprint: str, log_path: Optional[Path] = None, n_lines: int = LOG_BUNDLE_LINES) -> Optional[Path]: """Write the log tail to a file beside the report and return its path. The public issue names this path instead of carrying the log itself. More lines are kept here than would ever have gone in an issue -- once the log is not being pasted into a URL there is no length to stay under, and whoever reads the report wants the whole run, not a keyhole. :param fingerprint: the traceback hash, so one report's log is easy to match to the issue that names it. :param log_path: override for the log file path. :param n_lines: how many trailing lines to keep. :returns: the path written, or ``None`` if there was nothing to write or the write failed -- a report must still be filable on a read-only home directory. """ tail = log_tail(n_lines=n_lines, log_path=log_path) if not tail.strip(): return None try: folder = log_bundle_dir() folder.mkdir(parents=True, exist_ok=True) target = folder / f"log-{fingerprint}.txt" target.write_text(tail, encoding="utf-8") except Exception: return None return target
def _env_lines() -> List[str]: """Return lines describing the current spacr / python / OS env.""" try: from spacr.version import __version__ as _spacr_ver except Exception: _spacr_ver = "unknown" return [ f"- **spaCR**: {_spacr_ver}", f"- **Python**: {sys.version.split()[0]}", f"- **Platform**: {platform.platform()}", f"- **PySide6**: {_optional_version('PySide6')}", f"- **torch**: {_optional_version('torch')}", f"- **cellpose**: {_optional_version('cellpose')}", ] def _optional_version(pkg: str) -> str: """Report an optional package's version for the issue body. :param pkg: the distribution name. :returns: its version, or a marker saying it is not installed -- which is itself worth knowing in a bug report, since half of spaCR's failures are a missing extra. """ try: from importlib.metadata import version as _v return _v(pkg) except Exception: return "not installed" #: How much of spaCR AI's analysis goes into an issue. #: #: It sits between the traceback and the environment, and `issue_url` trims #: the TAIL of the body to fit GitHub's URL limit -- so an unbounded analysis #: would push the environment, settings and log-bundle path out of the report #: entirely. Four thousand characters is several screens of prose, which is #: more than any useful diagnosis needs. AI_ANALYSIS_MAX_CHARS = 4000
[docs] def build_report( traceback_text: str, active_app: str = "", settings: Optional[Dict[str, Any]] = None, include_log_tail: bool = True, ai_response: str = "", ) -> Dict[str, str]: """Build a ``(title, body)`` pair for a pre-filled GitHub issue. :param traceback_text: full traceback text (as caught by :func:`traceback.format_exc`). :param active_app: id of the app the user was in when the error fired (``"mask"`` / ``"measure"`` / …). :param settings: the pipeline settings dict in play, if any. Sanitised before inclusion. :param include_log_tail: also attach the last N log lines. :param ai_response: spaCR AI's analysis of this same error, when the AI is switched on and has already answered. Sanitised and length-capped like everything else here, and clearly marked as machine-generated: it is a lead for whoever reads the report, not a finding. :returns: dict with keys ``title``, ``body`` and ``fingerprint``, ready to be URL-encoded onto ``issues/new``. """ tb_clean = sanitize_traceback(traceback_text) tb_hash = _traceback_hash(tb_clean) err_line = "" for ln in reversed(tb_clean.splitlines()): if ln.strip() and not ln.startswith(" "): err_line = ln.strip() break err_line = err_line[:80] or "Runtime error" app_tag = f"[{active_app}] " if active_app else "" title = f"[auto {tb_hash}] {app_tag}{err_line}"[:120] body_parts: List[str] = [] body_parts.append( "> Auto-filed from the spaCR AI Console. " f"Traceback fingerprint: `{tb_hash}`. " f"Active app: `{active_app or 'unknown'}`." ) body_parts.append("") body_parts.append("### Traceback") body_parts.append("```") body_parts.append(tb_clean.strip()) body_parts.append("```") body_parts.append("") analysis = sanitize_path(str(ai_response or "")).strip() if analysis: if len(analysis) > AI_ANALYSIS_MAX_CHARS: analysis = (analysis[:AI_ANALYSIS_MAX_CHARS].rstrip() + "\n\n… (analysis truncated)") body_parts.append( "<details><summary>spaCR AI's analysis of this error" "</summary>") body_parts.append("") body_parts.append( "Generated by spaCR AI from the traceback above, unreviewed. " "Treat it as a lead rather than a diagnosis.") body_parts.append("") body_parts.append(analysis) body_parts.append("</details>") body_parts.append("") body_parts.append("### Environment") body_parts.extend(_env_lines()) body_parts.append("") if settings: clean_settings = sanitize_settings(settings) body_parts.append("<details><summary>Pipeline settings</summary>") body_parts.append("") body_parts.append("```") for k, v in clean_settings.items(): body_parts.append(f"{k} = {v!r}") body_parts.append("```") body_parts.append("</details>") body_parts.append("") if include_log_tail: saved = save_log_bundle(tb_hash) if saved is not None: body_parts.append("<details><summary>Log</summary>") body_parts.append("") body_parts.append( "The log is NOT attached: it can carry sample names, plate " "barcodes and folder names, and this issue is public.") body_parts.append("") body_parts.append(f"It was saved on the reporter's machine at " f"`{sanitize_path(str(saved))}`.") body_parts.append("") body_parts.append( "If you need it, ask -- and read it before sending it.") body_parts.append("</details>") return {"title": title, "body": "\n".join(body_parts), "fingerprint": tb_hash}
[docs] def issue_url(title: str, body: str, label: str = ISSUE_LABEL, repo: str = REPO) -> str: """Build the ``https://github.com/<repo>/issues/new?…`` URL. The URL is truncated to ~7.5 KB so it fits GitHub's parser limit; an ellipsis + note is appended to the body when we clip. :param title: URL-encodable issue title. :param body: markdown body; may be truncated. :param label: label to attach (created lazily by GitHub if it doesn't already exist). :param repo: ``owner/name`` slug. :returns: fully-quoted ``https://github.com/…`` URL. """ scaffold_len = ( len(f"https://github.com/{repo}/issues/new?labels={label}&title=&body=") + len(urllib.parse.quote(title)) ) if scaffold_len + len(urllib.parse.quote(body)) > MAX_URL_LEN: note = ( "\n\n_[report truncated to fit GitHub URL limit — " "the full log lives at ~/.spacr/logs/spacr.log]_" ) budget = MAX_URL_LEN - scaffold_len - len(urllib.parse.quote(note)) head = body while head and len(urllib.parse.quote(head)) > budget: head = head[:max(1, int(len(head) * 0.8))] body = head.rstrip() + note q = urllib.parse.urlencode({ "labels": label, "title": title, "body": body, }, quote_via=urllib.parse.quote) return f"https://github.com/{repo}/issues/new?{q}"
[docs] def open_issue_in_browser(url: str) -> bool: """Open ``url`` in the user's default browser. :param url: the address to open, typically from :func:`issue_url`; it is opened in a new tab where the browser supports one. :returns: ``True`` if webbrowser accepted the request, else False. """ import webbrowser try: return webbrowser.open(url, new=2) except Exception: return False
#: What :func:`file_without_review` reports back, one of these. FILED = "filed" SEEN_AGAIN = "seen_again" SIGNED_OUT = "signed_out" REFUSED = "refused" FAILED = "failed" def _post_report(report: Dict[str, str]) -> Dict[str, str]: """Post a report through the GitHub API, onto an open duplicate if any. Shared by both ways of filing. The open-issue search is by the fingerprint, which :func:`build_report` writes into every body, so a crash that already has an open issue gets a "Seen again" comment there instead of a second issue. A search that could not run does not stop the report. :param report: ``title``, ``body`` and ``fingerprint``. :returns: ``{"status": FILED or SEEN_AGAIN, "url": ...}``, or ``{"status": FAILED, "detail": ...}``. """ from . import github_auth searched, existing = github_auth.find_issue_by_fingerprint( REPO, report["fingerprint"]) if searched and existing: ok, _ = github_auth.comment_on_issue( REPO, existing.get("number"), "Seen again.\n\n" + report["body"]) if ok: return {"status": SEEN_AGAIN, "url": str(existing.get("html_url", "") or "")} ok, result = github_auth.create_issue( REPO, report["title"], report["body"], labels=[ISSUE_LABEL]) if ok and result: return {"status": FILED, "url": str(result)} return {"status": FAILED, "detail": str(result or "no issue came back")}
[docs] def submit_report(report: Dict[str, str]) -> str: """Submit one payload the user has already approved in the preview. :param report: the approved payload with ``title`` and ``body`` keys, plus ``fingerprint`` for the duplicate search when a GitHub sign-in posts it through the API. Without a sign-in, or when posting fails, the title and body are put into a pre-filled issue URL that is opened in the browser. :returns: the filed issue's URL, the pre-filled ``issues/new`` URL, or the transport refusal message when network use is refused (inside a test run). """ try: from . import github_auth refusal = github_auth._transport_refusal() if refusal: return refusal if github_auth.is_authenticated(): posted = _post_report(report) if posted.get("url"): return posted["url"] except Exception: pass url = issue_url(report["title"], report["body"]) open_issue_in_browser(url) return url
[docs] def file_without_review(report: Dict[str, str]) -> Dict[str, str]: """File a report automatically, for issue reporting set to 'always'. Runs on a worker thread: resolving the sign-in can run ``gh auth token``, and posting waits on api.github.com. It never opens a browser. The browser form is a prompt the user answers, and 'always' is the choice not to be prompted. So without a GitHub sign-in it files nothing and says so. :param report: the payload to post, already passed through :func:`public_report`. :returns: ``{"status": ..., "url": ..., "detail": ...}`` with a status of :data:`FILED`, :data:`SEEN_AGAIN` (a comment on the open issue with the same fingerprint), :data:`SIGNED_OUT`, :data:`REFUSED` (inside a test run) or :data:`FAILED`. Never raises. """ try: from . import github_auth refusal = github_auth._transport_refusal() if refusal: return {"status": REFUSED, "detail": refusal} if not github_auth.is_authenticated(): return {"status": SIGNED_OUT} return _post_report(report) except Exception as exc: # noqa: BLE001 return {"status": FAILED, "detail": f"{type(exc).__name__}: {exc}"}
[docs] def file_issue( traceback_text: str, active_app: str = "", settings: Optional[Dict[str, Any]] = None, *, include_log_tail: bool = True, ai_response: str = "", ) -> str: """Legacy end-to-end helper retained for API callers and tests. The GUI does not call this directly: it builds the payload, displays an editable preview, then passes the approved mapping to :func:`submit_report`. Headless callers invoking this function are the report-specific affirmative action themselves. :param traceback_text: full traceback text, passed to :func:`build_report`, which sanitises it and derives the issue title and fingerprint from it. """ report = build_report( traceback_text, active_app=active_app, settings=settings, include_log_tail=include_log_tail, ai_response=ai_response, ) return submit_report(report)