diff --git a/.github/reviewer-queue/config.yml b/.github/reviewer-queue/config.yml new file mode 100644 index 000000000..e7a6e92d5 --- /dev/null +++ b/.github/reviewer-queue/config.yml @@ -0,0 +1,60 @@ +# Reviewer Bot Configuration +# =========================== +# +# This file contains configuration for the reviewer bot. +# When migrating to a GitHub App, update the bot_name and related settings. + +# Bot identity +# ------------ +# The name users use to invoke commands (without @) +# When switching to a GitHub App, update this to match the app's name +bot_name: guidelines-bot + +# The label that triggers automatic reviewer assignment +coding_guideline_label: "coding guideline" + +# File paths +# ---------- +# Path to the members file (relative to repo root) +members_file: subcommittee/coding-guidelines/members.md + +# Path to the state file (relative to repo root) +state_file: .github/reviewer-queue/state.yml + +# Queue settings +# -------------- +# Maximum number of recent assignments to keep in history +max_recent_assignments: 20 + +# Role in members.md that indicates someone should be in the reviewer queue +reviewer_role: Producer + +# Review timeline (informational, not enforced) +# --------------------------------------------- +# Number of days a reviewer has to provide initial feedback +review_deadline_days: 14 + +# GitHub App Migration Notes +# -------------------------- +# When ready to migrate to a GitHub App: +# +# 1. Create a GitHub App at https://github.com/settings/apps +# - Name: guidelines-bot (or your preferred name) +# - Permissions needed: +# - Issues: Read & Write +# - Pull requests: Read & Write +# - Contents: Read & Write (for state file updates) +# - Subscribe to events: +# - Issues +# - Pull request +# - Issue comment +# +# 2. Install the app on the repository +# +# 3. Update .github/workflows/reviewer-bot.yml: +# - Add step to generate app token (use actions/create-github-app-token) +# - Use the app token instead of GITHUB_TOKEN +# +# 4. Update bot_name above to match your app's name +# +# 5. The bot will now post comments as the app instead of github-actions[bot] diff --git a/.github/reviewer-queue/state.yml b/.github/reviewer-queue/state.yml new file mode 100644 index 000000000..1a25aa63e --- /dev/null +++ b/.github/reviewer-queue/state.yml @@ -0,0 +1,42 @@ +# Reviewer Queue State +# ===================== +# This file is automatically maintained by the reviewer-bot workflow. +# It tracks the round-robin assignment of reviewers for coding guidelines. +# +# You can view this file to see: +# - Who is next up to review +# - Who is currently on a pass-until (vacation/away) +# - Recent assignment history +# +# DO NOT EDIT MANUALLY - changes will be overwritten by the bot. +# Use bot commands instead (see CONTRIBUTING.md for details). + +# Last updated timestamp (ISO 8601) +last_updated: null + +# Current position in round-robin queue (0-indexed) +# The reviewer at this index will be assigned next +current_index: 0 + +# Queue of active producers in rotation order +# This list is automatically synced from members.md +# Only members with Role="Producer" are included +queue: [] + +# Members temporarily excluded from rotation +# They will be automatically re-added to the queue after their return_date +pass_until: + # Example: + # - github: username + # return_date: 2025-02-01 + # reason: "Optional reason for being away" + # original_queue_position: 5 + +# Recent assignments for visibility and debugging +# Keeps the last 20 assignments +recent_assignments: + # Example: + # - github: username + # issue_number: 123 + # type: issue # or "pr" + # assigned_at: 2025-01-15T10:30:00Z diff --git a/.github/workflows/reviewer-bot.yml b/.github/workflows/reviewer-bot.yml new file mode 100644 index 000000000..933c16a0e --- /dev/null +++ b/.github/workflows/reviewer-bot.yml @@ -0,0 +1,94 @@ +name: Reviewer Bot + +on: + # Trigger when issues are opened or labeled + issues: + types: [opened, labeled] + + # Trigger when PRs are opened or labeled + # Using pull_request_target to handle PRs from forks with write permissions + pull_request_target: + types: [opened, labeled] + + # Trigger on comments for bot commands + issue_comment: + types: [created] + + # Allow manual triggering (useful for testing/debugging) + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: true + default: 'sync-members' + type: choice + options: + - sync-members + - show-state + +permissions: + issues: write + pull-requests: write + contents: write + +jobs: + reviewer-bot: + runs-on: ubuntu-latest + + # Skip if the comment is from a bot (prevent infinite loops) + if: > + github.event_name != 'issue_comment' || + github.event.comment.user.type != 'Bot' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Need to fetch the full repo to update state file + fetch-depth: 0 + # Use a token that can push (default GITHUB_TOKEN works for same-repo) + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Git for commits + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Run reviewer bot + id: bot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pass event context to the script + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + # Issue/PR context + ISSUE_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + ISSUE_TITLE: ${{ github.event.issue.title || github.event.pull_request.title }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login || github.event.pull_request.user.login }} + ISSUE_HTML_URL: ${{ github.event.issue.html_url || github.event.pull_request.html_url }} + IS_PULL_REQUEST: ${{ github.event.pull_request != null || (github.event.issue.pull_request != null) }} + # Label context (for labeled events) + LABEL_NAME: ${{ github.event.label.name }} + # Comment context (for issue_comment events) + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_ID: ${{ github.event.comment.id }} + # For manual dispatch + MANUAL_ACTION: ${{ github.event.inputs.action }} + # Repository info + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + # Labels on the issue/PR (as JSON) + ISSUE_LABELS: ${{ toJson(github.event.issue.labels.*.name || github.event.pull_request.labels.*.name) }} + run: | + uv run python scripts/reviewer_bot.py + + - name: Commit state changes + if: steps.bot.outputs.state_changed == 'true' + run: | + git add .github/reviewer-queue/state.yml + git commit -m "chore: update reviewer queue state [skip ci]" || echo "No changes to commit" + git push || echo "No changes to push" diff --git a/pyproject.toml b/pyproject.toml index 962faa0ba..867dba7ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "sphinx-autobuild>=2024.10.3", "sphinx-needs>=5.1.0", "sphinx-rtd-theme>=3.0.2", + "requests>=2.31.0", + "pyyaml>=6.0.1", ] [tool.uv.workspace] @@ -46,4 +48,3 @@ allow-dict-calls-with-keyword-arguments = true dev = [ "ruff>=0.12.3", ] - diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py new file mode 100644 index 000000000..1badb7ce3 --- /dev/null +++ b/scripts/reviewer_bot.py @@ -0,0 +1,1302 @@ +#!/usr/bin/env python3 +""" +Reviewer Bot for Safety-Critical Rust Coding Guidelines + +This bot manages round-robin assignment of reviewers for coding guideline +issues and PRs. It supports commands for passing reviews, vacations, and +label management. + +Commands (invoke with @guidelines-bot prefix): + @guidelines-bot pass! [reason] + - Skip the assigned reviewer for this issue/PR and assign the next person + - The skipped reviewer stays in queue position for future assignments + + @guidelines-bot pass-until! YYYY-MM-DD [reason] + - Remove yourself from the queue until the specified date + - Automatically assigns the next available reviewer + + @guidelines-bot claim + - Assign yourself as the reviewer for this issue/PR + - Removes any existing reviewer assignment + + @guidelines-bot release + - Release your assignment from this issue/PR + - The next person in the queue will be assigned + + @guidelines-bot assign @username + - Assign a specific person as the reviewer + - Also supports: r? @username + + r? @username + - Shorthand to assign a specific reviewer (Rust-style) + + r? producers + - Assign the next reviewer from the round-robin queue + - Useful for requesting a reviewer on an already-open issue/PR + + @guidelines-bot label +label-name + - Add a label to the issue/PR + + @guidelines-bot label -label-name + - Remove a label from the issue/PR + + @guidelines-bot sync-members + - Manually trigger sync of the queue with members.md + + @guidelines-bot status + - Show current queue status and who's next up +""" + +import json +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +# GitHub API interaction +try: + import requests +except ImportError: + # requests is available via uv + pass + + +# ============================================================================== +# Configuration +# ============================================================================== + +BOT_NAME = "guidelines-bot" +BOT_MENTION = f"@{BOT_NAME}" +CODING_GUIDELINE_LABEL = "coding guideline" +STATE_FILE = Path(".github/reviewer-queue/state.yml") +MEMBERS_FILE = Path("subcommittee/coding-guidelines/members.md") +MAX_RECENT_ASSIGNMENTS = 20 + + +# ============================================================================== +# GitHub API Helpers +# ============================================================================== + + +def get_github_token() -> str: + """Get the GitHub token from environment.""" + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("ERROR: GITHUB_TOKEN not set", file=sys.stderr) + sys.exit(1) + return token + + +def github_api(method: str, endpoint: str, data: dict | None = None) -> dict | None: + """Make a GitHub API request.""" + token = get_github_token() + repo = f"{os.environ['REPO_OWNER']}/{os.environ['REPO_NAME']}" + url = f"https://api.github.com/repos/{repo}/{endpoint}" + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + response = requests.request(method, url, headers=headers, json=data) + + if response.status_code >= 400: + print(f"GitHub API error: {response.status_code} - {response.text}", file=sys.stderr) + return None + + if response.content: + return response.json() + return {} + + +def post_comment(issue_number: int, body: str) -> bool: + """Post a comment on an issue or PR.""" + result = github_api("POST", f"issues/{issue_number}/comments", {"body": body}) + return result is not None + + +def add_label(issue_number: int, label: str) -> bool: + """Add a label to an issue or PR.""" + result = github_api("POST", f"issues/{issue_number}/labels", {"labels": [label]}) + return result is not None + + +def remove_label(issue_number: int, label: str) -> bool: + """Remove a label from an issue or PR.""" + # result = github_api("DELETE", f"issues/{issue_number}/labels/{label}") + # 404 is ok - label might not exist + return True + + +def assign_reviewer(issue_number: int, username: str) -> bool: + """Assign a user as a reviewer (via assignees for issues, reviewers for PRs).""" + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + + if is_pr: + # For PRs, request review + result = github_api("POST", f"pulls/{issue_number}/requested_reviewers", + {"reviewers": [username]}) + else: + # For issues, use assignees + result = github_api("POST", f"issues/{issue_number}/assignees", + {"assignees": [username]}) + + return result is not None + + +def get_issue_assignees(issue_number: int) -> list[str]: + """Get current assignees/reviewers for an issue/PR.""" + result = github_api("GET", f"issues/{issue_number}") + if result and "assignees" in result: + return [a["login"] for a in result["assignees"]] + return [] + + +def add_reaction(comment_id: int, reaction: str) -> bool: + """Add a reaction to a comment.""" + result = github_api("POST", f"issues/comments/{comment_id}/reactions", + {"content": reaction}) + return result is not None + + +def remove_assignee(issue_number: int, username: str) -> bool: + """Remove a user from assignees.""" + result = github_api("DELETE", f"issues/{issue_number}/assignees", + {"assignees": [username]}) + return result is not None + + +def remove_pr_reviewer(issue_number: int, username: str) -> bool: + """Remove a requested reviewer from a PR.""" + result = github_api("DELETE", f"pulls/{issue_number}/requested_reviewers", + {"reviewers": [username]}) + return result is not None + + +def unassign_reviewer(issue_number: int, username: str) -> bool: + """Remove a user as reviewer (handles both issues and PRs).""" + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + + if is_pr: + # For PRs, remove from requested reviewers + remove_pr_reviewer(issue_number, username) + + # Always try to remove from assignees (works for both) + return remove_assignee(issue_number, username) + + +# ============================================================================== +# Members Parsing +# ============================================================================== + + +def parse_members_file() -> list[dict]: + """ + Parse members.md to extract Producers. + + Returns a list of dicts with 'github' and 'name' keys. + """ + if not MEMBERS_FILE.exists(): + print(f"WARNING: Members file not found: {MEMBERS_FILE}", file=sys.stderr) + return [] + + content = MEMBERS_FILE.read_text() + producers = [] + + # Find the table in the markdown + lines = content.split("\n") + in_table = False + headers = [] + + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Check if this is a table row + if line.startswith("|") and line.endswith("|"): + cells = [c.strip() for c in line.split("|")[1:-1]] + + # Check if this is the header row + if not in_table and "Member Name" in cells: + headers = [h.lower().replace(" ", "_") for h in cells] + in_table = True + continue + + # Skip separator row + if in_table and all(c.replace("-", "").replace(":", "") == "" for c in cells): + continue + + # Parse data row + if in_table and len(cells) == len(headers): + row = dict(zip(headers, cells)) + + # Check if this is a Producer + role = row.get("role", "").strip() + if role == "Producer": + github_username = row.get("github_username", "").strip() + # Remove @ prefix if present + if github_username.startswith("@"): + github_username = github_username[1:] + + if github_username: + producers.append({ + "github": github_username, + "name": row.get("member_name", "").strip(), + }) + + return producers + + +# ============================================================================== +# State Management +# ============================================================================== + + +def load_state() -> dict: + """Load the current state from the state file.""" + if not STATE_FILE.exists(): + return { + "last_updated": None, + "current_index": 0, + "queue": [], + "pass_until": [], + "recent_assignments": [], + } + + content = STATE_FILE.read_text() + state = yaml.safe_load(content) or {} + + # Ensure all required keys exist + state.setdefault("last_updated", None) + state.setdefault("current_index", 0) + state.setdefault("queue", []) + state.setdefault("pass_until", []) + state.setdefault("recent_assignments", []) + + return state + + +def save_state(state: dict) -> None: + """Save the state to the state file.""" + state["last_updated"] = datetime.now(timezone.utc).isoformat() + + # Ensure directory exists + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Custom YAML formatting for readability + header = """# Reviewer Queue State +# ===================== +# This file is automatically maintained by the reviewer-bot workflow. +# It tracks the round-robin assignment of reviewers for coding guidelines. +# +# You can view this file to see: +# - Who is next up to review +# - Who is currently on a pass-until (vacation/away) +# - Recent assignment history +# +# DO NOT EDIT MANUALLY - changes will be overwritten by the bot. +# Use bot commands instead (see CONTRIBUTING.md for details). + +""" + + yaml_content = yaml.dump(state, default_flow_style=False, sort_keys=False, + allow_unicode=True) + + STATE_FILE.write_text(header + yaml_content) + + +def sync_members_with_queue(state: dict) -> tuple[dict, list[str]]: + """ + Sync the queue with the current members.md file. + + Returns the updated state and a list of changes made. + """ + producers = parse_members_file() + current_queue = {m["github"]: m for m in state["queue"]} + pass_until_users = {m["github"] for m in state.get("pass_until", [])} + + changes = [] + + # Find new producers to add + for producer in producers: + github = producer["github"] + if github not in current_queue and github not in pass_until_users: + state["queue"].append(producer) + changes.append(f"Added {github} to queue") + + # Find removed producers + current_producer_usernames = {p["github"] for p in producers} + state["queue"] = [ + m for m in state["queue"] + if m["github"] in current_producer_usernames + ] + + # Also clean up pass_until for removed producers + removed_from_queue = [ + m["github"] for m in current_queue.values() + if m["github"] not in current_producer_usernames + ] + for username in removed_from_queue: + changes.append(f"Removed {username} from queue (no longer a Producer)") + + # Update names in case they changed + producer_names = {p["github"]: p["name"] for p in producers} + for member in state["queue"]: + if member["github"] in producer_names: + member["name"] = producer_names[member["github"]] + + # Ensure current_index is valid + if state["queue"]: + state["current_index"] = state["current_index"] % len(state["queue"]) + else: + state["current_index"] = 0 + + return state, changes + + +def process_pass_until_expirations(state: dict) -> tuple[dict, list[str]]: + """ + Check for expired pass-until entries and restore them to the queue. + + Returns the updated state and a list of users restored. + """ + now = datetime.now(timezone.utc).date() + restored = [] + still_away = [] + + for entry in state.get("pass_until", []): + return_date = entry.get("return_date") + if return_date: + if isinstance(return_date, str): + return_date = datetime.fromisoformat(return_date).date() + elif isinstance(return_date, datetime): + return_date = return_date.date() + + if return_date <= now: + # Restore to queue + state["queue"].append({ + "github": entry["github"], + "name": entry.get("name", entry["github"]), + }) + restored.append(entry["github"]) + else: + still_away.append(entry) + else: + still_away.append(entry) + + state["pass_until"] = still_away + return state, restored + + +# ============================================================================== +# Reviewer Assignment +# ============================================================================== + + +def get_next_reviewer(state: dict, skip_usernames: set[str] | None = None) -> str | None: + """ + Get the next reviewer from the queue using round-robin. + + Args: + state: Current bot state + skip_usernames: Set of usernames to skip (e.g., issue author) + + Returns the username of the next reviewer, or None if queue is empty. + """ + if not state["queue"]: + return None + + skip_usernames = skip_usernames or set() + queue_size = len(state["queue"]) + start_index = state["current_index"] + + # Try each person in the queue starting from current_index + for i in range(queue_size): + index = (start_index + i) % queue_size + candidate = state["queue"][index] + + if candidate["github"] not in skip_usernames: + # Found a valid reviewer - advance the index + state["current_index"] = (index + 1) % queue_size + return candidate["github"] + + # Everyone in queue is in skip list + return None + + +def record_assignment(state: dict, github: str, issue_number: int, + issue_type: str) -> None: + """Record an assignment in the recent_assignments list.""" + assignment = { + "github": github, + "issue_number": issue_number, + "type": issue_type, + "assigned_at": datetime.now(timezone.utc).isoformat(), + } + + state["recent_assignments"].insert(0, assignment) + state["recent_assignments"] = state["recent_assignments"][:MAX_RECENT_ASSIGNMENTS] + + +# ============================================================================== +# Guidance Text +# ============================================================================== + + +def get_issue_guidance(reviewer: str, issue_author: str) -> str: + """Generate guidance text for an issue reviewer.""" + return f"""๐Ÿ‘‹ Hey @{reviewer}! You've been assigned to review this coding guideline issue. + +## Your Role as Reviewer + +As outlined in our [contribution guide](CONTRIBUTING.md), please: + +1. **Provide initial feedback within 14 days** +2. **Work with @{issue_author}** to flesh out the concept and ensure the guideline is well-prepared for a Pull Request +3. **Check the prerequisites** before the issue is ready to become a PR: + - The new rule isn't already covered by another rule + - All sections contain some content + - Content written may be *incomplete*, but must not be *incorrect* + - The `๐Ÿงช Code Example Test Results` section shows all example code compiles + +4. When ready, **add the `sign-off: create pr from issue` label** to signal the contributor should create a PR + +## Bot Commands + +If you need to pass this review: +- `{BOT_MENTION} pass! [reason]` - Pass just this issue to the next reviewer +- `{BOT_MENTION} pass-until! YYYY-MM-DD [reason]` - Step away from the queue until a date +- `{BOT_MENTION} release` - Release your assignment (next in queue will be assigned) + +To assign someone else: +- `{BOT_MENTION} assign @username` or `r? @username` - Assign a specific reviewer +- `r? producers` - Request the next reviewer from the queue + +Other commands: +- `{BOT_MENTION} claim` - Claim this review for yourself +- `{BOT_MENTION} label +label-name` - Add a label +- `{BOT_MENTION} label -label-name` - Remove a label +- `{BOT_MENTION} status` - Show current queue status +""" + + +def get_pr_guidance(reviewer: str, pr_author: str) -> str: + """Generate guidance text for a PR reviewer.""" + return f"""๐Ÿ‘‹ Hey @{reviewer}! You've been assigned to review this coding guideline PR. + +## Your Role as Reviewer + +As outlined in our [contribution guide](CONTRIBUTING.md), please: + +1. **Begin your review within 14 days** +2. **Provide constructive feedback** on the guideline content, examples, and formatting +3. **Iterate with @{pr_author}** - they may update the PR based on your feedback +4. When the guideline is ready, **approve and add to the merge queue** + +## Review Checklist + +- [ ] Guideline title is clear and follows conventions +- [ ] Amplification section expands on the title appropriately +- [ ] Rationale explains the "why" effectively +- [ ] Non-compliant example(s) clearly show the problem +- [ ] Compliant example(s) clearly show the solution +- [ ] Code examples compile (check the CI results) +- [ ] FLS paragraph ID is correct + +## Bot Commands + +If you need to pass this review: +- `{BOT_MENTION} pass! [reason]` - Pass just this PR to the next reviewer +- `{BOT_MENTION} pass-until! YYYY-MM-DD [reason]` - Step away from the queue until a date +- `{BOT_MENTION} release` - Release your assignment (next in queue will be assigned) + +To assign someone else: +- `{BOT_MENTION} assign @username` or `r? @username` - Assign a specific reviewer +- `r? producers` - Request the next reviewer from the queue + +Other commands: +- `{BOT_MENTION} claim` - Claim this review for yourself +- `{BOT_MENTION} label +label-name` - Add a label +- `{BOT_MENTION} label -label-name` - Remove a label +- `{BOT_MENTION} status` - Show current queue status +""" + + +# ============================================================================== +# Command Parsing & Handling +# ============================================================================== + + +def parse_command(comment_body: str) -> tuple[str, list[str]] | None: + """ + Parse a bot command from a comment body. + + Returns (command, args) or None if no command found. + + Supports: + - @guidelines-bot [args] + - r? @username (shorthand for assign specific user) + - r? producers (shorthand for assign next from queue) + """ + # First, check for r? pattern (Rust-style reviewer request) + # Match either "r? producers" or "r? @username" + r_pattern = r"^r\?\s+(\S+)" + r_match = re.search(r_pattern, comment_body, re.MULTILINE) + if r_match: + target = r_match.group(1) + # Check if it's the special "producers" keyword for queue assignment + if target.lower() == "producers": + return "assign-from-queue", [] + # Otherwise treat as username assignment + username = target.lstrip("@") + return "assign", [f"@{username}"] + + # Look for @guidelines-bot pattern + pattern = rf"{re.escape(BOT_MENTION)}\s+(\S+)(.*)$" + match = re.search(pattern, comment_body, re.IGNORECASE | re.MULTILINE) + + if not match: + return None + + command = match.group(1).lower().rstrip("!") + args_str = match.group(2).strip() + + # Parse arguments (handle quoted strings) + args = [] + if args_str: + # Simple argument parsing - split on whitespace but respect quotes + current_arg = "" + in_quotes = False + quote_char = None + + for char in args_str: + if char in ('"', "'") and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif char.isspace() and not in_quotes: + if current_arg: + args.append(current_arg) + current_arg = "" + else: + current_arg += char + + if current_arg: + args.append(current_arg) + + return command, args + + +def handle_pass_command(state: dict, issue_number: int, comment_author: str, + reason: str | None) -> tuple[str, bool]: + """ + Handle the pass! command - skip current reviewer for this issue only. + + Returns (response_message, success). + """ + # Get current assignees + current_assignees = get_issue_assignees(issue_number) + + if not current_assignees: + return "โŒ No reviewer is currently assigned to pass.", False + + # The person passing should be the current assignee (or we allow anyone?) + # For now, let's allow the assigned reviewer or anyone to pass + passed_reviewer = current_assignees[0] + + # Get the issue author to skip them + issue_author = os.environ.get("ISSUE_AUTHOR", "") + + # Get next reviewer, skipping the passed one and the author + skip_set = {passed_reviewer, issue_author} if issue_author else {passed_reviewer} + next_reviewer = get_next_reviewer(state, skip_usernames=skip_set) + + if not next_reviewer: + return "โŒ No other reviewers available in the queue.", False + + # Assign the new reviewer + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if not assign_reviewer(issue_number, next_reviewer): + return f"โŒ Failed to assign @{next_reviewer} as reviewer.", False + + # Record the assignment + record_assignment(state, next_reviewer, issue_number, "pr" if is_pr else "issue") + + reason_text = f" Reason: {reason}" if reason else "" + return (f"โœ… @{passed_reviewer} has passed this review.{reason_text}\n\n" + f"@{next_reviewer} is now assigned as the reviewer."), True + + +def handle_pass_until_command(state: dict, issue_number: int, comment_author: str, + return_date: str, reason: str | None) -> tuple[str, bool]: + """ + Handle the pass-until! command - remove user from queue until date. + + Returns (response_message, success). + """ + # Validate date format + try: + parsed_date = datetime.strptime(return_date, "%Y-%m-%d").date() + except ValueError: + return (f"โŒ Invalid date format: `{return_date}`. " + f"Please use YYYY-MM-DD format (e.g., 2025-02-01)."), False + + # Check date is in the future + if parsed_date <= datetime.now(timezone.utc).date(): + return "โŒ Return date must be in the future.", False + + # Find the user in the queue + user_in_queue = None + user_index = None + for i, member in enumerate(state["queue"]): + if member["github"].lower() == comment_author.lower(): + user_in_queue = member + user_index = i + break + + if not user_in_queue: + # Check if they're already in pass_until + for entry in state.get("pass_until", []): + if entry["github"].lower() == comment_author.lower(): + # Update their return date + entry["return_date"] = return_date + if reason: + entry["reason"] = reason + return (f"โœ… Updated your return date to {return_date}.\n\n" + f"You're already marked as away."), True + + return (f"โŒ @{comment_author} is not in the reviewer queue. " + f"Only Producers can use this command."), False + + # Move from queue to pass_until + state["queue"].remove(user_in_queue) + + pass_entry = { + "github": user_in_queue["github"], + "name": user_in_queue.get("name", user_in_queue["github"]), + "return_date": return_date, + "original_queue_position": user_index, + } + if reason: + pass_entry["reason"] = reason + + state["pass_until"].append(pass_entry) + + # Adjust current_index if needed + if state["queue"]: + if user_index is not None and user_index < state["current_index"]: + state["current_index"] = max(0, state["current_index"] - 1) + state["current_index"] = state["current_index"] % len(state["queue"]) + else: + state["current_index"] = 0 + + # Check if this user was assigned to the current issue + current_assignees = get_issue_assignees(issue_number) + reassigned_msg = "" + + if comment_author.lower() in [a.lower() for a in current_assignees]: + # Need to reassign + issue_author = os.environ.get("ISSUE_AUTHOR", "") + skip_set = {issue_author} if issue_author else set() + next_reviewer = get_next_reviewer(state, skip_usernames=skip_set) + + if next_reviewer: + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if assign_reviewer(issue_number, next_reviewer): + record_assignment(state, next_reviewer, issue_number, + "pr" if is_pr else "issue") + reassigned_msg = f"\n\n@{next_reviewer} has been assigned as the new reviewer for this issue." + else: + reassigned_msg = "\n\nโš ๏ธ Could not assign a new reviewer." + else: + reassigned_msg = "\n\nโš ๏ธ No other reviewers available to assign." + + reason_text = f" ({reason})" if reason else "" + return (f"โœ… @{comment_author} is now away until {return_date}{reason_text}.\n\n" + f"You'll be automatically added back to the queue on that date." + f"{reassigned_msg}"), True + + +def handle_label_command(issue_number: int, action: str, label: str) -> tuple[str, bool]: + """ + Handle the label command - add or remove labels. + + Returns (response_message, success). + """ + if action == "+": + if add_label(issue_number, label): + return f"โœ… Added label `{label}`.", True + else: + return f"โŒ Failed to add label `{label}`.", False + elif action == "-": + if remove_label(issue_number, label): + return f"โœ… Removed label `{label}`.", True + else: + return f"โŒ Failed to remove label `{label}`.", False + else: + return "โŒ Unknown label action. Use `+label-name` to add or `-label-name` to remove.", False + + +def handle_sync_members_command(state: dict) -> tuple[str, bool]: + """ + Handle the sync-members command - sync queue with members.md. + + Returns (response_message, success). + """ + state, changes = sync_members_with_queue(state) + + if changes: + changes_text = "\n".join(f"- {c}" for c in changes) + return f"โœ… Queue synced with members.md:\n\n{changes_text}", True + else: + return "โœ… Queue is already in sync with members.md.", True + + +def handle_status_command(state: dict) -> tuple[str, bool]: + """ + Handle the status command - show current queue status. + + Returns (response_message, success). + """ + queue_size = len(state["queue"]) + + if queue_size == 0: + return "๐Ÿ“Š **Queue Status**: No reviewers in queue.", True + + current_index = state["current_index"] + next_up = state["queue"][current_index]["github"] + + # Build queue list + queue_list = [] + for i, member in enumerate(state["queue"]): + marker = "โ†’" if i == current_index else " " + queue_list.append(f"{marker} {i + 1}. @{member['github']}") + + queue_text = "\n".join(queue_list) + + # Build pass_until list + away_text = "" + if state.get("pass_until"): + away_list = [] + for entry in state["pass_until"]: + reason = f" ({entry['reason']})" if entry.get("reason") else "" + away_list.append( + f"- @{entry['github']} until {entry['return_date']}{reason}" + ) + away_text = "\n\n**Currently Away:**\n" + "\n".join(away_list) + + return (f"๐Ÿ“Š **Queue Status**\n\n" + f"**Next up:** @{next_up}\n\n" + f"**Queue ({queue_size} reviewers):**\n```\n{queue_text}\n```" + f"{away_text}"), True + + +def handle_claim_command(state: dict, issue_number: int, + comment_author: str) -> tuple[str, bool]: + """ + Handle the claim command - assign yourself as reviewer. + + Returns (response_message, success). + """ + # Check if user is in the queue (is a Producer) + is_producer = any( + m["github"].lower() == comment_author.lower() + for m in state["queue"] + ) + is_away = any( + m["github"].lower() == comment_author.lower() + for m in state.get("pass_until", []) + ) + + if not is_producer and not is_away: + return (f"โŒ @{comment_author} is not in the reviewer queue. " + f"Only Producers can claim reviews."), False + + if is_away: + return (f"โŒ @{comment_author} is currently marked as away. " + f"Please use `{BOT_MENTION} pass-until!` to update your return date first, " + f"or wait until your scheduled return."), False + + # Get current assignees + current_assignees = get_issue_assignees(issue_number) + + # Remove existing assignees + for assignee in current_assignees: + unassign_reviewer(issue_number, assignee) + + # Assign the claimer + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if not assign_reviewer(issue_number, comment_author): + return f"โŒ Failed to assign @{comment_author} as reviewer.", False + + # Record the assignment + record_assignment(state, comment_author, issue_number, "pr" if is_pr else "issue") + + if current_assignees: + prev_text = f" (previously: @{', @'.join(current_assignees)})" + else: + prev_text = "" + + return f"โœ… @{comment_author} has claimed this review{prev_text}.", True + + +def handle_release_command(state: dict, issue_number: int, + comment_author: str) -> tuple[str, bool]: + """ + Handle the release command - release your assignment. + + Returns (response_message, success). + """ + # Get current assignees + current_assignees = get_issue_assignees(issue_number) + + if not current_assignees: + return "โŒ No reviewer is currently assigned to release.", False + + # Check if the comment author is assigned + is_assigned = comment_author.lower() in [a.lower() for a in current_assignees] + + if not is_assigned: + return (f"โŒ @{comment_author} is not assigned to this issue/PR. " + f"Current assignee(s): @{', @'.join(current_assignees)}"), False + + # Remove the assignment + if not unassign_reviewer(issue_number, comment_author): + return f"โŒ Failed to remove @{comment_author} from assignees.", False + + # Get the issue author to skip them when assigning next reviewer + issue_author = os.environ.get("ISSUE_AUTHOR", "") + skip_set = {issue_author, comment_author} if issue_author else {comment_author} + + # Assign the next person in the queue + next_reviewer = get_next_reviewer(state, skip_usernames=skip_set) + + if next_reviewer: + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if assign_reviewer(issue_number, next_reviewer): + record_assignment(state, next_reviewer, issue_number, + "pr" if is_pr else "issue") + return (f"โœ… @{comment_author} has released this review.\n\n" + f"@{next_reviewer} is now assigned as the reviewer."), True + else: + return (f"โœ… @{comment_author} has released this review.\n\n" + f"โš ๏ธ Could not assign the next reviewer."), True + else: + return (f"โœ… @{comment_author} has released this review.\n\n" + f"โš ๏ธ No other reviewers available in the queue."), True + + +def handle_assign_command(state: dict, issue_number: int, + username: str) -> tuple[str, bool]: + """ + Handle the assign command - assign a specific person as reviewer. + + Also handles r? @username syntax. + + Returns (response_message, success). + """ + # Clean up username (remove @ if present) + username = username.lstrip("@") + + if not username: + return (f"โŒ Missing username. Usage: `{BOT_MENTION} assign @username` " + f"or `r? @username`"), False + + # Check if user is in the queue (is a Producer) + is_producer = any( + m["github"].lower() == username.lower() + for m in state["queue"] + ) + is_away = any( + m["github"].lower() == username.lower() + for m in state.get("pass_until", []) + ) + + if not is_producer and not is_away: + return (f"โš ๏ธ @{username} is not in the reviewer queue (not a Producer). " + f"Assigning anyway, but they may not have review permissions."), False + + if is_away: + # Find their return date + for entry in state.get("pass_until", []): + if entry["github"].lower() == username.lower(): + return_date = entry.get("return_date", "unknown") + return (f"โš ๏ธ @{username} is currently marked as away until {return_date}. " + f"Consider assigning someone else or waiting."), False + + # Get current assignees and remove them + current_assignees = get_issue_assignees(issue_number) + for assignee in current_assignees: + unassign_reviewer(issue_number, assignee) + + # Assign the specified user + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if not assign_reviewer(issue_number, username): + return f"โŒ Failed to assign @{username} as reviewer.", False + + # Record the assignment (but don't advance queue - this is manual assignment) + record_assignment(state, username, issue_number, "pr" if is_pr else "issue") + + if current_assignees: + prev_text = f" (previously: @{', @'.join(current_assignees)})" + else: + prev_text = "" + + return f"โœ… @{username} has been assigned as reviewer{prev_text}.", True + + +def handle_assign_from_queue_command(state: dict, issue_number: int) -> tuple[str, bool]: + """ + Handle the assign-from-queue command (r? producers) - assign next from queue. + + This advances the round-robin queue, unlike manual assignment. + + Returns (response_message, success). + """ + # Get current assignees and remove them + current_assignees = get_issue_assignees(issue_number) + for assignee in current_assignees: + unassign_reviewer(issue_number, assignee) + + # Get the issue author to skip them + issue_author = os.environ.get("ISSUE_AUTHOR", "") + skip_set = {issue_author} if issue_author else set() + + # Get next reviewer from the queue (this advances the queue) + next_reviewer = get_next_reviewer(state, skip_usernames=skip_set) + + if not next_reviewer: + return ("โŒ No reviewers available in the queue. " + f"Please use `{BOT_MENTION} sync-members` to update the queue."), False + + # Assign the reviewer + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if not assign_reviewer(issue_number, next_reviewer): + return f"โŒ Failed to assign @{next_reviewer} as reviewer.", False + + # Record the assignment + record_assignment(state, next_reviewer, issue_number, "pr" if is_pr else "issue") + + if current_assignees: + prev_text = f" (previously: @{', @'.join(current_assignees)})" + else: + prev_text = "" + + # Post the appropriate guidance + if is_pr: + guidance = get_pr_guidance(next_reviewer, issue_author) + else: + guidance = get_issue_guidance(next_reviewer, issue_author) + + post_comment(issue_number, guidance) + + return f"โœ… @{next_reviewer} (next in queue) has been assigned as reviewer{prev_text}.", True + + +# ============================================================================== +# Event Handlers +# ============================================================================== + + +def handle_issue_or_pr_opened(state: dict) -> bool: + """ + Handle when an issue or PR is opened with the coding guideline label. + + Returns True if we took action, False otherwise. + """ + issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + if not issue_number: + return False + + # Check if already has a reviewer + current_assignees = get_issue_assignees(issue_number) + if current_assignees: + print(f"Issue #{issue_number} already has assignees: {current_assignees}") + return False + + # Check for coding guideline label + labels_json = os.environ.get("ISSUE_LABELS", "[]") + try: + labels = json.loads(labels_json) + except json.JSONDecodeError: + labels = [] + + if CODING_GUIDELINE_LABEL not in labels: + print(f"Issue #{issue_number} does not have '{CODING_GUIDELINE_LABEL}' label") + return False + + # Get issue author to skip them + issue_author = os.environ.get("ISSUE_AUTHOR", "") + skip_set = {issue_author} if issue_author else set() + + # Get next reviewer + reviewer = get_next_reviewer(state, skip_usernames=skip_set) + + if not reviewer: + post_comment(issue_number, + f"โš ๏ธ No reviewers available in the queue. " + f"Please use `{BOT_MENTION} sync-members` to update the queue.") + return False + + # Assign the reviewer + is_pr = os.environ.get("IS_PULL_REQUEST", "false").lower() == "true" + if not assign_reviewer(issue_number, reviewer): + post_comment(issue_number, + f"โš ๏ธ Failed to assign @{reviewer} as reviewer.") + return False + + # Record the assignment + record_assignment(state, reviewer, issue_number, "pr" if is_pr else "issue") + + # Post guidance comment + if is_pr: + guidance = get_pr_guidance(reviewer, issue_author) + else: + guidance = get_issue_guidance(reviewer, issue_author) + + post_comment(issue_number, guidance) + + return True + + +def handle_labeled_event(state: dict) -> bool: + """ + Handle when an issue or PR is labeled. + + If the coding guideline label was just added and there's no reviewer, + assign one. + """ + label_name = os.environ.get("LABEL_NAME", "") + + if label_name != CODING_GUIDELINE_LABEL: + return False + + # Treat the same as opened + return handle_issue_or_pr_opened(state) + + +def handle_comment_event(state: dict) -> bool: + """ + Handle a comment event - check for bot commands. + + Returns True if we took action, False otherwise. + """ + comment_body = os.environ.get("COMMENT_BODY", "") + comment_author = os.environ.get("COMMENT_AUTHOR", "") + comment_id = os.environ.get("COMMENT_ID", "") + issue_number = int(os.environ.get("ISSUE_NUMBER", 0)) + + if not comment_body or not issue_number: + return False + + # Parse for bot command + parsed = parse_command(comment_body) + if not parsed: + return False + + command, args = parsed + print(f"Parsed command: {command}, args: {args}") + + response = "" + success = False + state_changed = False + + # Handle each command + if command == "pass": + reason = " ".join(args) if args else None + response, success = handle_pass_command(state, issue_number, comment_author, reason) + state_changed = success + + elif command == "pass-until": + if not args: + response = (f"โŒ Missing date. Usage: `{BOT_MENTION} pass-until! YYYY-MM-DD [reason]`") + success = False + else: + return_date = args[0] + reason = " ".join(args[1:]) if len(args) > 1 else None + response, success = handle_pass_until_command( + state, issue_number, comment_author, return_date, reason + ) + state_changed = success + + elif command == "label": + if not args: + response = (f"โŒ Missing label. Usage: `{BOT_MENTION} label +label-name` or " + f"`{BOT_MENTION} label -label-name`") + success = False + else: + label_arg = args[0] + if label_arg.startswith("+"): + response, success = handle_label_command(issue_number, "+", label_arg[1:]) + elif label_arg.startswith("-"): + response, success = handle_label_command(issue_number, "-", label_arg[1:]) + else: + # Default to adding + response, success = handle_label_command(issue_number, "+", label_arg) + + elif command == "sync-members": + response, success = handle_sync_members_command(state) + state_changed = success + + elif command == "status": + response, success = handle_status_command(state) + + elif command == "claim": + response, success = handle_claim_command(state, issue_number, comment_author) + state_changed = success + + elif command == "release": + response, success = handle_release_command(state, issue_number, comment_author) + state_changed = success + + elif command == "assign": + if not args: + response = (f"โŒ Missing username. Usage: `{BOT_MENTION} assign @username` " + f"or `r? @username`") + success = False + else: + username = args[0] + response, success = handle_assign_command(state, issue_number, username) + state_changed = success + + elif command == "assign-from-queue": + # Handle "r? producers" - assign next from round-robin queue + response, success = handle_assign_from_queue_command(state, issue_number) + state_changed = success + + elif command == "r": + # Handle "r?" being parsed as command "r" with "?" in args + # This shouldn't normally happen due to parse_command, but handle it anyway + if args and args[0].startswith("?"): + # Extract username from "?@username" or "? @username" + remaining = args[0].lstrip("?").strip() + if remaining: + username = remaining + elif len(args) > 1: + username = args[1] + else: + username = "" + + if username: + response, success = handle_assign_command(state, issue_number, username) + state_changed = success + else: + response = "โŒ Missing username. Usage: `r? @username`" + success = False + else: + response = "โŒ Unknown command. Did you mean `r? @username`?" + success = False + + else: + response = (f"โŒ Unknown command: `{command}`\n\n" + f"Available commands:\n" + f"- `{BOT_MENTION} pass! [reason]` - Pass this review to next in queue\n" + f"- `{BOT_MENTION} pass-until! YYYY-MM-DD [reason]` - Step away from queue\n" + f"- `{BOT_MENTION} claim` - Claim this review for yourself\n" + f"- `{BOT_MENTION} release` - Release your assignment\n" + f"- `{BOT_MENTION} assign @username` or `r? @username` - Assign specific reviewer\n" + f"- `r? producers` - Assign next reviewer from queue\n" + f"- `{BOT_MENTION} label +/-label-name` - Add/remove labels\n" + f"- `{BOT_MENTION} sync-members` - Sync queue with members.md\n" + f"- `{BOT_MENTION} status` - Show queue status") + success = False + + # React to the command comment + if comment_id: + add_reaction(int(comment_id), "eyes") + if success: + add_reaction(int(comment_id), "+1") + + # Post response + if response: + post_comment(issue_number, response) + + return state_changed + + +def handle_manual_dispatch(state: dict) -> bool: + """Handle manual workflow dispatch.""" + action = os.environ.get("MANUAL_ACTION", "") + + if action == "sync-members": + state, changes = sync_members_with_queue(state) + if changes: + print(f"Sync changes: {changes}") + return True + + elif action == "show-state": + print(f"Current state:\n{yaml.dump(state, default_flow_style=False)}") + return False + + return False + + +# ============================================================================== +# Main +# ============================================================================== + + +def main(): + """Main entry point for the reviewer bot.""" + event_name = os.environ.get("EVENT_NAME", "") + event_action = os.environ.get("EVENT_ACTION", "") + + print(f"Event: {event_name}, Action: {event_action}") + + # Load current state + state = load_state() + + # Process any expired pass-until entries + state, restored = process_pass_until_expirations(state) + if restored: + print(f"Restored from pass-until: {restored}") + + # Always sync members on any event + state, sync_changes = sync_members_with_queue(state) + if sync_changes: + print(f"Members sync changes: {sync_changes}") + + # Handle the event + state_changed = False + + if event_name == "issues": + if event_action == "opened": + state_changed = handle_issue_or_pr_opened(state) + elif event_action == "labeled": + state_changed = handle_labeled_event(state) + + elif event_name == "pull_request_target": + if event_action == "opened": + state_changed = handle_issue_or_pr_opened(state) + elif event_action == "labeled": + state_changed = handle_labeled_event(state) + + elif event_name == "issue_comment": + if event_action == "created": + state_changed = handle_comment_event(state) + + elif event_name == "workflow_dispatch": + state_changed = handle_manual_dispatch(state) + + # Save state if changed (or if we synced members/pass-until) + if state_changed or sync_changes or restored: + save_state(state) + # Set output for the workflow + with open(os.environ.get("GITHUB_OUTPUT", "/dev/null"), "a") as f: + f.write("state_changed=true\n") + else: + with open(os.environ.get("GITHUB_OUTPUT", "/dev/null"), "a") as f: + f.write("state_changed=false\n") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 5e3954daf..b50b0f363 100644 --- a/uv.lock +++ b/uv.lock @@ -226,6 +226,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/e9/b145683854189bba84437ea569bfa786f408c8dc5bc16d8eb0753f5583bf/pypandoc-1.16.2-py3-none-any.whl", hash = "sha256:c200c1139c8e3247baf38d1e9279e85d9f162499d1999c6aa8418596558fe79b", size = 19451 }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -329,6 +347,8 @@ source = { virtual = "." } dependencies = [ { name = "builder" }, { name = "pypandoc" }, + { name = "pyyaml" }, + { name = "requests" }, { name = "sphinx" }, { name = "sphinx-autobuild" }, { name = "sphinx-needs" }, @@ -345,6 +365,8 @@ dev = [ requires-dist = [ { name = "builder", virtual = "builder" }, { name = "pypandoc" }, + { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "requests", specifier = ">=2.31.0" }, { name = "sphinx", specifier = ">=8.2.3" }, { name = "sphinx-autobuild", specifier = ">=2024.10.3" }, { name = "sphinx-needs", specifier = ">=5.1.0" },