#!/usr/bin/env python3"""intake_validation.py==================== Deterministic intake validation bot for `zed-industries/zed` GitHub issues.Runs on issue open/edit/reopen and on new comments. Performs two checks: 1. MISSING-INFO The body has no parseable `Zed:` or `OS:` line, or contains only the template's placeholder example.2. OUTDATED The reporter's version is a minor (or more) behind current stable — strict same-minor rule, patch ignored, no grace window (deliberate; see `is_outdated`). Non-stable channels are version-compared too, not exempt; ahead-of-stable is silent automatically. The bot posts a comment + applies `state:needs info` for either check.On reporter follow-up (body edit or new comment), the bot re-parses andclears the label if valid info is now present. Modes------ `live` Calls GitHub API to post comments + add/remove labels.- `shadow` Writes structured "would have done X" records to $GITHUB_STEP_SUMMARY. No API writes. The pre-prod stress mode.- `dry-run` Local only: prints decisions to stdout, no API calls at all. Auth----Reads GITHUB_TOKEN from env (or `gh auth token` as a fallback for localruns). Inside the workflow we pass the community-bot app token. Exit codes----------0 — script ran cleanly (including "no action needed").1 — fatal error (auth, malformed input, etc.). Outdated-version-lookup failures are NOT fatal: they downgrade the runto missing-info-only and log a warning. A wrong "you're outdated" commentis the worst failure mode, so we bias toward under-triggering.""" from __future__ import annotations import argparseimport jsonimport osimport reimport shutilimport subprocessimport sysfrom dataclasses import asdict, dataclass, fieldfrom datetime import datetime, timezonefrom enum import Enumfrom pathlib import Pathfrom typing import Optional import requests # ---------------------------------------------------------------------------# Constants REPO_OWNER = "zed-industries"REPO_NAME = "zed"REPO_FULL = f"{REPO_OWNER}/{REPO_NAME}" GITHUB_API = "https://api.github.com" # Feature flag: whether the bot requires an OS in addition to a Zed version.# Set to False (current) for a VERSION-ONLY bot — we still parse OS info# (it's surfaced in the replay/CSV for context and future use), but a# missing OS does NOT trigger a prompt. Flip to True to re-enable the# OS-gating behavior preserved in `archive/`. See archive/README.md.REQUIRE_OS = False LABEL_NEEDS_INFO = "state:needs info" # Issue types the bot gates on. Mirrors the dup bot's `should_skip()`:# typed issues that are neither Bug nor Crash (Feature, Meta, Tracking,# ...) are skipped entirely — they don't have a spec section, so asking# them for a Zed version would be a guaranteed false positive. UNTYPED# issues are allowed through: a brand-new bug report may not be typed# yet, and those are exactly the issues we want to validate.GATED_ISSUE_TYPES = {"Bug", "Crash"} # Marker comments — used to keep the bot idempotent. Each is appended as an# HTML comment to the bottom of the bot's posts. On every re-trigger we scan# the issue's bot comments for these markers before deciding to post again.MARKER_MISSING_INFO = "<!-- intake-validation: missing-info -->"MARKER_OUTDATED_VERSION = "<!-- intake-validation: outdated-version -->" # The git SHA in the bug/crash template's example spec block. The example# is a YAML-form `placeholder:` (greyed-out, never submitted), so this# exact SHA only appears when someone deliberately quotes the example.# Lines containing it are stripped before parsing so the example's# `v0.215.0` is never read as the reporter's version; real commit SHAs# don't match. Update if the template's example changes.TEMPLATE_PLACEHOLDER_SHA = "bfe141ea79aa4984028934067ba75c48d99136ae" # Keys that appear in the system-specs output of# `zed: copy system specs into clipboard`. Order matters only for# readability — the parser handles them in any order.SPEC_KEYS = ("Zed", "OS", "Memory", "Architecture", "GPU") # Channels we recognize. Anything else parses as None and is treated as# "unknown channel" — which we map to Stable for outdated checking.KNOWN_CHANNELS = {"stable", "preview", "dev", "nightly"} # ---------------------------------------------------------------------------# Logging def log(msg: str, level: str = "INFO") -> None: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") print(f"[{ts}] [{level}] {msg}", file=sys.stderr, flush=True) # ---------------------------------------------------------------------------# Auth def get_token() -> str: token = os.environ.get("GITHUB_TOKEN", "").strip() if token: return token if not shutil.which("gh"): sys.exit("ERROR: GITHUB_TOKEN not set and `gh` not on PATH") try: return subprocess.check_output(["gh", "auth", "token"], text=True).strip() except subprocess.CalledProcessError as e: sys.exit(f"ERROR: failed to read token from gh: {e}") # ---------------------------------------------------------------------------# Version @dataclass(frozen=True, order=True)class Version: """Semver-ish (major, minor, patch). Comparisons are tuple-order.""" major: int minor: int patch: int def __str__(self) -> str: return f"v{self.major}.{self.minor}.{self.patch}" @classmethod def parse(cls, raw: str) -> Optional["Version"]: """Parse a Zed version string into (major, minor, patch). Tries progressively looser forms (the looser ones mainly matter for older v0.x reporters who use shorthand): - `v1.2.3` / `1.2.3` / `1.2.3+stable.42.SHA` -> (1, 2, 3) - `1.4` (patch omitted) -> (1, 4, 0) - `.227` (major + patch omitted, v0.x shorthand) -> (0, 227, 0) Returns None when nothing version-like is present. """ if raw is None: return None s = raw.strip() # 1. Full 3-component (preferred). m = re.search(r"\bv?(\d+)\.(\d+)\.(\d+)\b", s) if m: return cls(int(m.group(1)), int(m.group(2)), int(m.group(3))) # 2. Two-component, patch implied 0 (`1.4`). m = re.search(r"\bv?(\d+)\.(\d+)\b", s) if m: return cls(int(m.group(1)), int(m.group(2)), 0) # 3. Leading-dot minor, major + patch implied 0 (`.227`). Last # resort — only reached for strings with no fuller version form. m = re.search(r"(?<!\d)\.(\d+)\b", s) if m: return cls(0, int(m.group(1)), 0) return None def is_outdated(reported: Version, current_stable: Version) -> bool: """Outdated iff the reported (major, minor) is strictly less than the current stable's (major, minor). Patch is intentionally ignored — patch releases ship 2-4x/week and we don't want the bot pinging reporters who are one patch behind. The strictness here means a Wednesday minor promotion immediately flips every reporter on the previous minor into "outdated" status the moment the new minor is published. That's the intended behavior at this stage: we want the data from a sharp rule before deciding whether to soften it with a grace window. See `spec.md` and the Linear discussion for context.""" return (reported.major, reported.minor) < ( current_stable.major, current_stable.minor, ) # ---------------------------------------------------------------------------# Parser @dataclassclass ParsedSpecs: """What we extracted from the issue body. Design note on the OS side: trying to maintain an exhaustive list of distro names and OS-token variants doesn't scale. The bot's job is to detect whether the reporter put SOMETHING substantive in the OS field, not to validate the format. So `has_os` gates on `os_raw` (the literal value the reporter wrote, minus obvious placeholders), and `os_family` / `os_version` are best-effort for display purposes only. """ zed_version: Optional[Version] = None zed_channel: Optional[str] = None # "stable" | "preview" | "dev" | "nightly" | None os_raw: Optional[str] = None # stripped value after `OS:`, None if placeholder os_family: Optional[str] = None # best-effort canonical family (display only) os_version: Optional[str] = None # best-effort numeric version (display only) # Reporter offered a commit SHA as their version (local/source build). # No semver to compare, but they DID identify their build — prompting # them would be a false positive, so this makes missing-info silent. has_commit_ref: bool = False # Reporter wrote "latest" (or similar) where the version goes. Still # missing_info — "latest" drifts with channel and auto-update timing — # but the prompt copy acknowledges their answer and names current # stable so they can confirm the exact build. says_latest: bool = False @property def has_zed(self) -> bool: return self.zed_version is not None @property def has_os(self) -> bool: # Any non-placeholder value in the OS field counts. We deliberately # don't gate on `os_family` because real-world OS values include # `Arch Linux`, `CachyOS`, `Win11`, `Ubuntu 20.04 (Focal)`, etc. # — the reporter clearly provided info even if we can't classify it. return self.os_raw is not None @property def is_complete(self) -> bool: # Version is always required. OS is only required when REQUIRE_OS is # on (currently off — version-only bot). if not self.has_zed: return False if REQUIRE_OS and not self.has_os: return False return True def to_dict(self) -> dict: return { "zed_version": str(self.zed_version) if self.zed_version else None, "zed_channel": self.zed_channel, "os_raw": self.os_raw, "os_family": self.os_family, "os_version": self.os_version, "has_commit_ref": self.has_commit_ref, "says_latest": self.says_latest, "is_complete": self.is_complete, } # Spec-line regexes. We anchor each to its own validation pattern (Zed: must# be followed by a version; OS: must be followed by a known family) rather# than using a generic key splitter, which would otherwise match prose like# `... as LLM in Zed:` and then capture arbitrary text as the value.## Both regexes work for multi-line spec blocks AND single-line concatenated# blocks (e.g. `Zed: v1.2.7+stable.42.SHA (Zed) OS: Linux X11 ubuntu 24.04`),# because we bound by the next spec key rather than by newline. # Boundary for the trailing content of a spec key. We stop at:# - the next spec key (so single-line concatenated specs work:# `Zed: v1.2.7 (Zed) OS: macOS 15.1 Memory: 36 GiB`)# - a newline (so multi-line specs don't accidentally absorb prose# written below the spec block)# - end of string_NEXT_KEY_BOUNDARY = r"(?=\s+(?:Zed|OS|Memory|Architecture|GPU)\s*:|\n|$)" # Measurement-unit words that disqualify a number from being a Zed version.# Prose like `Zed eats 1.5 GB of RAM`, `Zed takes .5s to open`, or# `Zed froze for 1.5 minutes` puts a decimal within the prefix-tolerance# window of the word "Zed"; without this guard those decimals parse as# versions — worst case an outdated-version comment about "v0.5.0". A# number immediately followed by a unit word is a measurement, not a# version, so we reject the match and let the body fall through to the# other strategies (usually ending in missing_info, which is the correct# answer for these bodies). Extend the list via fixtures as new unit# false-positives show up in replay._UNIT_WORDS = ( r"s\b|secs?\b|seconds?\b|ms\b|mins?\b|minutes?\b|hours?\b|days?\b" r"|%|x\b|times\b|stars?\b" r"|[KMGT]i?B\b|[KMGT]Hz\b|fps\b|px\b|pt\b")_NOT_A_UNIT_AHEAD = r"(?!\s{0,1}(?:" + _UNIT_WORDS + r"))" _ZED_SPEC_RE = re.compile( # `Zed` followed by a parseable version. Group 1: version (with optional # channel suffix — either `+channel.N.SHA` (full v1.x build metadata) or # the bare `+channel` form seen on early v1.0.x releases). Group 2: # optional `(Zed Nightly SHA)` legacy parenthetical channel hint. # # Prefix tolerates: # - `Zed:` / `Zed ` (the usual forms) # - `Zed version:` / `Zed version ` (the `--version` / About dialog forms) # - markdown emphasis around the label: `**Zed**:`, `__Zed__:`, `` `Zed` `` # Then any of: # - colon (`Zed: 1.2.7`) # - whitespace alone (`Zed 1.2.7`) # - colon + backticks / brackets (`Zed: `v1.2.7``) # Version capture accepts 3-component, 2-component (`1.4`), or the # leading-dot v0.x shorthand (`.227` — 2+ digits required, so prose # decimals like `.5s` can't masquerade as versions). # The trailing `_NOT_A_UNIT_AHEAD` guard rejects measurements # (`1.5 GB`, `3.4 seconds`) — see `_UNIT_WORDS` above. r"\bZed[*_`]{0,2}(?:\s+version)?[*_`]{0,2}[:\s]\s*(?P<gap>[^:\n]{0,15}?)?" r"v?(?P<ver>\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?|\d+\.\d+|\.\d{2,})" + _NOT_A_UNIT_AHEAD + r"\s*(?:\((?P<paren>Zed(?:\s+\w+)?)[^)]*\))?", re.IGNORECASE,) # Emphasized `Zed` label at the start of a line, e.g.# - **Zed**: Stable channel (auto-updated)1.4.4# (real intake #58294). Reporters who build their own "Environment"# lists often put prose between the label and the version, blowing past# the 15-char gap `_ZED_SPEC_RE` tolerates. A markdown-emphasized label# at line start is a strong structural signal that the line is a spec# field rather than prose, so we allow a longer same-line gap (60 chars)# — but ONLY for the emphasized form. Plain mid-sentence `Zed: ...` prose# never gets the long gap._ZED_LABELED_LINE_RE = re.compile( r"^[ \t]*(?:[-*+]\s+|>\s*)*[*_`]{1,3}Zed[*_`]{1,3}(?:\s+version)?\s*:\s*" r"(?P<gap>[^\n]{0,60}?)" r"v?(?P<ver>\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?|\d+\.\d+)" + _NOT_A_UNIT_AHEAD, re.IGNORECASE | re.MULTILINE,) # Commit-SHA-as-version detection. Reporters building Zed from source# often identify their build by commit instead of a version (real intake# #58699: `Zed: most recent commit: 6d68395c...`). There's no semver to# compare, but they DID identify their build — prompting them is a false# positive (team call, Lucas 2026-06). A bare hex string is NOT enough:# issue bodies are full of SHAs from logs, stack traces, and "introduced# by commit X" references. We accept only shapes where the SHA is offered# AS the reporter's build:# 1. the word commit/build/rev(ision) + hex (7-40 chars), same line# 2. a zed-industries/zed commit URL# plus, in `_extract_zed_from_section` only, a bare hex token inside the# spec section (the template told them to put their version there).# The `(?=[0-9]*[a-f])` lookahead requires at least one hex letter, so# pure-decimal runs (dates like 20260325, build ids) don't count as SHAs._COMMIT_REF_RE = re.compile( r"\b(?:commit|build|rev(?:ision)?)\b[:\s`]*(?=[0-9]*[a-f])[0-9a-f]{7,40}\b" r"|github\.com/zed-industries/zed/commit/[0-9a-f]{7,40}\b", re.IGNORECASE,)_BARE_SHA_RE = re.compile(r"\b(?=[0-9]*[a-f])[0-9a-f]{7,40}\b", re.IGNORECASE) # "latest"-as-version detection. Reporters answer the version section# with `Zed: latest stable` (#58386) or just `most latest zed version`# (#57222). The word "latest" is everywhere in prose ("the latest update# broke X"), so detection is deliberately narrow — two shapes only:# 1. a `Zed:`-labeled line whose value contains "latest" (≤15-char gap,# same prose tolerance as version parsing)# 2. a SHORT (≤40 chars), digit-free line containing "latest" inside# the spec-section chunk (the template told them to answer there;# digit-free because a line with digits would carry a real version)# Only consulted when no version parsed; the result is still# missing_info, just with copy that acknowledges the answer._ZED_LATEST_LABEL_RE = re.compile( r"\bZed[*_`]{0,2}(?:\s+version)?[*_`]{0,2}\s*:\s*[^:\n]{0,15}?\blatest\b", re.IGNORECASE,)_LATEST_WORD_RE = re.compile(r"\blatest\b", re.IGNORECASE) # `### Zed version and system specs` (or `## Zed version ...`) — the# canonical section header in the bug template. Used by the bare-version# fallback below when the main `_ZED_SPEC_RE` finds nothing._SPEC_SECTION_HEADER_RE = re.compile( r"^#{1,4}\s*Zed\s+version[^\n]*$", re.IGNORECASE | re.MULTILINE,) # A bare semver-ish version, no `Zed` prefix required. Used only inside the# spec section (bounded chunk) so this very-permissive regex doesn't fire on# random version mentions elsewhere in the body._BARE_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?)\b") # Other spec keys that bound the "this is the Zed version" search inside# the section. Once we hit `OS:`, `Memory:`, etc., we're past the Zed line._OTHER_SPEC_KEY_RE = re.compile( r"\b(?:OS|Memory|Architecture|GPU)\s*:", re.IGNORECASE,) # Template noise we strip before body-scanning for OS. The bug template# includes the literal line:# ### If you are using WSL on Windows, what flavor of Linux are you using?# which contains both `Windows` and `Linux` tokens. The reporter's answer# is on a subsequent line; the question itself is just template boilerplate# and shouldn't drive family detection._TEMPLATE_NOISE_RE = re.compile( r"#{1,4}\s*If you are using WSL on Windows[^\n]*", re.IGNORECASE,) # `OS:` followed by some value. We capture the rest of the value up to the# next spec key boundary so we can search inside it for a family (or distro# that implies Linux) and a version token. We deliberately don't anchor on# a specific family here — real-world `OS:` values include `Arch Linux`,# `CachyOS`, `Ubuntu 20.04 (Focal)`, etc., where the canonical family isn't# the first token. Family detection happens against `rest` via# `_detect_os_family`._OS_KEY_RE = re.compile( r"\bOS\s*:\s*(?P<rest>.+?)" + _NEXT_KEY_BOUNDARY, re.IGNORECASE | re.DOTALL,) # Obvious-placeholder OS values. If the reporter typed any of these into# the OS field (case-insensitive, after stripping whitespace), we treat# the field as not-filled-in. This list is intentionally TINY — the# tradeoff is being lenient on "is this a real OS?" so we don't have to# maintain a catalog of every distro and abbreviation under the sun._OS_PLACEHOLDER_VALUES = frozenset( { "", "n/a", "na", "none", "tbd", "idk", "unknown", "?", ".", }) # Linux distros we recognize as implying "Linux" when no canonical family# token is present. Used both inside `OS:` values (e.g. `OS: CachyOS`) and# in the body-scan fallback (e.g. `Fedora release 44` on a standalone# line). Order doesn't matter; all map to "Linux"._LINUX_DISTRO_ALIASES = ( "ubuntu", "fedora", "arch", "debian", "mint", "nixos", "manjaro", "opensuse", "suse", "centos", "rhel", "gentoo", "pop[\\s_!-]?os", "cachyos", "endeavouros", "slackware", "alpine", "tumbleweed", "kali", "raspbian", "elementary", "void", "garuda", "asahi",) # OS family detection. Used both to populate the display field `os_family`# (when we have an explicit `OS:` value) and as the body-scan fallback# when no `OS:` key is found at all._OS_FAMILY_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( (re.compile(r"\b(?:macOS|Mac\s*OS|OS\s*X)\b", re.IGNORECASE), "macOS"), (re.compile(r"\b(?:Windows|Win\s*\d+)\b", re.IGNORECASE), "Windows"), (re.compile(r"\bFreeBSD\b", re.IGNORECASE), "FreeBSD"), (re.compile(r"\bOpenBSD\b", re.IGNORECASE), "OpenBSD"), (re.compile(r"\bLinux\b", re.IGNORECASE), "Linux"), ( re.compile(r"\b(?:" + "|".join(_LINUX_DISTRO_ALIASES) + r")\b", re.IGNORECASE), "Linux", ),) # Numeric version token: 1-4 dotted components. Allows `15.1`, `24.04`, `11`,# and `15.1.2`. Used to find the OS version in the trailing OS text._VERSION_TOKEN_RE = re.compile(r"\b(\d+(?:\.\d+){0,3})\b") def _channel_from_version_suffix(version_segment: str) -> Optional[str]: """Extract `stable` from `v1.2.7+stable.42.SHA`-style strings AND from the bare `v1.0.1+stable` form (no build metadata) seen on early v1.0.x releases. Both are valid in the wild.""" m = re.search(r"\+(\w+)(?:\.\d+\.[0-9a-f]+)?", version_segment, re.IGNORECASE) if m: ch = m.group(1).lower() if ch in KNOWN_CHANNELS:Lines 1–500 of 1420. Load more
#!/usr/bin/env python3 """ intake_validation.py ==================== Deterministic intake validation bot for `zed-industries/zed` GitHub issues. Runs on issue open/edit/reopen and on new comments. Performs two checks: 1. MISSING-INFO The body has no parseable `Zed:` or `OS:` line, or contains only the template's placeholder example. 2. OUTDATED The reporter's version is a minor (or more) behind current stable — strict same-minor rule, patch ignored, no grace window (deliberate; see `is_outdated`). Non-stable channels are version-compared too, not exempt; ahead-of-stable is silent automatically. The bot posts a comment + applies `state:needs info` for either check. On reporter follow-up (body edit or new comment), the bot re-parses and clears the label if valid info is now present. Modes ----- - `live` Calls GitHub API to post comments + add/remove labels. - `shadow` Writes structured "would have done X" records to $GITHUB_STEP_SUMMARY. No API writes. The pre-prod stress mode. - `dry-run` Local only: prints decisions to stdout, no API calls at all. Auth ---- Reads GITHUB_TOKEN from env (or `gh auth token` as a fallback for local runs). Inside the workflow we pass the community-bot app token. Exit codes ---------- 0 — script ran cleanly (including "no action needed"). 1 — fatal error (auth, malformed input, etc.). Outdated-version-lookup failures are NOT fatal: they downgrade the run to missing-info-only and log a warning. A wrong "you're outdated" comment is the worst failure mode, so we bias toward under-triggering. """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from enum import Enum from pathlib import Path from typing import Optional import requests # --------------------------------------------------------------------------- # Constants REPO_OWNER = "zed-industries" REPO_NAME = "zed" REPO_FULL = f"{REPO_OWNER}/{REPO_NAME}" GITHUB_API = "https://api.github.com" # Feature flag: whether the bot requires an OS in addition to a Zed version. # Set to False (current) for a VERSION-ONLY bot — we still parse OS info # (it's surfaced in the replay/CSV for context and future use), but a # missing OS does NOT trigger a prompt. Flip to True to re-enable the # OS-gating behavior preserved in `archive/`. See archive/README.md. REQUIRE_OS = False LABEL_NEEDS_INFO = "state:needs info" # Issue types the bot gates on. Mirrors the dup bot's `should_skip()`: # typed issues that are neither Bug nor Crash (Feature, Meta, Tracking, # ...) are skipped entirely — they don't have a spec section, so asking # them for a Zed version would be a guaranteed false positive. UNTYPED # issues are allowed through: a brand-new bug report may not be typed # yet, and those are exactly the issues we want to validate. GATED_ISSUE_TYPES = {"Bug", "Crash"} # Marker comments — used to keep the bot idempotent. Each is appended as an # HTML comment to the bottom of the bot's posts. On every re-trigger we scan # the issue's bot comments for these markers before deciding to post again. MARKER_MISSING_INFO = "<!-- intake-validation: missing-info -->" MARKER_OUTDATED_VERSION = "<!-- intake-validation: outdated-version -->" # The git SHA in the bug/crash template's example spec block. The example # is a YAML-form `placeholder:` (greyed-out, never submitted), so this # exact SHA only appears when someone deliberately quotes the example. # Lines containing it are stripped before parsing so the example's # `v0.215.0` is never read as the reporter's version; real commit SHAs # don't match. Update if the template's example changes. TEMPLATE_PLACEHOLDER_SHA = "bfe141ea79aa4984028934067ba75c48d99136ae" # Keys that appear in the system-specs output of # `zed: copy system specs into clipboard`. Order matters only for # readability — the parser handles them in any order. SPEC_KEYS = ("Zed", "OS", "Memory", "Architecture", "GPU") # Channels we recognize. Anything else parses as None and is treated as # "unknown channel" — which we map to Stable for outdated checking. KNOWN_CHANNELS = {"stable", "preview", "dev", "nightly"} # --------------------------------------------------------------------------- # Logging def log(msg: str, level: str = "INFO") -> None: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") print(f"[{ts}] [{level}] {msg}", file=sys.stderr, flush=True) # --------------------------------------------------------------------------- # Auth def get_token() -> str: token = os.environ.get("GITHUB_TOKEN", "").strip() if token: return token if not shutil.which("gh"): sys.exit("ERROR: GITHUB_TOKEN not set and `gh` not on PATH") try: return subprocess.check_output(["gh", "auth", "token"], text=True).strip() except subprocess.CalledProcessError as e: sys.exit(f"ERROR: failed to read token from gh: {e}") # --------------------------------------------------------------------------- # Version @dataclass(frozen=True, order=True) class Version: """Semver-ish (major, minor, patch). Comparisons are tuple-order.""" major: int minor: int patch: int def __str__(self) -> str: return f"v{self.major}.{self.minor}.{self.patch}" @classmethod def parse(cls, raw: str) -> Optional["Version"]: """Parse a Zed version string into (major, minor, patch). Tries progressively looser forms (the looser ones mainly matter for older v0.x reporters who use shorthand): - `v1.2.3` / `1.2.3` / `1.2.3+stable.42.SHA` -> (1, 2, 3) - `1.4` (patch omitted) -> (1, 4, 0) - `.227` (major + patch omitted, v0.x shorthand) -> (0, 227, 0) Returns None when nothing version-like is present. """ if raw is None: return None s = raw.strip() # 1. Full 3-component (preferred). m = re.search(r"\bv?(\d+)\.(\d+)\.(\d+)\b", s) if m: return cls(int(m.group(1)), int(m.group(2)), int(m.group(3))) # 2. Two-component, patch implied 0 (`1.4`). m = re.search(r"\bv?(\d+)\.(\d+)\b", s) if m: return cls(int(m.group(1)), int(m.group(2)), 0) # 3. Leading-dot minor, major + patch implied 0 (`.227`). Last # resort — only reached for strings with no fuller version form. m = re.search(r"(?<!\d)\.(\d+)\b", s) if m: return cls(0, int(m.group(1)), 0) return None def is_outdated(reported: Version, current_stable: Version) -> bool: """Outdated iff the reported (major, minor) is strictly less than the current stable's (major, minor). Patch is intentionally ignored — patch releases ship 2-4x/week and we don't want the bot pinging reporters who are one patch behind. The strictness here means a Wednesday minor promotion immediately flips every reporter on the previous minor into "outdated" status the moment the new minor is published. That's the intended behavior at this stage: we want the data from a sharp rule before deciding whether to soften it with a grace window. See `spec.md` and the Linear discussion for context.""" return (reported.major, reported.minor) < ( current_stable.major, current_stable.minor, ) # --------------------------------------------------------------------------- # Parser @dataclass class ParsedSpecs: """What we extracted from the issue body. Design note on the OS side: trying to maintain an exhaustive list of distro names and OS-token variants doesn't scale. The bot's job is to detect whether the reporter put SOMETHING substantive in the OS field, not to validate the format. So `has_os` gates on `os_raw` (the literal value the reporter wrote, minus obvious placeholders), and `os_family` / `os_version` are best-effort for display purposes only. """ zed_version: Optional[Version] = None zed_channel: Optional[str] = None # "stable" | "preview" | "dev" | "nightly" | None os_raw: Optional[str] = None # stripped value after `OS:`, None if placeholder os_family: Optional[str] = None # best-effort canonical family (display only) os_version: Optional[str] = None # best-effort numeric version (display only) # Reporter offered a commit SHA as their version (local/source build). # No semver to compare, but they DID identify their build — prompting # them would be a false positive, so this makes missing-info silent. has_commit_ref: bool = False # Reporter wrote "latest" (or similar) where the version goes. Still # missing_info — "latest" drifts with channel and auto-update timing — # but the prompt copy acknowledges their answer and names current # stable so they can confirm the exact build. says_latest: bool = False @property def has_zed(self) -> bool: return self.zed_version is not None @property def has_os(self) -> bool: # Any non-placeholder value in the OS field counts. We deliberately # don't gate on `os_family` because real-world OS values include # `Arch Linux`, `CachyOS`, `Win11`, `Ubuntu 20.04 (Focal)`, etc. # — the reporter clearly provided info even if we can't classify it. return self.os_raw is not None @property def is_complete(self) -> bool: # Version is always required. OS is only required when REQUIRE_OS is # on (currently off — version-only bot). if not self.has_zed: return False if REQUIRE_OS and not self.has_os: return False return True def to_dict(self) -> dict: return { "zed_version": str(self.zed_version) if self.zed_version else None, "zed_channel": self.zed_channel, "os_raw": self.os_raw, "os_family": self.os_family, "os_version": self.os_version, "has_commit_ref": self.has_commit_ref, "says_latest": self.says_latest, "is_complete": self.is_complete, } # Spec-line regexes. We anchor each to its own validation pattern (Zed: must # be followed by a version; OS: must be followed by a known family) rather # than using a generic key splitter, which would otherwise match prose like # `... as LLM in Zed:` and then capture arbitrary text as the value. # # Both regexes work for multi-line spec blocks AND single-line concatenated # blocks (e.g. `Zed: v1.2.7+stable.42.SHA (Zed) OS: Linux X11 ubuntu 24.04`), # because we bound by the next spec key rather than by newline. # Boundary for the trailing content of a spec key. We stop at: # - the next spec key (so single-line concatenated specs work: # `Zed: v1.2.7 (Zed) OS: macOS 15.1 Memory: 36 GiB`) # - a newline (so multi-line specs don't accidentally absorb prose # written below the spec block) # - end of string _NEXT_KEY_BOUNDARY = r"(?=\s+(?:Zed|OS|Memory|Architecture|GPU)\s*:|\n|$)" # Measurement-unit words that disqualify a number from being a Zed version. # Prose like `Zed eats 1.5 GB of RAM`, `Zed takes .5s to open`, or # `Zed froze for 1.5 minutes` puts a decimal within the prefix-tolerance # window of the word "Zed"; without this guard those decimals parse as # versions — worst case an outdated-version comment about "v0.5.0". A # number immediately followed by a unit word is a measurement, not a # version, so we reject the match and let the body fall through to the # other strategies (usually ending in missing_info, which is the correct # answer for these bodies). Extend the list via fixtures as new unit # false-positives show up in replay. _UNIT_WORDS = ( r"s\b|secs?\b|seconds?\b|ms\b|mins?\b|minutes?\b|hours?\b|days?\b" r"|%|x\b|times\b|stars?\b" r"|[KMGT]i?B\b|[KMGT]Hz\b|fps\b|px\b|pt\b" ) _NOT_A_UNIT_AHEAD = r"(?!\s{0,1}(?:" + _UNIT_WORDS + r"))" _ZED_SPEC_RE = re.compile( # `Zed` followed by a parseable version. Group 1: version (with optional # channel suffix — either `+channel.N.SHA` (full v1.x build metadata) or # the bare `+channel` form seen on early v1.0.x releases). Group 2: # optional `(Zed Nightly SHA)` legacy parenthetical channel hint. # # Prefix tolerates: # - `Zed:` / `Zed ` (the usual forms) # - `Zed version:` / `Zed version ` (the `--version` / About dialog forms) # - markdown emphasis around the label: `**Zed**:`, `__Zed__:`, `` `Zed` `` # Then any of: # - colon (`Zed: 1.2.7`) # - whitespace alone (`Zed 1.2.7`) # - colon + backticks / brackets (`Zed: `v1.2.7``) # Version capture accepts 3-component, 2-component (`1.4`), or the # leading-dot v0.x shorthand (`.227` — 2+ digits required, so prose # decimals like `.5s` can't masquerade as versions). # The trailing `_NOT_A_UNIT_AHEAD` guard rejects measurements # (`1.5 GB`, `3.4 seconds`) — see `_UNIT_WORDS` above. r"\bZed[*_`]{0,2}(?:\s+version)?[*_`]{0,2}[:\s]\s*(?P<gap>[^:\n]{0,15}?)?" r"v?(?P<ver>\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?|\d+\.\d+|\.\d{2,})" + _NOT_A_UNIT_AHEAD + r"\s*(?:\((?P<paren>Zed(?:\s+\w+)?)[^)]*\))?", re.IGNORECASE, ) # Emphasized `Zed` label at the start of a line, e.g. # - **Zed**: Stable channel (auto-updated)1.4.4 # (real intake #58294). Reporters who build their own "Environment" # lists often put prose between the label and the version, blowing past # the 15-char gap `_ZED_SPEC_RE` tolerates. A markdown-emphasized label # at line start is a strong structural signal that the line is a spec # field rather than prose, so we allow a longer same-line gap (60 chars) # — but ONLY for the emphasized form. Plain mid-sentence `Zed: ...` prose # never gets the long gap. _ZED_LABELED_LINE_RE = re.compile( r"^[ \t]*(?:[-*+]\s+|>\s*)*[*_`]{1,3}Zed[*_`]{1,3}(?:\s+version)?\s*:\s*" r"(?P<gap>[^\n]{0,60}?)" r"v?(?P<ver>\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?|\d+\.\d+)" + _NOT_A_UNIT_AHEAD, re.IGNORECASE | re.MULTILINE, ) # Commit-SHA-as-version detection. Reporters building Zed from source # often identify their build by commit instead of a version (real intake # #58699: `Zed: most recent commit: 6d68395c...`). There's no semver to # compare, but they DID identify their build — prompting them is a false # positive (team call, Lucas 2026-06). A bare hex string is NOT enough: # issue bodies are full of SHAs from logs, stack traces, and "introduced # by commit X" references. We accept only shapes where the SHA is offered # AS the reporter's build: # 1. the word commit/build/rev(ision) + hex (7-40 chars), same line # 2. a zed-industries/zed commit URL # plus, in `_extract_zed_from_section` only, a bare hex token inside the # spec section (the template told them to put their version there). # The `(?=[0-9]*[a-f])` lookahead requires at least one hex letter, so # pure-decimal runs (dates like 20260325, build ids) don't count as SHAs. _COMMIT_REF_RE = re.compile( r"\b(?:commit|build|rev(?:ision)?)\b[:\s`]*(?=[0-9]*[a-f])[0-9a-f]{7,40}\b" r"|github\.com/zed-industries/zed/commit/[0-9a-f]{7,40}\b", re.IGNORECASE, ) _BARE_SHA_RE = re.compile(r"\b(?=[0-9]*[a-f])[0-9a-f]{7,40}\b", re.IGNORECASE) # "latest"-as-version detection. Reporters answer the version section # with `Zed: latest stable` (#58386) or just `most latest zed version` # (#57222). The word "latest" is everywhere in prose ("the latest update # broke X"), so detection is deliberately narrow — two shapes only: # 1. a `Zed:`-labeled line whose value contains "latest" (≤15-char gap, # same prose tolerance as version parsing) # 2. a SHORT (≤40 chars), digit-free line containing "latest" inside # the spec-section chunk (the template told them to answer there; # digit-free because a line with digits would carry a real version) # Only consulted when no version parsed; the result is still # missing_info, just with copy that acknowledges the answer. _ZED_LATEST_LABEL_RE = re.compile( r"\bZed[*_`]{0,2}(?:\s+version)?[*_`]{0,2}\s*:\s*[^:\n]{0,15}?\blatest\b", re.IGNORECASE, ) _LATEST_WORD_RE = re.compile(r"\blatest\b", re.IGNORECASE) # `### Zed version and system specs` (or `## Zed version ...`) — the # canonical section header in the bug template. Used by the bare-version # fallback below when the main `_ZED_SPEC_RE` finds nothing. _SPEC_SECTION_HEADER_RE = re.compile( r"^#{1,4}\s*Zed\s+version[^\n]*$", re.IGNORECASE | re.MULTILINE, ) # A bare semver-ish version, no `Zed` prefix required. Used only inside the # spec section (bounded chunk) so this very-permissive regex doesn't fire on # random version mentions elsewhere in the body. _BARE_VERSION_RE = re.compile(r"\bv?(\d+\.\d+\.\d+(?:\+\w+(?:\.\d+\.[0-9a-f]+)?)?)\b") # Other spec keys that bound the "this is the Zed version" search inside # the section. Once we hit `OS:`, `Memory:`, etc., we're past the Zed line. _OTHER_SPEC_KEY_RE = re.compile( r"\b(?:OS|Memory|Architecture|GPU)\s*:", re.IGNORECASE, ) # Template noise we strip before body-scanning for OS. The bug template # includes the literal line: # ### If you are using WSL on Windows, what flavor of Linux are you using? # which contains both `Windows` and `Linux` tokens. The reporter's answer # is on a subsequent line; the question itself is just template boilerplate # and shouldn't drive family detection. _TEMPLATE_NOISE_RE = re.compile( r"#{1,4}\s*If you are using WSL on Windows[^\n]*", re.IGNORECASE, ) # `OS:` followed by some value. We capture the rest of the value up to the # next spec key boundary so we can search inside it for a family (or distro # that implies Linux) and a version token. We deliberately don't anchor on # a specific family here — real-world `OS:` values include `Arch Linux`, # `CachyOS`, `Ubuntu 20.04 (Focal)`, etc., where the canonical family isn't # the first token. Family detection happens against `rest` via # `_detect_os_family`. _OS_KEY_RE = re.compile( r"\bOS\s*:\s*(?P<rest>.+?)" + _NEXT_KEY_BOUNDARY, re.IGNORECASE | re.DOTALL, ) # Obvious-placeholder OS values. If the reporter typed any of these into # the OS field (case-insensitive, after stripping whitespace), we treat # the field as not-filled-in. This list is intentionally TINY — the # tradeoff is being lenient on "is this a real OS?" so we don't have to # maintain a catalog of every distro and abbreviation under the sun. _OS_PLACEHOLDER_VALUES = frozenset( { "", "n/a", "na", "none", "tbd", "idk", "unknown", "?", ".", } ) # Linux distros we recognize as implying "Linux" when no canonical family # token is present. Used both inside `OS:` values (e.g. `OS: CachyOS`) and # in the body-scan fallback (e.g. `Fedora release 44` on a standalone # line). Order doesn't matter; all map to "Linux". _LINUX_DISTRO_ALIASES = ( "ubuntu", "fedora", "arch", "debian", "mint", "nixos", "manjaro", "opensuse", "suse", "centos", "rhel", "gentoo", "pop[\\s_!-]?os", "cachyos", "endeavouros", "slackware", "alpine", "tumbleweed", "kali", "raspbian", "elementary", "void", "garuda", "asahi", ) # OS family detection. Used both to populate the display field `os_family` # (when we have an explicit `OS:` value) and as the body-scan fallback # when no `OS:` key is found at all. _OS_FAMILY_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( (re.compile(r"\b(?:macOS|Mac\s*OS|OS\s*X)\b", re.IGNORECASE), "macOS"), (re.compile(r"\b(?:Windows|Win\s*\d+)\b", re.IGNORECASE), "Windows"), (re.compile(r"\bFreeBSD\b", re.IGNORECASE), "FreeBSD"), (re.compile(r"\bOpenBSD\b", re.IGNORECASE), "OpenBSD"), (re.compile(r"\bLinux\b", re.IGNORECASE), "Linux"), ( re.compile(r"\b(?:" + "|".join(_LINUX_DISTRO_ALIASES) + r")\b", re.IGNORECASE), "Linux", ), ) # Numeric version token: 1-4 dotted components. Allows `15.1`, `24.04`, `11`, # and `15.1.2`. Used to find the OS version in the trailing OS text. _VERSION_TOKEN_RE = re.compile(r"\b(\d+(?:\.\d+){0,3})\b") def _channel_from_version_suffix(version_segment: str) -> Optional[str]: """Extract `stable` from `v1.2.7+stable.42.SHA`-style strings AND from the bare `v1.0.1+stable` form (no build metadata) seen on early v1.0.x releases. Both are valid in the wild.""" m = re.search(r"\+(\w+)(?:\.\d+\.[0-9a-f]+)?", version_segment, re.IGNORECASE) if m: ch = m.group(1).lower() if ch in KNOWN_CHANNELS: