From c4e65d58c88153e824879406c68588faa682cc64 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 31 May 2026 14:53:25 -0400 Subject: [PATCH] docs(skills): remove promoted local skills --- .../skills/code-review-change-size/SKILL.md | 11 - .codex/skills/codex-bug/SKILL.md | 48 - .codex/skills/codex-issue-digest/SKILL.md | 150 --- .../codex-issue-digest/agents/openai.yaml | 4 - .../scripts/collect_issue_digest.py | 994 ------------------ .../scripts/test_collect_issue_digest.py | 685 ------------ .codex/skills/codex-pr-body/SKILL.md | 59 -- 7 files changed, 1951 deletions(-) delete mode 100644 .codex/skills/code-review-change-size/SKILL.md delete mode 100644 .codex/skills/codex-bug/SKILL.md delete mode 100644 .codex/skills/codex-issue-digest/SKILL.md delete mode 100644 .codex/skills/codex-issue-digest/agents/openai.yaml delete mode 100755 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py delete mode 100644 .codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py delete mode 100644 .codex/skills/codex-pr-body/SKILL.md diff --git a/.codex/skills/code-review-change-size/SKILL.md b/.codex/skills/code-review-change-size/SKILL.md deleted file mode 100644 index 4e8048dcd4b..00000000000 --- a/.codex/skills/code-review-change-size/SKILL.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: code-review-change-size -description: Change size guidance (800 lines) ---- - -Unless the change is mechanical the total number of changed lines should not exceed 800 lines. -For complex logic changes the size should be under 500 lines. - -If the change is larger, explain whether it can be split into reviewable stages and identify the smallest coherent stage to land first. -Base the staging suggestion on the actual diff, dependencies, and affected call sites. - diff --git a/.codex/skills/codex-bug/SKILL.md b/.codex/skills/codex-bug/SKILL.md deleted file mode 100644 index c7a688e64f6..00000000000 --- a/.codex/skills/codex-bug/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: codex-bug -description: Diagnose GitHub bug reports in openai/codex. Use when given a GitHub issue URL from openai/codex and asked to decide next steps such as verifying against the repo, requesting more info, or explaining why it is not a bug; follow any additional user-provided instructions. ---- - -# Codex Bug - -## Overview - -Diagnose a Codex GitHub bug report and decide the next action: verify against sources, request more info, or explain why it is not a bug. - -## Workflow - -1. Confirm the input - -- Require a GitHub issue URL that points to `github.com/openai/codex/issues/…`. -- If the URL is missing or not in the right repo, ask the user for the correct link. - -2. Network access - -- Always access the issue over the network immediately, even if you think access is blocked or unavailable. -- Prefer the GitHub API over HTML pages because the HTML is noisy: - - Issue: `https://api.github.com/repos/openai/codex/issues/` - - Comments: `https://api.github.com/repos/openai/codex/issues//comments` -- If the environment requires explicit approval, request it on demand via the tool and continue without additional user prompting. -- Only if the network attempt fails after requesting approval, explain what you can do offline (e.g., draft a response template) and ask how to proceed. - -3. Read the issue - -- Use the GitHub API responses (issue + comments) as the source of truth rather than scraping the HTML issue page. -- Extract: title, body, repro steps, expected vs actual, environment, logs, and any attachments. -- Note whether the report already includes logs or session details. -- If the report includes a thread ID, mention it in the summary and use it to look up the logs and session details if you have access to them. - -4. Summarize the bug before investigating - -- Before inspecting code, docs, or logs in depth, write a short summary of the report in your own words. -- Include the reported behavior, expected behavior, repro steps, environment, and what evidence is already attached or missing. - -5. Decide the course of action - -- **Verify with sources** when the report is specific and likely reproducible. Inspect relevant Codex files (or mention the files to inspect if access is unavailable). -- **Request more information** when the report is vague, missing repro steps, or lacks logs/environment. -- **Explain not a bug** when the report contradicts current behavior or documented constraints (cite the evidence from the issue and any local sources you checked). - -6. Respond - -- Provide a concise report of your findings and next steps. diff --git a/.codex/skills/codex-issue-digest/SKILL.md b/.codex/skills/codex-issue-digest/SKILL.md deleted file mode 100644 index 42cd39af2ab..00000000000 --- a/.codex/skills/codex-issue-digest/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: codex-issue-digest -description: Run a GitHub issue digest for openai/codex by feature-area labels, all areas, and configurable time windows. Use when asked to summarize recent Codex bug reports or enhancement requests, especially for owner-specific labels such as tui, exec, app, or similar areas. -resources: - - path: scripts/collect_issue_digest.py - kind: script - description: GitHub issue activity collector for owner-focused Codex digests. -commands: - - name: collect-label-digest - resource_path: scripts/collect_issue_digest.py - example_argv: ["python3", ".codex/skills/codex-issue-digest/scripts/collect_issue_digest.py", "--labels", "tui", "exec", "--window-hours", "24"] - purpose: Collect recent bug/enhancement issue activity for selected feature-area labels. - - name: collect-all-areas-digest - resource_path: scripts/collect_issue_digest.py - example_argv: ["python3", ".codex/skills/codex-issue-digest/scripts/collect_issue_digest.py", "--all-labels", "--window", "past week", "--limit-issues", "10"] - purpose: Collect recent bug/enhancement issue activity across all feature-area labels. -workflow_defaults: - - name: repo - value: openai/codex - description: Default GitHub repository for Codex issue digests. - - name: window - value: previous 24 hours - description: Default lookback unless the user asks for another duration. - - name: output_mode - value: summary-only - description: Include details only when the user asks for a table, details, or a full digest. ---- - -# Codex Issue Digest - -## Objective - -Produce a headline-first, insight-oriented digest of `openai/codex` issues for the requested feature-area labels over the previous 24 hours by default. Honor a different duration when the user asks for one, for example "past week" or "48 hours". Default to a summary-only response; include details only when requested. - -Include only issues that currently have `bug` or `enhancement` plus at least one requested owner label. If the user asks for all areas or all labels, collect `bug`/`enhancement` issues across all labels. - -## Inputs - -- Feature-area labels, for example `tui exec` -- `all areas` / `all labels` to scan all current feature labels -- Optional repo override, default `openai/codex` -- Optional time window, default previous 24 hours; examples: `48h`, `7d`, `1w`, `past week` - -## Workflow - -1. Run the collector from a current Codex repo checkout: - -```bash -python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --labels tui exec --window-hours 24 -``` - -Use `--window "past week"` or `--window-hours 168` when the user asks for a non-default duration. Use `--all-labels` when the user says all areas or all labels. - -2. Use the JSON as the source of truth. It includes new issues, new issue comments, new reactions/upvotes, current labels, current reaction counts, model-ready `summary_inputs`, and detailed `digest_rows`. -3. Choose the output mode from the user's request: - - Default mode: start the report with `## Summary` and do not emit `## Details`. - - Details-upfront mode: if the user asks for details, a table, a full digest, "include details", or similar, start with `## Summary`, then include `## Details`. - - Follow-up details mode: if the user asks for more detail after a summary-only digest, produce `## Details` from the existing collector JSON when it is still available; otherwise rerun the collector. -4. In `## Summary`, write a headline-first executive summary: - - The first nonblank line under `## Summary` must be a single-line headline or judgment, not a bullet. It should be useful even if the reader stops there. - - On quiet days, prefer exactly: `No major issues reported by users.` Use this when there are no elevated rows, no newly repeated theme, and nothing that needs owner action. - - When users are surfacing notable issues, make the headline name the count or theme, for example `Two issues are being surfaced by users:`. - - Immediately under an active headline, list only the issues or themes driving attention, ordered by importance. Start each line with the row's `attention_marker` when present, then a concise owner-readable description and inline issue refs. - - Treat `🔥🔥` as headline-worthy and `🔥` as elevated. Do not add fire emoji yourself; only copy the row's `attention_marker`. - - Keep any extra summary detail after the headline to 1-3 terse lines, only when it adds a decision-relevant caveat, repeated theme, or owner action. - - Do not include routine counts, broad stats, or low-signal table summaries in `## Summary` unless they change the headline. Put metadata and optional counts in `## Details` or the footer. - - In default mode, end the report with a concise prompt such as `Want details? I can expand this into the issue table.` Keep this separate from the summary headline so the headline stays clean. - - Cluster and name themes yourself from `summary_inputs`; the collector intentionally does not hard-code issue categories. - - Use a cluster only when the issues genuinely share the same product problem. If several issues merely share a broad platform or label, describe them individually. - - Do not omit a repeated theme just because its individual issues fall below the details table cutoff. Several similar reports should be called out as a repeated customer concern. - - For single-issue rows, summarize the concern directly instead of calling it a cluster. - - Use inline numbered issue links from each relevant row's `ref_markdown`. - - Example quiet summary: - -```markdown -## Summary -No major issues reported by users. - -Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. -Want details? I can expand this into the issue table. -``` - - - Example active summary: - -```markdown -## Summary -Two issues are being surfaced by users: -🔥🔥 Terminal launch hangs on startup [1](https://github.com/openai/codex/issues/123) -🔥 Resume switches model providers unexpectedly [2](https://github.com/openai/codex/issues/456) - -Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. -Want details? I can expand this into the issue table. -``` -5. In `## Details`, when details are requested, include a compact table only when useful: - - Prefer rows from `digest_rows`; include a `Refs` column using each row's `ref_markdown`. - - Keep the table short; omit low-signal rows when the summary already covers them. - - Use compact columns such as marker, area, type, description, interactions, and refs. - - The `Description` cell should be a short owner-readable phrase. Use row `description`, title, body excerpts, and recent comments, but do not mechanically copy the raw GitHub issue title when it contains incidental details. - - A clear quiet/no-concern sentence when there is no meaningful signal. -6. Use the JSON `attention_marker` exactly. It is empty for normal rows, `🔥` for elevated rows, and `🔥🔥` for very high-attention rows. The actual cutoffs are in `attention_thresholds`. -7. Use inline numbered references where a row or bullet points to issues, for example `Compaction bugs [1](https://github.com/openai/codex/issues/123), [2](https://github.com/openai/codex/issues/456)`. Do not add a separate footnotes section. -8. Label `interactions` as `Interactions`; it counts posts/comments/reactions during the requested window, not unique people. -9. Mention the collector `script_version`, repo checkout `git_head`, and time window in one compact source line. In default mode, put this before the details prompt so the final line still asks whether the user wants details. In details-upfront mode, it can be the footer. - -## Reaction Handling - -The collector uses GitHub reactions endpoints, which include `created_at`, to count reactions created during the digest window for hydrated issues. It reports both in-window reaction counts and current reaction totals. Treat current reaction totals as standing engagement, and treat `new_reactions` / `new_upvotes` as windowed activity. - -By default, the collector fetches issue comments with `since=` and caps the number of comment pages per issue. This keeps very long historical threads from dominating a digest run and focuses the report on recent posts. Use `--fetch-all-comments` only when exhaustive comment history is more important than runtime. - -GitHub issue search is still seeded by issue `updated_at`, so a purely reaction-only issue may be missed if reactions do not bump `updated_at`. Covering every reaction-only case would require either a persisted snapshot store or a broader scan of labeled issues. - -## Attention Markers - -The collector scales attention markers by the requested time window. The baseline is 5 human user interactions for `🔥` and 10 for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 35 and 70 interactions. Human user interactions are human-authored new issue posts, human-authored new comments, and human reactions created during the window, including upvotes. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji. - -## Freshness - -The automation should run from a repo checkout that contains this skill. For shared daily use, prefer one of these patterns: - -- Run the automation in a checkout that is refreshed before the automation starts, for example with `git pull --ff-only`. -- If the automation cannot safely mutate the checkout, have it report the current `git_head` from the collector output so readers know which skill/script version produced the digest. - -## Sample Owner Prompt - -```text -Use $codex-issue-digest to run the Codex issue digest for labels tui and exec over the previous 24 hours. -``` - -```text -Use $codex-issue-digest to run the Codex issue digest for all areas over the past week. -``` - -## Validation - -Dry run the collector against recent issues: - -```bash -python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --labels tui exec --window-hours 24 -``` - -```bash -python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --all-labels --window "past week" --limit-issues 10 -``` - -Run the focused script tests: - -```bash -pytest .codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py -``` diff --git a/.codex/skills/codex-issue-digest/agents/openai.yaml b/.codex/skills/codex-issue-digest/agents/openai.yaml deleted file mode 100644 index 706ce5e11b3..00000000000 --- a/.codex/skills/codex-issue-digest/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Codex Issue Digest" - short_description: "Summarize Codex issues by labels or all areas" - default_prompt: "Use $codex-issue-digest to run the Codex issue digest for labels tui and exec over the previous 24 hours." diff --git a/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py deleted file mode 100755 index a4f3982db2b..00000000000 --- a/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py +++ /dev/null @@ -1,994 +0,0 @@ -#!/usr/bin/env python3 -"""Collect recent openai/codex issue activity for owner-focused digests.""" - -import argparse -import json -import math -import re -import subprocess -import sys -from datetime import datetime, timedelta, timezone -from pathlib import Path -from urllib.parse import quote - -SCRIPT_VERSION = 4 -QUALIFYING_KIND_LABELS = ("bug", "enhancement") -REACTION_KEYS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes") -BASE_ATTENTION_WINDOW_HOURS = 24.0 -ONE_ATTENTION_INTERACTION_THRESHOLD = 5 -TWO_ATTENTION_INTERACTION_THRESHOLD = 10 -ALL_LABEL_PHRASES = {"all", "all areas", "all labels", "all-areas", "all-labels", "*"} - - -class GhCommandError(RuntimeError): - pass - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Collect recent GitHub issue activity for a Codex owner digest." - ) - parser.add_argument( - "--repo", default="openai/codex", help="OWNER/REPO, default openai/codex" - ) - parser.add_argument( - "--labels", - nargs="+", - default=[], - help="Feature-area labels owned by the digest recipient, for example: tui exec", - ) - parser.add_argument( - "--all-labels", - action="store_true", - help="Collect bug/enhancement issues across all feature-area labels", - ) - parser.add_argument( - "--window", - help='Lookback duration such as "24h", "7d", "1w", or "past week"', - ) - parser.add_argument( - "--window-hours", type=float, default=24.0, help="Lookback window" - ) - parser.add_argument( - "--since", help="UTC ISO timestamp override for the window start" - ) - parser.add_argument("--until", help="UTC ISO timestamp override for the window end") - parser.add_argument( - "--limit-issues", - type=int, - default=200, - help="Maximum candidate issues to hydrate after search", - ) - parser.add_argument( - "--body-chars", type=int, default=1200, help="Issue body excerpt length" - ) - parser.add_argument( - "--comment-chars", type=int, default=900, help="Comment excerpt length" - ) - parser.add_argument( - "--max-comment-pages", - type=int, - default=3, - help=( - "Maximum pages of issue comments to hydrate per issue after applying the " - "window filter. Use 0 with --fetch-all-comments for no page cap." - ), - ) - parser.add_argument( - "--fetch-all-comments", - action="store_true", - help="Hydrate complete issue comment histories instead of only window-updated comments.", - ) - return parser.parse_args() - - -def parse_timestamp(value, arg_name): - if value is None: - return None - normalized = value.strip() - if not normalized: - return None - if normalized.endswith("Z"): - normalized = f"{normalized[:-1]}+00:00" - try: - parsed = datetime.fromisoformat(normalized) - except ValueError as err: - raise ValueError(f"{arg_name} must be an ISO timestamp") from err - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -def format_timestamp(value): - return ( - value.astimezone(timezone.utc) - .replace(microsecond=0) - .isoformat() - .replace("+00:00", "Z") - ) - - -def resolve_window(args): - until = parse_timestamp(args.until, "--until") or datetime.now(timezone.utc) - since = parse_timestamp(args.since, "--since") - if since is None: - hours = parse_duration_hours(getattr(args, "window", None)) - if hours is None: - hours = getattr(args, "window_hours", 24.0) - if hours <= 0: - raise ValueError("window duration must be > 0") - since = until - timedelta(hours=hours) - if since >= until: - raise ValueError("--since must be before --until") - return since, until - - -def parse_duration_hours(value): - if value is None: - return None - text = value.strip().casefold().replace("_", " ") - if not text: - return None - text = re.sub(r"^(past|last)\s+", "", text) - aliases = { - "day": 24.0, - "24h": 24.0, - "week": 168.0, - "7d": 168.0, - } - if text in aliases: - return aliases[text] - match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours)", text) - if match: - return float(match.group(1)) - match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(d|day|days)", text) - if match: - return float(match.group(1)) * 24.0 - match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(w|week|weeks)", text) - if match: - return float(match.group(1)) * 168.0 - raise ValueError(f"Unsupported duration: {value}") - - -def normalize_requested_labels(labels, all_labels=False): - out = [] - seen = set() - for raw in labels: - for piece in raw.split(","): - label = piece.strip() - if not label: - continue - key = label.casefold() - if key not in seen: - out.append(label) - seen.add(key) - phrase = " ".join(label.casefold() for label in out) - if all_labels or phrase in ALL_LABEL_PHRASES: - return [], True - if not out: - raise ValueError( - "At least one feature-area label is required, or use --all-labels" - ) - return out, False - - -def quote_label(label): - if re.fullmatch(r"[A-Za-z0-9_.:-]+", label): - return f"label:{label}" - escaped = label.replace('"', '\\"') - return f'label:"{escaped}"' - - -def build_search_queries( - repo, owner_labels, since, kind_labels=QUALIFYING_KIND_LABELS, all_labels=False -): - since_date = since.date().isoformat() - queries = [] - if all_labels: - for kind_label in kind_labels: - queries.append( - " ".join( - [ - f"repo:{repo}", - "is:issue", - f"updated:>={since_date}", - quote_label(kind_label), - ] - ) - ) - return queries - for owner_label in owner_labels: - for kind_label in kind_labels: - queries.append( - " ".join( - [ - f"repo:{repo}", - "is:issue", - f"updated:>={since_date}", - quote_label(owner_label), - quote_label(kind_label), - ] - ) - ) - return queries - - -def _format_gh_error(cmd, err): - stdout = (err.stdout or "").strip() - stderr = (err.stderr or "").strip() - parts = [f"GitHub CLI command failed: {' '.join(cmd)}"] - if stdout: - parts.append(f"stdout: {stdout}") - if stderr: - parts.append(f"stderr: {stderr}") - return "\n".join(parts) - - -def gh_json(args): - cmd = ["gh", *args] - try: - proc = subprocess.run(cmd, check=True, capture_output=True, text=True) - except FileNotFoundError as err: - raise GhCommandError("`gh` command not found") from err - except subprocess.CalledProcessError as err: - raise GhCommandError(_format_gh_error(cmd, err)) from err - raw = proc.stdout.strip() - if not raw: - return None - try: - return json.loads(raw) - except json.JSONDecodeError as err: - raise GhCommandError( - f"Failed to parse JSON from gh output for {' '.join(args)}" - ) from err - - -def gh_text(args): - cmd = ["gh", *args] - try: - proc = subprocess.run(cmd, check=True, capture_output=True, text=True) - except (FileNotFoundError, subprocess.CalledProcessError): - return "" - return proc.stdout.strip() - - -def git_head(): - try: - proc = subprocess.run( - ["git", "rev-parse", "--short=12", "HEAD"], - check=True, - capture_output=True, - text=True, - ) - except (FileNotFoundError, subprocess.CalledProcessError): - return None - return proc.stdout.strip() or None - - -def skill_relative_path(): - try: - return str(Path(__file__).resolve().relative_to(Path.cwd().resolve())) - except ValueError: - return str(Path(__file__).resolve()) - - -def gh_api_list_paginated(endpoint, per_page=100, max_pages=None, with_metadata=False): - items = [] - page = 1 - truncated = False - while True: - sep = "&" if "?" in endpoint else "?" - page_endpoint = f"{endpoint}{sep}per_page={per_page}&page={page}" - payload = gh_json(["api", page_endpoint]) - if payload is None: - break - if not isinstance(payload, list): - raise GhCommandError(f"Unexpected paginated payload from gh api {endpoint}") - items.extend(payload) - if len(payload) < per_page: - break - if max_pages is not None and page >= max_pages: - truncated = True - break - page += 1 - if with_metadata: - return { - "items": items, - "truncated": truncated, - "pages": page, - "max_pages": max_pages, - } - return items - - -def search_issue_numbers(queries, limit): - numbers = {} - for query in queries: - page = 1 - seen_for_query = 0 - while True: - payload = gh_json( - [ - "api", - "search/issues", - "-X", - "GET", - "-f", - f"q={query}", - "-f", - "sort=updated", - "-f", - "order=desc", - "-f", - "per_page=100", - "-f", - f"page={page}", - ] - ) - if not isinstance(payload, dict): - raise GhCommandError("Unexpected payload from GitHub issue search") - items = payload.get("items") or [] - if not isinstance(items, list): - raise GhCommandError("Expected search `items` to be a list") - for item in items: - if not isinstance(item, dict): - continue - number = item.get("number") - if isinstance(number, int): - numbers[number] = str(item.get("updated_at") or "") - seen_for_query += 1 - if len(items) < 100 or seen_for_query >= limit: - break - page += 1 - ordered = sorted( - numbers, key=lambda number: (numbers[number], number), reverse=True - ) - return ordered[:limit] - - -def fetch_issue(repo, number): - payload = gh_json(["api", f"repos/{repo}/issues/{number}"]) - if not isinstance(payload, dict): - raise GhCommandError(f"Unexpected issue payload for #{number}") - return payload - - -def fetch_comments(repo, number, since=None, max_pages=None): - endpoint = f"repos/{repo}/issues/{number}/comments" - if since is not None: - endpoint = f"{endpoint}?since={quote(format_timestamp(since), safe='')}" - return gh_api_list_paginated( - endpoint, - max_pages=max_pages, - with_metadata=True, - ) - - -def fetch_reactions_for_item(endpoint, item): - if reaction_summary(item)["total"] <= 0: - return [] - return gh_api_list_paginated(endpoint) - - -def fetch_comment_reactions(repo, comments): - reactions_by_comment_id = {} - for comment in comments: - comment_id = comment.get("id") - if comment_id in (None, ""): - continue - endpoint = f"repos/{repo}/issues/comments/{comment_id}/reactions" - reactions_by_comment_id[comment_id] = fetch_reactions_for_item( - endpoint, comment - ) - return reactions_by_comment_id - - -def extract_login(user_obj): - if isinstance(user_obj, dict): - return str(user_obj.get("login") or "") - return "" - - -def is_bot_login(login): - return bool(login) and login.lower().endswith("[bot]") - - -def is_human_user(user_obj): - login = extract_login(user_obj) - return bool(login) and not is_bot_login(login) - - -def label_names(issue): - labels = [] - for label in issue.get("labels") or []: - if isinstance(label, dict) and label.get("name"): - labels.append(str(label["name"])) - return sorted(labels, key=str.casefold) - - -def matching_labels(labels, requested): - labels_by_key = {label.casefold(): label for label in labels} - return [label for label in requested if label.casefold() in labels_by_key] - - -def area_labels(labels): - kind_keys = {label.casefold() for label in QUALIFYING_KIND_LABELS} - return [label for label in labels if label.casefold() not in kind_keys] - - -def attention_thresholds_for_window(window_hours): - if window_hours <= 0: - raise ValueError("window_hours must be > 0") - window_hours = round(window_hours, 6) - scale = window_hours / BASE_ATTENTION_WINDOW_HOURS - elevated = max(1, math.ceil(ONE_ATTENTION_INTERACTION_THRESHOLD * scale)) - very_high = max( - elevated + 1, math.ceil(TWO_ATTENTION_INTERACTION_THRESHOLD * scale) - ) - return { - "base_window_hours": BASE_ATTENTION_WINDOW_HOURS, - "window_hours": round(window_hours, 3), - "scale": round(scale, 3), - "elevated": elevated, - "very_high": very_high, - } - - -def attention_level_for(user_interactions, attention_thresholds=None): - thresholds = attention_thresholds or attention_thresholds_for_window( - BASE_ATTENTION_WINDOW_HOURS - ) - if user_interactions >= thresholds["very_high"]: - return 2 - if user_interactions >= thresholds["elevated"]: - return 1 - return 0 - - -def attention_marker_for(user_interactions, attention_thresholds=None): - return "🔥" * attention_level_for(user_interactions, attention_thresholds) - - -def reaction_summary(item): - reactions = item.get("reactions") - if not isinstance(reactions, dict): - return {"total": 0, "counts": {}} - counts = {} - for key in REACTION_KEYS: - value = reactions.get(key, 0) - if isinstance(value, int) and value: - counts[key] = value - total = reactions.get("total_count") - if not isinstance(total, int): - total = sum(counts.values()) - return {"total": total, "counts": counts} - - -def reaction_event_summary(reactions, since, until): - counts = {} - total = 0 - for reaction in reactions or []: - if not isinstance(reaction, dict): - continue - if not is_in_window(str(reaction.get("created_at") or ""), since, until): - continue - if not is_human_user(reaction.get("user")): - continue - content = str(reaction.get("content") or "") - if not content: - continue - counts[content] = counts.get(content, 0) + 1 - total += 1 - return { - "total": total, - "counts": counts, - "upvotes": counts.get("+1", 0), - } - - -def compact_text(value, limit): - text = re.sub(r"\s+", " ", str(value or "")).strip() - if limit <= 0: - return "" - if len(text) <= limit: - return text - return f"{text[: max(limit - 1, 0)].rstrip()}..." - - -def clean_title_for_description(title): - cleaned = re.sub(r"\s+", " ", str(title or "")).strip() - cleaned = re.sub( - r"^(codex(?: desktop| app|\.app| cli)?|desktop|windows codex app)\s*[:,-]\s*", - "", - cleaned, - flags=re.IGNORECASE, - ) - cleaned = re.sub(r"^on windows,\s*", "Windows: ", cleaned, flags=re.IGNORECASE) - cleaned = cleaned.strip(" -:;") - return compact_text(cleaned, 80) or "Issue needs owner review" - - -def issue_description(issue): - return clean_title_for_description(issue.get("title")) - - -def is_in_window(timestamp, since, until): - parsed = parse_timestamp(timestamp, "timestamp") - if parsed is None: - return False - return since <= parsed < until - - -def summarize_comment( - comment, comment_chars, reaction_events=None, since=None, until=None -): - reactions = reaction_summary(comment) - new_reactions = ( - reaction_event_summary(reaction_events, since, until) - if since is not None and until is not None - else {"total": 0, "counts": {}, "upvotes": 0} - ) - human_user_interaction = is_human_user(comment.get("user")) - return { - "id": comment.get("id"), - "author": extract_login(comment.get("user")), - "author_association": str(comment.get("author_association") or ""), - "created_at": str(comment.get("created_at") or ""), - "updated_at": str(comment.get("updated_at") or ""), - "url": str(comment.get("html_url") or ""), - "human_user_interaction": human_user_interaction, - "reactions": reactions["counts"], - "reaction_total": reactions["total"], - "new_reactions": new_reactions["total"], - "new_upvotes": new_reactions["upvotes"], - "new_reaction_counts": new_reactions["counts"], - "body_excerpt": compact_text(comment.get("body"), comment_chars), - } - - -def summarize_issue( - issue, - comments, - requested_labels, - since, - until, - body_chars, - comment_chars, - issue_reaction_events=None, - comment_reactions_by_id=None, - all_labels=False, - comments_hydration=None, - attention_thresholds=None, -): - labels = label_names(issue) - labels_by_key = {label.casefold() for label in labels} - kind_labels = [ - label for label in QUALIFYING_KIND_LABELS if label.casefold() in labels_by_key - ] - if all_labels: - owner_labels = area_labels(labels) or ["unlabeled"] - else: - owner_labels = matching_labels(labels, requested_labels) - if not kind_labels or not owner_labels: - return None - - updated_at = str(issue.get("updated_at") or "") - if not is_in_window(updated_at, since, until): - return None - - new_issue = is_in_window(str(issue.get("created_at") or ""), since, until) - comment_reactions_by_id = comment_reactions_by_id or {} - new_comments = [ - summarize_comment( - comment, - comment_chars, - reaction_events=comment_reactions_by_id.get(comment.get("id")), - since=since, - until=until, - ) - for comment in comments - if is_in_window(str(comment.get("created_at") or ""), since, until) - ] - new_comments.sort(key=lambda item: (item["created_at"], str(item["id"]))) - - issue_reactions = reaction_summary(issue) - issue_reaction_events_summary = reaction_event_summary( - issue_reaction_events, since, until - ) - comment_reaction_events_summary = reaction_event_summary( - [ - reaction - for reactions in comment_reactions_by_id.values() - for reaction in reactions - ], - since, - until, - ) - new_reactions = ( - issue_reaction_events_summary["total"] - + comment_reaction_events_summary["total"] - ) - new_upvotes = ( - issue_reaction_events_summary["upvotes"] - + comment_reaction_events_summary["upvotes"] - ) - all_comment_reaction_total = sum( - reaction_summary(comment)["total"] for comment in comments - ) - new_comment_reaction_total = sum( - comment["reaction_total"] for comment in new_comments - ) - new_issue_user_interaction = new_issue and is_human_user(issue.get("user")) - new_comment_user_interactions = sum( - 1 for comment in new_comments if comment["human_user_interaction"] - ) - user_interactions = ( - int(new_issue_user_interaction) + new_comment_user_interactions + new_reactions - ) - attention_level = attention_level_for(user_interactions, attention_thresholds) - attention_marker = attention_marker_for(user_interactions, attention_thresholds) - updated_without_visible_new_post = ( - not new_issue and not new_comments and new_reactions == 0 - ) - - engagement_score = ( - len(new_comments) * 3 - + new_reactions - + issue_reactions["total"] - + new_comment_reaction_total - + min(int(issue.get("comments") or len(comments) or 0), 10) - ) - - return { - "number": issue.get("number"), - "title": str(issue.get("title") or ""), - "description": issue_description(issue), - "url": str(issue.get("html_url") or ""), - "state": str(issue.get("state") or ""), - "author": extract_login(issue.get("user")), - "author_association": str(issue.get("author_association") or ""), - "created_at": str(issue.get("created_at") or ""), - "updated_at": updated_at, - "labels": labels, - "kind_labels": kind_labels, - "owner_labels": owner_labels, - "comments_total": int(issue.get("comments") or len(comments) or 0), - "comments_hydration": comments_hydration - or { - "fetched": len(comments), - "since": None, - "truncated": False, - "max_pages": None, - }, - "issue_reactions": issue_reactions["counts"], - "issue_reaction_total": issue_reactions["total"], - "comment_reaction_total": all_comment_reaction_total, - "new_comment_reaction_total": new_comment_reaction_total, - "new_issue_reactions": issue_reaction_events_summary["total"], - "new_issue_upvotes": issue_reaction_events_summary["upvotes"], - "new_comment_reactions": comment_reaction_events_summary["total"], - "new_comment_upvotes": comment_reaction_events_summary["upvotes"], - "new_reactions": new_reactions, - "new_upvotes": new_upvotes, - "user_interactions": user_interactions, - "attention": attention_level > 0, - "attention_level": attention_level, - "attention_marker": attention_marker, - "engagement_score": engagement_score, - "activity": { - "new_issue": new_issue, - "new_comments": len(new_comments), - "new_human_comments": new_comment_user_interactions, - "new_reactions": new_reactions, - "new_upvotes": new_upvotes, - "updated_without_visible_new_post": updated_without_visible_new_post, - }, - "body_excerpt": compact_text(issue.get("body"), body_chars), - "new_comments": new_comments, - } - - -def count_by_label(issues, labels): - out = {} - for label in labels: - matching = [issue for issue in issues if label in issue["owner_labels"]] - out[label] = { - "issues": len(matching), - "new_issues": sum( - 1 for issue in matching if issue["activity"]["new_issue"] - ), - "new_comments": sum( - issue["activity"]["new_comments"] for issue in matching - ), - } - return out - - -def count_by_kind(issues): - out = {} - for kind in QUALIFYING_KIND_LABELS: - matching = [issue for issue in issues if kind in issue["kind_labels"]] - out[kind] = { - "issues": len(matching), - "new_issues": sum( - 1 for issue in matching if issue["activity"]["new_issue"] - ), - "new_comments": sum( - issue["activity"]["new_comments"] for issue in matching - ), - } - return out - - -def hot_items(issues, limit=8): - ranked = sorted( - issues, - key=lambda issue: ( - issue["attention"], - issue["attention_level"], - issue["user_interactions"], - issue["engagement_score"], - issue["activity"]["new_comments"], - issue["issue_reaction_total"] + issue["comment_reaction_total"], - issue["updated_at"], - ), - reverse=True, - ) - return [ - { - "number": issue["number"], - "title": issue["title"], - "url": issue["url"], - "owner_labels": issue["owner_labels"], - "kind_labels": issue["kind_labels"], - "attention": issue["attention"], - "attention_level": issue["attention_level"], - "attention_marker": issue["attention_marker"], - "user_interactions": issue["user_interactions"], - "new_reactions": issue["new_reactions"], - "new_upvotes": issue["new_upvotes"], - "engagement_score": issue["engagement_score"], - "new_comments": issue["activity"]["new_comments"], - "reaction_total": issue["issue_reaction_total"] - + issue["comment_reaction_total"], - } - for issue in ranked[:limit] - if issue["engagement_score"] > 0 - ] - - -def ranked_digest_issues(issues): - return sorted( - issues, - key=lambda issue: ( - issue["attention"], - issue["attention_level"], - issue["user_interactions"], - issue["engagement_score"], - issue["activity"]["new_comments"], - issue["updated_at"], - ), - reverse=True, - ) - - -def digest_rows(issues, limit=10, ref_map=None): - ranked = ranked_digest_issues(issues) - if ref_map is None: - ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} - rows = [] - for issue in ranked[:limit]: - ref = ref_map[issue["number"]] - reaction_total = issue["issue_reaction_total"] + issue["comment_reaction_total"] - rows.append( - { - "ref": ref, - "ref_markdown": f"[{ref}]({issue['url']})", - "marker": issue["attention_marker"], - "attention_marker": issue["attention_marker"], - "number": issue["number"], - "description": issue["description"], - "title": issue["title"], - "url": issue["url"], - "area": ", ".join(issue["owner_labels"]), - "kind": ", ".join(issue["kind_labels"]), - "state": issue["state"], - "interactions": issue["user_interactions"], - "user_interactions": issue["user_interactions"], - "new_reactions": issue["new_reactions"], - "new_upvotes": issue["new_upvotes"], - "current_reactions": reaction_total, - } - ) - return rows - - -def issue_ref_markdown(issue, ref_map): - ref = ref_map[issue["number"]] - return f"[{ref}]({issue['url']})" - - -def summary_inputs(issues, limit=80, ref_map=None): - ranked = ranked_digest_issues(issues) - if ref_map is None: - ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} - rows = [] - for issue in ranked[:limit]: - rows.append( - { - "ref": ref_map[issue["number"]], - "ref_markdown": issue_ref_markdown(issue, ref_map), - "number": issue["number"], - "title": issue["title"], - "description": issue["description"], - "url": issue["url"], - "labels": issue["labels"], - "owner_labels": issue["owner_labels"], - "kind_labels": issue["kind_labels"], - "state": issue.get("state", ""), - "attention_marker": issue.get("attention_marker", ""), - "interactions": issue["user_interactions"], - "new_comments": issue["activity"].get("new_comments", 0), - "new_reactions": issue.get("new_reactions", 0), - "new_upvotes": issue.get("new_upvotes", 0), - "current_reactions": issue.get("issue_reaction_total", 0) - + issue.get("comment_reaction_total", 0), - } - ) - return rows - - -def collect_digest(args): - since, until = resolve_window(args) - window_hours = (until - since).total_seconds() / 3600 - attention_thresholds = attention_thresholds_for_window(window_hours) - requested_labels, all_labels = normalize_requested_labels( - args.labels, all_labels=args.all_labels - ) - queries = build_search_queries( - args.repo, requested_labels, since, all_labels=all_labels - ) - numbers = search_issue_numbers(queries, args.limit_issues) - gh_version_output = gh_text(["--version"]) - - issues = [] - max_comment_pages = None if args.max_comment_pages <= 0 else args.max_comment_pages - for number in numbers: - issue = fetch_issue(args.repo, number) - comments_since = None if args.fetch_all_comments else since - comments_payload = fetch_comments( - args.repo, - number, - since=comments_since, - max_pages=max_comment_pages, - ) - comments = comments_payload["items"] - issue_reaction_events = fetch_reactions_for_item( - f"repos/{args.repo}/issues/{number}/reactions", issue - ) - comment_reactions_by_id = fetch_comment_reactions(args.repo, comments) - comments_hydration = { - "fetched": len(comments), - "total": int(issue.get("comments") or len(comments) or 0), - "since": format_timestamp(comments_since) if comments_since else None, - "truncated": comments_payload["truncated"], - "max_pages": comments_payload["max_pages"], - "fetch_all_comments": args.fetch_all_comments, - } - summary = summarize_issue( - issue, - comments, - requested_labels, - since, - until, - args.body_chars, - args.comment_chars, - issue_reaction_events=issue_reaction_events, - comment_reactions_by_id=comment_reactions_by_id, - all_labels=all_labels, - comments_hydration=comments_hydration, - attention_thresholds=attention_thresholds, - ) - if summary is not None: - issues.append(summary) - - issues.sort( - key=lambda issue: (issue["updated_at"], int(issue["number"] or 0)), reverse=True - ) - totals = { - "candidate_issues": len(numbers), - "included_issues": len(issues), - "new_issues": sum(1 for issue in issues if issue["activity"]["new_issue"]), - "issues_with_new_comments": sum( - 1 for issue in issues if issue["activity"]["new_comments"] > 0 - ), - "new_comments": sum(issue["activity"]["new_comments"] for issue in issues), - "comments_fetched": sum( - issue["comments_hydration"]["fetched"] for issue in issues - ), - "issues_with_truncated_comment_hydration": sum( - 1 for issue in issues if issue["comments_hydration"]["truncated"] - ), - "updated_without_visible_new_post": sum( - 1 - for issue in issues - if issue["activity"]["updated_without_visible_new_post"] - ), - "issue_reactions_current_total": sum( - issue["issue_reaction_total"] for issue in issues - ), - "comment_reactions_current_total": sum( - issue["comment_reaction_total"] for issue in issues - ), - "new_reactions": sum(issue["new_reactions"] for issue in issues), - "new_upvotes": sum(issue["new_upvotes"] for issue in issues), - "user_interactions": sum(issue["user_interactions"] for issue in issues), - } - ranked = ranked_digest_issues(issues) - ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} - filter_label = "all" if all_labels else requested_labels - - return { - "generated_at": format_timestamp(datetime.now(timezone.utc)), - "source": { - "repo": args.repo, - "skill": "codex-issue-digest", - "collector": skill_relative_path(), - "script_version": SCRIPT_VERSION, - "git_head": git_head(), - "gh_version": gh_version_output.splitlines()[0] - if gh_version_output - else None, - }, - "window": { - "since": format_timestamp(since), - "until": format_timestamp(until), - "hours": round(window_hours, 3), - }, - "attention_thresholds": attention_thresholds, - "filters": { - "owner_labels": filter_label, - "all_labels": all_labels, - "kind_labels": list(QUALIFYING_KIND_LABELS), - }, - "collection_notes": [ - "Issues are selected when they currently have bug or enhancement plus at least one requested owner label and were updated during the window.", - "By default, issue comments are fetched with since=window_start and a max page cap to avoid long historical threads; use --fetch-all-comments when exhaustive comment history is needed.", - "New issue comments are filtered by comment creation time within the window from the fetched comment set.", - "Reaction events are counted by GitHub reaction created_at timestamps for hydrated issues and fetched comments.", - "Current reaction totals are standing engagement signals; new_reactions and new_upvotes are windowed activity.", - "The collector does not assign semantic clusters; use summary_inputs as model-ready evidence for report-time clustering.", - "Pure reaction-only issues may be missed if GitHub issue search does not surface them via updated_at.", - "Issues updated during the window without a new issue body or new comment are retained because label/status edits can still be useful owner signals.", - ], - "totals": totals, - "by_owner_label": count_by_label( - issues, - sorted( - {area for issue in issues for area in issue["owner_labels"]}, - key=str.casefold, - ) - if all_labels - else requested_labels, - ), - "by_kind_label": count_by_kind(issues), - "hot_items": hot_items(issues), - "summary_inputs": summary_inputs(issues, ref_map=ref_map), - "digest_rows": digest_rows(issues, ref_map=ref_map), - "issues": issues, - } - - -def main(): - args = parse_args() - try: - digest = collect_digest(args) - except (GhCommandError, RuntimeError, ValueError) as err: - sys.stderr.write(f"collect_issue_digest.py error: {err}\n") - return 1 - sys.stdout.write(json.dumps(digest, indent=2, sort_keys=True) + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py deleted file mode 100644 index 8619f867ac4..00000000000 --- a/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py +++ /dev/null @@ -1,685 +0,0 @@ -import importlib.util -from datetime import timezone -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("collect_issue_digest.py") -MODULE_SPEC = importlib.util.spec_from_file_location( - "collect_issue_digest", MODULE_PATH -) -collect_issue_digest = importlib.util.module_from_spec(MODULE_SPEC) -assert MODULE_SPEC.loader is not None -MODULE_SPEC.loader.exec_module(collect_issue_digest) - - -def test_build_search_queries_uses_each_owner_and_kind_label(): - since = collect_issue_digest.parse_timestamp("2026-04-25T12:34:56Z", "--since") - - queries = collect_issue_digest.build_search_queries( - "openai/codex", ["tui", "exec"], since - ) - - assert queries == [ - "repo:openai/codex is:issue updated:>=2026-04-25 label:tui label:bug", - "repo:openai/codex is:issue updated:>=2026-04-25 label:tui label:enhancement", - "repo:openai/codex is:issue updated:>=2026-04-25 label:exec label:bug", - "repo:openai/codex is:issue updated:>=2026-04-25 label:exec label:enhancement", - ] - - -def test_build_search_queries_can_scan_all_labels(): - since = collect_issue_digest.parse_timestamp("2026-04-25T12:34:56Z", "--since") - - queries = collect_issue_digest.build_search_queries( - "openai/codex", [], since, all_labels=True - ) - - assert queries == [ - "repo:openai/codex is:issue updated:>=2026-04-25 label:bug", - "repo:openai/codex is:issue updated:>=2026-04-25 label:enhancement", - ] - - -def test_normalize_requested_labels_accepts_all_area_phrases(): - assert collect_issue_digest.normalize_requested_labels(["all", "areas"]) == ( - [], - True, - ) - assert collect_issue_digest.normalize_requested_labels(["all-labels"]) == ( - [], - True, - ) - - -def test_search_issue_numbers_requests_updated_sort(monkeypatch): - calls = [] - - def fake_gh_json(args): - calls.append(args) - return { - "items": [ - {"number": 1, "updated_at": "2026-04-25T00:00:00Z"}, - ] - } - - monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json) - - assert collect_issue_digest.search_issue_numbers(["query"], limit=10) == [1] - assert "-f" in calls[0] - assert "sort=updated" in calls[0] - assert "order=desc" in calls[0] - - -def test_search_issue_numbers_applies_limit_per_query(monkeypatch): - calls = [] - - def fake_gh_json(args): - calls.append(args) - query = next( - value.removeprefix("q=") for value in args if value.startswith("q=") - ) - page = int( - next( - value.removeprefix("page=") - for value in args - if value.startswith("page=") - ) - ) - base = 10_000 if query == "first" else 20_000 - offset = (page - 1) * 100 - return { - "items": [ - { - "number": base + offset + idx, - "updated_at": f"2026-04-25T00:{idx:02d}:00Z", - } - for idx in range(100) - ] - } - - monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json) - - collect_issue_digest.search_issue_numbers(["first", "second"], limit=150) - - queried_pages = [ - ( - next( - value.removeprefix("q=") for value in args if value.startswith("q=") - ), - next( - value.removeprefix("page=") - for value in args - if value.startswith("page=") - ), - ) - for args in calls - ] - assert queried_pages == [ - ("first", "1"), - ("first", "2"), - ("second", "1"), - ("second", "2"), - ] - - -def test_summarize_issue_keeps_new_comments_and_reaction_signals(): - since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") - until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") - issue = { - "number": 123, - "title": "TUI does not redraw", - "html_url": "https://github.com/openai/codex/issues/123", - "state": "open", - "created_at": "2026-04-24T20:00:00Z", - "updated_at": "2026-04-25T10:00:00Z", - "user": {"login": "alice"}, - "author_association": "NONE", - "comments": 2, - "body": "The terminal freezes after resize.", - "labels": [{"name": "bug"}, {"name": "tui"}], - "reactions": {"total_count": 3, "+1": 2, "rocket": 1}, - } - comments = [ - { - "id": 1, - "created_at": "2026-04-25T11:00:00Z", - "updated_at": "2026-04-25T11:00:00Z", - "html_url": "https://github.com/openai/codex/issues/123#issuecomment-1", - "user": {"login": "bob"}, - "author_association": "MEMBER", - "body": "I can reproduce this on main.", - "reactions": {"total_count": 4, "heart": 1, "+1": 3}, - }, - { - "id": 2, - "created_at": "2026-04-24T11:00:00Z", - "updated_at": "2026-04-24T11:00:00Z", - "html_url": "https://github.com/openai/codex/issues/123#issuecomment-2", - "user": {"login": "carol"}, - "author_association": "NONE", - "body": "Older comment.", - "reactions": {"total_count": 1, "eyes": 1}, - }, - ] - - summary = collect_issue_digest.summarize_issue( - issue, - comments, - ["tui", "exec"], - since, - until, - body_chars=200, - comment_chars=200, - ) - - assert summary == { - "number": 123, - "title": "TUI does not redraw", - "description": "TUI does not redraw", - "url": "https://github.com/openai/codex/issues/123", - "state": "open", - "author": "alice", - "author_association": "NONE", - "created_at": "2026-04-24T20:00:00Z", - "updated_at": "2026-04-25T10:00:00Z", - "labels": ["bug", "tui"], - "kind_labels": ["bug"], - "owner_labels": ["tui"], - "comments_total": 2, - "comments_hydration": { - "fetched": 2, - "since": None, - "truncated": False, - "max_pages": None, - }, - "issue_reactions": {"+1": 2, "rocket": 1}, - "issue_reaction_total": 3, - "comment_reaction_total": 5, - "new_comment_reaction_total": 4, - "new_issue_reactions": 0, - "new_issue_upvotes": 0, - "new_comment_reactions": 0, - "new_comment_upvotes": 0, - "new_reactions": 0, - "new_upvotes": 0, - "user_interactions": 1, - "attention": False, - "attention_level": 0, - "attention_marker": "", - "engagement_score": 12, - "activity": { - "new_issue": False, - "new_comments": 1, - "new_human_comments": 1, - "new_reactions": 0, - "new_upvotes": 0, - "updated_without_visible_new_post": False, - }, - "body_excerpt": "The terminal freezes after resize.", - "new_comments": [ - { - "id": 1, - "author": "bob", - "author_association": "MEMBER", - "created_at": "2026-04-25T11:00:00Z", - "updated_at": "2026-04-25T11:00:00Z", - "url": "https://github.com/openai/codex/issues/123#issuecomment-1", - "human_user_interaction": True, - "reactions": {"+1": 3, "heart": 1}, - "reaction_total": 4, - "new_reactions": 0, - "new_upvotes": 0, - "new_reaction_counts": {}, - "body_excerpt": "I can reproduce this on main.", - } - ], - } - - -def test_summarize_issue_filters_non_owner_or_non_kind_labels(): - since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") - until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") - base_issue = { - "number": 1, - "title": "Question", - "created_at": "2026-04-25T01:00:00Z", - "updated_at": "2026-04-25T01:00:00Z", - "labels": [{"name": "question"}, {"name": "tui"}], - } - - assert ( - collect_issue_digest.summarize_issue( - base_issue, - [], - ["tui"], - since, - until, - body_chars=100, - comment_chars=100, - ) - is None - ) - - issue_without_owner = dict(base_issue) - issue_without_owner["labels"] = [{"name": "bug"}, {"name": "app"}] - - assert ( - collect_issue_digest.summarize_issue( - issue_without_owner, - [], - ["tui"], - since, - until, - body_chars=100, - comment_chars=100, - ) - is None - ) - - -def test_resolve_window_defaults_to_previous_hours(): - class Args: - since = None - until = "2026-04-26T12:00:00Z" - window_hours = 24 - - since, until = collect_issue_digest.resolve_window(Args()) - - assert since.isoformat() == "2026-04-25T12:00:00+00:00" - assert until.tzinfo == timezone.utc - - -def test_parse_duration_hours_accepts_common_phrases(): - assert collect_issue_digest.parse_duration_hours("past week") == 168 - assert collect_issue_digest.parse_duration_hours("48h") == 48 - assert collect_issue_digest.parse_duration_hours("2 days") == 48 - assert collect_issue_digest.parse_duration_hours("1w") == 168 - - -def test_attention_thresholds_scale_by_window_length(): - one_day = collect_issue_digest.attention_thresholds_for_window(24) - assert one_day["elevated"] == 5 - assert one_day["very_high"] == 10 - - half_day = collect_issue_digest.attention_thresholds_for_window(12) - assert half_day["elevated"] == 3 - assert half_day["very_high"] == 5 - - week = collect_issue_digest.attention_thresholds_for_window(168) - assert week["elevated"] == 35 - assert week["very_high"] == 70 - assert collect_issue_digest.attention_marker_for(34, week) == "" - assert collect_issue_digest.attention_marker_for(35, week) == "🔥" - assert collect_issue_digest.attention_marker_for(70, week) == "🔥🔥" - - -def test_fetch_comments_uses_since_filter_and_page_cap(monkeypatch): - calls = [] - - def fake_gh_json(args): - calls.append(args) - return [{"id": idx} for idx in range(100)] - - monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json) - since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") - - payload = collect_issue_digest.fetch_comments( - "openai/codex", 123, since=since, max_pages=1 - ) - - assert len(payload["items"]) == 100 - assert payload["truncated"] is True - assert payload["max_pages"] == 1 - assert calls == [ - [ - "api", - "repos/openai/codex/issues/123/comments?since=2026-04-25T00%3A00%3A00Z&per_page=100&page=1", - ] - ] - - -def test_issue_description_prefers_title_over_body_noise(): - issue = { - "title": "Codex.app GUI: MCP child processes not reaped after task completion", - "body": "A later crash mention should not override the title-level symptom.", - "labels": [{"name": "app"}, {"name": "bug"}], - } - - description = collect_issue_digest.issue_description(issue) - assert "MCP child processes" in description - assert "crash" not in description.casefold() - - -def test_attention_markers_count_human_user_interactions(): - since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") - until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") - issue = { - "number": 456, - "title": "Agent context is exploding", - "html_url": "https://github.com/openai/codex/issues/456", - "state": "open", - "created_at": "2026-04-25T01:00:00Z", - "updated_at": "2026-04-25T12:00:00Z", - "user": {"login": "alice"}, - "labels": [{"name": "bug"}, {"name": "agent"}], - } - comments = [ - { - "id": idx, - "created_at": "2026-04-25T02:00:00Z", - "updated_at": "2026-04-25T02:00:00Z", - "user": {"login": f"user-{idx}"}, - "body": "same here", - } - for idx in range(4) - ] - comments.append( - { - "id": 99, - "created_at": "2026-04-25T02:00:00Z", - "updated_at": "2026-04-25T02:00:00Z", - "user": {"login": "github-actions[bot]"}, - "body": "duplicate bot note", - } - ) - - summary = collect_issue_digest.summarize_issue( - issue, - comments, - ["agent"], - since, - until, - body_chars=100, - comment_chars=100, - ) - - assert summary["user_interactions"] == 5 - assert summary["activity"]["new_human_comments"] == 4 - assert summary["attention"] is True - assert summary["attention_level"] == 1 - assert summary["attention_marker"] == "🔥" - - issue["created_at"] = "2026-04-24T01:00:00Z" - comments.extend( - { - "id": idx, - "created_at": "2026-04-25T03:00:00Z", - "updated_at": "2026-04-25T03:00:00Z", - "user": {"login": f"extra-user-{idx}"}, - "body": "also seeing this", - } - for idx in range(100, 106) - ) - - summary = collect_issue_digest.summarize_issue( - issue, - comments, - ["agent"], - since, - until, - body_chars=100, - comment_chars=100, - ) - - assert summary["user_interactions"] == 10 - assert summary["attention_level"] == 2 - assert summary["attention_marker"] == "🔥🔥" - - -def test_reactions_count_toward_attention_markers(): - since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") - until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") - issue = { - "number": 789, - "title": "Support 1M token context", - "html_url": "https://github.com/openai/codex/issues/789", - "state": "open", - "created_at": "2026-04-24T01:00:00Z", - "updated_at": "2026-04-25T12:00:00Z", - "user": {"login": "alice"}, - "labels": [{"name": "enhancement"}, {"name": "context"}], - "reactions": {"total_count": 20, "+1": 20}, - } - comments = [ - { - "id": 1, - "created_at": "2026-04-25T02:00:00Z", - "updated_at": "2026-04-25T02:00:00Z", - "user": {"login": "commenter"}, - "body": "please", - "reactions": {"total_count": 2, "+1": 2}, - } - ] - issue_reactions = [ - { - "content": "+1", - "created_at": "2026-04-25T03:00:00Z", - "user": {"login": f"reactor-{idx}"}, - } - for idx in range(18) - ] - comment_reactions_by_id = { - 1: [ - { - "content": "heart", - "created_at": "2026-04-25T04:00:00Z", - "user": {"login": "human-reactor"}, - }, - { - "content": "+1", - "created_at": "2026-04-25T04:00:00Z", - "user": {"login": "github-actions[bot]"}, - }, - ] - } - - summary = collect_issue_digest.summarize_issue( - issue, - comments, - ["context"], - since, - until, - body_chars=100, - comment_chars=100, - issue_reaction_events=issue_reactions, - comment_reactions_by_id=comment_reactions_by_id, - ) - - assert summary["new_reactions"] == 19 - assert summary["new_upvotes"] == 18 - assert summary["user_interactions"] == 20 - assert summary["attention_level"] == 2 - assert summary["attention_marker"] == "🔥🔥" - assert summary["new_comments"][0]["new_reactions"] == 1 - assert summary["new_comments"][0]["new_upvotes"] == 0 - - -def test_digest_rows_are_table_ready_with_concise_descriptions(): - rows = collect_issue_digest.digest_rows( - [ - { - "number": 1, - "title": "Quiet bug", - "description": "Quiet bug", - "url": "https://github.com/openai/codex/issues/1", - "owner_labels": ["context"], - "kind_labels": ["bug"], - "state": "open", - "attention": False, - "attention_level": 0, - "attention_marker": "", - "user_interactions": 1, - "new_reactions": 0, - "new_upvotes": 0, - "engagement_score": 3, - "issue_reaction_total": 0, - "comment_reaction_total": 0, - "updated_at": "2026-04-25T01:00:00Z", - "activity": { - "new_issue": True, - "new_comments": 0, - "new_reactions": 0, - "updated_without_visible_new_post": False, - }, - }, - { - "number": 2, - "title": "Busy bug", - "description": "High-volume bug report", - "url": "https://github.com/openai/codex/issues/2", - "owner_labels": ["agent"], - "kind_labels": ["bug"], - "state": "open", - "attention": True, - "attention_level": 1, - "attention_marker": "🔥", - "user_interactions": 17, - "new_reactions": 3, - "new_upvotes": 2, - "engagement_score": 20, - "issue_reaction_total": 5, - "comment_reaction_total": 2, - "updated_at": "2026-04-25T02:00:00Z", - "activity": { - "new_issue": False, - "new_comments": 16, - "new_reactions": 3, - "updated_without_visible_new_post": False, - }, - }, - ] - ) - - assert rows[0] == { - "ref": 1, - "ref_markdown": "[1](https://github.com/openai/codex/issues/2)", - "marker": "🔥", - "attention_marker": "🔥", - "number": 2, - "description": "High-volume bug report", - "title": "Busy bug", - "url": "https://github.com/openai/codex/issues/2", - "area": "agent", - "kind": "bug", - "state": "open", - "interactions": 17, - "user_interactions": 17, - "new_reactions": 3, - "new_upvotes": 2, - "current_reactions": 7, - } - - -def test_summary_inputs_are_model_ready_without_preclustering(): - issues = [ - { - "number": 20, - "title": "Windows app Browser Use external navigation fails", - "description": "Browser Use navigation or app-server failure", - "url": "https://github.com/openai/codex/issues/20", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "attention": False, - "attention_level": 0, - "attention_marker": "", - "user_interactions": 3, - "new_reactions": 1, - "engagement_score": 8, - "updated_at": "2026-04-25T04:00:00Z", - "activity": {"new_comments": 2}, - }, - { - "number": 21, - "title": "On Windows, cmake output waits until timeout", - "description": "Windows command timeout/capture problem", - "url": "https://github.com/openai/codex/issues/21", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "attention": False, - "attention_level": 0, - "attention_marker": "", - "user_interactions": 3, - "new_reactions": 0, - "engagement_score": 7, - "updated_at": "2026-04-25T03:00:00Z", - "activity": {"new_comments": 3}, - }, - { - "number": 22, - "title": "Windows computer use tool fails to click buttons", - "description": "Computer-use workflow failure", - "url": "https://github.com/openai/codex/issues/22", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "attention": False, - "attention_level": 0, - "attention_marker": "", - "user_interactions": 3, - "new_reactions": 0, - "engagement_score": 6, - "updated_at": "2026-04-25T02:00:00Z", - "activity": {"new_comments": 3}, - }, - ] - - rows = collect_issue_digest.summary_inputs(issues, ref_map={20: 1, 21: 2, 22: 3}) - - assert rows == [ - { - "ref": 1, - "ref_markdown": "[1](https://github.com/openai/codex/issues/20)", - "number": 20, - "title": "Windows app Browser Use external navigation fails", - "description": "Browser Use navigation or app-server failure", - "url": "https://github.com/openai/codex/issues/20", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "state": "", - "attention_marker": "", - "interactions": 3, - "new_comments": 2, - "new_reactions": 1, - "new_upvotes": 0, - "current_reactions": 0, - }, - { - "ref": 2, - "ref_markdown": "[2](https://github.com/openai/codex/issues/21)", - "number": 21, - "title": "On Windows, cmake output waits until timeout", - "description": "Windows command timeout/capture problem", - "url": "https://github.com/openai/codex/issues/21", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "state": "", - "attention_marker": "", - "interactions": 3, - "new_comments": 3, - "new_reactions": 0, - "new_upvotes": 0, - "current_reactions": 0, - }, - { - "ref": 3, - "ref_markdown": "[3](https://github.com/openai/codex/issues/22)", - "number": 22, - "title": "Windows computer use tool fails to click buttons", - "description": "Computer-use workflow failure", - "url": "https://github.com/openai/codex/issues/22", - "labels": ["app", "bug"], - "owner_labels": ["app"], - "kind_labels": ["bug"], - "state": "", - "attention_marker": "", - "interactions": 3, - "new_comments": 3, - "new_reactions": 0, - "new_upvotes": 0, - "current_reactions": 0, - }, - ] diff --git a/.codex/skills/codex-pr-body/SKILL.md b/.codex/skills/codex-pr-body/SKILL.md deleted file mode 100644 index 76b37b87507..00000000000 --- a/.codex/skills/codex-pr-body/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: codex-pr-body -description: Update the title and body of one or more pull requests. ---- - -## Determining the PR(s) - -When this skill is invoked, the PR(s) to update may be specified explicitly, but in the common case, the PR(s) to update will be inferred from the branch / commit that the user is currently working on. For ordinary Git usage (i.e., not Sapling as discussed below), you may have to use a combination of `git branch` and `gh pr view --repo openai/codex --json number --jq '.number'` to determine the PR associated with the current branch / commit. - -## PR Body Contents - -When invoked, use `gh` to edit the pull request body and title to reflect the contents of the specified PR. Make sure to check the existing pull request body to see if there is key information that should be preserved. For example, NEVER remove an image in the existing pull request body, as the author may have no way to recover it if you remove it. - -It is critically important to explain _why_ the change is being made. If the current conversation in which this skill is invoked has discussed the motivation, be sure to capture this in the pull request body. - -The body should also explain _what_ changed, but this should appear after the _why_. - -Limit discussion to the _net change_ of the commit. It is generally frowned upon to discuss changes that were attempted but later undone in the course of the development of the pull request. When rewriting the pull request body, you may need to eliminate details such as these when they are no longer appropriate / of interest to future readers. - -Avoid references to absolute paths on my local disk. When talking about a path that is within the repository, simply use the repo-relative path. - -It is generally helpful to discuss how the change was verified. That said, it is unnecessary to mention things that CI checks automatically, e.g., do not include "ran `just fmt`" as part of the test plan. Though identifying the new tests that were purposely introduced to verify the new behavior introduced by the pull request is often appropriate. - -Make use of Markdown to format the pull request professionally. Ensure "code things" appear in single backticks when referenced inline. Fenced code blocks are useful when referencing code or showing a shell transcript. Also, make use of GitHub permalinks when citing existing pieces of code that are relevant to the change. - -Make sure to reference any relevant pull requests or issues, though there should be no need to reference the pull request in its own PR body. - -If there is documentation that should be updated on https://developers.openai.com/codex as a result of this change, please note that in a separate section near the end of the pull request. Omit this section if there is no documentation that needs to be updated. - -## Working with Stacks - -Sometimes a pull request is composed of a stack of commits that build on one another. In these cases, the PR body should reflect the _net_ change introduced by the stack as a whole, rather than the individual commits that make up the stack. - -Similarly, sometimes a user may be using a tool like Sapling to leverage _stacked pull requests_, in which case the `base` of the PR may be the a branch that is the `head` of another PR in the stack rather than `main`. In this case, be sure to discuss only the net change between the `base` and `head` of the PR that is being opened against that stacked base, rather than the changes relative to `main`. - -## Sapling - -If `.git/sl/store` is present, then this Git repository is governed by Sapling SCM (https://sapling-scm.com). - -In Sapling, run the following to see if there is a GitHub pull request associated with the current revision: - -```shell -sl log --template '{github_pull_request_url}' -r . -``` - -Alternatively, you can run `sl sl` to see the current development branch and whether there is a GitHub pull request associated with the current commit. For example, if the output were: - -``` - @ cb032b31cf 72 minutes ago mbolin #11412 -╭─╯ tui: show non-file layer content in /debug-config -│ -o fdd0cd1de9 Today at 20:09 origin/main -│ -~ -``` - -- `@` indicates the current commit is `cb032b31cf` -- it is a development branch containing a single commit branched off of `origin/main` -- it is associated with GitHub pull request #11412