diff --git a/.gitignore b/.gitignore index eb5b7a6..23c0bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ __pycache__/ venv/ dist/ .claude/settings.local.json -.mcp.json +.mcp.json* .env* .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index eb9a7f5..a36c6c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,11 +134,27 @@ When contributing to this repository: - Avoid force-pushing to shared branches unless necessary - Keep commit messages informative and professional +## github-projects-client: Prefer GitHub-Supported Tools + +This project exists only to fill gaps in GitHub's own tooling for Projects v2. +Any functionality that can be replaced by a GitHub-supported tool (`gh` CLI, +GitHub REST/GraphQL APIs, GitHub's official MCP server) **should be** replaced. +Before adding new capabilities, check whether GitHub has shipped native support. + +Current gaps filled (as of 2026-05): +1. `gh project item-list` has no server-side filtering (`--query` flag) +2. All GitHub mutation tools require raw node IDs, not human-readable names +3. No GitHub tool supports batch field mutations + +Track upstream: [github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383) + ## Active Technologies - Python >=3.13 (constraint from all `pyproject.toml` files) + `requests>=2.31` (shared client), `mcp>=1.0` (MCP server only) (003-generalize-mcp-client) - `action_log.jsonl` (append-only, MCP layer only) (003-generalize-mcp-client) - Python >=3.13 + `requests>=2.31`, `github-projects-client` (local editable) (004-or-filter-syntax) - N/A (stateless — reads from GitHub API, writes TSV) (004-or-filter-syntax) +- Python >=3.13 (consistent with existing packages) + `github-projects-client` (local editable), `requests>=2.31` (transitive via client) (005-rest-api-layer) +- Append-only JSONL file for audit log (same pattern as current `action_log.jsonl`) (005-rest-api-layer) --- diff --git a/filozzy-mcp/README.md b/filozzy-mcp/README.md index 0f12a2c..a8e8e46 100644 --- a/filozzy-mcp/README.md +++ b/filozzy-mcp/README.md @@ -1,46 +1,33 @@ # FilOzzy MCP Server -MCP server for managing GitHub Projects v2 boards. Configurable via environment variables to work with any org/project. +Thin MCP coordinator for GitHub Projects v2 boards. Provides board context and API usage instructions to LLM agents. -Fills the gap that GitHub's official MCP and `gh` CLI don't cover: **reading and setting project-level field values** (Status, Cycle Theme, Dev Days Estimate, Cycle, etc.). +**This server does NOT make any GitHub API calls.** Instead, it tells agents where the "github-projects-client" REST API server is and how to call it via curl. All board data operations go through the [github-projects-client REST API](../github-projects-client/). For issue/PR-level operations (assignees, milestones, reviewers), use `gh` CLI directly. ## Setup -### 1. Install dependencies +### 1. Start the REST API server -```bash -cd filozzy-mcp -uv sync -``` - -### 2. Get a GitHub token - -You need a token with scopes: `project`, `repo`, `read:org`. - -The easiest way is to use the GitHub CLI: +The API server must be running for agents to access board data: ```bash -gh auth token +cd github-projects-client +uv run github-projects-api ``` -If your token doesn't have the `project` scope yet: +### 2. Install MCP dependencies ```bash -gh auth refresh -s project +cd filozzy-mcp +uv sync ``` -Alternatively, create a [personal access token](https://github.com/settings/tokens) with the required scopes. - ### 3. Configure Claude Code -You can configure the MCP server in one of two places: - **Option A: Project-level config (`.mcp.json` in the repo root)** -Create a `.mcp.json` file in the `tpm-utils/` root (this file is gitignored): - ```json { "mcpServers": { @@ -48,156 +35,71 @@ Create a `.mcp.json` file in the `tpm-utils/` root (this file is gitignored): "command": "uv", "args": ["--directory", "./filozzy-mcp", "run", "filozzy-mcp"], "env": { - "GITHUB_TOKEN": "", "GITHUB_ORG": "FilOzone", "GITHUB_PROJECT_NUMBER": "14", - "BOARD_NAMES": "FOC Board,FOC Project Board" + "BOARD_NAMES": "FOC Board,FOC Project Board", + "API_BASE_URL": "http://localhost:8080" } } } } ``` -**Option B: User-level config (`~/.claude/settings.json`)** - -Add the server to your global Claude Code settings. You can do this via the CLI: - -```bash -claude mcp add filozzy \ - --command uv \ - --args "--directory" "/absolute/path/to/tpm-utils/filozzy-mcp" "run" "filozzy-mcp" \ - --env GITHUB_TOKEN="" \ - --env GITHUB_ORG="FilOzone" \ - --env GITHUB_PROJECT_NUMBER="14" \ - --env BOARD_NAMES="FOC Board,FOC Project Board" -``` - -Or edit `~/.claude/settings.json` directly and add the same JSON block as Option A under `mcpServers`. +Note: `GITHUB_TOKEN` is no longer needed in the MCP config. The LLM uses its own token when calling the API directly. ### Environment variables | Variable | Required | Default | Description | |---|---|---|---| -| `GITHUB_TOKEN` | Yes | — | GitHub PAT with `project`, `repo`, `read:org` scopes | | `GITHUB_ORG` | No | `FilOzone` | GitHub organization that owns the project | | `GITHUB_PROJECT_NUMBER` | No | `14` | Project number within the org | -| `BOARD_NAMES` | No | `FOC Board,FOC Project Board` | Comma-separated aliases for the board (used in MCP instructions to help LLMs route requests) | +| `BOARD_NAMES` | No | `FOC Board,FOC Project Board` | Comma-separated aliases for the board | +| `API_BASE_URL` | No | `http://localhost:8080` | Base URL of the REST API server | ### 4. Restart Claude Code -After configuring, restart Claude Code (or start a new session). The FilOzzy tools will be available automatically. +After configuring, restart Claude Code (or start a new session). The FilOzzy coordinator tool will be available automatically. ## Available tools -### Read tools +### `get_board_context` -- **`list_board_items`** — List project items with optional filter (same `q` syntax as the board UI) -- **`list_board_view_items`** — List items for a project view URL (resolves saved view filter, URL `filterQuery` override, and optional `visibleFields` field ordering) -- **`get_board_item`** — Get full details of a specific item (e.g., `dealbot#111`) -- **`list_board_fields`** — List all project fields -- **`list_board_field_options`** — List valid options for a field (e.g., Status values) +Returns everything an LLM agent needs to interact with the board: -For `list_board_view_items`: -- `visibleFields` in the URL is authoritative and preserved exactly in that order. -- If `visibleFields` is not present, FilOzzy uses the view default field order from GraphQL metadata, which may differ from the live UI column order. -- `sliceBy[...]` URL parameters are currently ignored. +- Board identity (org, project number, aliases) +- API base URL and OpenAPI spec link +- Endpoint catalog with descriptions and example curl commands +- Query syntax reference for filtering board items -`get_board_item` is intentionally kept as a first-class primitive, even though -you could emulate it with `list_board_items`. It gives MCP clients one stable -"resolve this reference" operation that accepts `repo#number`, `owner/repo#number`, -or a GitHub URL, then returns a single hydrated board item. This keeps lookup -logic (reference parsing, query construction, pagination, exact-match selection) -inside the server instead of duplicating it across agents and clients. +The agent then calls the REST API endpoints directly via curl using its own `GITHUB_TOKEN`. -### Mutation tools +## Architecture -- **`set_board_item_field`** — Set a project field value (e.g., set Status to "In Progress") +FilOzzy MCP is now a **thin coordinator** — it provides naming resolution and API discovery without touching the GitHub API directly. -### Logging +``` +LLM Agent + ├── FilOzzy MCP (get_board_context) → board identity + API instructions + └── REST API (curl) → board data operations + └── github-projects-client (library) → GitHub API +``` -- **`get_action_log`** — View recent FilOzzy actions +Previously, FilOzzy handled all GitHub API calls directly. The REST API layer was introduced so that: -Every mutation is logged to `action_log.jsonl`. +1. Board data can be piped directly to disk (bypassing LLM context) +2. Multiple clients (curl, scripts, other agents) can share the same API ## Testing -Tests are split across packages. Shared client tests live in `github-projects-client/`; -MCP-layer-specific tests (docstring validation) live here. - -### Run tests - ```bash -# This package only (MCP-layer tests) cd filozzy-mcp -GITHUB_TOKEN=$(gh auth token) uv run pytest tests/ -v - -# All packages from repo root -./scripts/test-all.sh +uv run pytest tests/ -v ``` -### Prerequisites - -- A GitHub token with `project` and `repo` scopes -- Network access to `api.github.com` - -## Example usage (in Claude Code) - -- "Show me all non-Done items missing a milestone" -- "What are the valid Status values?" -- "Set the status of dealbot#88 to Done" -- "What has FilOzzy done recently?" +Tests verify the coordinator tool returns complete board context. No GitHub token required. ## FAQ ### Why not just use GitHub's official MCP server? -GitHub's [official MCP server](https://github.com/github/github-mcp-server) has a Projects v2 toolset (`projects_list`, `projects_get`, `projects_write`) available at the `/x/projects` endpoint. It supports query filtering, pagination, field discovery (including single-select options), and mutations. So why does filozzy-mcp exist? - -**The non-negotiable blocker: context window bloat.** Each project item response from GitHub's MCP is ~8KB because it includes the full issue/PR body, complete repository object (~2KB of URL templates), and full user objects for every author/assignee/milestone-creator. The `fields` parameter controls which *project fields* are returned but there is no way to suppress the content blob. - -| Query size | GitHub MCP payload | Token cost | Impact | -|---|---|---|---| -| 10 items | ~80KB | ~20K tokens | Noticeable | -| 50 items (max per_page) | ~400KB | ~100K tokens | Half the context window | -| 100 items (2 pages) | ~800KB | ~200K+ tokens | Entire context window consumed | - -filozzy-mcp returns ~200-300 bytes per item (just the project field values you asked for) — a **~40x reduction**. The LLM cannot filter the response before it enters context, so those tokens are consumed regardless of whether the LLM "ignores" the content. - -| | github-projects MCP | filozzy-mcp | -|---|---|---| -| Per-item response size | ~8KB | ~200-300 bytes | -| 50-item query | ~400KB / ~100K tokens | ~10-15KB / ~3-4K tokens | -| Field name resolution | Raw IDs required | By name (`"Status"` → `"Done"`) | -| Filter syntax docs | None in tool description | Comprehensive reference in docstring | -| Mutation UX | 3 tool calls with raw IDs | 1 call: `set_board_item_field("dealbot#111", "Status", "Done")` | -| Audit logging | None | JSONL with old/new values | - -This is a [known problem across the GitHub MCP server](https://github.com/github/github-mcp-server/issues/142) (20+ comments, open since April 2025). The maintainers have been fixing it tool-by-tool using "minimal types", but the projects tools haven't been optimized yet. We filed [github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383) requesting compact output for project items. - -**If #2383 gets addressed**, we should revisit this decision — GitHub's official tooling plus CLAUDE.md prompt engineering could replace filozzy-mcp. Until then, filozzy-mcp stays as the thin, context-efficient wrapper it was designed to be. - -For the full evaluation, see [FilOzone/tpm-utils#25 (comment)](https://github.com/FilOzone/tpm-utils/issues/25#issuecomment-4314857318). - -## Architecture - -FilOzzy MCP is a thin adapter layer on top of -[`github-projects-client`](../github-projects-client/), a reusable Python library for -GitHub Projects v2. The shared client handles all API communication; the MCP -server adds: - -- Environment-based board configuration -- Response formatting for LLM consumption -- Audit logging (`action_log.jsonl`) -- MCP tool descriptions with query syntax reference - -## Next steps - -1. ~~Clarify positioning~~ — Done. See FAQ above. filozzy-mcp exists because - GitHub's official MCP returns ~8KB per item with no way to suppress the - content blob. Tracking upstream fix at [github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383). -2. ~~Generalize for any board~~ — Done. The MCP server now reads org/project - from environment variables. Shared client logic extracted to `github-projects-client`. -3. ~~Factor shared client~~ — Done. `github-projects-client/` is a standalone package - used by `filozzy-mcp`, `foc-pr-report`, and `github-project-export`. -4. Review FilOzzy MCP docstrings/tool descriptions against GitHub MCP tool - descriptions and improve wording, guidance, and examples accordingly. +See the [github-projects-client FAQ](../github-projects-client/README.md) for the detailed rationale. In short: GitHub's MCP returns ~8KB per item with no way to suppress the content blob, consuming the LLM's entire context window for a 50-item query. The REST API returns ~200-300 bytes per item and enables the LLM to query the data with curl so the context isn't populated. diff --git a/filozzy-mcp/filozzy_mcp/server.py b/filozzy-mcp/filozzy_mcp/server.py index 2ddbc12..358ff92 100644 --- a/filozzy-mcp/filozzy_mcp/server.py +++ b/filozzy-mcp/filozzy_mcp/server.py @@ -1,24 +1,17 @@ -"""FilOzzy MCP server — project board operations for GitHub Projects v2.""" +"""FilOzzy MCP server — thin coordinator for GitHub Projects v2 board operations. + +This server provides board context and API usage instructions to LLM agents. +It does NOT make any GitHub API calls directly. Instead, agents use the +REST API server (github-projects-api) for all board data operations. +""" from __future__ import annotations -import json import os -from typing import Optional -import requests from mcp.server import FastMCP -from filozzy_mcp.action_log import log_action, read_recent_actions -from github_projects_client import ( - get_item, - list_field_options as client_list_field_options, - list_fields as client_list_fields, - list_items, - resolve_view_url, - set_field_value, - set_field_value_bulk, -) +from github_projects_client.query_syntax import QUERY_SYNTAX_REFERENCE # --------------------------------------------------------------------------- # Configuration from environment @@ -45,6 +38,7 @@ def _get_github_project_number() -> int: for n in os.environ.get("BOARD_NAMES", "FOC Board,FOC Project Board").split(",") if n.strip() ] +API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8080") def _build_instructions() -> str: @@ -56,728 +50,109 @@ def _build_instructions() -> str: f"FilOzzy MCP server for managing the {BOARD_NAMES[0] if BOARD_NAMES else 'project board'} " f"(GitHub Projects v2 #{GITHUB_PROJECT_NUMBER} in the {GITHUB_ORG} org). " f"Also known as: {names_str}. " - "Use these tools to read and modify project board items, fields, and statuses. " - "For issue/PR-level operations (assignees, milestones, reviewers), " - "use the `gh` CLI directly instead." + "Use the get_board_context tool to discover the board's REST API endpoints, " + "then call them directly via curl with your GITHUB_TOKEN. " + "\n\nWhen to use which tool:\n" + "- Board field reads (list items, filter by status/assignee/etc): Use this API via curl. " + "Supports server-side filtering, compact responses (~200 bytes/item), and direct-to-disk output.\n" + "- Board field mutations (set Status, Cycle Theme, etc): Use this API via curl. " + "Only tool that accepts human-readable field names and values (no raw node IDs).\n" + "- Issue/PR metadata (review state, draft status, labels): Use `gh` CLI " + "(e.g., `gh pr view --json reviewDecision,reviews`).\n" + "- Issue/PR mutations (assignees, milestones, reviewers): Use `gh` CLI " + "(e.g., `gh issue edit --add-assignee`).\n" + "Do NOT use GitHub's projects MCP for board item reads — responses are too verbose for LLM context." ) mcp = FastMCP("filozzy", instructions=_build_instructions()) -def _build_session() -> requests.Session: - """Build a GitHub API session from environment.""" - token = os.environ.get("GITHUB_TOKEN") - if not token: - raise RuntimeError( - "GITHUB_TOKEN environment variable is required. " - "Set it to a GitHub PAT with 'read:project' (reads) and " - "'project' (mutations) scopes, plus 'repo' and 'read:org'. " - "See the README for setup instructions." - ) - session = requests.Session() - session.headers.update( - { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - ) - return session - - -def _build_display_items(items: list[dict]) -> list[dict]: - """Strip internal fields and empty values from items for output.""" - return [ - {k: v for k, v in item.items() if not k.startswith("_") and v not in (None, "")} - for item in items - ] - - -def _format_json( - display_items: list[dict], - has_more: bool, - next_cursor: Optional[str], -) -> str: - """Return a JSON object with items array and pagination metadata.""" - payload: dict = {"items": display_items, "total_in_page": len(display_items)} - if has_more: - payload["has_more"] = True - payload["next_cursor"] = next_cursor - return json.dumps(payload, ensure_ascii=False) - - -def _format_compact( - display_items: list[dict], - has_more: bool, - next_cursor: Optional[str], -) -> str: - """Return columnar JSON: column names once, then rows as arrays. - - Much more token-efficient than ``_format_json`` for large result sets - because field names appear once instead of once-per-item. The output - is still valid JSON and can be converted back to objects with jq:: - - jq '[.columns as $c | .rows[] | [$c, .] | transpose | map({(.[0]): .[1]}) | add]' - """ - # Build a stable column order from the union of all items' keys, - # preserving first-seen order (which follows the requested fields). - columns: list[str] = [] - seen: set[str] = set() - for item in display_items: - for key in item: - if key not in seen: - columns.append(key) - seen.add(key) - - rows = [[item.get(col, "") for col in columns] for item in display_items] - - payload: dict = { - "columns": columns, - "rows": rows, - "total_in_page": len(display_items), - } - if has_more: - payload["has_more"] = True - payload["next_cursor"] = next_cursor - return json.dumps(payload, ensure_ascii=False) - - -@mcp.tool() -def list_board_items( - query: str = '-status:"🎉 Done"', - fields: Optional[str] = None, - per_page: int = 50, - cursor: Optional[str] = None, - verbose: bool = False, - format: Optional[str] = None, -) -> str: - """List project board items, filtered via the `query` parameter. - - IMPORTANT: The filter is passed via the `query` parameter (not `filter`). - Unknown parameters are silently ignored — if you pass a parameter name - that doesn't exist (e.g., `filter`), it will be dropped and the default - query will be used instead. - - The query uses GitHub Projects v2 filter syntax — the same syntax as the - board UI search bar. Multiple filters are ANDed together. - - Results are paginated using cursor-based pagination. Each call fetches one - page of items from the GitHub REST API. If more items are available, the - response includes a next_cursor value — pass it back as `cursor` to fetch - the next page. - - Reference: https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects - - Args: - query: Project search filter (this is the parameter to use for - filtering — do NOT pass a `filter` parameter, it doesn't exist). - Default: '-status:"🎉 Done"'. - fields: Comma-separated list of fields to include. - Default: Repository, Id, url, Title, Status, Kind, - Milestone, Assignees, Cycle Theme, Dev Days Estimate. - Use list_board_fields to see available fields. - In addition to board fields, the following pseudo-fields - are available (derived from item metadata, not board columns): - Node ID — project item node ID (e.g., "PVTI_..."), needed - for set_board_item_field / bulk_set_board_item_field. - Repository, Id, Number, url, Title, Kind, Assignees — - built-in item properties (some included by default). - per_page: Number of items per page (default: 50, max: 100). - cursor: Opaque cursor from a previous response to fetch the next page. - When provided, the same query and fields from the original - request are used automatically. - verbose: If true, include debug info showing the raw REST query, - endpoint, requested field IDs, and item counts. - format: Output format. Default (None) returns human-readable JSONL - with a "Found N items:" header line — designed for LLM - readability in conversation. - "json" returns a single JSON object: - {"items": [...], "total_in_page": N} - {"items": [...], "total_in_page": N, "has_more": true, "next_cursor": "..."} - "compact" returns columnar JSON — field names once, rows as arrays: - {"columns": ["Repository", "Title", ...], - "rows": [["curio", "Fix X", ...], ...], - "total_in_page": N} - Much more token-efficient than "json" for large result sets - (~40-60% smaller) because field names appear once instead of - once-per-item. Convert back to objects with jq: - jq '[.columns as $c | .rows[] | [$c, .] | transpose | map({(.[0]): .[1]}) | add]' - **Recommended for sweep automation** — use this when writing - results to disk for programmatic processing. - - Query syntax reference (passed as the REST API `q` parameter): - - The query uses the same syntax as the GitHub Projects board UI - search bar. It is passed directly to the REST API endpoint - GET /orgs/{org}/projectsV2/{project_number}/items?q=... - - Docs: https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects - - CUSTOM PROJECT FIELDS (use kebab-case of the field display name): - status:"⌨️ In Progress" — match a Status value - cycle-theme:"Contract Upgrade" — match a Cycle Theme value - milestone:"M4.2: mainnet GA" — match a Milestone value - dev-days-estimate:>1 — numeric comparison - cycle:"202604-2" — match iteration by title - Use list_board_fields to discover field names. - Use list_board_field_options to see valid values for a field. - - ITEM TYPE & STATE: - is:issue — issues only - is:pr — pull requests only - is:draft — draft issues or PRs - is:open — open items - is:closed — closed items - is:merged — merged PRs - - PEOPLE: - assignee:rjan90 — assigned to user - assignee:rjan90,biglep — assigned to either (OR) - reviewers:biglep — PR reviewer - assignee:@me — current authenticated user - - PRESENCE / ABSENCE: - has:assignee — items with at least one assignee - no:milestone — items with no milestone set - no:assignee — items with no assignee - -no:milestone — only items WITH a milestone (double-negation) - - REPOSITORY: - repo:FilOzone/dealbot — items from a specific repo - repo:FilOzone/dealbot,FilOzone/curio — items from either repo - - LABELS: - label:bug — items with label "bug" - label:"help wanted" — labels with spaces need quotes - - TIME-BASED (built-in filters, not project board fields): - IMPORTANT: `last-updated` has counterintuitive semantics: - last-updated:1days — items NOT updated within 1 day (stale items) - -last-updated:1days — items updated within the last day (recent items) - last-updated:7days — items NOT updated within 7 days - -last-updated:7days — items updated within the last 7 days - - Alternative syntax using `updated:` (equivalent results): - updated:@today — items updated today - updated:>@today-1d — items updated within the last day - updated:>@today-7d — items updated within the last 7 days - - To find RECENTLY updated items, use one of: - -last-updated:Ndays (board UI style) - updated:>@today-Nd (docs style with comparison operator) - - To find STALE items (not updated recently), use one of: - last-updated:Ndays (board UI style, no negation) - -updated:>@today-Nd (docs style, negated) - - RELATIONSHIPS: - blocking:FilOzone/dealbot#470 — items blocking a specific issue - blocked-by:FilOzone/filecoin-pay-explorer#77 — items blocked by a specific issue - parent-issue:FilOzone/synapse-sdk#3 — sub-issues of a parent - - CLOSE REASON: - reason:completed — closed as completed - reason:"not planned" — closed as not planned - - TEXT SEARCH: - "search text" — free text search across fields - title:"*API*" — title contains text - Wildcards: title:API* — prefix matching - - NEGATION (prefix any filter with -): - -status:"🎉 Done" — exclude Done items - -assignee:rjan90 — not assigned to rjan90 - -is:draft — exclude drafts - -no:milestone — only items WITH a milestone - - OR (comma-separated values within one filter): - assignee:rjan90,biglep - label:bug,enhancement - status:"⌨️ In Progress","🔍 Review" - - COMBINING FILTERS (space-separated = implicit AND): - is:pr assignee:rjan90 -status:"🎉 Done" - cycle-theme:"Contract Upgrade" -last-updated:1days - is:issue no:milestone has:assignee - - QUOTING: Use double quotes around values with spaces or special chars: - status:"⌨️ In Progress" - milestone:"M4.2: mainnet GA" - - FIELD PRESENCE with custom fields: - -has:"cycle-theme" — items where Cycle Theme is NOT set - has:"cycle-theme" — items where Cycle Theme IS set - -has:"milestone" — items with no milestone - Works with any project field name (use kebab-case). - - TIPS — prefer targeted queries: - When looking for items that need a specific fix (e.g., missing field, - wrong status), build the query to match the rule condition directly - rather than fetching all items and scanning manually. - - Examples: - is:pr -status:"🎉 Done" -has:"cycle-theme" - → PRs missing Cycle Theme (much better than fetching all PRs) - is:pr no:assignee -status:"🎉 Done" - → unassigned open PRs - is:pr status:"📌 Triage" - → PRs still in Triage (for applying triage rules) - is:pr is:merged -status:"🎉 Done" - → merged PRs not yet marked Done - - NOTE: Invalid filters return 0 results (they are not silently ignored). - - Returns: - Formatted list of matching project items with their field values. - When verbose=true, includes debug info about the REST query. - """ - session = _build_session() - - field_list = None - if fields: - field_list = [f.strip() for f in fields.split(",")] - - result = list_items( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - query=query, - fields=field_list, - per_page=per_page, - cursor=cursor, - ) - items = result["items"] - debug = result["debug"] - next_cursor = result["next_cursor"] - has_more = result["has_more"] - - _VALID_FORMATS = {None, "json", "compact"} - if format not in _VALID_FORMATS: - raise ValueError( - f'Unknown format {format!r}. Must be one of: "json", "compact", or omitted for default JSONL.' - ) - - display_items = _build_display_items(items) if items else [] - - if format == "json": - return _format_json(display_items, has_more, next_cursor) - if format == "compact": - return _format_compact(display_items, has_more, next_cursor) - - if not items: - msg = f"No items found matching query: {query}" - if verbose: - msg += f"\n\n--- Debug ---\n{json.dumps(debug, indent=2)}" - return msg - - lines = [json.dumps(d, ensure_ascii=False) for d in display_items] - - header = f"Found {len(items)} items" - if has_more: - header += " (more available)" - output = header + ":\n" + "\n".join(lines) - - if has_more: - output += ( - f"\n\n--- Next page ---\nPass this cursor to fetch more: {next_cursor}" - ) - - if verbose: - output += f"\n\n--- Debug ---\n{json.dumps(debug, indent=2)}" - - return output - - -@mcp.tool() -def list_board_view_items( - view_url: str, - fields: Optional[str] = None, - per_page: int = 50, - cursor: Optional[str] = None, - verbose: bool = False, - format: Optional[str] = None, -) -> str: - """List project items for a GitHub project view URL. - - This resolves the effective filter from the view URL and then delegates to - list_board_items/list_items. - - Behavior: - - Uses the saved view filter from GitHub (project view metadata). - - If URL has `filterQuery=...`, that overrides the saved view filter. - - `sliceBy[...]` URL parameters are ignored. - - If URL has `visibleFields=[...]`, that exact order is used. - - Else if `fields` is omitted, uses the view's configured default fields. - Note: default field order may differ from what the live UI currently renders. - - Args: - view_url: Full GitHub project view URL. - fields: Comma-separated list of fields to include (same as list_board_items). - per_page: Number of items per page (default: 50, max: 100). - cursor: Opaque cursor from a previous response to fetch the next page. - verbose: If true, include resolved view/filter debug details. - format: Output format. Default (None) returns human-readable JSONL. - "json" returns a single JSON object with an "items" array - and pagination metadata (see list_board_items for details). - "compact" returns columnar JSON (see list_board_items). - """ - _VALID_FORMATS = {None, "json", "compact"} - if format not in _VALID_FORMATS: - raise ValueError( - f'Unknown format {format!r}. Must be one of: "json", "compact", or omitted for default JSONL.' - ) - - session = _build_session() - resolved = resolve_view_url(session, view_url=view_url) - - field_list = None - if fields: - field_list = [f.strip() for f in fields.split(",")] - elif resolved.get("view_fields"): - field_list = resolved["view_fields"] - order_warning = None - if fields is None and resolved.get("visible_fields_override") is None: - order_warning = ( - "Warning: URL has no visibleFields override; using saved view default " - "field order, which may differ from the live UI column order." - ) - - result = list_items( - session, - org=resolved["org"], - project_number=resolved["project_number"], - query=resolved["effective_filter"], - fields=field_list, - per_page=per_page, - cursor=cursor, - ) - - items = result["items"] - debug = result["debug"] - next_cursor = result["next_cursor"] - has_more = result["has_more"] - - display_items = _build_display_items(items) if items else [] - - if format == "json": - return _format_json(display_items, has_more, next_cursor) - if format == "compact": - return _format_compact(display_items, has_more, next_cursor) - - if not items: - msg = f"No items found for view URL: {view_url}" - if order_warning: - msg = order_warning + "\n\n" + msg - if verbose: - msg += ( - "\n\n--- Resolved view ---\n" - + json.dumps(resolved, indent=2, ensure_ascii=False) - + "\n\n--- Query debug ---\n" - + json.dumps(debug, indent=2) - ) - return msg - - lines = [json.dumps(d, ensure_ascii=False) for d in display_items] - - header = f"Found {len(items)} items for view #{resolved['view_number']} ({resolved['view_name']})" - if has_more: - header += " (more available)" - output = header + ":\n" + "\n".join(lines) - if order_warning: - output = order_warning + "\n\n" + output - - if has_more: - output += ( - f"\n\n--- Next page ---\nPass this cursor to fetch more: {next_cursor}" - ) - - if verbose: - output += ( - "\n\n--- Resolved view ---\n" - + json.dumps(resolved, indent=2, ensure_ascii=False) - + "\n\n--- Query debug ---\n" - + json.dumps(debug, indent=2) - ) - - return output - - -@mcp.tool() -def get_board_item(item_ref: str) -> str: - """Get full details of a specific project board item. - - Args: - item_ref: Item reference. Supported formats: - - "repo#number" (e.g., "dealbot#111") - - "owner/repo#number" (e.g., "FilOzone/dealbot#111") - - Full URL (e.g., "https://github.com/FilOzone/dealbot/issues/111") - - Returns: - All field values for the item. - """ - session = _build_session() - details = get_item( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - item_ref=item_ref, - ) - - if details is None: - return f"Item not found: {item_ref}" - - display = { - k: v - for k, v in details.items() - if not k.startswith("_") and v not in (None, "") - } - return json.dumps(display, ensure_ascii=False, indent=2) - - -@mcp.tool() -def list_board_fields() -> str: - """List all fields on the project board and their REST numeric IDs. - - Returns: - List of field names available on the project. - """ - session = _build_session() - fields = client_list_fields( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - ) - - lines = [f" {name} (id: {fid})" for name, fid in sorted(fields.items())] - return f"Project fields ({len(fields)}):\n" + "\n".join(lines) - - -@mcp.tool() -def list_board_field_options(field_name: str) -> str: - """List available options for a project board field. - - Useful for single-select fields (Status, Cycle Theme, Kind, etc.) - and iteration fields (Cycle) to see what values are valid. - - Args: - field_name: Name of the field (e.g., "Status", "Cycle Theme", "Cycle"). - - Returns: - Available options/values for the field. - """ - session = _build_session() - data = client_list_field_options( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - field_name=field_name, - ) - - fields = data.get("fields", {}) - if not fields: - return f"Field not found: {field_name}" - - field_info = next(iter(fields.values())) - field_type = field_info.get("type", "unknown") - - if field_type == "single_select": - options = field_info.get("options", []) - lines = [f" {opt['name']}" for opt in options] - return f"Options for '{field_name}' ({len(options)}):\n" + "\n".join(lines) - - if field_type == "iteration": - active = field_info.get("iterations", []) - completed = field_info.get("completed_iterations", []) - lines = ["Active iterations:"] - for it in active: - start = it.get("startDate", "") - lines.append(f" {it['title']} (starts: {start})") - if completed: - lines.append(f"Completed iterations ({len(completed)}):") - for it in completed[:5]: - lines.append(f" {it['title']}") - if len(completed) > 5: - lines.append(f" ... and {len(completed) - 5} more") - return "\n".join(lines) - - return f"Field '{field_name}' is type '{field_type}' (no predefined options)" - - -@mcp.tool() -def set_board_item_field( - item_ref: str, - field_name: str, - value: str, -) -> str: - """Set a project board field value on an item. - - Use this for project-level fields like Status, Cycle Theme, Dev Days Estimate, Cycle. - For issue/PR-level changes (assignees, milestones, reviewers), use `gh` CLI instead. - - Args: - item_ref: Item reference (e.g., "dealbot#111", "FilOzone/synapse-sdk#250", or URL). - field_name: Display name of the project field (e.g., "Status", "Cycle Theme"). - Use list_board_field_options to see valid values for a field. - value: The value to set. For single-select fields, use the option name - (e.g., "🐱 Todo", "⌨️ In Progress"). For iteration fields, use the - iteration title. For number fields, use a numeric string. - Pass an empty string ("") to clear the field. - - Returns: - Result of the mutation (success/failure, old and new values). - """ - session = _build_session() - - result = set_field_value( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - item_ref=item_ref, - field_name=field_name, - value=value, - ) - - if result.get("success"): - old = result.get("old_value", "") - new = result.get("new_value", "") - - log_action( - tool="set_board_item_field", - params={ - "org": GITHUB_ORG, - "project_number": GITHUB_PROJECT_NUMBER, - "item_ref": item_ref, - "field_name": field_name, - "value": value, - }, - result="success", - old_value=old, - new_value=new if new else "(cleared)", - ) - - if new: - from_part = f'from "{old}" ' if old else "" - return f'Updated {item_ref}: {field_name} {from_part}to "{new}"' - else: - was_part = f'(was "{old}")' if old else "(was already empty)" - return f"Cleared {item_ref}: {field_name} {was_part}" - else: - return f"Failed: {result.get('error', 'unknown error')}" - - -@mcp.tool() -def bulk_set_board_item_field( - item_refs: str, - field_name: str, - value: str, -) -> str: - """Set a project board field value on multiple items at once. - - This is much more efficient than calling set_board_item_field repeatedly. - Field resolution (looking up field ID, option ID) is done once, and - GraphQL mutations are batched. - - Args: - item_refs: Comma-separated item references - (e.g., "dealbot#458, synapse-sdk#748, filecoin-pin#412"). - Also accepts raw project item node IDs (e.g., "PVTI_...") - to skip the per-item lookup — useful when you already have - node IDs from a prior list_board_items call. To get node IDs, - include "Node ID" in the fields parameter of list_board_items. - field_name: Display name of the project field (e.g., "Status", "Cycle Theme"). - Use list_board_field_options to see valid values for a field. - value: The value to set on ALL items. For single-select fields, use the - option name (e.g., "🐱 Todo"). For iteration fields, use the - iteration title. For number fields, use a numeric string. - Pass an empty string ("") to clear the field on all items. - - Returns: - Summary of changes with per-item old → new values and any failures. - """ - refs = [r.strip() for r in item_refs.split(",") if r.strip()] - if not refs: - return "No item references provided." - - session = _build_session() - - result = set_field_value_bulk( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - item_refs=refs, - field_name=field_name, - value=value, - ) - - # Log each successful mutation individually - for r in result.get("results", []): - if r.get("success"): - log_action( - tool="bulk_set_board_item_field", - params={ - "org": GITHUB_ORG, - "project_number": GITHUB_PROJECT_NUMBER, - "item_ref": r["item_ref"], - "field_name": field_name, - "value": value, - }, - result="success", - old_value=r.get("old_value", ""), - new_value=r.get("new_value", ""), - ) - - # Format output - lines = [] - success_count = result.get("success_count", 0) - failure_count = result.get("failure_count", 0) - lines.append(f"Bulk update: {success_count} succeeded, {failure_count} failed") - lines.append("") - - for r in result.get("results", []): - if r.get("success"): - old = r.get("old_value", "") - new = r.get("new_value", "") - if new: - from_part = f'from "{old}" ' if old else "" - lines.append(f' ✓ {r["item_ref"]}: {field_name} {from_part}to "{new}"') - else: - was_part = f'cleared (was "{old}")' if old else "already empty" - lines.append(f" ✓ {r['item_ref']}: {field_name} {was_part}") - else: - lines.append(f" ✗ {r['item_ref']}: {r.get('error', 'unknown error')}") - - return "\n".join(lines) - - @mcp.tool() -def get_action_log(count: int = 20) -> str: - """Get recent FilOzzy actions from the action log. +def get_board_context() -> str: + """Get board identity, API base URL, and endpoint documentation. - Args: - count: Number of recent actions to retrieve (default: 20). + Returns everything an LLM agent needs to interact with the board: + - Board identity (org, project number, names) + - API base URL and OpenAPI spec link + - Endpoint catalog with descriptions and example curl commands + - Query syntax reference for filtering board items - Returns: - Recent actions taken by FilOzzy, newest last. + This tool does NOT return board data — use the REST API endpoints + directly via curl to fetch items, update fields, etc. """ - actions = read_recent_actions(count) - if not actions: - return "No actions recorded yet." - - lines = [] - for action in actions: - ts = action.get("timestamp", "?") - tool = action.get("tool", "?") - params = action.get("params", {}) - result = action.get("result", "?") - old = action.get("old_value", "") - new = action.get("new_value", "") - - desc = f"[{ts}] {tool}: {json.dumps(params, ensure_ascii=False)} -> {result}" - if old or new: - desc += f' (was: "{old}", now: "{new}")' - lines.append(desc) - - return f"Recent actions ({len(actions)}):\n" + "\n".join(lines) + base = API_BASE_URL.rstrip("/") + prefix = f"{base}/orgs/{GITHUB_ORG}/projects/{GITHUB_PROJECT_NUMBER}" + + return f"""## Board Identity + +- **Organization**: {GITHUB_ORG} +- **Project Number**: {GITHUB_PROJECT_NUMBER} +- **Board Names**: {", ".join(BOARD_NAMES)} +- **API Base URL**: {base} +- **OpenAPI Spec**: {base}/openapi.json (fetch this for full endpoint docs, parameters, and schemas) +- **Interactive Docs**: {base}/docs + +## Quick Start + +All endpoints require `Authorization: Bearer $GITHUB_TOKEN` header. +The API URL pattern is: `{base}/orgs/{{org}}/projects/{{project_number}}/...` + +**List non-Done PRs (direct to disk):** +``` +curl -s -G "{prefix}/items" \\ + --data-urlencode 'query=is:pr -status:"🎉 Done"' \\ + --data-urlencode 'per_page=100' \\ + -H "Authorization: Bearer $GITHUB_TOKEN" > board_prs.json +``` + +Always use `curl -G --data-urlencode` for queries — manual percent-encoding of emojis and spaces is fragile. + +**Get a single item** (URL-encode # as %23): +``` +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" "{prefix}/items/dealbot%23458" +``` + +**Set a field** (single or bulk — accepts PVTI_ node IDs from prior list call to skip lookups): +``` +curl -s -X PUT -H "Authorization: Bearer $GITHUB_TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{{"item_refs": ["dealbot#458", "synapse-sdk#748"], "value": "🎉 Done"}}' \\ + "{prefix}/items/field/Status" +``` + +For field names with spaces, URL-encode the space (e.g., `Cycle%20Theme`): +``` +curl -s -X PUT -H "Authorization: Bearer $GITHUB_TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{{"item_refs": ["dealbot#458"], "value": "Dealbot"}}' \\ + "{prefix}/items/field/Cycle%20Theme" +``` + +Fetch `{base}/openapi.json` for the complete endpoint reference including +all parameters, request/response schemas, and detailed descriptions. + +## Query Syntax Reference + +{QUERY_SYNTAX_REFERENCE} + +## When to Use Which Tool + +| Operation | Tool | Why | +|---|---|---| +| List/filter board items | This API (curl) | Server-side filtering, ~200 bytes/item, direct to disk | +| Set board field (Status, Cycle Theme, etc.) | This API (curl) | Name-based mutations (no raw IDs), supports single and batch | +| PR review state, draft status | `gh pr view --json reviewDecision,reviews,isDraft` | Not a board field — lives on the PR | +| Set assignees | `gh issue edit --add-assignee` or `gh pr edit --add-assignee` | Not a board field — lives on the issue/PR | +| Set milestone | `gh issue edit --milestone` | Not a board field — lives on the issue/PR | +| Label operations | `gh issue edit --add-label` | Not a board field — lives on the issue/PR | +| Request reviews | `gh pr edit --add-reviewer` | Not a board field — lives on the PR | +| Discover field options | `gh project field-list 14 --owner {GITHUB_ORG} --format json` | Native GitHub tool | + +**Do NOT use GitHub's projects MCP (`projects_get`/`projects_write`) for board item reads** — each item +response is verbose enough that 50 items will consume ~100K tokens of LLM context. +See [github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383). +""" def main() -> None: diff --git a/filozzy-mcp/pyproject.toml b/filozzy-mcp/pyproject.toml index daf55c2..3dac33c 100644 --- a/filozzy-mcp/pyproject.toml +++ b/filozzy-mcp/pyproject.toml @@ -6,10 +6,12 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "mcp>=1.0", - "requests>=2.31", "github-projects-client", ] +[tool.uv.sources] +github-projects-client = { path = "../github-projects-client", editable = true } + [project.scripts] filozzy-mcp = "filozzy_mcp.server:main" @@ -17,9 +19,6 @@ filozzy-mcp = "filozzy_mcp.server:main" requires = ["hatchling>=1.24.2"] build-backend = "hatchling.build" -[tool.uv.sources] -github-projects-client = { path = "../github-projects-client", editable = true } - [tool.hatch.build.targets.wheel] packages = ["filozzy_mcp"] diff --git a/filozzy-mcp/tests/test_format.py b/filozzy-mcp/tests/test_format.py index 06f5d4d..6c3d702 100644 --- a/filozzy-mcp/tests/test_format.py +++ b/filozzy-mcp/tests/test_format.py @@ -1,4 +1,4 @@ -"""Unit tests for output format helpers (_format_json, _format_compact). +"""Unit tests for the get_board_context coordinator tool. These are pure-logic tests — no network access or GITHUB_TOKEN required. @@ -9,158 +9,46 @@ from __future__ import annotations -import json - -from filozzy_mcp.server import _format_json, _format_compact - - -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - -SAMPLE_ITEMS = [ - { - "Repository": "curio", - "Title": "Fix X", - "Status": "⌨️ In Progress", - "Node ID": "PVTI_abc", - "Assignees": "alice", - }, - { - "Repository": "dealbot", - "Title": "Add Y", - "Status": "📌 Triage", - # Node ID and Assignees intentionally missing (sparse row) - }, - { - "Repository": "curio", - "Title": "Refactor Z", - "Status": "👀 Awaiting review", - "Node ID": "PVTI_xyz", - "Assignees": "bob, carol", - }, -] - - -def _parse(result: str) -> dict: - return json.loads(result) - - -# --------------------------------------------------------------------------- -# _format_json -# --------------------------------------------------------------------------- - - -class TestFormatJson: - def test_basic_structure(self): - out = _parse(_format_json(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - assert out["total_in_page"] == 3 - assert len(out["items"]) == 3 - assert "has_more" not in out - assert "next_cursor" not in out - - def test_items_are_original_dicts(self): - out = _parse(_format_json(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - assert out["items"][0]["Repository"] == "curio" - assert out["items"][1]["Title"] == "Add Y" - - def test_pagination_fields(self): - out = _parse(_format_json(SAMPLE_ITEMS, has_more=True, next_cursor="cur_42")) - assert out["has_more"] is True - assert out["next_cursor"] == "cur_42" - - def test_empty_items(self): - out = _parse(_format_json([], has_more=False, next_cursor=None)) - assert out["items"] == [] - assert out["total_in_page"] == 0 - - -# --------------------------------------------------------------------------- -# _format_compact -# --------------------------------------------------------------------------- - - -class TestFormatCompact: - def test_columns_are_union_of_all_keys(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - assert set(out["columns"]) == { - "Repository", - "Title", - "Status", - "Node ID", - "Assignees", - } - - def test_column_order_follows_first_seen(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - # First item's keys define the initial order - cols = out["columns"] - assert cols.index("Repository") < cols.index("Title") - assert cols.index("Title") < cols.index("Status") - - def test_row_count_matches_items(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - assert len(out["rows"]) == 3 - assert out["total_in_page"] == 3 - - def test_row_values_match_items(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - cols = out["columns"] - first_row = dict(zip(cols, out["rows"][0])) - assert first_row["Repository"] == "curio" - assert first_row["Title"] == "Fix X" - assert first_row["Node ID"] == "PVTI_abc" - - def test_sparse_rows_get_empty_string(self): - """Items missing a column that other items have should get ''.""" - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - cols = out["columns"] - second_row = dict(zip(cols, out["rows"][1])) - assert second_row["Node ID"] == "" - assert second_row["Assignees"] == "" - - def test_pagination_fields(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=True, next_cursor="cur_99")) - assert out["has_more"] is True - assert out["next_cursor"] == "cur_99" - - def test_no_pagination_fields_when_not_needed(self): - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - assert "has_more" not in out - assert "next_cursor" not in out - - def test_empty_items(self): - out = _parse(_format_compact([], has_more=False, next_cursor=None)) - assert out["columns"] == [] - assert out["rows"] == [] - assert out["total_in_page"] == 0 - - def test_roundtrip_via_jq_reconstruction(self): - """Verify the documented jq reconstruction produces the original items.""" - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - cols = out["columns"] - # Simulate: jq '[.columns as $c | .rows[] | [$c, .] | transpose | map({(.[0]): .[1]}) | add]' - reconstructed = [ - {col: val for col, val in zip(cols, row)} for row in out["rows"] - ] - # First and third items should match exactly - assert reconstructed[0] == SAMPLE_ITEMS[0] - assert reconstructed[2] == SAMPLE_ITEMS[2] - # Second item had missing fields — reconstruction has "" instead - for key in SAMPLE_ITEMS[1]: - assert reconstructed[1][key] == SAMPLE_ITEMS[1][key] - - def test_compact_is_smaller_than_json(self): - """The whole point: compact should use fewer bytes than json.""" - json_out = _format_json(SAMPLE_ITEMS, has_more=False, next_cursor=None) - compact_out = _format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None) - assert len(compact_out) < len(json_out), ( - f"compact ({len(compact_out)}) should be smaller than json ({len(json_out)})" - ) - - def test_unicode_preserved(self): - """Emoji status values should survive the round-trip.""" - out = _parse(_format_compact(SAMPLE_ITEMS, has_more=False, next_cursor=None)) - cols = out["columns"] - first_row = dict(zip(cols, out["rows"][0])) - assert first_row["Status"] == "⌨️ In Progress" +from filozzy_mcp.server import ( + get_board_context, + GITHUB_ORG, + GITHUB_PROJECT_NUMBER, + API_BASE_URL, +) + + +class TestGetBoardContext: + def test_returns_string(self): + result = get_board_context() + assert isinstance(result, str) + + def test_contains_board_identity(self): + result = get_board_context() + assert GITHUB_ORG in result + assert str(GITHUB_PROJECT_NUMBER) in result + + def test_contains_api_base_url(self): + result = get_board_context() + assert API_BASE_URL in result + + def test_contains_quick_start_examples(self): + result = get_board_context() + assert "/items" in result + assert "PUT" in result + assert "/items/field/" in result + + def test_contains_query_syntax(self): + result = get_board_context() + assert "Query Syntax" in result + assert "status:" in result + assert "is:pr" in result + + def test_contains_openapi_link(self): + result = get_board_context() + assert "/openapi.json" in result + assert "/docs" in result + + def test_contains_curl_examples(self): + result = get_board_context() + assert "curl" in result + assert "Authorization: Bearer" in result diff --git a/filozzy-mcp/tests/test_integration.py b/filozzy-mcp/tests/test_integration.py index 827e462..d5acaf6 100644 --- a/filozzy-mcp/tests/test_integration.py +++ b/filozzy-mcp/tests/test_integration.py @@ -1,155 +1,45 @@ """ -Integration tests for the filozzy-mcp MCP layer against the live GitHub API. +Integration tests for the filozzy-mcp MCP coordinator. -These tests cover MCP-specific behavior (docstring validation, tool formatting). -Shared client tests live in github-projects-client/tests/test_integration.py. - -Requirements: - - GITHUB_TOKEN env var (or `gh auth token` available) - - Network access to api.github.com - - Read access to FilOzone org project #14 +The coordinator no longer calls the GitHub API directly — it only +returns board context and API usage instructions. These tests verify +the coordinator tool works correctly. Run: cd filozzy-mcp - GITHUB_TOKEN=$(gh auth token) uv run pytest tests/test_integration.py -v + uv run pytest tests/test_integration.py -v """ from __future__ import annotations -import os -import re -import subprocess - -import pytest -import requests - -from github_projects_client import list_items -from filozzy_mcp.server import list_board_items, GITHUB_ORG, GITHUB_PROJECT_NUMBER - -pytestmark = pytest.mark.integration - - -@pytest.fixture(scope="session") -def session() -> requests.Session: - """Build a GitHub API session from env or gh CLI.""" - token = os.environ.get("GITHUB_TOKEN") - if not token: - try: - token = subprocess.check_output( - ["gh", "auth", "token"], - text=True, - ).strip() - except (subprocess.CalledProcessError, FileNotFoundError): - pytest.skip("No GITHUB_TOKEN and gh CLI unavailable") - - s = requests.Session() - s.headers.update( - { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - ) - return s - - -# --------------------------------------------------------------------------- -# MCP tool docstring validation -# --------------------------------------------------------------------------- - - -class TestListBoardItemsDocstringExamples: - """Validate list_board_items docstring query examples against live API.""" - - @staticmethod - def _parse_docstring_query_examples() -> tuple[list[str], list[str]]: - doc = list_board_items.__doc__ - assert doc is not None - - runnable_queries: list[str] = [] - skipped_queries: list[str] = [] - for line in doc.splitlines(): - match = re.match(r"^\s{2,}(.+?)\s+—\s+.+$", line) - if not match: - continue - query = match.group(1).strip() - if not query: - continue - - should_skip = False - if query.startswith('"search text"'): - should_skip = True - if query == "blocking:FilOzone/dealbot#470": - should_skip = True - if re.match(r'^[a-zA-Z0-9_:"@#.,><\-/\s]+$', query): - if should_skip: - skipped_queries.append(query) - else: - runnable_queries.append(query) - - seen = set() - runnable_ordered: list[str] = [] - for q in runnable_queries: - if q in seen: - continue - seen.add(q) - runnable_ordered.append(q) - - seen.clear() - skipped_ordered: list[str] = [] - for q in skipped_queries: - if q in seen: - continue - seen.add(q) - skipped_ordered.append(q) - - return runnable_ordered, skipped_ordered - - def test_docstring_examples_return_non_empty(self, session: requests.Session): - examples, _skipped = self._parse_docstring_query_examples() - assert len(examples) > 0, ( - "No query examples parsed from list_board_items docstring" - ) - print(f"Extracted {len(examples)} docstring query examples:") - for idx, query in enumerate(examples, start=1): - print(f" {idx:02d}. {query}") - - empty_results: list[str] = [] - for query in examples: - result = list_items( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - query=query, - per_page=1, - ) - if len(result["items"]) == 0: - empty_results.append(query) - - assert not empty_results, ( - "These docstring query examples returned no items:\n" - + "\n".join(f"- {q}" for q in empty_results) - ) - - def test_docstring_skipped_examples_are_syntactically_accepted( - self, session: requests.Session - ): - _examples, skipped = self._parse_docstring_query_examples() - assert len(skipped) > 0, ( - "No skipped query examples parsed from list_board_items docstring" - ) - print( - f"Skipped {len(skipped)} docstring query examples (syntax-only validation):" - ) - for idx, query in enumerate(skipped, start=1): - print(f" {idx:02d}. {query}") - - for query in skipped: - result = list_items( - session, - org=GITHUB_ORG, - project_number=GITHUB_PROJECT_NUMBER, - query=query, - per_page=1, - ) - assert "items" in result - assert "has_more" in result +from filozzy_mcp.server import get_board_context, GITHUB_ORG, GITHUB_PROJECT_NUMBER + + +class TestCoordinatorIntegration: + """Verify the coordinator returns complete board context.""" + + def test_context_includes_key_content(self): + result = get_board_context() + # Quick start examples + assert "/items" in result + assert "PUT" in result + assert "/items/field/" in result + # Query syntax reference + assert "status:" in result + assert "is:pr" in result + # When to use which tool + assert "gh pr view" in result + # OpenAPI pointer + assert "/openapi.json" in result + + def test_context_includes_board_identity(self): + result = get_board_context() + assert GITHUB_ORG in result + assert str(GITHUB_PROJECT_NUMBER) in result + assert "FOC Board" in result + + def test_no_github_token_required(self): + """The coordinator should work without GITHUB_TOKEN.""" + # If we got here, the module imported without GITHUB_TOKEN — pass + result = get_board_context() + assert len(result) > 100 diff --git a/filozzy-mcp/uv.lock b/filozzy-mcp/uv.lock index cdaf4df..32fe782 100644 --- a/filozzy-mcp/uv.lock +++ b/filozzy-mcp/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.13" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -217,6 +226,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985 }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683 }, +] + [[package]] name = "filozzy-mcp" version = "0.1.0" @@ -224,7 +249,6 @@ source = { editable = "." } dependencies = [ { name = "github-projects-client" }, { name = "mcp" }, - { name = "requests" }, ] [package.dev-dependencies] @@ -236,7 +260,6 @@ dev = [ requires-dist = [ { name = "github-projects-client", editable = "../github-projects-client" }, { name = "mcp", specifier = ">=1.0" }, - { name = "requests", specifier = ">=2.31" }, ] [package.metadata.requires-dev] @@ -247,14 +270,23 @@ name = "github-projects-client" version = "0.1.0" source = { editable = "../github-projects-client" } dependencies = [ + { name = "fastapi" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.31" }] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "requests", specifier = ">=2.31" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.3" }] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=9.0.3" }, +] [[package]] name = "h11" @@ -278,6 +310,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -554,6 +608,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, ] +[[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/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -696,11 +786,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, ] [[package]] @@ -715,3 +805,133 @@ sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e wheels = [ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926 }, ] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] diff --git a/foc-board-rules/README.md b/foc-board-rules/README.md index 8b2f1b2..fc80332 100644 --- a/foc-board-rules/README.md +++ b/foc-board-rules/README.md @@ -1,6 +1,6 @@ # FOC Board Rules -Rules for keeping the [FilOzone FOC project board](https://github.com/orgs/FilOzone/projects/14) consistent and up-to-date. These rules are intended to be applied by an LLM (via the FilOzzy MCP server) or by a human during board triage. +Rules for keeping the [FilOzone FOC project board](https://github.com/orgs/FilOzone/projects/14) consistent and up-to-date. These rules are intended to be applied by an LLM (via the board REST API, discovered through the FilOzzy MCP coordinator) or by a human during board triage. These rules will eventually move to a shared Notion document. For now they live here so they can be iterated on alongside the tooling. @@ -16,7 +16,7 @@ When applying rules (whether by LLM or human): 4. **Always include item titles and hyperlinks.** When listing items (in summaries, flags, or reports), always include the item title alongside the `org/repo#number` reference, and render the reference as a clickable hyperlink (e.g., `[filecoin-pay-explorer#162](https://github.com/FilOzone/filecoin-pay-explorer/issues/162)`). The number alone lacks context, and non-linked references require manual navigation to act on. -5. **Choose query strategy by scope.** When enforcing a *single rule* or verifying a *specific condition*, use targeted board queries (e.g., `is:pr -status:"🎉 Done" no:cycle-theme` to find PRs missing a Cycle Theme). When doing a *full rule sweep* across all rules, fetch all open items in one query (`is:pr -status:"🎉 Done"`) and evaluate each item against every rule — this avoids redundant overlapping queries and is more efficient overall. See `list_board_items` tool docs for filter syntax. **For field-gap checks** (R-FC-005, R-FC-006, R-FC-008), always use `no:field` filter queries (e.g., `no:cycle`, `no:cycle-theme`, `no:assignee`) rather than scanning bulk results — bulk output doesn't clearly distinguish "field is empty" from "field not returned." +5. **Choose query strategy by scope.** When enforcing a *single rule* or verifying a *specific condition*, use targeted board queries (e.g., `is:pr -status:"🎉 Done" no:cycle-theme` to find PRs missing a Cycle Theme). When doing a *full rule sweep* across all rules, fetch all open items in one query (`is:pr -status:"🎉 Done"`) and evaluate each item against every rule — this avoids redundant overlapping queries and is more efficient overall. See the query syntax reference (returned by `get_board_context`) for filter syntax. **For field-gap checks** (R-FC-005, R-FC-006, R-FC-008), always use `no:field` filter queries (e.g., `no:cycle`, `no:cycle-theme`, `no:assignee`) rather than scanning bulk results — bulk output doesn't clearly distinguish "field is empty" from "field not returned." 6. **Supplement board data with GitHub PR metadata in two phases.** The project board provides field values (Status, Cycle Theme, Milestone, etc.) but not PR-specific metadata like author, draft status, reviewer assignments, or review decisions. Many rules (R-PR-001, R-PR-002, R-PR-005, R-PR-006, R-PR-007, R-SL-001) need this metadata. Use a two-phase approach to keep context lean. @@ -25,7 +25,7 @@ When applying rules (whether by LLM or human): - R-PR-002/003/004: `author` → bot/release detection - R-PR-005: `isDraft` - R-PR-007: `reviewRequests` → identifies Phase 2 candidates (empty `reviewRequests` is ambiguous — pending requests are consumed when a review is submitted, so empty ≠ "no engagement") - - Initial triage for R-PR-006, R-SL-001, R-SL-007: `reviewDecision` gives the quick signal for which PRs need Phase 2. **Caveat:** `reviewDecision: ""` (empty) is ambiguous — it does NOT mean "no reviews". COMMENTED reviews, approvals from non-CODEOWNERS, and reviews that don't satisfy branch protection all leave `reviewDecision` empty. Never treat empty as "no engagement" — always Phase 2 these PRs if a status change is under consideration. + - Initial triage for R-PR-006, R-SL-001, R-SL-007: `reviewDecision` gives the quick signal for which PRs need Phase 2. **Caveat:** `reviewDecision: ""` (empty) is ambiguous — it does NOT mean "no reviews". COMMENTED reviews, approvals from non-CODEOWNERS, and reviews that don't satisfy branch protection all leave `reviewDecision` empty. Never treat empty as "no engagement" — but **only Phase 2 if a status change is actually under consideration**. A PR already in Awaiting Review with pending `reviewRequests` and no competing rule trigger (R-SL-001, R-SL-007) is correctly placed — skip Phase 2. **Phase 2 — targeted per-PR deep dive.** For specific PRs identified by cross-referencing Phase 1 data with board status, run `gh pr view -R --json reviews,commits,reviewRequests`. Phase 2 candidates: - **R-PR-006 (status determination):** Non-draft, non-bot PRs in Triage or In Progress — need `reviews` and `commits` to compare last human review timestamp vs last commit timestamp. **This includes PRs with `reviewDecision: ""`** — empty does NOT mean "no reviews"; it means GitHub hasn't produced a formal verdict (e.g., reviews exist but don't satisfy branch protection rules, or only COMMENTED reviews were submitted). Always Phase 2 before changing status on these PRs. @@ -43,15 +43,15 @@ When applying rules (whether by LLM or human): **Avoid: `gh search prs`** — The search index has lag and a 200-result limit that can silently truncate results. -7. **Use bulk operations when possible.** When applying the same field+value to multiple items, use `bulk_set_board_item_field` instead of individual `set_board_item_field` calls. This is common when applying a rule that affects many items the same way (e.g., setting Cycle Theme on several PRs from the same repo, or moving multiple dependabot PRs from Triage to Todo). Even two items is worth batching — it saves a tool call and resolves field info only once. +7. **Use bulk operations when possible.** When applying the same field+value to multiple items, use the mutation endpoint (`PUT .../items/field/{field_name}`) with multiple `item_refs` instead of separate calls. This is common when applying a rule that affects many items the same way (e.g., setting Cycle Theme on several PRs from the same repo, or moving multiple dependabot PRs from Triage to Todo). Even two items is worth batching — it saves a round-trip and resolves field info only once. 8. **Flag unfamiliar Cycle Theme values.** You don't need to proactively audit all Cycle Theme values, but if while processing an item you encounter a Cycle Theme that isn't in the established values list (R-FC-004), flag it. It may be a misspelling or an unauthorized new value. 9. **Reconcile counts before and after bulk operations.** When building a bulk operation from query results, verify the item count matches. For example, if a query returns 25 synapse-sdk items, the bulk call should contain exactly 25 item refs. After applying, re-query to confirm zero items remain. Manually transcribing item IDs from large result sets is error-prone — group items programmatically (e.g., by repo) rather than cherry-picking from a wall of text. -10. **Prefer `list_board_items` with extra fields over individual `get_board_item` calls.** When you need additional fields (e.g., Parent issue, Milestone) for a set of items, re-query with `list_board_items` including those fields rather than calling `get_board_item` on each item individually. One call with `fields: "Repository, Id, Title, Status, Parent issue, Milestone"` replaces N individual lookups. Reserve `get_board_item` for when you need the full detail on a single specific item. +10. **Prefer `GET .../items` with extra fields over individual item lookups.** When you need additional fields (e.g., Parent issue, Milestone) for a set of items, re-query `GET .../items` including those fields rather than fetching each item individually via `GET .../items/{ref}`. One call with `fields=Repository,Id,Title,Status,Milestone` replaces N individual lookups. Reserve the single-item endpoint for when you need the full detail on one specific item. - **Note:** `list_board_items` returns relationship fields (Parent issue, Linked pull requests) as display strings (e.g., `"Cleanup epic"`, not `"dealbot#271"`). To get a durable identifier (repo#number), search for the item by title on the board. Don't treat a title-only string as a dead end — it's enough to look up the item. + **Note:** `GET .../items` returns relationship fields (Parent issue, Linked pull requests) as display strings (e.g., `"Cleanup epic"`, not `"dealbot#271"`). To get a durable identifier (repo#number), search for the item by title on the board. Don't treat a title-only string as a dead end — it's enough to look up the item. 11. **Check reviewer permissions before acting on approvals.** Some rules (R-SL-001) require confirming that a reviewer has sufficient access (write, maintain, or admin) before treating their approval as authoritative. Use `gh api repos/{owner}/{repo}/collaborators/{username}/permission --jq '.permission'` to check — look for `write`, `maintain`, or `admin`. An approval from a user with only `read` or `triage` access doesn't unblock a PR for merge. When checking multiple reviewers across repos, batch by repo to minimize API calls (one permission check per unique reviewer-repo pair). diff --git a/foc-board-rules/field-completeness.md b/foc-board-rules/field-completeness.md index c0bfcca..69d1120 100644 --- a/foc-board-rules/field-completeness.md +++ b/foc-board-rules/field-completeness.md @@ -18,13 +18,15 @@ Rules for ensuring board items have the right fields populated based on their st ## R-FC-001: In-flight and done items must have an assignee **When:** An [active item](status-lifecycle.md#terminology) or recently-done item (per R-FC-008) has no assignee. -**Exclude:** Items with Cycle Theme "zOrganizing Item" (meta/tracking items) and [external items](status-lifecycle.md#terminology) (assignees can't be set). +**Exclude:** Items with Cycle Theme "zOrganizing Item" (meta/tracking items), [external items](status-lifecycle.md#terminology) (assignees can't be set), and bot-created Done items (automated reports/releases with no human DRI — unassigned is fine). **Action:** Determine the assignee using this priority order: 1. **PRs:** Assign to the PR author (per [R-PR-001](pr-hygiene.md#r-pr-001-unassigned-prs-should-be-assigned-to-their-author)). Skip if the author is a bot. -2. **Issues with linked PRs:** Assign to the assignee of the linked PR. Use `get_board_item` to find "Linked pull requests", then look up the PR's assignee. +2. **Issues with linked PRs:** Assign to the assignee of the linked PR. Fetch the item via `GET .../items/{ref}` to find "Linked pull requests", then look up the PR's assignee. 3. **Issues without linked PRs:** Investigate the issue's comment stream and description for who is doing the work. Look for patterns like: who opened it, who is actively commenting with progress updates, who was mentioned as the DRI, who posted the closing comment. 4. **If still uncertain:** Propose an assignee with justification and flag for human confirmation. Do not leave it blank — always make a best-effort proposal. +**After assigning, verify it persisted.** The GitHub API silently succeeds even when the user lacks sufficient repo permissions (write/triage). If the inferred assignee is a contributor without write access (common on blessed-org repos), the assignment won't stick. Flag for human — the user may need elevated access or a different assignee. + **Why:** Every item that has progressed beyond Triage should have someone accountable. Unassigned in-flight items are a planning gap, and unassigned done items get missed in workload reporting. ## R-FC-002: In Progress items should have a Cycle Theme @@ -36,7 +38,7 @@ Rules for ensuring board items have the right fields populated based on their st ## R-FC-003: All open issues should have a Milestone **When:** An **issue** on the board (any status except "🎉 Done") has no milestone set. Exclude items with Cycle Theme "zOrganizing Item" (meta/tracking items) and [external items](status-lifecycle.md#terminology) (milestones can't be set). -**Action:** First, check if the issue has a parent issue (via `get_board_item` — look for "Parent issue" field). If the parent has a milestone, inherit it. Otherwise, flag for human review, grouped by Cycle Theme or repository. Report the item's current status, assignee, and Cycle Theme to help the human decide. +**Action:** First, check if the issue has a parent issue (via `GET .../items/{ref}` — look for "Parent issue" field). If the parent has a milestone, inherit it. Otherwise, flag for human review, grouped by Cycle Theme or repository. Report the item's current status, assignee, and Cycle Theme to help the human decide. **Scope:** Issues only. **Milestones on PRs are optional** — PRs often inherit their delivery context from the issue they close, and many repos don't milestone PRs at all. **Why:** Every real issue should be tied to a delivery milestone so it's tracked against a timeline. Issues without milestones fall through the cracks during planning. Even Triage items benefit from early milestone assignment — it helps prioritize what to triage first. @@ -87,14 +89,14 @@ If no reasonable inference can be made, flag for human review — do not invent ## R-FC-006: In-flight PRs without a cycle should be in the current cycle **When:** A PR on the board has no Cycle set and is **actively in flight** — meaning its status is "⌨️ In Progress", "🔎 Awaiting review", or "✔️ Approved by reviewer". Also applies to **dependabot and release PRs in "🐱 Todo"** — these are known mechanical work items that will get done in the current cycle, not speculative backlog. -**Action:** Set the Cycle to the current active cycle. Use `list_board_field_options("Cycle")` to find the current iteration. +**Action:** Set the Cycle to the current active cycle. Use `gh project field-list` (with the org and project number from `get_board_context`) to find the current iteration. **Scope:** Does **not** apply to non-bot Todo PRs or Triage PRs. Todo PRs from humans — especially those with future milestones (MX, M4.5) — are backlog items. Forcing them into the current cycle overstates what's actually being worked on. They'll get a cycle when they move to In Progress. **Why:** PRs actively being worked on or reviewed should be visible in cycle planning. Dependabot and release PRs in Todo are different from human-authored Todo PRs — they represent known, bounded work that belongs in the current cycle for tracking purposes. ## R-FC-009: In-flight items in active milestones should have a cycle **When:** An item (PR or issue) has a milestone that is currently active, has no Cycle set, and is **actively in flight** — meaning its status is "⌨️ In Progress", "🔎 Awaiting review", "✔️ Approved by reviewer", or "⌚️ Issue awaiting PR merge". -**Action:** Set the Cycle to the current active cycle. Use `list_board_field_options("Cycle")` to find the current iteration. +**Action:** Set the Cycle to the current active cycle. Use `gh project field-list` (with the org and project number from `get_board_context`) to find the current iteration. **Scope:** Does **not** apply to items in "📌 Triage" or "🐱 Todo" — those are backlog items planned for the milestone but not yet started. Adding them to the current cycle would overstate the cycle's scope. They'll get a cycle when work begins. **Active milestones** (update this list as milestones are retired/created): diff --git a/foc-board-rules/future-ideas.md b/foc-board-rules/future-ideas.md index 76b233d..1b85d9c 100644 --- a/foc-board-rules/future-ideas.md +++ b/foc-board-rules/future-ideas.md @@ -2,47 +2,36 @@ Ideas for improving the FOC board tooling, collected during rule application sessions. -## Augmented PR metadata endpoint in MCP +## Augmented PR metadata endpoint in REST API -Add an MCP tool (or option on `list_board_items`) that returns GitHub PR metadata (author, isDraft, reviewDecision, reviewRequests) alongside board field data, so the LLM doesn't need to make separate `gh pr list` calls per repo. +Add a REST API endpoint (or option on `GET .../items`) that returns GitHub PR metadata (author, isDraft, reviewDecision, reviewRequests) alongside board field data, so the agent doesn't need to make separate `gh pr list` calls per repo. -**Why it would help:** In the first real sweep (2026-05-04), the `gh pr list` calls with full `reviews` bodies consumed thousands of context lines — mostly for PRs not on the board (e.g., curio has 15+ open PRs but only 1 on the board). This caused scanning errors, missed items, and incomplete follow-through on R-PR-006 candidates. The two-phase approach (general behavior rule 6) mitigates this, but the MCP server could do even better: +**Why it would help:** In the first real sweep (2026-05-04), the `gh pr list` calls with full `reviews` bodies consumed thousands of context lines — mostly for PRs not on the board (e.g., curio has 15+ open PRs but only 1 on the board). This caused scanning errors, missed items, and incomplete follow-through on R-PR-006 candidates. The two-phase approach (general behavior rule 6) mitigates this, but the REST API could do even better: - **Filter to board PRs only.** The server knows which items are PRs and which repos they're in. It could query GitHub for just those PRs, not every open PR in the repo. - **Return normalized summaries.** Instead of raw review bodies, return compact fields: `isDraft`, `author`, `reviewDecision`, `reviewRequests` (names only), and optionally `lastCommitDate` / `lastHumanReviewDate` for R-PR-006 timestamp comparisons. -- **Eliminate cross-referencing.** The LLM currently has to mentally join board data with GitHub data across 70+ items. The MCP server could return a single unified view. +- **Eliminate cross-referencing.** The agent currently has to join board data with GitHub data across 70+ items via `jq`. The server could return a single unified view. -**Estimated impact:** Would replace ~10 parallel `gh pr list` calls + ~5 targeted `gh pr view` calls with a single MCP call, and eliminate the most error-prone step of the sweep (cross-referencing two data sources across 70 items). +**Estimated impact:** Would replace ~10 parallel `gh pr list` calls + ~5 targeted `gh pr view` calls with a single API call, and eliminate the most error-prone step of the sweep (cross-referencing two data sources across 70 items). -## Async result refs for large query results +## ~~Async result refs for large query results~~ — Solved -Add an async mode to `list_board_items` (and `list_board_view_items`) where the server stores results and returns only a lightweight reference, rather than streaming the full dataset through the LLM's token pipeline. +**Status: Solved by the REST API architecture.** The REST API server is accessed via `curl`, so data goes directly to disk (`curl ... > $SWEEP/file.json`) without ever entering LLM context. This eliminates the MCP context-passthrough problem entirely — no async refs needed. The Write-tool workarounds that were documented in the sweep playbook are no longer necessary. -**The problem:** MCP tool results flow through the LLM context as tokens. When a board query returns 61 PRs, the full JSON blob (~15K characters) is tokenized as input into the agent's context. The agent then re-emits that entire blob as a parameter to the Write tool to save it to disk. The same data is tokenized twice — once in, once out — and neither pass involves the agent actually reasoning about the content. It's pure passthrough. Multiply by 6-7 board queries per sweep, and you're burning significant token I/O on data that just needs to get to a file. +## Remove `format=compact` from the REST API -The fundamental problem: **data must pass through the LLM context as a waypoint to reach disk.** There is no "pipe MCP output directly to a file" path in the MCP protocol. +The compact columnar format (`format=compact`) was designed for MCP responses that land in LLM context, where token count matters. With the REST API, data goes to disk via curl and gets processed by jq — standard JSON is easier to work with (`jq '.items[]'` vs the columnar reconstruction dance) and disk space isn't a constraint. If no consumer depends on it, remove it from the API and the underlying `formats.py` module. -**Proposed design:** +**Triggered by:** Reviewing the value of each feature against GitHub's own tooling (project tenet). The compact format was a context-window optimization that no longer applies when data bypasses LLM context entirely. -1. Agent calls `list_board_items(query='...', fields='...', async=true)` -2. Server executes the query, stores results (on disk or in memory), auto-paginates to collect all pages, and returns only a summary: - ```json - {"ref": "abc123", "total_items": 62, "endpoint": "/results/abc123"} - ``` -3. Agent fetches the full data via Bash, bypassing context entirely: - ```bash - curl -s /results/abc123 > $SWEEP/board_prs.json - ``` -4. Agent processes with `jq` as usual. +## Remove `GET /fields/{name}/options` endpoint -**Why not just write to disk from the server?** The MCP server will eventually be external (not running on the agent's machine), so it can't write to the agent's local filesystem. The ref-based approach works regardless of where the server runs. +`gh project field-list --format json` returns field names and option names cleanly. The `/fields/{name}/options` endpoint doesn't provide unique value over GitHub's own tooling. Per the [project tenet](../github-projects-client/README.md#project-tenet-prefer-github-supported-tools), it should be retired. -**Why not a more compact format (TSV, columnar JSON)?** Format optimization helps at the margins, but the core issue is architectural: the data shouldn't pass through the LLM context at all. A compact format still gets tokenized twice (in as tool result, out as Write parameter). The ref pattern reduces the token footprint to ~30 tokens regardless of result size. +**Triggered by:** Comparing the endpoint against `gh project field-list` output — both return the same information, and the `gh` version is already available to any agent with CLI access. -**Estimated impact:** Would eliminate ~30K+ tokens of I/O per sweep (conservative — just the main PR query round-trip; more with all board queries). Also eliminates the Write-tool workarounds currently documented in the sweep playbook (Stage 0 workspace, "immediately Write to disk" instructions, pagination merge steps). +## Expose built-in item properties in REST API list endpoint -## Expose built-in item properties in list_board_items +The board REST API returns built-in properties like `updated_at` and `creator` on each item internally, but `GET .../items` only surfaces custom project fields (Status, Cycle Theme, etc.) and a few display fields (Repository, Id, Title). Expose `updated_at` and `creator` as requestable fields so reports like R-FC-010 (Dev Days Estimate gaps) can include "last updated" and "created by" without supplemental GitHub API calls. -The board REST API returns built-in properties like `updated_at` and `creator` on each item, but `list_board_items` only surfaces custom project fields (Status, Cycle Theme, etc.) and a few display fields (Repository, Id, Title). Expose `updated_at` and `creator` as requestable fields so reports like R-FC-010 (Dev Days Estimate gaps) can include "last updated" and "created by" without supplemental GitHub API calls. - -**Triggered by:** Stage 6 (effort estimation gaps) wants to show when each item was last updated and who created it, but neither field is available from the tool today. +**Triggered by:** Stage 6 (effort estimation gaps) wants to show when each item was last updated and who created it, but neither field is available from the API today. diff --git a/foc-board-rules/pr-hygiene.md b/foc-board-rules/pr-hygiene.md index 5852cec..63b369e 100644 --- a/foc-board-rules/pr-hygiene.md +++ b/foc-board-rules/pr-hygiene.md @@ -40,11 +40,13 @@ Rules for keeping pull request items on the FOC board well-formed. **Action:** Determine the correct status based on the PR's review state. Check the PR's reviews, commits, and reviewer permissions to pick the right destination: 1. **Write-access approval, no blocking changes_requested** → `✔️ Approved by reviewer` (per [R-SL-001](status-lifecycle.md#r-sl-001-prs-with-approved-reviews-should-be-approved-by-reviewer)). -2. **Human reviewer left comments/questions/changes_requested and the author has NOT pushed new commits after** → `⌨️ In Progress`. The author still needs to respond to feedback. -3. **Human reviewer left comments but the author HAS pushed new commits after** → `🔎 Awaiting review`. The author likely addressed the feedback and is ready for re-review. +2. **Last human review is more recent than last commit** (`lastHumanReview > lastCommit`) → `⌨️ In Progress`. The reviewer left feedback and the author hasn't responded with new commits yet. +3. **Last commit is more recent than last human review** (`lastCommit > lastHumanReview`) → `🔎 Awaiting review`. The author pushed after the feedback, likely addressing it — the PR is ready for re-review. (A re-requested reviewer further confirms this — the author explicitly asked the reviewer to look again.) 4. **No human reviewer has engaged yet** (only bot reviews or no reviews at all) → `🔎 Awaiting review`. The PR needs initial review. -**How to check:** Use `gh pr view -R --json reviews,commits --jq '{reviews: [.reviews[] | {author: .author.login, state: .state, submittedAt: .submittedAt}], lastCommit: .commits[-1].committedDate}'` to compare the last human review timestamp against the last commit timestamp. Only needed for PRs where the per-repo `gh pr list` data shows human review engagement. +**Note on R-SL-001 re-request exception:** The [R-SL-001](status-lifecycle.md#r-sl-001-prs-with-approved-reviews-should-be-approved-by-reviewer) re-request exception only prevents moving to *Approved* — it does not affect R-PR-006 routing. A re-requested reviewer who previously requested changes means the PR is awaiting their re-review, which is consistent with case 3 (→ Awaiting Review), not a reason to keep the PR in In Progress. + +**How to check:** Use `gh pr view -R --json reviews,commits --jq '{reviews: [.reviews[] | {author: .author.login, state: .state, submittedAt: .submittedAt}], lastCommit: .commits[-1].committedDate}'` to compare timestamps. The key comparison: if `lastHumanReview > lastCommit`, the author hasn't responded yet (case 2); if `lastCommit > lastHumanReview`, the author addressed feedback (case 3). Only needed for PRs where the per-repo `gh pr list` data shows human review engagement. **Why:** Non-draft, non-bot PRs should always leave Triage, and In Progress PRs may need re-evaluation. But the destination depends on review state — not every PR goes to Awaiting Review. A PR with unaddressed feedback belongs in In Progress, a PR with a merge-authority approval belongs in Approved, and a PR with no feedback or addressed feedback belongs in Awaiting Review. This is the counterpart to R-PR-005: when a draft PR becomes non-draft, it advances — but the destination depends on what reviewers have already said. ## R-PR-007: Awaiting Review PRs must have human reviewer engagement diff --git a/foc-board-rules/status-lifecycle.md b/foc-board-rules/status-lifecycle.md index 276d763..0d8458f 100644 --- a/foc-board-rules/status-lifecycle.md +++ b/foc-board-rules/status-lifecycle.md @@ -63,12 +63,13 @@ Rules governing how items should transition through board statuses. ## R-SL-007: PRs with changes requested should move back to In Progress **When:** A PR has status "🔎 Awaiting review" or "✔️ Approved by reviewer" and receives a "changes requested" review from a user **with write/maintain/admin access to the repo**. +**Skip if:** The author has pushed commits after the most recent changes-requested review. This means the author has likely addressed the feedback and the PR is awaiting re-review — consistent with R-PR-006 case 3 (lastCommit > lastReview → Awaiting Review). Only move to In Progress when the changes-requested review is the most recent activity. **Action:** Set Status to `⌨️ In Progress`. **Why:** A reviewer with merge authority requesting changes means the PR needs rework before it can proceed. The board should reflect that it's back in active development, not waiting for review. This is the counterpart to R-SL-001 — just as an approval advances the status, a changes-requested pushes it back. Only objections from reviewers with merge authority trigger this; a "changes requested" from a read-only reviewer doesn't warrant a status change since it doesn't block the PR. ## R-SL-008: Issues with linked PRs should be "Issue awaiting PR merge" -**When:** An issue on the board has one or more linked pull requests (visible via the "Linked pull requests" field in `list_board_items` or via GraphQL `closedByPullRequestsReferences`), **at least one linked PR is actively in flight** (board status is one of "⌨️ In Progress", "🔎 Awaiting review", or "✔️ Approved by reviewer"), and the issue's status is not already "⌚️ Issue awaiting PR merge" or "🎉 Done". +**When:** An issue on the board has one or more linked pull requests (visible via the "Linked pull requests" field in `GET .../items` results or via GraphQL `closedByPullRequestsReferences`), **at least one linked PR is actively in flight** (board status is one of "⌨️ In Progress", "🔎 Awaiting review", or "✔️ Approved by reviewer"), and the issue's status is not already "⌚️ Issue awaiting PR merge" or "🎉 Done". **Action:** Set Status to `⌚️ Issue awaiting PR merge`. Once a PR is actively in flight for an issue, the PR represents the active work — the issue just needs to wait for the PR to land. Also ensure field completeness: if the issue has no assignee, inherit from the linked PR's assignee (per [R-FC-001](field-completeness.md#r-fc-001-in-flight-and-done-items-must-have-an-assignee)); if no cycle, match the linked PR's cycle; if no milestone, check the linked PR or parent issue (per [R-FC-003](field-completeness.md#r-fc-003-all-open-issues-should-have-a-milestone)). **Skip if:** All linked PRs are in "📌 Triage" or "🐱 Todo" (including drafts in backlog). A PR that hasn't started active work doesn't make the issue "awaiting merge" — both the issue and PR are still in planning/backlog. The issue should stay in its current status until the PR moves to In Progress or beyond. **Cycle inheritance for existing "Issue awaiting PR merge" items:** Even if R-SL-008 doesn't trigger a status change (because the issue is already in "Issue awaiting PR merge"), cycle inheritance still applies. When checking items in this status that lack a cycle, look up the linked PR's cycle and inherit it — this applies regardless of the issue's milestone. (Added after dealbot#427 was missed because its MX milestone excluded it from R-FC-009, but its linked PR dealbot#487 was in the current cycle.) diff --git a/foc-board-rules/sweep-agent-prompt.md b/foc-board-rules/sweep-agent-prompt.md index f83ab75..9e0fe28 100644 --- a/foc-board-rules/sweep-agent-prompt.md +++ b/foc-board-rules/sweep-agent-prompt.md @@ -15,19 +15,18 @@ This file tells the agent *how to behave* — disposition, workflow, and known p - `status-lifecycle.md` — Rules R-SL-001 through R-SL-009, plus status definitions and terminology - `field-completeness.md` — Rules R-FC-001 through R-FC-010 -2. **Create the sweep workspace** (see Stage 0 in `sweep-playbook.md`): Run `mkdir /tmp/foc-board-sweep@$(date -u +%Y-%m-%dT%H:%M:%SZ)` and store the path as `$SWEEP`. All working files go here. +2. **Run Stage 0** from `sweep-playbook.md` — this sets up `$GITHUB_TOKEN`, `$API`, `$SWEEP`, discovers the current cycle, and verifies the server is running. -3. **Check the current cycle**: Run `list_board_field_options("Cycle")` to find the current active iteration. - -4. **Note today's date** for time-based queries (staleness checks, recently-done window). +3. **Note today's date** for time-based queries (staleness checks, recently-done window). ## Required tools The sweep agent **must** have access to the following. If any are missing, stop and ask the human to configure them before proceeding. -- **FilOzzy MCP server** (`filozzy` tools): Board queries and mutations. This is non-negotiable — without it you cannot read or write board fields. -- **GitHub MCP server** (`github` tools) **or** **`gh` CLI** (via Bash): At least one of these must be available for reading issues, PRs, reviewer permissions, assignee mutations, and GraphQL queries. The `gh` CLI is preferred for batch operations (Phase 1/Phase 2 metadata, permission checks, REST mutations) due to its flexibility with `--json`, `--jq`, and `gh api`. -- **Bash**: Running `gh` commands and other shell operations. +- **FilOzzy MCP server** (`filozzy` tools): Coordinator that provides board identity, API base URL, and endpoint documentation via `get_board_context`. Call this first to discover the REST API server address. Does NOT make GitHub API calls itself. +- **Board REST API server**: All board queries and mutations go through this server via `curl`. The base URL and OpenAPI spec link come from `get_board_context`. Verify it's running during Stage 0 setup — if not, start it from `github-projects-client/` (see Stage 0 in `sweep-playbook.md`). +- **`gh` CLI** (via Bash): Required for issue/PR metadata (review state, draft status, labels), issue/PR mutations (assignees, milestones, reviewers), GraphQL queries (including cycle iteration discovery), and `GITHUB_TOKEN` export (`gh auth token`). +- **Bash**: Running `curl`, `gh`, `jq`, and other shell operations. ## How to work @@ -64,7 +63,11 @@ These are things that went wrong in past sweeps. The rules cover the "what" — 6. **zOrganizing Items are excluded** from most field completeness rules. Don't try to assign them or set their fields. -7. **Use `format: "compact"`, Write to `$SWEEP/` immediately, and check pagination.** When calling `list_board_items`, pass `format: "compact"` to get columnar JSON (field names once, rows as arrays — ~40-60% fewer tokens than `format: "json"`). **Immediately after receiving each tool result, use the Write tool** (not Bash) **to save the raw JSON string to your `$SWEEP/` directory** (e.g., `$SWEEP/board_prs.json`). The tool result is in your conversation context, not in a shell variable — you cannot `echo` it to a file. The Write tool is the only reliable way to get it to disk. Writing to the sweep workspace (created in Stage 0) ensures all files are new, avoiding the Write tool's read-first requirement for existing files. Convert to objects for `jq` joins with: `jq '[.columns as $c | .rows[] | [$c, .] | transpose | map({(.[0]): .[1]}) | add]'`. **After writing, check `jq -e '.has_more'` on the file** — if true, fetch the next page with the returned `next_cursor` and merge. Use `per_page: 100` to minimize pages. See the playbook's "Cross-referencing board data with GitHub metadata" section for the full pattern. +7. **Each Bash tool call is a fresh shell.** `export`, `PATH`, and shell functions don't persist between calls. Follow the playbook's Stage 0 calling convention exactly — it handles this. + +8. **Don't use `jq -e` in parallel batches.** `jq -e '.has_more'` returns exit code 1 when false, which cancels sibling parallel commands. Use `jq -r` instead. See the playbook's pagination section. + +9. **Keep data on disk, not in context.** Process all query results and action lists with jq on disk. Read only counts or compact summaries into context — never raw JSON walls. This applies to Phase 2 review data, action list contents, and field-gap results. The playbook has specific jq templates for each. Use `PVTI_` node IDs (from board queries) in mutation calls to avoid backend re-resolution. -8. **Use jq to join board and Phase 1 data — don't reason through raw JSON.** Fetch board PRs (to disk, per pitfall 7), fetch Phase 1 per repo, then use `jq` to filter Phase 1 to board-only PRs and produce per-rule action lists (e.g., draft PRs in non-draft statuses, non-draft non-bot in Triage, CHANGES_REQUESTED in Awaiting Review). Doing this join manually in your reasoning is slow, error-prone at scale, and burns context. Produce structured action lists programmatically, then only read individual items that need Phase 2 judgment. +10. **Verify assignee mutations stuck.** The GitHub API returns 201 even when the user lacks write/triage access. Re-read after assigning to confirm. See R-FC-001. diff --git a/foc-board-rules/sweep-playbook.md b/foc-board-rules/sweep-playbook.md index 08fca9a..a914c7a 100644 --- a/foc-board-rules/sweep-playbook.md +++ b/foc-board-rules/sweep-playbook.md @@ -6,15 +6,114 @@ Work through stages in order. Complete all actions and reporting for one stage b ## Stage 0: Create sweep workspace -Before any queries, create a unique temp directory for this sweep's working files: +Before any queries, set up the sweep workspace and discover the board API: + +1. **Call `get_board_context`** (FilOzzy MCP) to get the board's org, project number, and API base URL. +2. **Set shell variables** from the response: + +```bash +SWEEP=/tmp/foc-board-sweep_$(date -u +%Y-%m-%dT%H:%M:%SZ) +mkdir -p "$SWEEP/bin" + +# Persist non-sensitive env so scripts and future shell invocations can source it. +# GITHUB_TOKEN is NOT written to disk — scripts resolve it at runtime via `gh auth token`. +# Values from get_board_context — example, not hardcoded: +cat > "$SWEEP/env.sh" << EOF +export SWEEP="$SWEEP" +export API=/orgs//projects/ +export PATH="$SWEEP/bin:\$PATH" +EOF +source "$SWEEP/env.sh" +``` + +3. **Create helper scripts** for board API calls. These are executable scripts in `$SWEEP/bin/` that source `env.sh` themselves, so they work in any shell invocation: + +```bash +cat > "$SWEEP/bin/foc_gh_get" << 'SCRIPT' +#!/usr/bin/env bash +source "$(dirname "$0")/../env.sh" +TOKEN=${GITHUB_TOKEN:-$(gh auth token)} +# GET with auto --data-urlencode for each arg (handles emoji, spaces, etc.) +endpoint="$1"; shift +args=() +for param in "$@"; do + args+=(--data-urlencode "$param") +done +curl -s -G "$API/$endpoint" \ + -H "Authorization: Bearer $TOKEN" \ + "${args[@]}" +SCRIPT + +cat > "$SWEEP/bin/foc_gh_put" << 'SCRIPT' +#!/usr/bin/env bash +source "$(dirname "$0")/../env.sh" +TOKEN=${GITHUB_TOKEN:-$(gh auth token)} +# PUT with JSON body +curl -s -X PUT "$API/$1" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$2" +SCRIPT + +cat > "$SWEEP/bin/foc_timer" << 'SCRIPT' +#!/usr/bin/env bash +echo "$(date +%s) $1" >> "$(dirname "$0")/../timing.log" +SCRIPT + +chmod +x "$SWEEP/bin/foc_gh_get" "$SWEEP/bin/foc_gh_put" "$SWEEP/bin/foc_timer" +``` + +**Timing:** Use `"$SWEEP/bin/foc_timer" "phase1_start"` at the start and end of each phase/stage to capture timestamps. At the end of each stage, print the timing log to help identify whether slowness is API latency, LLM reasoning, or context processing. + +**Calling the scripts:** Each Bash tool invocation is a fresh shell — `export`, `PATH`, and shell functions all reset. The scripts persist on disk but PATH won't be set, so **always call them with the absolute path** using `$SWEEP`: + +```bash +# At the start of every Bash tool call, set SWEEP (paste the actual path): +SWEEP=/tmp/foc-board-sweep_2026-05-08T... +# Then call scripts with full path: +"$SWEEP/bin/foc_gh_get" items 'query=...' > "$SWEEP/output.json" +``` + +The scripts source `env.sh` internally so `$API` and `$GITHUB_TOKEN` are handled automatically — you only need `$SWEEP` itself. + +4. **Verify the server is running:** Check the port first, then start only if needed. Use dev mode (auto-reloads on code changes): + +```bash +# Check if already running +curl -sf "http://127.0.0.1:8080/openapi.json" > /dev/null && echo "Server already running" || { + cd /github-projects-client + .venv/bin/github-projects-api-dev & + sleep 2 +} +# Verify +"$SWEEP/bin/foc_gh_get" fields > /dev/null +``` + +The full API spec is at `/openapi.json`. + +5. **Discover the current cycle** using GraphQL (note: `gh project field-list` shows the Cycle field exists but does **not** list iteration values): ```bash -mkdir /tmp/foc-board-sweep@$(date -u +%Y-%m-%dT%H:%M:%SZ) +gh api graphql -f query='{ + organization(login: "") { + projectV2(number: ) { + field(name: "Cycle") { + ... on ProjectV2IterationField { + configuration { + iterations { id title startDate duration } + } + } + } + } + } +}' --jq '.data.organization.projectV2.field.configuration.iterations' ``` -Store the path (e.g., `/tmp/foc-board-sweep@2026-05-07T14:30:00Z`) and use it for all file writes throughout the sweep. This keeps artifacts organized per run, avoids collisions with prior sweeps, and — critically — ensures every Write tool call targets a new file (the Write tool requires a prior Read for existing files, but new files in a fresh directory just work). +The first iteration in the list whose date range contains today is the current cycle. -All file path examples in this playbook use `$SWEEP/` as shorthand for this directory. +`$SWEEP` holds all working files for this run (avoids collisions with prior sweeps). `$API` is the board REST API prefix — all board queries and mutations go through it via `curl`. + +All examples in this playbook use `$SWEEP/` as shorthand for this directory. Board API calls use `"$SWEEP/bin/foc_gh_get"` and `"$SWEEP/bin/foc_gh_put"` (scripts created in step 3). **Always use the full path** — each Bash tool call is a fresh shell, so `PATH` and `export` don't persist. Set `SWEEP=` at the start of each Bash call (the scripts handle `$API` and `$GITHUB_TOKEN` internally via `env.sh`). ## Stage 1: Open PRs @@ -24,7 +123,17 @@ All file path examples in this playbook use `$SWEEP/` as shorthand for this dire - Board: `is:pr -status:"🎉 Done"` (all non-Done PRs) - Board: `is:pr is:merged -status:"🎉 Done"` (merged PRs not yet Done — R-PR-008) - Board: `is:pr is:closed -status:"🎉 Done"` (closed PRs not yet Done — R-PR-009) -- Board (field gaps): `is:pr -status:"🎉 Done" no:assignee`, `is:pr -status:"🎉 Done" no:cycle-theme`, `is:pr -status:"🎉 Done" -status:"🐱 Todo" -status:"📌 Triage" no:cycle` — use these targeted queries for field-gap checks (R-PR-001, R-FC-005, R-FC-006) instead of scanning the bulk PR list +- Board (field gaps): `is:pr -status:"🎉 Done" no:assignee`, `is:pr -status:"🎉 Done" no:cycle-theme`, `is:pr -status:"🎉 Done" -status:"🐱 Todo" -status:"📌 Triage" no:cycle` — use these targeted queries for field-gap checks (R-PR-001, R-FC-005, R-FC-006) instead of scanning the bulk PR list. **Process field-gap results on disk, not in context.** Filter out known-handled cases (bots, external items) with jq before reading anything: + + ```bash + # Example: unassigned PRs — filter out bots and external repos, keep only actionable fields + jq '[.items[] | select( + (.Title | test("^chore\\(deps"; "i") | not) and + (.Repository | IN("ipshipyard/ipfs-deploy-action") | not) + ) | {repo: .Repository, id: .Id, title: .Title, node_id: .["Node ID"]}]' "$SWEEP/prs_no_assignee.json" + ``` + + Expect ~75% of unassigned PRs to be bots (dependabot, release-please) — the bot PRs are handled by R-PR-001's skip rules and don't need investigation. - GitHub Phase 1 (lightweight): `gh pr list -R --state open --json number,author,isDraft,reviewDecision,reviewRequests` (one call per repo — **no `reviews` field**) - GitHub Phase 2 (targeted): `gh pr view -R --json reviews,commits,reviewRequests` — only for PRs needing deep analysis (R-PR-006 status determination, R-SL-001 approval verification, R-SL-007 changes-requested check). See general behavior rule 6 for trigger conditions. @@ -51,61 +160,114 @@ All file path examples in this playbook use `$SWEEP/` as shorthand for this dire The main bottleneck in Stage 1 is joining board query results (65+ items) with GitHub Phase 1 metadata (14+ repos). Do this programmatically with `jq`, not by manually scanning JSON walls. -**Use `format: "compact"` and immediately Write to disk.** Pass `format: "compact"` to `list_board_items` to get columnar JSON — field names appear once, rows are arrays of values: +**Fetch board data directly to disk via `curl`.** Board queries go through the REST API — data lands on disk without entering LLM context: -```json -{"columns": ["Repository", "Title", "Status", ...], - "rows": [["curio", "Fix X", "⌨️ In Progress", ...], ...], - "total_in_page": 62} +```bash +"$SWEEP/bin/foc_gh_get" items \ + 'query=is:pr -status:"🎉 Done"' \ + 'fields=Repository,Id,url,Title,Status,Kind,Assignees,Cycle Theme,Node ID' \ + 'per_page=100' \ + > "$SWEEP/board_prs.json" ``` -This is ~40-60% smaller than `format: "json"` (which repeats field names per item), meaning fewer tokens through your context. **Immediately after receiving the tool result, use the Write tool to save the raw JSON string to a file in your `$SWEEP/` directory** (e.g., `$SWEEP/board_prs.json`). Do NOT try to echo/cat the result via Bash — the tool output is in your conversation context, not in a shell variable. The Write tool is the only reliable way to transfer it to disk. +`foc_gh_get` wraps each argument in `--data-urlencode` automatically — no manual percent-encoding of emoji statuses or field names with spaces. -To convert back to an array of objects for `jq` joins: +The response is standard JSON with an `items` array: -```bash -jq '[.columns as $c | .rows[] | [$c, .] | transpose | map({(.[0]): .[1]}) | add]' $SWEEP/board_prs.json > $SWEEP/board_prs_objects.json +```json +{ + "items": [{"Repository": "FilOzone/dealbot", "Id": "458", "Title": "Fix X", "Status": "⌨️ In Progress", ...}], + "total_in_page": 62, + "has_more": true, + "next_cursor": "opaque_string" +} ``` -**Why this matters:** Without writing to disk first, the agent falls into a trap: it has the JSON in context, tries to reconstruct it in a bash heredoc, and wastes time hand-copying 62+ items. The Write tool is instant and exact. +Process with `jq` as usual: `jq '.items[]' "$SWEEP/board_prs.json"`. -**Pagination: always check `has_more`.** After writing to disk, check for more pages: +**Pagination: always check `has_more`.** After fetching, check for more pages: ```bash -jq -e '.has_more' $SWEEP/board_prs.json # exits 0 if true, 1 if false/missing +jq -r '.has_more' "$SWEEP/board_prs.json" # prints "true" or "false" — do NOT use jq -e in parallel batches (exit code 1 cancels siblings) ``` -If true, fetch the next page with the returned `next_cursor`, Write that result to a second file, then merge rows: +If true, fetch the next page with the returned `next_cursor` and merge: ```bash -jq -s '{"columns": .[0].columns, "rows": [.[].rows[]]}' $SWEEP/board_prs.json $SWEEP/board_prs_p2.json > $SWEEP/board_prs_all.json +CURSOR=$(jq -r '.next_cursor' "$SWEEP/board_prs.json") +"$SWEEP/bin/foc_gh_get" items \ + 'query=is:pr -status:"🎉 Done"' \ + 'fields=Repository,Id,url,Title,Status,Kind,Assignees,Cycle Theme,Node ID' \ + 'per_page=100' \ + "cursor=$CURSOR" \ + > "$SWEEP/board_prs_p2.json" +jq -s '{"items": [.[].items[]]}' "$SWEEP/board_prs.json" "$SWEEP/board_prs_p2.json" > "$SWEEP/board_prs_all.json" ``` -**Tip:** Use `per_page: 100` (the maximum) to reduce the number of pages. Most board queries fit in 1-2 pages. - -Without a `format` parameter, the default output is human-readable JSONL (one JSON object per line after a "Found N items:" header). This is fine for small result sets displayed in conversation, but for programmatic use prefer `format: "compact"` (fewer tokens) or `format: "json"` (one object per item). +**Tip:** Use `per_page=100` (the maximum) to reduce the number of pages. Most board queries fit in 1-2 pages. -Include `"Node ID"` in the fields parameter to get project item node IDs (`PVTI_...`), which can be passed directly to `bulk_set_board_item_field` to skip per-item re-resolution. +Include `Node ID` in the fields parameter to get project item node IDs (`PVTI_...`), which can be passed to the bulk mutation endpoint to skip per-item re-resolution. After fetching both datasets: 1. **Filter Phase 1 to board-only PRs.** Extract the PR numbers from the board query, then use `jq` to select only matching entries from each repo's Phase 1 output. This drops the noise (e.g., curio has 18 open PRs but only 3 are on the board). -2. **Produce action lists per rule.** After the join (step 1), each entry should have both GitHub fields (`.isDraft`, `.reviewDecision`, `.author`) and a `.board_status` field added during the join. Pipe through `jq` selects to identify rule violations: +2. **Produce action lists per rule — to disk, not context.** After the join (step 1), each entry should have both GitHub fields (`.isDraft`, `.reviewDecision`, `.author`) and a `.board_status` field added during the join. Pipe through `jq` selects to identify rule violations and **write results to files** (e.g., `> "$SWEEP/actions_pr005.json"`). Include all automatable rules in a single pass — don't make follow-up queries for rules you could have checked here: + - R-PR-002/003: `select(.author.is_bot and (.author.login == "app/dependabot"))` — dependabot PRs needing Cycle Theme and/or Triage → Todo. **Note:** `gh pr list --json author` returns `{"login": "app/dependabot", "is_bot": true}`, not `dependabot[bot]` (the `[bot]` form appears in GitHub UI and webhooks, not in the CLI JSON output). + - R-PR-004: `select(.title | test("^chore\\("; "i")) and (.board_status == "📌 Triage")` — release PRs in Triage → Todo - R-PR-005: `select(.isDraft and (.board_status | IN("📌 Triage","🔎 Awaiting review","✔️ Approved by reviewer","⌚️ Issue awaiting PR merge")))` - - R-PR-006 Phase 2 candidates: `select(.isDraft == false and .author.is_bot == false and (.board_status | IN("📌 Triage","⌨️ In Progress")))` + - R-PR-006 Phase 2 candidates: `select(.isDraft == false and .author.is_bot == false and (.title | test("^chore\\((deps|master)\\)|^chore: release"; "i") | not) and (.board_status | IN("📌 Triage","⌨️ In Progress")))` — excludes dependabot/release PRs (which should be handled by R-PR-003/004 first). Title-based exclusion is more reliable than `.author.is_bot` since release-please isn't always flagged as a bot. + - **Phase 2 skip for In Progress + CHANGES_REQUESTED:** PRs already in In Progress with `reviewDecision == "CHANGES_REQUESTED"` will almost always stay In Progress — the only exception is if the author pushed after the review (case 3 in R-PR-006). Check Phase 1 `reviewDecision` first: if it's `CHANGES_REQUESTED` and the PR is already In Progress, skip Phase 2 unless there's a signal the author responded (e.g., a re-requested reviewer in `reviewRequests`). This avoids wasting Phase 2 calls to confirm the status quo. - R-SL-007: `select(.reviewDecision == "CHANGES_REQUESTED" and (.board_status | IN("🔎 Awaiting review","✔️ Approved by reviewer")))` - R-PR-007 Phase 2 candidates: `select(.board_status == "🔎 Awaiting review" and (.reviewRequests | length == 0))` — empty `reviewRequests` is ambiguous (pending requests are consumed when a review is submitted), so always Phase 2 before flagging - -3. **Treat `reviewDecision: ""` as ambiguous.** Empty means GitHub produced no formal verdict — not that no reviews exist. Always Phase 2 before changing status on these PRs. See general behavior rule 6. - -4. **Batch all Phase 2 candidates, then fetch in parallel.** Collect the union of Phase 2 candidates from step 2 (R-PR-006, R-PR-007, and any `reviewDecision: ""` PRs from step 3) into a single list. Then run all `gh pr view` calls in parallel — don't process one rule's candidates, then discover the next rule needs Phase 2 on overlapping PRs. One parallel batch of `gh pr view` calls is faster than sequential per-rule fetches, and avoids duplicate lookups when the same PR is a candidate for multiple rules. + + **Produce all action lists in a single jq pass** rather than sequential per-rule commands: + + ```bash + jq '{ + pr002: [.[] | select(.author.is_bot and .author.login == "app/dependabot" and .cycle_theme != "Dependency Updates")], + pr003: [.[] | select(.author.is_bot and .author.login == "app/dependabot" and .board_status == "📌 Triage")], + pr005: [.[] | select(.isDraft and (.board_status | IN("📌 Triage","🔎 Awaiting review","✔️ Approved by reviewer","⌚️ Issue awaiting PR merge")))], + pr006: [.[] | select(.isDraft == false and .author.is_bot == false and (.title | test("^chore\\((deps|master)\\)|^chore: release"; "i") | not) and (.board_status | IN("📌 Triage","⌨️ In Progress")))], + sl007: [.[] | select(.reviewDecision == "CHANGES_REQUESTED" and (.board_status | IN("🔎 Awaiting review","✔️ Approved by reviewer")))], + pr007: [.[] | select(.board_status == "🔎 Awaiting review" and (.reviewRequests | length == 0))] + }' "$SWEEP/joined_prs.json" > "$SWEEP/action_buckets.json" + ``` + + **Read counts first, not contents.** After writing action lists to disk, verify with a single summary command: + + ```bash + jq 'to_entries[] | "\(.key): \(.value | length)"' "$SWEEP/action_buckets.json" + ``` + + Only read individual items when you need to make a judgment call (e.g., which status to route to). Even then, use jq to select only the fields you need. + +3. **Treat `reviewDecision: ""` as ambiguous — but only Phase 2 if a status change is under consideration.** Empty means GitHub produced no formal verdict — not that no reviews exist. But if the PR is already in the correct status and no rule is proposing to move it, Phase 2 is wasted work. For example, a PR in Awaiting Review with pending `reviewRequests` and empty `reviewDecision` is already in the right place — skip it. Only Phase 2 PRs where you'd actually change the status based on the result. See general behavior rule 6. + +4. **Batch all Phase 2 candidates, then fetch in parallel.** Collect the union of Phase 2 candidates from step 2 (R-PR-006, R-PR-007, and any `reviewDecision: ""` PRs from step 3 **that need a status change**) into a single list. Then run all `gh pr view` calls in parallel — don't process one rule's candidates, then discover the next rule needs Phase 2 on overlapping PRs. One parallel batch of `gh pr view` calls is faster than sequential per-rule fetches, and avoids duplicate lookups when the same PR is a candidate for multiple rules. + + **Never print raw Phase 2 JSON into context.** Reviews can have 20+ entries per PR. After fetching, extract a compact summary to disk, then read only the summaries: + + ```bash + for f in "$SWEEP"/phase2_*.json; do + jq '{ + lastCommit: .commits[-1].committedDate, + lastHumanReview: [.reviews[] | select(.author.login != "copilot-pull-request-reviewer" and (.author.login | startswith("app/") | not)) | .submittedAt] | sort | last, + hasApproval: ([.reviews[] | select(.state == "APPROVED")] | length > 0), + approvers: [.reviews[] | select(.state == "APPROVED") | .author.login], + hasChangesRequested: ([.reviews[] | select(.state == "CHANGES_REQUESTED")] | length > 0), + changesRequestedBy: [.reviews[] | select(.state == "CHANGES_REQUESTED") | {who: .author.login, when: .submittedAt}], + reviewRequests: [.reviewRequests[]? | .login // .name], + humanReviewerCount: ([.reviews[] | select(.author.login != "copilot-pull-request-reviewer" and (.author.login | startswith("app/") | not)) | .author.login] | unique | length) + }' "$f" > "${f%.json}_summary.json" + done + ``` Example — build a joined dataset in one bash call: ```bash -# After fetching board PRs (format: "compact") and Writing to $SWEEP/board_prs.json, +# After fetching board PRs to $SWEEP/board_prs.json via curl, # and Phase 1 per-repo results to $SWEEP/phase1_*.json: -BOARD_JSON=$(jq '[.columns as $c | .rows[] | [., $c] | transpose | map({(.[1]): .[0]}) | add]' "$SWEEP/board_prs.json") +BOARD_JSON=$(jq '[.items[] | {repo: (.Repository | split("/") | last), number: (.Id | tonumber)}]' "$SWEEP/board_prs.json") for repo_file in "$SWEEP"/phase1_*.json; do repo=$(basename "$repo_file" .json | sed 's/phase1_//') jq --argjson board "$BOARD_JSON" --arg repo "$repo" ' @@ -183,7 +345,9 @@ This gives you a clean, small dataset to reason about — typically 15-30 items 3. Also inherit assignee, cycle, and milestone from the linked PR if missing (per R-SL-008). **How to discover unlinked PRs (per R-SL-008):** -After processing formal linked PRs, do a targeted check for In Progress issues that have **no** formal linked PRs — these may have cross-referencing PRs that weren't formally linked. Query the board for `is:issue status:"⌨️ In Progress" no:linked-pull-requests`, exclude zOrganizing Items, then batch-fetch `timelineItems(itemTypes: [CROSS_REFERENCED_EVENT])` via GraphQL (general behavior rule 12) for **only this set**. This is an expensive query — do not run it broadly. See R-SL-008 "Discovering unlinked PRs" for the full procedure. Flag findings for human rather than auto-transitioning. +After processing formal linked PRs, do a targeted check for In Progress issues **in the current cycle** that have **no** formal linked PRs — these may have cross-referencing PRs that weren't formally linked. Query the board for `is:issue status:"⌨️ In Progress" no:linked-pull-requests cycle:""`, exclude zOrganizing Items, then batch-fetch `timelineItems(itemTypes: [CROSS_REFERENCED_EVENT])` via GraphQL (general behavior rule 12) for **only this set**. This is an expensive query with historically low yield (~0 actionable findings per sweep) — limiting to the current cycle keeps the scope small and targets issues most likely to have fresh PRs. See R-SL-008 "Discovering unlinked PRs" for the full procedure. Flag findings for human rather than auto-transitioning. + +**Note on cross-reference GraphQL results:** The `... on PullRequest` fragment returns empty objects (`{}`) for cross-references from issues (not PRs). This is expected — filter them out with `select(.title != null)` or add `__typename` to the fragment to distinguish PR refs from issue refs. **How to report stale items (per R-SL-009):** 1. Exclude zOrganizing Items and "Issue awaiting PR merge" items @@ -211,9 +375,19 @@ where date is 7 days ago. Do **not** fetch all recently-done items first — tha - R-FC-004: Infer Cycle Theme from repository and title - R-PR-001: For unassigned PRs, assign to the PR author. For merged release PRs (bot-authored), assign to the person who merged/approved them. Dependabot PRs can be left unassigned. +**How to investigate unassigned Done issues:** + +Batch-fetch issue metadata using GraphQL (general behavior rule 12), including **both** `closedByPullRequestsReferences` and `timelineItems(itemTypes: [CROSS_REFERENCED_EVENT])`. The `closedByPullRequestsReferences` only catches formal closing syntax (`Closes #N`, `Fixes #N`). PRs that reference an issue informally (e.g., "Addresses #765" in the PR body) show up only as cross-referenced timeline items. Priority order for inferring assignee: + +1. `closedByPullRequestsReferences` → use the closing PR's assignee/author +2. `timelineItems(CROSS_REFERENCED_EVENT)` → if a cross-referencing PR is merged, use its assignee/author (merged = likely the actual fix) +3. Comment stream / issue author (fallback) + +If uncertain, propose with justification and flag for human confirmation. + **Automated vs. flagged:** - Automated: Cycle Theme from repo defaults (R-FC-004), Cycle set to current cycle, PR assignees set to author (or merger for release PRs) -- Flagged for human: Issues without assignees (investigate linked PRs and comment stream, propose assignee with justification), items where Cycle Theme can't be inferred from R-FC-004 +- Flagged for human: Issues without assignees (propose assignee per priority order above), items where Cycle Theme can't be inferred from R-FC-004 **Note:** Use the GitHub API (`gh api repos/{owner}/{repo}/issues/{number}/assignees`) for assignments — `gh pr edit --add-assignee` may fail on repos with Projects Classic enabled. For release PRs, use `gh api repos/{owner}/{repo}/pulls/{number} --jq '.merged_by.login'` to find who merged. diff --git a/github-project-export/uv.lock b/github-project-export/uv.lock index 0e43aec..923b17e 100644 --- a/github-project-export/uv.lock +++ b/github-project-export/uv.lock @@ -1,6 +1,36 @@ version = 1 revision = 1 -requires-python = ">=3.10" +requires-python = ">=3.13" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, +] [[package]] name = "certifi" @@ -17,54 +47,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, @@ -116,6 +98,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -126,15 +120,19 @@ wheels = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.1" +name = "fastapi" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683 }, ] [[package]] @@ -165,15 +163,52 @@ name = "github-projects-client" version = "0.1.0" source = { editable = "../github-projects-client" } dependencies = [ + { name = "fastapi" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.31" }] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "requests", specifier = ">=2.31" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=9.0.3" }] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, +] + [[package]] name = "idna" version = "3.11" @@ -210,6 +245,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -225,18 +331,61 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[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/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + [[package]] name = "requests" version = "2.33.1" @@ -253,57 +402,15 @@ wheels = [ ] [[package]] -name = "tomli" -version = "2.4.1" +name = "starlette" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, ] [[package]] @@ -315,6 +422,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -323,3 +442,146 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6 wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] diff --git a/github-projects-client/README.md b/github-projects-client/README.md index 3ad4b70..eab521f 100644 --- a/github-projects-client/README.md +++ b/github-projects-client/README.md @@ -1,75 +1,197 @@ # github-projects-client -Context-efficient Python client library for GitHub Projects v2 boards. +Optimized wrapper around GitHub APIs for working with Projects v2 boards. Designed to be agent-friendly — trimmed responses, human-readable field names for mutations, server-side filtering, and direct-to-disk output via curl. -No MCP dependency — this is a pure `requests`-based library that can be used -standalone or as the foundation for MCP servers, CLI tools, and report generators. +## Project Tenet: Prefer GitHub-Supported Tools -## Public API +This project exists only to fill gaps in GitHub's own tooling. Any functionality that can be replaced by a GitHub-supported tool (`gh` CLI, GitHub REST/GraphQL APIs, GitHub's official MCP server) **should be** replaced. Before adding new capabilities, check whether GitHub has shipped native support. Before keeping existing capabilities, periodically verify they're still necessary. -| Function | Module | Description | +Specific gaps filled (as of 2026-05): + +1. **`gh project item-list` has no server-side filtering.** On a 3,000+ item board, fetching everything and filtering client-side isn't workable. +2. **All GitHub mutation tools require raw node IDs.** `gh project item-edit`, GitHub's MCP `projects_write`, and raw GraphQL all need `PVTI_`, `PVTSSF_`, and option IDs — not human-readable names like "Status" and "In Progress." +3. **No GitHub tool supports batch field mutations.** Setting the same field on 15 items means 15 sequential calls, each re-resolving IDs. + +If GitHub adds filtering to `gh project item-list`, name-based mutations, or batch operations, the corresponding code here should be retired. + +### Comparison to Standard GitHub Tools + +An agent doing board maintenance needs two categories of operations: **board field operations** (Status, Cycle Theme, Dev Days Estimate — project-level fields) and **issue/PR operations** (review state, assignees, milestones, labels — repository-level properties). No single tool covers both well. + +#### Summary: when to use what + +- **Board field reads** (list items, filter, get field values): This project — server-side filtering with trimmed responses, direct to disk via curl +- **Board field mutations** (set Status, Cycle Theme, etc.): This project — only tool with name-based mutations and bulk support +- **Issue/PR metadata reads** (review state, draft, labels): `gh` CLI +- **Issue/PR mutations** (assignees, milestones, reviewers): `gh` CLI +- **Schema discovery** (what fields exist, what options are valid): `gh project field-list` works fine — no unique value from this project + +#### Board field operations + +These are the operations specific to GitHub Projects v2 boards — reading and writing the custom fields that live on the project, not on the issue/PR itself. + +| Capability | [`gh` CLI](https://cli.github.com/manual/gh_project) | [GitHub REST](https://docs.github.com/en/rest/projects/projects) | [GitHub GraphQL](https://docs.github.com/en/graphql/reference/objects#projectv2) | [GitHub MCP](https://github.com/github/github-mcp-server) | **This project** | +|---|---|---|---|---|---| +| **List board items** | `gh project item-list` | `GET /projectsV2/{n}/items` | `projectV2.items` query | `list_project_items` | `GET /items` | +| **Server-side filtering** | No `--query` flag; must fetch all items | `q=` param with [filter syntax](https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects) | Manual query construction | `query` param | `query` param (wraps REST) | +| **OR filter syntax** | No | No | No | No | `(status:"In Progress") OR (status:"Review")` — expanded to multiple queries, deduplicated | +| **Response size per item** | ~800 bytes (includes body text) | ~8KB (full PR/issue objects embedded) | You pick fields, but must craft query | Verbose (field values wrapped in `{html, raw}`). Upstream tracking issue:[github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383) | ~200-300 bytes (trimmed to field values only) | +| **Field value trimming** | No (returns full PR/issue body text) | No (~8KB linked PR objects) | You craft the query | No (full objects in response) | All fields trimmed to display values: single-select/text/iteration → display name; milestone → title; sub-issues → `N/N`; linked PRs → `{repo, number, state, title, author}` (~100 bytes vs ~8KB); assignees/reviewers → comma-separated logins | +| **Direct to disk (bypass LLM context)** | Yes (pipe stdout) | Yes (curl) | Yes (curl) | No (MCP responses enter context — [#2383](https://github.com/github/github-mcp-server/issues/2383)) | Yes (curl) | +| **Set a field value (single or bulk)** | `item-edit` — requires `--field-id`, `--single-select-option-id`, `--project-id` (all raw node IDs) | No mutation support | `updateProjectV2ItemFieldValue` — requires project/field/option node IDs (3-4 lookups) | `update_project_item` — requires numeric field ID | `PUT /items/field/{name}` — human-readable names, single or bulk, batches up to 25 per request | +| **Item lookup by reference** | No (need PVTI_ node ID) | No (need to query + filter) | No (need node ID) | No (need item ID) | `GET /items/dealbot%23458` — parses `repo#number`, `owner/repo#number`, or URL | +| **Item `updated_at` and `creator`** | No | Available in raw response but not surfaced | Queryable but manual | No | Not yet — see [future ideas](../foc-board-rules/future-ideas.md#expose-built-in-item-properties-in-list_board_items) | +| **Discover field options** | `gh project field-list` (clean) | `GET /projectsV2/{n}/fields` | Inline fragment query | `list_project_fields` | `GET /fields/{name}/options` | +| **Audit logging** | No | No | No | No | Append-only JSONL with caller, old/new values | + +#### Why not just use GitHub's official MCP server for board operations? + +GitHub's [official MCP server](https://github.com/github/github-mcp-server) has a Projects v2 toolset (`projects_list`, `projects_get`, `projects_write`) available at the `/x/projects` endpoint. It supports query filtering, pagination, field discovery (including single-select options), and mutations. + +**The non-negotiable blocker: context window bloat.** Each project item response from GitHub's MCP is ~8KB because it includes the full issue/PR body, complete repository object (~2KB of URL templates), and full user objects for every author/assignee/milestone-creator. The `fields` parameter controls which *project fields* are returned but there is no way to suppress the content blob. + +| Query size | GitHub MCP payload | Token cost | Impact | +|---|---|---|---| +| 10 items | ~80KB | ~20K tokens | Noticeable | +| 50 items (max per_page) | ~400KB | ~100K tokens | Half the context window | +| 100 items (2 pages) | ~800KB | ~200K+ tokens | Entire context window consumed | + +This project returns ~200-300 bytes per item (just the project field values) — a **~40x reduction**. With the REST API, data goes directly to disk via curl and never enters LLM context at all. + +| | GitHub Projects MCP | This project | |---|---|---| -| `list_items` | `items` | List project items with filter query, pagination, compact output | -| `get_item` | `items` | Look up a single item by `repo#number`, `owner/repo#number`, or URL | -| `list_fields` | `items` | List all project field names and REST numeric IDs | -| `list_field_options` | `fields` | Enumerate options for single-select and iteration fields | -| `resolve_view_url` | `views` | Parse a project view URL into filter, fields, and group-by metadata | -| `set_field_value` | `mutations` | Set a project field by name (resolves field/option IDs internally) | -| `graphql_query` | `api` | Low-level GraphQL query helper | -| `list_field_ids_by_name` | `api` | REST field name → numeric ID mapping | -| `fetch_items_rest` | `api` | Low-level REST item fetcher with pagination | +| Per-item response size | ~8KB | ~200-300 bytes | +| 50-item query | ~400KB / ~100K tokens | ~10-15KB / ~3-4K tokens | +| Field name resolution | Raw IDs required | By name (`"Status"` → `"Done"`) | +| Filter syntax docs | None in tool description | Comprehensive reference in MCP coordinator | +| Mutation UX | 3 tool calls with raw IDs | 1 curl call: `PUT /items/field/Status` with `{"item_refs": ["dealbot#458"], "value": "..."}` | +| Bulk mutations | No | Up to 25 per request | +| Audit logging | None | JSONL with old/new values | + +This is a [known problem across the GitHub MCP server](https://github.com/github/github-mcp-server/issues/142) (20+ comments, open since April 2025). The maintainers have been fixing it tool-by-tool using "minimal types", but the projects tools haven't been optimized yet. We filed [github/github-mcp-server#2383](https://github.com/github/github-mcp-server/issues/2383) requesting compact output for project items. + +**If #2383 gets addressed**, we should revisit this decision — GitHub's official tooling could replace this project's read path. For the full evaluation, see [FilOzone/tpm-utils#25 (comment)](https://github.com/FilOzone/tpm-utils/issues/25#issuecomment-4314857318). + +#### Issue/PR operations (use GitHub's tools directly) + +These operations are **not covered by this project** and should use GitHub's own tools: + +| Capability | Best tool | Example | +|---|---|---| +| PR review state (approved, changes requested) | `gh pr view --json reviewDecision,reviews` | Check if PR has approval before status transition | +| PR draft status | `gh pr list --json isDraft` | Identify draft PRs for triage rules | +| Assignee mutations | `gh issue edit --add-assignee` / `gh pr edit --add-assignee` | Set PR author as assignee | +| Milestone mutations | `gh issue edit --milestone` | Assign milestones | +| Label operations | `gh issue edit --add-label` | Add/remove labels | +| Issue/PR creation | `gh issue create` / `gh pr create` | Create new items | +| Cross-repo search | `gh search prs --repo` | Find PRs across repos | +| Review requests | `gh pr edit --add-reviewer` | Request reviews | + +## Affordances -All functions take `session`, `org`, and `project_number` as explicit arguments — -no hardcoded defaults or environment variables. +### Python Library -## Usage +All functions take `session`, `org`, and `project_number` as explicit arguments — no hardcoded defaults or environment variables. ```python import requests -from github_projects_client import list_items, get_item, list_field_options +from github_projects_client import list_items, get_item, set_field_value session = requests.Session() session.headers["Authorization"] = f"Bearer {token}" session.headers["Content-Type"] = "application/json" -# List non-Done items -result = list_items(session, org="MyOrg", project_number=1, query='-status:"Done"') +# List non-Done PRs +result = list_items(session, org="MyOrg", project_number=1, query='is:pr -status:"Done"') for item in result["items"]: print(item["Title"], item["Status"]) # Look up a specific item detail = get_item(session, org="MyOrg", project_number=1, item_ref="my-repo#42") -# Discover field options -options = list_field_options(session, org="MyOrg", project_number=1, field_name="Status") +# Set a field by name +set_field_value(session, org="MyOrg", project_number=1, + item_ref="my-repo#42", field_name="Status", value="⌨️ In Progress") ``` -## Testing +**Public API:** + +| Function | Module | Description | +|---|---|---| +| `list_items` | `items` | List project items with filter query and pagination | +| `get_item` | `items` | Look up a single item by `repo#number`, `owner/repo#number`, or URL | +| `list_fields` | `items` | List all project field names and REST numeric IDs | +| `list_field_options` | `fields` | Enumerate options for single-select and iteration fields | +| `resolve_view_url` | `views` | Parse a project view URL into filter, fields, and group-by metadata | +| `set_field_value` | `mutations` | Set a project field by name (resolves field/option IDs internally) | +| `set_field_value_bulk` | `mutations` | Set a field on multiple items in batched GraphQL mutations | +| `expand_or_query` | `query` | Expand `(branch1) OR (branch2)` syntax into individual queries | +| `graphql_query` | `api` | Low-level GraphQL query helper | +| `list_field_ids_by_name` | `api` | REST field name → numeric ID mapping | +| `fetch_items_rest` | `api` | Low-level REST item fetcher with pagination | + +### HTTP Server + +A REST API wrapping the Python library, powered by FastAPI. Designed for agents that work via curl — data goes directly to disk without entering LLM context. ```bash cd github-projects-client -GITHUB_TOKEN=$(gh auth token) uv run pytest tests/ -v +uv run github-projects-api ``` -Tests are integration tests against a live GitHub Projects v2 board. +Server starts on `http://127.0.0.1:8080`. Override with `HOST` and `PORT` environment variables. -## Field value trimming +Once running, visit `http://localhost:8080/docs` for the interactive Swagger UI, or fetch the OpenAPI spec at `http://localhost:8080/openapi.json`. -The GitHub Projects v2 REST API returns some field values as full API objects — notably "Linked pull requests", which includes the complete PR payload (~8KB per linked PR: full body text, user objects with avatar URLs, all API endpoints, labels, etc.). This client trims these down to just the useful fields before returning them. +All endpoints require a GitHub PAT as a bearer token: -**Trimmed fields:** +```bash +# List non-Done PRs → disk, never enters LLM context +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items?query=is:pr+-status:%22🎉+Done%22" \ + > board_prs.json + +# Set a field (single or bulk) +curl -s -X PUT -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"item_refs": ["dealbot#458"], "value": "🎉 Done"}' \ + "http://localhost:8080/orgs/FilOzone/projects/14/items/field/Status" +``` -| Field type | Raw API shape | What the client returns | +**Endpoints:** + +| Method | Path | Description | |---|---|---| -| Linked pull requests / issues | Full PR/issue objects (~8KB each) | JSON array of `{repo, number, state, draft, title, author}` (~100 bytes each) | -| Assignees / reviewers | User objects with avatar URLs, API endpoints | Comma-separated logins | +| GET | `/orgs/{org}/projects/{n}/items` | List board items with filtering and pagination | +| GET | `/orgs/{org}/projects/{n}/items/{item_ref}` | Get a single board item | +| GET | `/orgs/{org}/projects/{n}/items/view` | List items from a saved view URL | +| GET | `/orgs/{org}/projects/{n}/fields` | List all board fields | +| GET | `/orgs/{org}/projects/{n}/fields/{name}/options` | List field options | +| PUT | `/orgs/{org}/projects/{n}/items/field/{name}` | Set a field (single or bulk) | +| GET | `/orgs/{org}/projects/{n}/audit-log` | Read audit log entries | + +--- + +## Testing + +```bash +cd github-projects-client -This trimming happens in `_format_field_value()` in `items.py`. All other field values are passed through as-is from the API. +# Unit tests (no GitHub API calls) +uv run pytest tests/ -v -m "not integration" + +# Integration tests (requires GITHUB_TOKEN) +GITHUB_TOKEN=$(gh auth token) uv run pytest tests/ -v +``` -## Known gaps +## Known gaps and future ideas +See [foc-board-rules/future-ideas.md](../foc-board-rules/future-ideas.md) for the central list. Relevant items: -- `list_items` does not surface built-in item properties like `updated_at` and `creator` — see [foc-board-rules/future-ideas.md](../foc-board-rules/future-ideas.md#expose-built-in-item-properties-in-list_board_items). +- `list_items` does not surface built-in item properties like `updated_at` and `creator`. See [future ideas](../foc-board-rules/future-ideas.md#expose-built-in-item-properties-in-list_board_items). +- Remove `format=compact` — context-window optimization that doesn't apply when data goes to disk +- Remove `GET /fields/{name}/options` — `gh project field-list` covers this natively ## Design contract -See [specs/003-generalize-mcp-client/contracts/shared-client-api.md](../specs/003-generalize-mcp-client/contracts/shared-client-api.md). +- [specs/003-generalize-mcp-client/contracts/shared-client-api.md](../specs/003-generalize-mcp-client/contracts/shared-client-api.md) — original client library API +- [specs/005-rest-api-layer/](../specs/005-rest-api-layer/) — REST API server spec, plan, and contracts diff --git a/github-projects-client/github_projects_client/api.py b/github-projects-client/github_projects_client/api.py index 36dc9d7..c29b56d 100644 --- a/github-projects-client/github_projects_client/api.py +++ b/github-projects-client/github_projects_client/api.py @@ -9,6 +9,14 @@ GRAPHQL_URL = "https://api.github.com/graphql" +class GitHubAPIError(Exception): + """Raised when the GitHub API returns an unexpected or error response.""" + + +class GitHubAuthError(GitHubAPIError): + """Raised when the GitHub API rejects a request due to auth/scope issues.""" + + def graphql_query( session: requests.Session, query: str, @@ -34,8 +42,8 @@ def graphql_query( "Or create a PAT that includes the read:project scope. " f"Original API message: {e.get('message', errs)}" ) - raise Exception(msg) from None - raise Exception(f"GraphQL errors: {errs}") + raise GitHubAuthError(msg) from None + raise GitHubAPIError(f"GraphQL errors: {errs}") return result["data"] @@ -71,7 +79,7 @@ def list_field_ids_by_name( resp.raise_for_status() batch = resp.json() if not isinstance(batch, list): - raise Exception(f"Unexpected /fields response type: {type(batch)}") + raise GitHubAPIError(f"Unexpected /fields response type: {type(batch)}") for f in batch: name = f.get("name") @@ -140,7 +148,7 @@ def fetch_items_rest( resp.raise_for_status() batch = resp.json() if not isinstance(batch, list): - raise Exception(f"Unexpected /items response type: {type(batch)}") + raise GitHubAPIError(f"Unexpected /items response type: {type(batch)}") all_rows.extend(batch) diff --git a/filozzy-mcp/filozzy_mcp/action_log.py b/github-projects-client/github_projects_client/audit_log.py similarity index 63% rename from filozzy-mcp/filozzy_mcp/action_log.py rename to github-projects-client/github_projects_client/audit_log.py index 2a6d769..1601781 100644 --- a/filozzy-mcp/filozzy_mcp/action_log.py +++ b/github-projects-client/github_projects_client/audit_log.py @@ -1,4 +1,4 @@ -"""Append-only JSONL action log for FilOzzy mutations.""" +"""Append-only JSONL audit log for GitHub Projects API mutations.""" from __future__ import annotations @@ -9,24 +9,25 @@ from typing import Any, Dict, Optional -# Default: next to the package root (dev use). -# Override with FILOZZY_ACTION_LOG for installed/read-only environments. _default_log = Path(__file__).resolve().parent.parent / "action_log.jsonl" -LOG_PATH = Path(os.environ.get("FILOZZY_ACTION_LOG", str(_default_log))) +LOG_PATH = Path(os.environ.get("ACTION_LOG_PATH", str(_default_log))) def log_action( - tool: str, + *, + caller: str, + endpoint: str, params: Dict[str, Any], result: str, - *, old_value: Optional[str] = None, new_value: Optional[str] = None, + error: Optional[str] = None, ) -> None: - """Append a mutation record to the action log.""" - entry = { + """Append a mutation record to the audit log.""" + entry: Dict[str, Any] = { "timestamp": datetime.now(timezone.utc).isoformat(), - "tool": tool, + "caller": caller, + "endpoint": endpoint, "params": params, "result": result, } @@ -34,13 +35,16 @@ def log_action( entry["old_value"] = old_value if new_value is not None: entry["new_value"] = new_value + if error is not None: + entry["error"] = error + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") -def read_recent_actions(n: int = 50) -> list[Dict[str, Any]]: - """Read the last N actions from the log.""" +def read_recent_entries(n: int = 50) -> list[Dict[str, Any]]: + """Read the last N entries from the audit log.""" if not LOG_PATH.exists(): return [] lines = LOG_PATH.read_text(encoding="utf-8").strip().splitlines() diff --git a/github-projects-client/github_projects_client/fields.py b/github-projects-client/github_projects_client/fields.py index 394eedf..c5c505b 100644 --- a/github-projects-client/github_projects_client/fields.py +++ b/github-projects-client/github_projects_client/fields.py @@ -6,7 +6,7 @@ import requests -from .api import graphql_query +from .api import graphql_query, GitHubAPIError FIELD_OPTIONS_QUERY = """ @@ -78,7 +78,7 @@ def list_field_options( project = (data.get("organization") or {}).get("projectV2") if not project: - raise Exception( + raise GitHubAPIError( f"Project {project_number} not found in org '{org}'. " "Check that the project number is correct and your token has access." ) diff --git a/github-projects-client/github_projects_client/items.py b/github-projects-client/github_projects_client/items.py index 66da464..12d4ce7 100644 --- a/github-projects-client/github_projects_client/items.py +++ b/github-projects-client/github_projects_client/items.py @@ -82,6 +82,9 @@ def _format_field_value(value: Any) -> str: return json.dumps(trimmed, ensure_ascii=False) return str(value) if isinstance(value, dict): + # Sub-issues progress: {"total": N, "completed": N, "percent_completed": N} + if "total" in value and "completed" in value and "percent_completed" in value: + return f"{value['completed']}/{value['total']}" # Reviewers payload if "requested_reviewers" in value or "requested_teams" in value: parts = [] @@ -105,6 +108,8 @@ def _format_field_value(value: Any) -> str: if isinstance(text, str): return text title = value.get("title") + if isinstance(title, dict) and "raw" in title: + return title["raw"] if isinstance(title, str): return title return "" diff --git a/github-projects-client/github_projects_client/query_syntax.py b/github-projects-client/github_projects_client/query_syntax.py new file mode 100644 index 0000000..4308be8 --- /dev/null +++ b/github-projects-client/github_projects_client/query_syntax.py @@ -0,0 +1,112 @@ +"""Query syntax reference for GitHub Projects v2 filter syntax. + +Single source of truth — used by the REST API endpoint descriptions +and the MCP coordinator's get_board_context output. +""" + +QUERY_SYNTAX_REFERENCE = """ +The `query` parameter uses GitHub Projects v2 filter syntax — +the same syntax as the board UI search bar. It is passed directly to the REST API +endpoint `GET /orgs/{org}/projectsV2/{project_number}/items?q=...` + +Docs: https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/filtering-projects + +### Custom Project Fields (use kebab-case of the field display name) +- `status:"⌨️ In Progress"` — match a Status value +- `cycle-theme:"Contract Upgrade"` — match a Cycle Theme value +- `dev-days-estimate:>1` — numeric comparison +- `cycle:"202604-2"` — match iteration by title +- Use `gh project field-list` to discover field names. + +### Item Type & State +- `is:issue` / `is:pr` +- `is:open` / `is:draft` / `is:closed` / `is:merged` + +### People +- `assignee:rjan90` — assigned to user +- `assignee:rjan90,biglep` — assigned to either (OR) +- `reviewers:biglep` — PR reviewer +- `assignee:@me` — current authenticated user + +### Milestones +- `milestone:"M4.2: mainnet GA"` — match a Milestone value + +### Repository +- `repo:FilOzone/dealbot` — items from a specific repo +- `repo:FilOzone/dealbot,filecoin-project/curio` — items from either repo + +### Labels +- `label:bug` / `label:"help wanted"` — labels with spaces need quotes + +### Time-Based (built-in filters, not project board fields) +IMPORTANT: `last-updated` has counterintuitive semantics: +- `last-updated:1days` — items NOT updated within 1 day (stale items) +- `-last-updated:1days` — items updated within the last day (recent items) +- `-last-updated:7days` — items updated within the last 7 days + +Alternative syntax using `updated:` (equivalent results): +- `updated:@today` — items updated today +- `updated:>@today-7d` — items updated within the last 7 days +- `updated:>2026-04-01` — items updated after a specific date +- `updated:2026-04-01..2026-05-01` — items updated within a date range + +To find RECENTLY updated items, use one of: + `-last-updated:Ndays` (board UI style) + `updated:>@today-Nd` or `updated:>YYYY-MM-DD` (comparison operator) + +To find STALE items (not updated recently), use one of: + `last-updated:Ndays` (board UI style, no negation) + `-updated:>@today-Nd` (negated comparison) + +### Relationships +- `blocking:FilOzone/dealbot#470` — items blocking a specific issue +- `blocked-by:FilOzone/filecoin-pay-explorer#77` — items blocked by a specific issue +- `parent-issue:FilOzone/synapse-sdk#3` — sub-issues of a parent + +### Close Reason +- `reason:completed` / `reason:"not planned"` + +### Text Search +- `"search text"` — free text search across fields +- `title:"*API*"` — title contains text +- `title:API*` — prefix matching (wildcards) + +### Presence / Absence +- `has:assignee` — items with at least one assignee +- `has:milestone` — items with a milestone set +- `has:"cycle-theme"` — items where Cycle Theme IS set +- `has:linked-pull-requests` — issues with formally linked PRs +- Works with any project field name (use kebab-case). +- `no:milestone` — items with no milestone set +- `no:assignee` — items with no assignee + +### Negation (prefix any filter with -) +- `-status:"🎉 Done"` — exclude Done items +- `-assignee:rjan90` — not assigned to rjan90 +- `-is:draft` — exclude drafts + +### Combining Filters (space-separated = implicit AND) +- `is:pr assignee:rjan90 -status:"🎉 Done"` +- `cycle-theme:"Contract Upgrade" -last-updated:1days` + +### OR (comma-separated values within one filter) +- `assignee:rjan90,biglep` +- `status:"⌨️ In Progress","🔍 Review"` + +### Quoting +Use double quotes around values with spaces or special chars: +- `status:"⌨️ In Progress"` / `milestone:"M4.2: mainnet GA"` + +### Tips — prefer targeted queries +When looking for items that need a specific fix (e.g., missing field, wrong status), +build the query to match the rule condition directly rather than fetching all items. + +Examples: +- `is:pr -status:"🎉 Done" -has:"cycle-theme"` → PRs missing Cycle Theme +- `is:pr no:assignee -status:"🎉 Done"` → unassigned open PRs +- `is:pr status:"📌 Triage"` → PRs still in Triage +- `is:pr is:merged -status:"🎉 Done"` → merged PRs not yet marked Done + +NOTE: Invalid filters return 0 results (they are not silently ignored). +Default query: `-status:"🎉 Done"` (excludes completed items). +""".strip() diff --git a/github-projects-client/github_projects_client/server/__init__.py b/github-projects-client/github_projects_client/server/__init__.py new file mode 100644 index 0000000..58b0939 --- /dev/null +++ b/github-projects-client/github_projects_client/server/__init__.py @@ -0,0 +1 @@ +"""REST API server for GitHub Projects v2.""" diff --git a/github-projects-client/github_projects_client/server/app.py b/github-projects-client/github_projects_client/server/app.py new file mode 100644 index 0000000..fec7ca2 --- /dev/null +++ b/github-projects-client/github_projects_client/server/app.py @@ -0,0 +1,206 @@ +"""FastAPI application for GitHub Projects v2 REST API.""" + +from __future__ import annotations + +import os + +import requests +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from github_projects_client.api import GitHubAPIError, GitHubAuthError + +from .routes import items, fields, mutations + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title="GitHub Projects API", + description="REST API for GitHub Projects v2 board operations", + version="0.1.0", + ) + + # --- Error handlers --- + + @app.exception_handler(HTTPException) + async def http_exception_handler( + request: Request, exc: HTTPException + ) -> JSONResponse: + """Map FastAPI HTTPExceptions to consistent error JSON.""" + if isinstance(exc.detail, dict): + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": "http_error", + "message": str(exc.detail), + "details": {}, + }, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + """Map validation errors to consistent error JSON.""" + return JSONResponse( + status_code=422, + content={ + "error": "validation_error", + "message": "Request validation failed", + "details": {"errors": exc.errors()}, + }, + ) + + @app.exception_handler(requests.HTTPError) + async def github_http_error_handler( + request: Request, exc: requests.HTTPError + ) -> JSONResponse: + """Map GitHub API HTTP errors to consistent error JSON.""" + response = exc.response + status = response.status_code if response is not None else 502 + + if status == 401: + return JSONResponse( + status_code=401, + content={ + "error": "unauthorized", + "message": "GitHub API authentication failed — check your bearer token", + "details": {}, + }, + ) + + if status == 404: + return JSONResponse( + status_code=404, + content={ + "error": "not_found", + "message": "Resource not found on GitHub", + "details": {}, + }, + ) + + if status == 403 and response is not None: + body = {} + try: + body = response.json() + except Exception: + pass + if "rate limit" in body.get("message", "").lower(): + retry_after = response.headers.get("Retry-After") + reset_at = response.headers.get("X-RateLimit-Reset") + return JSONResponse( + status_code=429, + content={ + "error": "rate_limited", + "message": "GitHub API rate limit exceeded", + "details": { + "retry_after": int(retry_after) if retry_after else None, + "limit": response.headers.get("X-RateLimit-Limit"), + "remaining": 0, + "reset_at": reset_at, + }, + }, + ) + + return JSONResponse( + status_code=status, + content={ + "error": "github_api_error", + "message": str(exc), + "details": {}, + }, + ) + + @app.exception_handler(ValueError) + async def value_error_handler(request: Request, exc: ValueError) -> JSONResponse: + """Map ValueErrors (e.g., invalid query syntax) to 422.""" + return JSONResponse( + status_code=422, + content={ + "error": "invalid_request", + "message": str(exc), + "details": {}, + }, + ) + + @app.exception_handler(GitHubAuthError) + async def github_auth_error_handler( + request: Request, exc: GitHubAuthError + ) -> JSONResponse: + """Map GitHub auth/scope errors to 401 with actionable message.""" + return JSONResponse( + status_code=401, + content={ + "error": "unauthorized", + "message": str(exc), + "details": {}, + }, + ) + + @app.exception_handler(GitHubAPIError) + async def github_api_error_handler( + request: Request, exc: GitHubAPIError + ) -> JSONResponse: + """Map GitHub API errors to 502 with the original message preserved.""" + return JSONResponse( + status_code=502, + content={ + "error": "github_api_error", + "message": str(exc), + "details": {}, + }, + ) + + @app.exception_handler(Exception) + async def unhandled_exception_handler( + request: Request, exc: Exception + ) -> JSONResponse: + """Catch-all for unhandled exceptions.""" + import logging + + logging.exception("Unhandled exception in %s %s", request.method, request.url) + return JSONResponse( + status_code=500, + content={ + "error": "internal_error", + "message": "Internal server error", + "details": {}, + }, + ) + + # --- Route registration --- + app.include_router(items.router) + app.include_router(mutations.router) + app.include_router(fields.router) + + return app + + +app = create_app() + + +def main() -> None: + """Entry point for the github-projects-api CLI command.""" + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "8080")) + uvicorn.run(app, host=host, port=port) + + +def main_dev() -> None: + """Entry point for the github-projects-api-dev CLI command (auto-reload).""" + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "8080")) + uvicorn.run( + "github_projects_client.server.app:app", + host=host, + port=port, + reload=True, + ) + + +if __name__ == "__main__": + main() diff --git a/github-projects-client/github_projects_client/server/auth.py b/github-projects-client/github_projects_client/server/auth.py new file mode 100644 index 0000000..6c1a2e7 --- /dev/null +++ b/github-projects-client/github_projects_client/server/auth.py @@ -0,0 +1,57 @@ +"""Bearer token extraction and validation as a FastAPI dependency.""" + +from __future__ import annotations + +import requests as req +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +_bearer_scheme = HTTPBearer() + + +def get_token( + credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), +) -> str: + """Extract bearer token from Authorization header. + + Uses FastAPI's HTTPBearer scheme so Swagger UI shows a proper + "Authorize" button instead of a raw header text field. + + Returns the raw token string for use with the GitHub API. + """ + return credentials.credentials + + +def get_validated_token( + token: str = Depends(get_token), +) -> str: + """Extract and validate bearer token against the GitHub API. + + Calls GET /user to confirm the token is real. Raises 401 if + GitHub rejects it. + """ + try: + resp = req.get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + except req.RequestException: + raise HTTPException( + status_code=502, detail="Unable to reach GitHub API for token validation" + ) + if not resp.ok: + raise HTTPException(status_code=401, detail="Invalid or expired bearer token") + return token + + +def build_session(token: str) -> req.Session: + """Build a requests.Session authenticated with the given token.""" + session = req.Session() + session.headers.update( + { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + ) + return session diff --git a/github-projects-client/github_projects_client/server/formats.py b/github-projects-client/github_projects_client/server/formats.py new file mode 100644 index 0000000..e829080 --- /dev/null +++ b/github-projects-client/github_projects_client/server/formats.py @@ -0,0 +1,45 @@ +"""Compact columnar format helper for REST API responses.""" + +from __future__ import annotations + +from typing import Any + + +def build_display_items(items: list[dict]) -> list[dict]: + """Strip internal fields from items for output. + + Empty strings are preserved so callers can distinguish "field has no value" + from "field was not requested." + """ + return [{k: v for k, v in item.items() if not k.startswith("_")} for item in items] + + +def format_compact( + display_items: list[dict], + has_more: bool, + next_cursor: str | None, +) -> dict[str, Any]: + """Return columnar dict: column names once, then rows as arrays. + + Much more token-efficient than full JSON for large result sets + because field names appear once instead of once-per-item. + """ + columns: list[str] = [] + seen: set[str] = set() + for item in display_items: + for key in item: + if key not in seen: + columns.append(key) + seen.add(key) + + rows = [[item.get(col, "") for col in columns] for item in display_items] + + payload: dict[str, Any] = { + "columns": columns, + "rows": rows, + "total_in_page": len(display_items), + } + if has_more: + payload["has_more"] = True + payload["next_cursor"] = next_cursor + return payload diff --git a/github-projects-client/github_projects_client/server/models.py b/github-projects-client/github_projects_client/server/models.py new file mode 100644 index 0000000..a2e9a8a --- /dev/null +++ b/github-projects-client/github_projects_client/server/models.py @@ -0,0 +1,107 @@ +"""Pydantic request/response models for the REST API.""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# --- Response models --- + + +class ErrorResponse(BaseModel): + error: str + message: str + details: dict[str, Any] = Field(default_factory=dict) + + +class ItemsResponse(BaseModel): + items: list[dict[str, Any]] + total_in_page: int + has_more: bool = False + next_cursor: Optional[str] = None + + +class CompactItemsResponse(BaseModel): + columns: list[str] + rows: list[list[Any]] + total_in_page: int + has_more: bool = False + next_cursor: Optional[str] = None + + +class BulkMutationResult(BaseModel): + item_ref: str + success: bool + old_value: str = "" + new_value: str = "" + error: Optional[str] = None + + +class BulkMutationResponse(BaseModel): + success_count: int + failure_count: int + results: list[BulkMutationResult] + + +# --- Request models --- + + +class BulkSetFieldRequest(BaseModel): + item_refs: list[str] + value: str + + +# --- Field models --- + + +class FieldInfo(BaseModel): + name: str + id: str + type: str + + +class FieldsResponse(BaseModel): + fields: list[FieldInfo] + + +class FieldOptionItem(BaseModel): + name: str + + +class SingleSelectOptionsResponse(BaseModel): + field_name: str + type: str = "single_select" + options: list[FieldOptionItem] + + +class IterationItem(BaseModel): + title: str + start_date: Optional[str] = None + + +class IterationOptionsResponse(BaseModel): + field_name: str + type: str = "iteration" + active: list[IterationItem] + completed: list[IterationItem] + + +# --- Audit log models --- + + +class AuditLogEntry(BaseModel): + timestamp: str + caller: str = "" + endpoint: str = "" + params: dict[str, Any] = Field(default_factory=dict) + result: str = "" + old_value: Optional[str] = None + new_value: Optional[str] = None + error: Optional[str] = None + + +class AuditLogResponse(BaseModel): + entries: list[AuditLogEntry] + total: int diff --git a/github-projects-client/github_projects_client/server/routes/__init__.py b/github-projects-client/github_projects_client/server/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/github-projects-client/github_projects_client/server/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/github-projects-client/github_projects_client/server/routes/fields.py b/github-projects-client/github_projects_client/server/routes/fields.py new file mode 100644 index 0000000..7261e27 --- /dev/null +++ b/github-projects-client/github_projects_client/server/routes/fields.py @@ -0,0 +1,110 @@ +"""Field discovery endpoints for the GitHub Projects REST API.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from ..auth import build_session, get_token + +from github_projects_client import ( + list_fields as client_list_fields, + list_field_options as client_list_field_options, +) + +router = APIRouter() + + +@router.get("/orgs/{org}/projects/{project_number}/fields") +def list_fields( + org: str, + project_number: int, + token: str = Depends(get_token), +) -> dict: + """List all fields on the board with their REST numeric IDs and types. + + Note: `gh project field-list` also provides this information. + """ + session = build_session(token) + + field_map = client_list_fields( + session, + org=org, + project_number=project_number, + ) + + fields_list = [ + {"name": name, "id": str(fid), "type": "unknown"} + for name, fid in sorted(field_map.items()) + ] + + return {"fields": fields_list} + + +@router.get( + "/orgs/{org}/projects/{project_number}/fields/{field_name}/options", + description=( + "List valid options for a single-select or iteration field. " + "Useful to discover what values are accepted by the mutation endpoints " + "(e.g., valid Status values, active iteration titles).\n\n" + "Note: `gh project field-list` also provides this information." + ), +) +def list_field_options( + org: str, + project_number: int, + field_name: str, + token: str = Depends(get_token), +) -> dict: + """List options for a single-select or iteration field.""" + session = build_session(token) + + data = client_list_field_options( + session, + org=org, + project_number=project_number, + field_name=field_name, + ) + + fields = data.get("fields", {}) + if not fields: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"Field '{field_name}' not found", + "details": {}, + }, + ) + + field_info = next(iter(fields.values())) + field_type = field_info.get("type", "unknown") + + if field_type == "single_select": + return { + "field_name": field_name, + "type": "single_select", + "options": [{"name": opt["name"]} for opt in field_info.get("options", [])], + } + + if field_type == "iteration": + return { + "field_name": field_name, + "type": "iteration", + "active": [ + { + "title": it["title"], + "start_date": it.get("startDate"), + } + for it in field_info.get("iterations", []) + ], + "completed": [ + {"title": it["title"]} + for it in field_info.get("completed_iterations", []) + ], + } + + return { + "field_name": field_name, + "type": field_type, + "options": [], + } diff --git a/github-projects-client/github_projects_client/server/routes/items.py b/github-projects-client/github_projects_client/server/routes/items.py new file mode 100644 index 0000000..b002331 --- /dev/null +++ b/github-projects-client/github_projects_client/server/routes/items.py @@ -0,0 +1,202 @@ +"""Item endpoints for the GitHub Projects REST API.""" + +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from ..auth import build_session, get_token +from ..formats import build_display_items, format_compact + +from github_projects_client import get_item as client_get_item +from github_projects_client import list_items as client_list_items +from github_projects_client import resolve_view_url as client_resolve_view_url +from github_projects_client.query_syntax import QUERY_SYNTAX_REFERENCE + +router = APIRouter() + + +def _items_response( + result: dict[str, Any], + fmt: str, +) -> dict[str, Any]: + """Build the response dict/model from a list_items result.""" + items = result["items"] + has_more = result["has_more"] + next_cursor = result["next_cursor"] + display_items = build_display_items(items) + + if fmt == "compact": + return format_compact(display_items, has_more, next_cursor) + + payload: dict[str, Any] = { + "items": display_items, + "total_in_page": len(display_items), + "has_more": has_more, + } + if has_more: + payload["next_cursor"] = next_cursor + return payload + + +_LIST_ITEMS_DESCRIPTION = f"""\ +List board items with optional filtering. Results are paginated — check `has_more` +and pass `next_cursor` back to get the next page. + +**IMPORTANT**: The filter is passed via the `query` parameter (not `filter`). +Unknown parameters are silently ignored — if you pass a parameter name +that doesn't exist (e.g., `filter`), it will be dropped and the default +query will be used instead. + +OR queries (e.g., `(branch1) OR (branch2)`) fetch all matching items in a single +request and do not support cursor-based pagination. + +{QUERY_SYNTAX_REFERENCE} +""" + + +@router.get( + "/orgs/{org}/projects/{project_number}/items", + description=_LIST_ITEMS_DESCRIPTION, +) +def list_items( + org: str, + project_number: int, + token: str = Depends(get_token), + query: str = Query( + default='-status:"🎉 Done"', + description="GitHub Projects v2 filter syntax (same as board UI search bar). Default excludes Done items.", + ), + fields: Optional[str] = Query( + default=None, + description=( + "Comma-separated field names to include. Three categories of fields are available:\n\n" + "**Board fields** (project-level, varies by board — use GET /fields or " + "`gh project field-list` to discover): " + "Status, Cycle Theme, Dev Days Estimate, Cycle, Prio, Owner (custom fields); " + "Title, Assignees, Labels, Linked pull requests, Milestone, Repository, " + "Reviewers, Parent issue, Sub-issues progress, Type (built-in fields).\n\n" + "**Pseudo-fields** (derived from item metadata, not board columns): " + "Node ID (PVTI_... project item node ID — needed for bulk mutations), " + "Repository, Id, Number, url, Title, Kind, Assignees.\n\n" + "**Default** (when omitted): Repository, Id, url, Title, Status, Kind, " + "Milestone, Assignees, Cycle Theme, Dev Days Estimate." + ), + ), + format: str = Query( + default="json", + description="Response format: 'json' (default, object per item) or 'compact' (columnar: field names once, rows as arrays).", + ), + per_page: int = Query( + default=50, + le=100, + description="Items per page (max 100).", + ), + cursor: Optional[str] = Query( + default=None, + description="Opaque cursor from a previous response's next_cursor. Same query/fields from original request are used automatically.", + ), +) -> dict: + """List board items with optional filtering.""" + session = build_session(token) + field_list = [f.strip() for f in fields.split(",")] if fields else None + + result = client_list_items( + session, + org=org, + project_number=project_number, + query=query, + fields=field_list, + per_page=per_page, + cursor=cursor, + ) + return _items_response(result, format) + + +@router.get( + "/orgs/{org}/projects/{project_number}/items/view", + description=( + "List items from a saved GitHub project view URL.\n\n" + "- Uses the saved view filter from GitHub (project view metadata).\n" + "- If URL has `filterQuery=...`, that overrides the saved view filter.\n" + "- `sliceBy[...]` URL parameters are ignored.\n" + "- If URL has `visibleFields=[...]`, that exact field order is used.\n" + "- Otherwise uses the view's configured default fields (may differ from live UI column order)." + ), +) +def list_view_items( + org: str, + project_number: int, + token: str = Depends(get_token), + view_url: str = Query( + description="Full GitHub project view URL (e.g., https://github.com/orgs/FilOzone/projects/14/views/1)." + ), + fields: Optional[str] = Query( + default=None, + description="Comma-separated field names to override the view's configured fields.", + ), + format: str = Query( + default="json", + description="Response format: 'json' (default) or 'compact' (columnar).", + ), + per_page: int = Query(default=50, le=100, description="Items per page (max 100)."), + cursor: Optional[str] = Query( + default=None, description="Pagination cursor from a previous response." + ), +) -> dict: + """List items from a saved GitHub project view URL.""" + session = build_session(token) + + resolved = client_resolve_view_url(session, view_url=view_url) + + field_list = None + if fields: + field_list = [f.strip() for f in fields.split(",")] + elif resolved.get("view_fields"): + field_list = resolved["view_fields"] + + result = client_list_items( + session, + org=resolved["org"], + project_number=resolved["project_number"], + query=resolved["effective_filter"], + fields=field_list, + per_page=per_page, + cursor=cursor, + ) + return _items_response(result, format) + + +@router.get("/orgs/{org}/projects/{project_number}/items/{item_ref:path}") +def get_item( + org: str, + project_number: int, + item_ref: str, + token: str = Depends(get_token), +) -> dict: + """Get a single board item by reference. + + item_ref accepts: `repo#number` (e.g., `dealbot#458`), `owner/repo#number` + (e.g., `FilOzone/dealbot#458`), or a full GitHub URL. URL-encode the `#` as `%23`. + """ + session = build_session(token) + + details = client_get_item( + session, + org=org, + project_number=project_number, + item_ref=item_ref, + ) + + if details is None: + raise HTTPException( + status_code=404, + detail={ + "error": "not_found", + "message": f"Item {item_ref} not found on project board", + "details": {}, + }, + ) + + return {k: v for k, v in details.items() if not k.startswith("_")} diff --git a/github-projects-client/github_projects_client/server/routes/mutations.py b/github-projects-client/github_projects_client/server/routes/mutations.py new file mode 100644 index 0000000..d35753c --- /dev/null +++ b/github-projects-client/github_projects_client/server/routes/mutations.py @@ -0,0 +1,120 @@ +"""Mutation endpoints for the GitHub Projects REST API.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from ..auth import build_session, get_token, get_validated_token +from ..models import BulkSetFieldRequest + +from github_projects_client import ( + set_field_value_bulk as client_set_field_value_bulk, +) +from github_projects_client.audit_log import log_action, read_recent_entries + +router = APIRouter() + + +def _get_caller(token: str) -> str: + """Extract caller identity from the bearer token via GitHub API.""" + import requests + + try: + resp = requests.get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + if resp.ok: + return resp.json().get("login", "unknown") + except Exception: + pass + return "unknown" + + +@router.put( + "/orgs/{org}/projects/{project_number}/items/field/{field_name}", + description=( + "Update a project-level field on one or more board items. " + "Use this for fields like Status, Cycle Theme, Dev Days Estimate, Cycle. " + "For issue/PR-level changes (assignees, milestones, reviewers), use `gh` CLI instead.\n\n" + "**field_name** is the display name of the project field (e.g., `Status`, `Cycle%20Theme`).\n\n" + "**item_refs** accepts: `repo#number` (e.g., `dealbot#458`), " + "`owner/repo#number`, a full GitHub URL, or raw " + "project item node IDs (e.g., `PVTI_...`) to skip per-item lookup — useful " + "when you already have node IDs from a prior list items call with `Node ID` " + "in the fields parameter.\n\n" + "**value** accepts:\n" + "- Option name for single-select fields (e.g., `🐱 Todo`, `⌨️ In Progress`)\n" + "- Iteration title for iteration fields (e.g., `202605-1`)\n" + "- Numeric string for number fields (e.g., `3`)\n" + '- Empty string `""` to clear the field\n\n' + "Partial failures are reported per-item (the request does not roll back)." + ), +) +def set_field( + org: str, + project_number: int, + field_name: str, + body: BulkSetFieldRequest, + token: str = Depends(get_token), +) -> dict: + """Update a project-level field on one or more board items.""" + session = build_session(token) + caller = _get_caller(token) + + result = client_set_field_value_bulk( + session, + org=org, + project_number=project_number, + item_refs=body.item_refs, + field_name=field_name, + value=body.value, + ) + + for r in result.get("results", []): + if r.get("success"): + log_action( + caller=caller, + endpoint=f"/items/field/{field_name}", + params={ + "org": org, + "project_number": project_number, + "item_ref": r["item_ref"], + "field_name": field_name, + "value": body.value, + }, + result="success", + old_value=r.get("old_value", ""), + new_value=r.get("new_value", ""), + ) + + return { + "success_count": result["success_count"], + "failure_count": result["failure_count"], + "results": [ + { + "item_ref": r["item_ref"], + "success": r.get("success", False), + "old_value": r.get("old_value", ""), + "new_value": r.get("new_value", ""), + "error": r.get("error"), + } + for r in result.get("results", []) + ], + } + + +@router.get("/orgs/{org}/projects/{project_number}/audit-log") +def get_audit_log( + org: str, + project_number: int, + token: str = Depends(get_validated_token), + count: int = Query(default=20, description="Number of recent entries to return."), +) -> dict: + """Read recent audit log entries. Each entry includes timestamp, caller, endpoint, old/new values.""" + entries = read_recent_entries(count) + return { + "entries": entries, + "total": len(entries), + } diff --git a/github-projects-client/pyproject.toml b/github-projects-client/pyproject.toml index 2d67984..04fcfd4 100644 --- a/github-projects-client/pyproject.toml +++ b/github-projects-client/pyproject.toml @@ -6,8 +6,14 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "requests>=2.31", + "fastapi>=0.115", + "uvicorn[standard]>=0.34", ] +[project.scripts] +github-projects-api = "github_projects_client.server.app:main" +github-projects-api-dev = "github_projects_client.server.app:main_dev" + [build-system] requires = ["hatchling>=1.24.2"] build-backend = "hatchling.build" @@ -23,4 +29,5 @@ markers = [ [dependency-groups] dev = [ "pytest>=9.0.3", + "httpx>=0.27", ] diff --git a/github-projects-client/tests/test_items_unit.py b/github-projects-client/tests/test_items_unit.py index f1655b5..a6bf69b 100644 --- a/github-projects-client/tests/test_items_unit.py +++ b/github-projects-client/tests/test_items_unit.py @@ -2,6 +2,20 @@ Unit tests for items.py — no network access required. These test _format_item and related helpers with mocked REST API responses. +Mock data uses the real GitHub Projects v2 REST API response shapes. + +To regenerate or verify mock data, fetch a real item with all fields: + + export GITHUB_TOKEN=$(gh auth token) + curl -s "https://api.github.com/orgs/FilOzone/projectsV2/14/items \ + ?q=repo:FilOzone/dealbot%20159 \ + &fields=194437026,194437027,194437028,194437029,194437030,194437031, \ + 194437032,194437033,194437036,194437037,194437038,194437039, \ + 204711739,242588518,244708427,245538973 \ + &per_page=1" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" | jq '.[0]' Run: cd github-projects-client @@ -13,21 +27,96 @@ import json from github_projects_client.items import _format_field_value, _format_item +from github_projects_client.server.formats import build_display_items + + +# --------------------------------------------------------------------------- +# Mock data — matches real GitHub Projects v2 REST API response shapes. +# +# Each field value uses the exact structure the API returns (verified 2026-05-08 +# against FilOzone project #14). See module docstring for the curl command to +# regenerate. +# --------------------------------------------------------------------------- + +# single_select: {"id": "...", "name": {"raw": "...", "html": "..."}, "color": "...", ...} +SINGLE_SELECT_STATUS = { + "id": "47fc9ee4", + "name": {"raw": "⌨️ In Progress", "html": "⌨️ In Progress"}, + "description": {"raw": "Actively in progress", "html": "Actively in progress"}, + "color": "YELLOW", +} + +SINGLE_SELECT_TODO = { + "id": "f75ad846", + "name": {"raw": "🐱 Todo", "html": "🐱 Todo"}, + "description": {"raw": "Ready to start", "html": "Ready to start"}, + "color": "BLUE", +} +# text: {"raw": "...", "html": "..."} +TEXT_CYCLE_THEME = {"raw": "Dealbot", "html": "Dealbot"} + +# number: bare numeric value +NUMBER_DEV_DAYS = 0.5 + +# iteration: {"id": "...", "title": {"raw": "...", "html": "..."}, "start_date": "...", ...} +ITERATION_CYCLE = { + "id": "af356b6e", + "start_date": "2026-02-16", + "duration": 14, + "title": {"raw": "202602-2 Acies", "html": "202602-2 Acies"}, + "completed": True, +} + +# milestone: full GitHub milestone object (title is a plain string, not a dict) +MILESTONE_VALUE = { + "url": "https://api.github.com/repos/FilOzone/dealbot/milestones/7", + "html_url": "https://github.com/FilOzone/dealbot/milestone/7", + "id": 14626044, + "node_id": "MI_kwDOPdGgVs4A3yz8", + "number": 7, + "title": "M4.2: mainnet GA", + "state": "open", + "open_issues": 10, + "closed_issues": 39, +} + +# sub_issues_progress: {"total": N, "completed": N, "percent_completed": N} +SUB_ISSUES_PROGRESS = {"total": 2, "completed": 2, "percent_completed": 100} + +# reviewers (with requested_reviewers): {"requested_reviewers": [...], "requested_teams": [...]} +REVIEWERS_VALUE = { + "requested_reviewers": [ + {"login": "rjan90", "id": 8628857}, + {"login": "SgtPooki", "id": 1173416}, + ], + "requested_teams": [{"slug": "foc-core", "name": "FOC Core"}], +} -# A minimal mock of a REST API item response +# A realistic mock item using real API field shapes. MOCK_ITEM = { "node_id": "PVTI_lADOBt3abc4AkXYZzgZ1234", "content": { "url": "https://api.github.com/repos/FilOzone/dealbot/issues/458", + "html_url": "https://github.com/FilOzone/dealbot/issues/458", "number": 458, "title": "chore: release to production (main)", - "assignees": [{"login": "SgtPooki"}], + "assignees": [ + {"login": "SgtPooki", "id": 1173416}, + ], }, "fields": [ - {"name": "Status", "value": {"name": "🐱 Todo"}}, - {"name": "Cycle Theme", "value": "Dealbot"}, - {"name": "Milestone", "value": {"title": "M4.2: mainnet GA"}}, + {"name": "Status", "data_type": "single_select", "value": SINGLE_SELECT_TODO}, + {"name": "Cycle Theme", "data_type": "text", "value": TEXT_CYCLE_THEME}, + {"name": "Milestone", "data_type": "milestone", "value": MILESTONE_VALUE}, + {"name": "Dev Days Estimate", "data_type": "number", "value": NUMBER_DEV_DAYS}, + {"name": "Cycle", "data_type": "iteration", "value": ITERATION_CYCLE}, + { + "name": "Sub-issues progress", + "data_type": "sub_issues_progress", + "value": SUB_ISSUES_PROGRESS, + }, + {"name": "Prio", "data_type": "single_select", "value": None}, ], } @@ -40,55 +129,108 @@ "assignees": [], }, "fields": [ - {"name": "Status", "value": {"name": "🐱 Todo"}}, + {"name": "Status", "data_type": "single_select", "value": SINGLE_SELECT_TODO}, ], } -class TestFormatItemNodeId: - """Tests for the 'Node ID' synthetic field in _format_item.""" +# --------------------------------------------------------------------------- +# _format_field_value tests — one class per field type +# --------------------------------------------------------------------------- - def test_node_id_field_returned_when_requested(self): - result = _format_item(MOCK_ITEM, ["Title", "Node ID"]) - assert "Node ID" in result - assert result["Node ID"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" - def test_node_id_case_insensitive(self): - result = _format_item(MOCK_ITEM, ["node id"]) - assert result["node id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" +class TestFormatFieldValueSingleSelect: + """single_select: name is {"raw": "...", "html": "..."}, not a plain string.""" - def test_node_id_underscore_variant(self): - result = _format_item(MOCK_ITEM, ["node_id"]) - assert result["node_id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" + def test_returns_raw_name(self): + assert _format_field_value(SINGLE_SELECT_STATUS) == "⌨️ In Progress" - def test_node_id_falls_back_to_id_field(self): - result = _format_item(MOCK_ITEM_ID_ONLY, ["Node ID"]) - assert result["Node ID"] == "PVTI_fallback_id_field" + def test_todo_status(self): + assert _format_field_value(SINGLE_SELECT_TODO) == "🐱 Todo" - def test_node_id_empty_when_missing(self): - item_no_id = {"content": {"url": "", "number": 1, "title": "x"}, "fields": []} - result = _format_item(item_no_id, ["Node ID"]) - assert result["Node ID"] == "" + def test_null_returns_empty(self): + assert _format_field_value(None) == "" - def test_node_id_not_included_unless_requested(self): - result = _format_item(MOCK_ITEM, ["Title", "Status"]) - assert "Node ID" not in result - # But _node_id is always there internally - assert "_node_id" in result - assert result["_node_id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" - def test_node_id_alongside_other_fields(self): - result = _format_item( - MOCK_ITEM, ["Repository", "Id", "Title", "Node ID", "Status"] +class TestFormatFieldValueText: + """text: {"raw": "...", "html": "..."}.""" + + def test_returns_raw(self): + assert _format_field_value(TEXT_CYCLE_THEME) == "Dealbot" + + +class TestFormatFieldValueNumber: + """number: bare numeric value.""" + + def test_float(self): + assert _format_field_value(0.5) == "0.5" + + def test_int(self): + assert _format_field_value(3) == "3" + + def test_zero(self): + assert _format_field_value(0) == "0" + + +class TestFormatFieldValueIteration: + """iteration: title is {"raw": "...", "html": "..."}, not a plain string. + + This was a bug — _format_field_value checked isinstance(title, str) but the + REST API returns title as a dict. See Bug 2 fix in items.py. + """ + + def test_returns_title_raw(self): + assert _format_field_value(ITERATION_CYCLE) == "202602-2 Acies" + + def test_null_value_returns_empty(self): + assert _format_field_value(None) == "" + + +class TestFormatFieldValueMilestone: + """milestone: full GitHub milestone object — title is a plain string.""" + + def test_returns_title(self): + assert _format_field_value(MILESTONE_VALUE) == "M4.2: mainnet GA" + + +class TestFormatFieldValueSubIssuesProgress: + """sub_issues_progress: {"total": N, "completed": N, "percent_completed": N}. + + Previously returned "" (silent data loss) because no dict branch matched. + """ + + def test_returns_completed_over_total(self): + assert _format_field_value(SUB_ISSUES_PROGRESS) == "2/2" + + def test_partial_progress(self): + assert ( + _format_field_value({"total": 5, "completed": 3, "percent_completed": 60}) + == "3/5" ) - assert result["Node ID"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" - assert result["Title"] == "chore: release to production (main)" - assert result["Id"] == "458" - assert result["Status"] == "🐱 Todo" + + def test_zero_progress(self): + assert ( + _format_field_value({"total": 4, "completed": 0, "percent_completed": 0}) + == "0/4" + ) + + +class TestFormatFieldValueReviewers: + """reviewers: {"requested_reviewers": [...], "requested_teams": [...]}.""" + + def test_returns_logins_and_teams(self): + result = _format_field_value(REVIEWERS_VALUE) + assert "rjan90" in result + assert "SgtPooki" in result + assert "foc-core" in result + + def test_empty_reviewers(self): + result = _format_field_value({"requested_reviewers": [], "requested_teams": []}) + assert result == "" class TestFormatFieldValueLinkedPRs: - """Tests for _format_field_value handling of linked PR/issue objects.""" + """linked_pull_requests: list of full GitHub PR objects (~8KB each).""" LINKED_PRS = [ { @@ -112,8 +254,7 @@ class TestFormatFieldValueLinkedPRs: ] def test_returns_valid_json(self): - result = _format_field_value(self.LINKED_PRS) - parsed = json.loads(result) + parsed = json.loads(_format_field_value(self.LINKED_PRS)) assert isinstance(parsed, list) assert len(parsed) == 2 @@ -127,44 +268,125 @@ def test_keeps_useful_fields(self): assert first["title"] == "feat: add retry logic" assert first["author"] == "alice" - def test_second_item(self): - parsed = json.loads(_format_field_value(self.LINKED_PRS)) - second = parsed[1] - assert second["number"] == 492 - assert second["state"] == "closed" - assert second["draft"] is True - assert second["author"] == "bob" - def test_strips_verbose_fields(self): - """Full user objects, labels, URLs should NOT be in output.""" result = _format_field_value(self.LINKED_PRS) assert "avatar_url" not in result assert "html_url" not in result assert "labels" not in result - assert "enhancement" not in result assert "repository_url" not in result - def test_compact_size(self): - """Output should be smaller than raw str(value). - - With real API data (~8KB per PR), the ratio is ~50:1. - With minimal test fixtures it's ~2:1. - """ - compact = _format_field_value(self.LINKED_PRS) - raw = str(self.LINKED_PRS) - assert len(compact) < len(raw) - - def test_single_linked_pr(self): - parsed = json.loads(_format_field_value([self.LINKED_PRS[0]])) - assert len(parsed) == 1 - assert parsed[0]["number"] == 487 - def test_empty_list(self): - result = _format_field_value([]) - assert result == "[]" + assert _format_field_value([]) == "[]" def test_assignee_list_still_works(self): - """Existing login-based handling should still work.""" users = [{"login": "alice"}, {"login": "bob"}] - result = _format_field_value(users) - assert result == "alice, bob" + assert _format_field_value(users) == "alice, bob" + + +# --------------------------------------------------------------------------- +# _format_item tests — full item formatting with real field shapes +# --------------------------------------------------------------------------- + + +class TestFormatItemNodeId: + """Tests for the 'Node ID' synthetic field in _format_item.""" + + def test_node_id_field_returned_when_requested(self): + result = _format_item(MOCK_ITEM, ["Title", "Node ID"]) + assert result["Node ID"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" + + def test_node_id_case_insensitive(self): + result = _format_item(MOCK_ITEM, ["node id"]) + assert result["node id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" + + def test_node_id_underscore_variant(self): + result = _format_item(MOCK_ITEM, ["node_id"]) + assert result["node_id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" + + def test_node_id_falls_back_to_id_field(self): + result = _format_item(MOCK_ITEM_ID_ONLY, ["Node ID"]) + assert result["Node ID"] == "PVTI_fallback_id_field" + + def test_node_id_empty_when_missing(self): + item_no_id = {"content": {"url": "", "number": 1, "title": "x"}, "fields": []} + result = _format_item(item_no_id, ["Node ID"]) + assert result["Node ID"] == "" + + def test_node_id_not_included_unless_requested(self): + result = _format_item(MOCK_ITEM, ["Title", "Status"]) + assert "Node ID" not in result + assert "_node_id" in result + assert result["_node_id"] == "PVTI_lADOBt3abc4AkXYZzgZ1234" + + +class TestFormatItemAllFieldTypes: + """Verify _format_item correctly renders all field types from MOCK_ITEM.""" + + def test_all_fields_render(self): + fields = [ + "Repository", + "Id", + "Title", + "Status", + "Cycle Theme", + "Milestone", + "Dev Days Estimate", + "Cycle", + "Sub-issues progress", + ] + result = _format_item(MOCK_ITEM, fields) + assert result["Repository"] == "FilOzone/dealbot" + assert result["Id"] == "458" + assert result["Title"] == "chore: release to production (main)" + assert result["Status"] == "🐱 Todo" + assert result["Cycle Theme"] == "Dealbot" + assert result["Milestone"] == "M4.2: mainnet GA" + assert result["Dev Days Estimate"] == "0.5" + assert result["Cycle"] == "202602-2 Acies" + assert result["Sub-issues progress"] == "2/2" + + def test_null_field_renders_as_empty(self): + """Fields with null value should render as empty string, not vanish.""" + result = _format_item(MOCK_ITEM, ["Prio"]) + assert result["Prio"] == "" + + def test_unrequested_field_not_present(self): + result = _format_item(MOCK_ITEM, ["Status"]) + assert "Cycle" not in result + + +# --------------------------------------------------------------------------- +# build_display_items tests +# --------------------------------------------------------------------------- + + +class TestBuildDisplayItems: + """Tests for build_display_items — strips _internal fields, preserves empties.""" + + def test_strips_underscore_fields(self): + items = [{"Title": "foo", "_node_id": "PVTI_abc"}] + result = build_display_items(items) + assert "_node_id" not in result[0] + assert result[0]["Title"] == "foo" + + def test_preserves_empty_strings(self): + """Empty string means 'field has no value', not 'field missing'.""" + items = [{"Title": "foo", "Cycle": "", "Status": "🐱 Todo"}] + result = build_display_items(items) + assert result[0]["Cycle"] == "" + + def test_preserves_none_values(self): + items = [{"Title": "foo", "Prio": None}] + result = build_display_items(items) + assert "Prio" in result[0] + + def test_multiple_items(self): + items = [ + {"Title": "a", "_node_id": "x", "Status": ""}, + {"Title": "b", "_node_id": "y", "Status": "🐱 Todo"}, + ] + result = build_display_items(items) + assert len(result) == 2 + assert "_node_id" not in result[0] + assert result[0]["Status"] == "" + assert result[1]["Status"] == "🐱 Todo" diff --git a/github-projects-client/tests/test_mutations_route.py b/github-projects-client/tests/test_mutations_route.py new file mode 100644 index 0000000..2b75569 --- /dev/null +++ b/github-projects-client/tests/test_mutations_route.py @@ -0,0 +1,401 @@ +""" +Unit tests for the mutation route — no network access required. + +Tests the consolidated PUT /orgs/{org}/projects/{project_number}/items/field/{field_name} +endpoint with mocked client functions. + +Run: + cd github-projects-client + uv run pytest tests/test_mutations_route.py -v +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from github_projects_client.server.app import create_app + +AUTH_HEADER = {"Authorization": "Bearer fake-token-for-tests"} + + +@pytest.fixture() +def client(): + app = create_app() + return TestClient(app, raise_server_exceptions=False) + + +# --- Helpers --- + + +def _bulk_result(items: list[dict], *, success_count=None, failure_count=None): + """Build a mock return value matching client_set_field_value_bulk's shape.""" + sc = ( + success_count + if success_count is not None + else sum(1 for i in items if i.get("success")) + ) + fc = ( + failure_count + if failure_count is not None + else sum(1 for i in items if not i.get("success")) + ) + return {"success_count": sc, "failure_count": fc, "results": items} + + +# --- Success cases --- + + +class TestSetFieldSuccess: + def test_single_item(self, client): + mock_result = _bulk_result( + [ + { + "item_ref": "dealbot#458", + "success": True, + "old_value": "🐱 Todo", + "new_value": "⌨️ In Progress", + }, + ] + ) + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + return_value=mock_result, + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + patch("github_projects_client.server.routes.mutations.log_action"), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"item_refs": ["dealbot#458"], "value": "⌨️ In Progress"}, + headers=AUTH_HEADER, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["success_count"] == 1 + assert body["failure_count"] == 0 + assert len(body["results"]) == 1 + assert body["results"][0]["item_ref"] == "dealbot#458" + assert body["results"][0]["old_value"] == "🐱 Todo" + assert body["results"][0]["new_value"] == "⌨️ In Progress" + + def test_multiple_items(self, client): + mock_result = _bulk_result( + [ + { + "item_ref": "dealbot#458", + "success": True, + "old_value": "", + "new_value": "🎉 Done", + }, + { + "item_ref": "synapse-sdk#748", + "success": True, + "old_value": "🐱 Todo", + "new_value": "🎉 Done", + }, + ] + ) + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + return_value=mock_result, + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + patch("github_projects_client.server.routes.mutations.log_action"), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={ + "item_refs": ["dealbot#458", "synapse-sdk#748"], + "value": "🎉 Done", + }, + headers=AUTH_HEADER, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["success_count"] == 2 + assert body["failure_count"] == 0 + assert len(body["results"]) == 2 + + def test_field_name_with_spaces(self, client): + """Field names with spaces work via URL-encoding (Cycle%20Theme).""" + mock_result = _bulk_result( + [ + { + "item_ref": "dealbot#458", + "success": True, + "old_value": "", + "new_value": "Dealbot", + }, + ] + ) + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + return_value=mock_result, + ) as mock_bulk, + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + patch("github_projects_client.server.routes.mutations.log_action"), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Cycle%20Theme", + json={"item_refs": ["dealbot#458"], "value": "Dealbot"}, + headers=AUTH_HEADER, + ) + + assert resp.status_code == 200 + # Verify the decoded field name was passed to the client + mock_bulk.assert_called_once() + call_kwargs = mock_bulk.call_args + assert call_kwargs.kwargs["field_name"] == "Cycle Theme" + + +# --- Partial failure --- + + +class TestSetFieldPartialFailure: + def test_mixed_success_and_failure(self, client): + mock_result = _bulk_result( + [ + { + "item_ref": "dealbot#458", + "success": True, + "old_value": "", + "new_value": "⌨️ In Progress", + }, + { + "item_ref": "bad-ref#999", + "success": False, + "error": "Could not find item: bad-ref#999", + }, + ] + ) + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + return_value=mock_result, + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + patch("github_projects_client.server.routes.mutations.log_action"), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={ + "item_refs": ["dealbot#458", "bad-ref#999"], + "value": "⌨️ In Progress", + }, + headers=AUTH_HEADER, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["success_count"] == 1 + assert body["failure_count"] == 1 + failed = [r for r in body["results"] if not r["success"]] + assert len(failed) == 1 + assert "Could not find item" in failed[0]["error"] + + +# --- Audit logging --- + + +class TestAuditLogging: + def test_logs_successful_mutations_only(self, client): + mock_result = _bulk_result( + [ + { + "item_ref": "dealbot#458", + "success": True, + "old_value": "🐱 Todo", + "new_value": "⌨️ In Progress", + }, + {"item_ref": "bad#999", "success": False, "error": "not found"}, + ] + ) + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + return_value=mock_result, + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="sweepbot", + ), + patch( + "github_projects_client.server.routes.mutations.log_action" + ) as mock_log, + ): + client.put( + "/orgs/FilOzone/projects/14/items/field/Status", + json={ + "item_refs": ["dealbot#458", "bad#999"], + "value": "⌨️ In Progress", + }, + headers=AUTH_HEADER, + ) + + # Only the successful mutation should be logged + assert mock_log.call_count == 1 + call_kwargs = mock_log.call_args.kwargs + assert call_kwargs["caller"] == "sweepbot" + assert call_kwargs["endpoint"] == "/items/field/Status" + assert call_kwargs["params"]["item_ref"] == "dealbot#458" + assert call_kwargs["result"] == "success" + assert call_kwargs["old_value"] == "🐱 Todo" + assert call_kwargs["new_value"] == "⌨️ In Progress" + + +# --- Validation errors --- + + +class TestValidation: + def test_missing_auth_header(self, client): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"item_refs": ["dealbot#458"], "value": "⌨️ In Progress"}, + ) + assert resp.status_code in (401, 403) + + def test_missing_item_refs(self, client): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"value": "⌨️ In Progress"}, + headers=AUTH_HEADER, + ) + assert resp.status_code == 422 + + def test_missing_value(self, client): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"item_refs": ["dealbot#458"]}, + headers=AUTH_HEADER, + ) + assert resp.status_code == 422 + + def test_empty_body(self, client): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + headers=AUTH_HEADER, + ) + assert resp.status_code == 422 + + +# --- Audit log token validation --- + + +class TestAuditLogAuth: + def test_invalid_token_returns_401(self, client): + """Audit log endpoint must validate the bearer token against GitHub.""" + mock_response = type("R", (), {"ok": False, "status_code": 401})() + with patch( + "github_projects_client.server.auth.req.get", + return_value=mock_response, + ): + resp = client.get( + "/orgs/TestOrg/projects/1/audit-log", + headers={"Authorization": "Bearer bad-token"}, + ) + assert resp.status_code == 401 + + def test_valid_token_returns_entries(self, client): + """Audit log returns entries when the token is validated by GitHub.""" + mock_response = type( + "R", + (), + { + "ok": True, + "status_code": 200, + "json": lambda self: {"login": "testuser"}, + }, + )() + with ( + patch( + "github_projects_client.server.auth.req.get", + return_value=mock_response, + ), + patch( + "github_projects_client.server.routes.mutations.read_recent_entries", + return_value=[], + ), + ): + resp = client.get( + "/orgs/TestOrg/projects/1/audit-log", + headers=AUTH_HEADER, + ) + assert resp.status_code == 200 + assert resp.json()["entries"] == [] + + +# --- GraphQL auth error handling --- + + +class TestGitHubErrorHandling: + def test_insufficient_scopes_returns_401(self, client): + """INSUFFICIENT_SCOPES from GraphQL should surface as 401, not 500.""" + from github_projects_client.api import GitHubAuthError + + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + side_effect=GitHubAuthError( + "GitHub token is missing required OAuth/PAT scopes" + ), + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"item_refs": ["dealbot#458"], "value": "⌨️ In Progress"}, + headers=AUTH_HEADER, + ) + assert resp.status_code == 401 + body = resp.json() + assert body["error"] == "unauthorized" + assert "scopes" in body["message"] + + def test_graphql_error_returns_502_with_message(self, client): + """Non-auth GraphQL errors should surface as 502, not swallowed as 500.""" + from github_projects_client.api import GitHubAPIError + + with ( + patch( + "github_projects_client.server.routes.mutations.client_set_field_value_bulk", + side_effect=GitHubAPIError( + "GraphQL errors: [{'message': 'something broke'}]" + ), + ), + patch( + "github_projects_client.server.routes.mutations._get_caller", + return_value="testuser", + ), + ): + resp = client.put( + "/orgs/TestOrg/projects/1/items/field/Status", + json={"item_refs": ["dealbot#458"], "value": "⌨️ In Progress"}, + headers=AUTH_HEADER, + ) + assert resp.status_code == 502 + body = resp.json() + assert body["error"] == "github_api_error" + assert "something broke" in body["message"] diff --git a/github-projects-client/uv.lock b/github-projects-client/uv.lock index 099a25b..2f27d54 100644 --- a/github-projects-client/uv.lock +++ b/github-projects-client/uv.lock @@ -1,6 +1,36 @@ version = 1 revision = 1 -requires-python = ">=3.10" +requires-python = ">=3.13" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, +] [[package]] name = "certifi" @@ -17,54 +47,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, @@ -116,6 +98,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -126,15 +120,19 @@ wheels = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.1" +name = "fastapi" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683 }, ] [[package]] @@ -142,19 +140,88 @@ name = "github-projects-client" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pytest" }, ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.31" }] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "requests", specifier = ">=2.31" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.3" }] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=9.0.3" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] [[package]] name = "idna" @@ -192,6 +259,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -207,18 +345,61 @@ version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[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/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + [[package]] name = "requests" version = "2.33.1" @@ -235,57 +416,15 @@ wheels = [ ] [[package]] -name = "tomli" -version = "2.4.1" +name = "starlette" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, ] [[package]] @@ -297,6 +436,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -305,3 +456,146 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6 wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] diff --git a/specs/005-rest-api-layer/checklists/requirements.md b/specs/005-rest-api-layer/checklists/requirements.md new file mode 100644 index 0000000..7448e52 --- /dev/null +++ b/specs/005-rest-api-layer/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: REST API Layer for GitHub Projects Client + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items passed initial validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/005-rest-api-layer/contracts/rest-api.md b/specs/005-rest-api-layer/contracts/rest-api.md new file mode 100644 index 0000000..ba3be31 --- /dev/null +++ b/specs/005-rest-api-layer/contracts/rest-api.md @@ -0,0 +1,267 @@ +# REST API Contract: GitHub Projects API + +**Feature**: 005-rest-api-layer | **Date**: 2026-05-08 + +## Base URL + +`http://{host}:{port}` (default: `http://127.0.0.1:8080`) + +## Authentication + +All endpoints require `Authorization: Bearer ` header. Requests without a valid header receive `401 Unauthorized`. + +## URL Pattern + +All board-scoped endpoints follow: `/orgs/{org}/projects/{project_number}/...` + +This mirrors the GitHub API URL structure for familiarity. + +--- + +## Endpoints + +### GET /orgs/{org}/projects/{project_number}/items + +List board items with optional filtering. + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|-----------|--------|--------------------------|------------------------------------------------| +| query | string | `-status:"🎉 Done"` | GitHub Projects v2 filter syntax | +| fields | string | (default set) | Comma-separated field names to include | +| format | string | `json` | Response format: `json`, `compact` | +| per_page | int | 50 | Items per page (max 100) | +| cursor | string | (none) | Pagination cursor from previous response | + +**Response** (`format=json`): + +```json +{ + "items": [ + {"Repository": "dealbot", "Id": "dealbot#458", "Title": "Fix timeout", "Status": "⌨️ In Progress", ...} + ], + "total_in_page": 1, + "has_more": false +} +``` + +**Response** (`format=compact`): + +```json +{ + "columns": ["Repository", "Id", "Title", "Status"], + "rows": [["dealbot", "dealbot#458", "Fix timeout", "⌨️ In Progress"]], + "total_in_page": 1, + "has_more": false +} +``` + +When `has_more` is true, response includes `next_cursor`. + +**Errors**: +- `401`: Missing or invalid bearer token +- `404`: Org or project not found +- `422`: Invalid query syntax +- `429`: GitHub API rate limit exceeded (includes `retry_after` field) + +--- + +### GET /orgs/{org}/projects/{project_number}/items/{item_ref} + +Get a single board item by reference. + +**Path Parameters**: + +| Parameter | Type | Description | +|-----------|--------|--------------------------------------------------------------| +| item_ref | string | URL-encoded item reference: `repo#number`, `owner/repo#number`, or full URL | + +**Response**: + +```json +{ + "Repository": "dealbot", + "Id": "dealbot#458", + "Title": "Fix timeout", + "Status": "⌨️ In Progress", + "Assignees": "rjan90", + "Cycle Theme": "Contract Upgrade", + ... +} +``` + +**Errors**: +- `401`: Missing or invalid bearer token +- `404`: Item not found on the board + +--- + +### GET /orgs/{org}/projects/{project_number}/items/view + +List items from a saved GitHub project view URL. + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|-----------|--------|---------------|------------------------------------------------| +| view_url | string | (required) | Full GitHub project view URL | +| fields | string | (from view) | Override comma-separated field names | +| format | string | `json` | Response format: `json`, `compact` | +| per_page | int | 50 | Items per page (max 100) | +| cursor | string | (none) | Pagination cursor | + +**Response**: Same shape as list items endpoint. + +--- + +### GET /orgs/{org}/projects/{project_number}/fields + +List all fields on the board. + +**Response**: + +```json +{ + "fields": [ + {"name": "Status", "id": "12345", "type": "single_select"}, + {"name": "Cycle Theme", "id": "12346", "type": "single_select"}, + {"name": "Dev Days Estimate", "id": "12347", "type": "number"} + ] +} +``` + +--- + +### GET /orgs/{org}/projects/{project_number}/fields/{field_name}/options + +List options for a single-select or iteration field. + +**Response** (single-select): + +```json +{ + "field_name": "Status", + "type": "single_select", + "options": [ + {"name": "📌 Triage"}, + {"name": "🐱 Todo"}, + {"name": "⌨️ In Progress"}, + {"name": "🔍 Review"}, + {"name": "🎉 Done"} + ] +} +``` + +**Response** (iteration): + +```json +{ + "field_name": "Cycle", + "type": "iteration", + "active": [ + {"title": "202604-2", "start_date": "2026-04-14"} + ], + "completed": [ + {"title": "202604-1"} + ] +} +``` + +--- + +### PUT /orgs/{org}/projects/{project_number}/items/field/{field_name} + +Update a project-level field on one or more board items. + +**Request Body**: + +```json +{ + "item_refs": ["dealbot#458", "synapse-sdk#748", "filecoin-pin#412"], + "value": "⌨️ In Progress" +} +``` + +Pass `""` (empty string) as `value` to clear the field. `item_refs` accepts `repo#number`, `owner/repo#number`, full GitHub URLs, or raw project item node IDs (`PVTI_...`). + +**Response**: + +```json +{ + "success_count": 3, + "failure_count": 0, + "results": [ + {"item_ref": "dealbot#458", "success": true, "old_value": "🐱 Todo", "new_value": "⌨️ In Progress"}, + {"item_ref": "synapse-sdk#748", "success": true, "old_value": "", "new_value": "⌨️ In Progress"}, + {"item_ref": "filecoin-pin#412", "success": true, "old_value": "📌 Triage", "new_value": "⌨️ In Progress"} + ] +} +``` + +Partial failures are reported per-item (the request does not roll back). + +**Errors**: +- `401`: Missing or invalid bearer token +- `404`: Field not found +- `422`: Invalid value for field type + +--- + +### GET /orgs/{org}/projects/{project_number}/audit-log + +Read recent audit log entries. + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|-----------|------|---------|------------------------------| +| count | int | 20 | Number of recent entries | + +**Response**: + +```json +{ + "entries": [ + { + "timestamp": "2026-05-08T14:30:00Z", + "caller": "biglep", + "endpoint": "/items/field/Status", + "params": {"org": "FilOzone", "project_number": 14, "item_ref": "dealbot#458", "field_name": "Status", "value": "⌨️ In Progress"}, + "result": "success", + "old_value": "🐱 Todo", + "new_value": "⌨️ In Progress" + } + ], + "total": 1 +} +``` + +--- + +## Error Response Format + +All errors follow a consistent shape: + +```json +{ + "error": "not_found", + "message": "Item dealbot#999 not found on project board", + "details": {} +} +``` + +For rate limit errors: + +```json +{ + "error": "rate_limited", + "message": "GitHub API rate limit exceeded", + "details": { + "retry_after": 42, + "limit": 5000, + "remaining": 0, + "reset_at": "2026-05-08T15:00:00Z" + } +} +``` diff --git a/specs/005-rest-api-layer/data-model.md b/specs/005-rest-api-layer/data-model.md new file mode 100644 index 0000000..53e17f6 --- /dev/null +++ b/specs/005-rest-api-layer/data-model.md @@ -0,0 +1,81 @@ +# Data Model: REST API Layer for GitHub Projects Client + +**Feature**: 005-rest-api-layer | **Date**: 2026-05-08 + +## Entities + +### API Request Context + +Every API request carries these required parameters: + +- **org** (string): GitHub organization (e.g., "FilOzone") +- **project_number** (integer): GitHub Projects v2 project number (e.g., 14) +- **bearer_token** (string): GitHub PAT, passed in `Authorization: Bearer ` header + +These replace the server-side environment variables used in the current MCP implementation. The API server is stateless — it does not store org, project_number, or tokens between requests. + +### Board Item (read) + +Returned by list and get endpoints. Same shape as current `github-projects-client` output: + +- **Repository** (string): Short repo name (e.g., "dealbot") +- **Id** (string): Issue/PR reference (e.g., "dealbot#458") +- **Title** (string): Issue/PR title +- **Status** (string): Board status field value (e.g., "⌨️ In Progress") +- **Kind** (string): "Issue" or "Pull Request" +- **Assignees** (string): Comma-separated usernames +- Additional fields as requested (Milestone, Cycle Theme, Dev Days Estimate, etc.) +- Internal fields (prefixed with `_`) are stripped before returning + +### Field Metadata (read) + +- **name** (string): Human-readable field name (e.g., "Status", "Cycle Theme") +- **id** (string): REST numeric field ID +- **type** (string): "single_select", "iteration", "text", "number", etc. +- **options** (list, optional): For single-select fields, the allowed values + +### Mutation Request + +- **item_ref** (string): Item reference in any supported format (repo#number, owner/repo#number, URL, or PVTI_ node ID) +- **field_name** (string): Display name of the project field +- **value** (string): New value to set (option name for single-select, iteration title, numeric string, or empty string to clear) + +### Bulk Mutation Request + +- **updates** (list): Array of mutation objects, each with item_ref, field_name, value +- Alternatively: **item_refs** (list of strings) + **field_name** + **value** (for setting the same field/value on multiple items) + +### Audit Log Entry + +Extended from current `action_log.py` format: + +- **timestamp** (string): ISO 8601 UTC timestamp +- **caller** (string, new): GitHub username associated with the bearer token +- **endpoint** (string, new): API endpoint that triggered the mutation (e.g., "/items/field", "/items/field/bulk") +- **params** (object): Request parameters (org, project_number, item_ref, field_name, value) +- **result** (string): "success" or "failure" +- **old_value** (string, optional): Previous field value +- **new_value** (string, optional): New field value +- **error** (string, optional): Error message if result is "failure" + +### Pagination + +- **cursor** (string, optional): Opaque cursor for next page +- **has_more** (boolean): Whether more results are available +- **total_in_page** (integer): Number of items in current page + +## State Transitions + +No state machine — the API is stateless. Each request is independent. The audit log is append-only (no updates or deletes). + +## Relationships + +``` +API Request Context ──carries──▷ Bearer Token ──authenticates──▷ GitHub API + │ + ├── list/get ──▷ Board Items (read-only, from GitHub) + ├── fields ──▷ Field Metadata (read-only, from GitHub) + └── mutations ──▷ Mutation Request ──produces──▷ Audit Log Entry +``` + +The `github-projects-client` library handles all GitHub API communication. The API layer handles: request parsing, token extraction, client invocation, response formatting, and audit logging. diff --git a/specs/005-rest-api-layer/plan.md b/specs/005-rest-api-layer/plan.md new file mode 100644 index 0000000..37f8311 --- /dev/null +++ b/specs/005-rest-api-layer/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: REST API Layer for GitHub Projects Client + +**Branch**: `005-rest-api-layer` | **Date**: 2026-05-08 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/005-rest-api-layer/spec.md` + +## Summary + +Add a stateless REST API server inside the existing `github-projects-client` package, exposing its capabilities as HTTP endpoints. Each request carries org, project_number, and a bearer token — no server-side config or session state. Audit logging (currently in `filozzy-mcp`) moves into the client package. `filozzy-mcp` is simplified to a thin MCP coordinator that returns board context and API usage instructions to LLMs, without handling any GitHub data itself. The package will be renamed to `github-projects-turbo` in a future PR to minimize churn in this change. + +## Technical Context + +**Language/Version**: Python >=3.13 (consistent with existing packages) +**Primary Dependencies**: `requests>=2.31` (existing), `fastapi` + `uvicorn` (HTTP framework — auto-generates OpenAPI spec from route definitions) +**Storage**: Append-only JSONL file for audit log (same pattern as current `action_log.jsonl`) +**Testing**: pytest (consistent with existing packages), unit tests + integration tests +**Target Platform**: Local server (localhost), same machine as the LLM agent +**Project Type**: Library + HTTP API server (same package) + MCP server refactor (existing package) +**Performance Goals**: Standard — board queries are bottlenecked by GitHub API latency, not the local server +**Constraints**: Must start quickly (subsecond), minimal memory footprint, no external service dependencies beyond GitHub API +**Scale/Scope**: Single concurrent user (the LLM agent), boards with up to ~100 items per query + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution is the default template with no project-specific gates defined. No violations to check. + +**Pre-Phase 0**: PASS (no gates defined) +**Post-Phase 1**: PASS (no gates defined) + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-rest-api-layer/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── rest-api.md # REST endpoint contracts +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +github-projects-client/ # EXTENDED (existing library + new API server) +├── pyproject.toml # Updated: add HTTP framework dep + server entry point +├── github_projects_client/ +│ ├── __init__.py # Existing public API (unchanged) +│ ├── api.py # Existing: low-level GitHub communication +│ ├── items.py # Existing: list/get items +│ ├── fields.py # Existing: field enumeration +│ ├── mutations.py # Existing: field updates +│ ├── query.py # Existing: OR-query expansion +│ ├── views.py # Existing: view URL resolution +│ ├── server/ # NEW: REST API server (FastAPI) +│ │ ├── __init__.py +│ │ ├── app.py # FastAPI app, route registration, error handlers +│ │ ├── routes/ +│ │ │ ├── __init__.py +│ │ │ ├── items.py # list_items, get_item, list_view_items +│ │ │ ├── fields.py # list_fields, list_field_options +│ │ │ └── mutations.py # set_field, bulk_set_field +│ │ ├── auth.py # FastAPI Depends() for bearer token extraction +│ │ ├── models.py # Pydantic request/response models (drives OpenAPI schema) +│ │ └── formats.py # Compact columnar format (custom; json is default via FastAPI) +│ └── audit_log.py # NEW: moved from filozzy-mcp/action_log.py +├── tests/ +│ ├── test_items_unit.py # Existing +│ ├── test_query_unit.py # Existing +│ ├── test_integration.py # Existing +│ ├── test_server_routes.py # NEW +│ ├── test_server_auth.py # NEW +│ └── test_audit_log.py # NEW +└── uv.lock + +filozzy-mcp/ # MODIFIED (simplified) +├── filozzy_mcp/ +│ ├── server.py # Refactored: thin coordinator, no GitHub calls +│ └── action_log.py # REMOVED (moved to github-projects-client) +└── tests/ +``` + +**Structure Decision**: The REST API server lives inside `github-projects-client` as a `server/` subpackage. The existing library API is unchanged — `github-project-export` and other consumers are unaffected. The server is an additive layer that imports the same internal modules. A new entry point in `pyproject.toml` provides the `github-projects-api` CLI command. `filozzy-mcp` gets simplified, not removed. + +## Complexity Tracking + +No constitution violations to justify — no gates defined. diff --git a/specs/005-rest-api-layer/quickstart.md b/specs/005-rest-api-layer/quickstart.md new file mode 100644 index 0000000..aa75eb5 --- /dev/null +++ b/specs/005-rest-api-layer/quickstart.md @@ -0,0 +1,117 @@ +# Quickstart: REST API Layer for GitHub Projects Client + +**Feature**: 005-rest-api-layer | **Date**: 2026-05-08 + +## Prerequisites + +- Python >=3.13 +- `uv` package manager +- GitHub PAT with `read:project`, `project`, `repo`, and `read:org` scopes + +## Start the API Server + +```bash +cd github-projects-client +uv run github-projects-api +``` + +Server starts on `http://127.0.0.1:8080` by default. Override with: + +```bash +HOST=0.0.0.0 PORT=9090 uv run github-projects-api +``` + +## Query Board Items + +```bash +# List open items (default: excludes Done) +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items" > board_items.json + +# With query filter +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items?query=is:pr%20status:%22%E2%8C%A8%EF%B8%8F%20In%20Progress%22" > in_progress_prs.json + +# Compact format (recommended for large result sets) +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items?format=compact" > board_compact.json + +# With specific fields +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items?fields=Repository,Id,Title,Status,Assignees" > board_items.json + +# Pagination +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items?cursor=NEXT_CURSOR_VALUE" > page2.json +``` + +## Get a Single Item + +```bash +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/items/dealbot%23458" +``` + +## List Fields and Options + +```bash +# All board fields +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/fields" + +# Options for a single-select field +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + "http://localhost:8080/orgs/FilOzone/projects/14/fields/Status/options" +``` + +## Update a Field + +```bash +# Single item update +curl -s -X PUT -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"value": "⌨️ In Progress"}' \ + "http://localhost:8080/orgs/FilOzone/projects/14/items/dealbot%23458/fields/Status" + +# Bulk update +curl -s -X PUT -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"item_refs": ["dealbot#458", "synapse-sdk#748"], "value": "⌨️ In Progress"}' \ + "http://localhost:8080/orgs/FilOzone/projects/14/fields/Status/bulk" +``` + +## MCP Coordinator + +After the refactor, `.mcp.json` changes to: + +```json +{ + "mcpServers": { + "filozzy": { + "command": "uv", + "args": ["--directory", "./filozzy-mcp", "run", "filozzy-mcp"], + "env": { + "GITHUB_ORG": "FilOzone", + "GITHUB_PROJECT_NUMBER": "14", + "BOARD_NAMES": "FOC Board,FOC Project Board", + "API_BASE_URL": "http://localhost:8080" + } + } + } +} +``` + +Note: `GITHUB_TOKEN` is no longer in the MCP config. The LLM uses its own token when calling the API directly. + +## Run Tests + +```bash +cd github-projects-client +uv run pytest tests/ -v + +# Unit tests only (no GitHub API calls) +uv run pytest tests/ -v -m "not integration" + +# Integration tests (requires GITHUB_TOKEN) +GITHUB_TOKEN=$GITHUB_TOKEN uv run pytest tests/ -v -m integration +``` diff --git a/specs/005-rest-api-layer/research.md b/specs/005-rest-api-layer/research.md new file mode 100644 index 0000000..a339c89 --- /dev/null +++ b/specs/005-rest-api-layer/research.md @@ -0,0 +1,78 @@ +# Research: REST API Layer for GitHub Projects Client + +**Feature**: 005-rest-api-layer | **Date**: 2026-05-08 + +## R1: HTTP Framework Choice + +**Decision**: Use FastAPI with uvicorn. + +**Rationale**: FastAPI auto-generates an OpenAPI spec from route definitions and Pydantic models — the API spec lives in code, stays in sync, and is served at `/openapi.json` and `/docs` for free. This eliminates maintaining a separate spec file. FastAPI's `Depends()` system handles auth middleware concisely, and Pydantic models define request/response shapes that double as validation and documentation. Sync route functions are run in a threadpool automatically, so the `requests`-based client library works without async changes. Net less code than Flask or manual approaches. + +**Tradeoff**: Pulls in `uvicorn`, `starlette`, `pydantic`, `anyio` as transitive deps. Acceptable for a localhost server — all well-maintained, fast install. + +**Alternatives considered**: +- **Flask**: Mature, synchronous by default (good fit), but no auto-generated OpenAPI. Would require `flask-smorest` or a manually-maintained spec file. +- **Starlette**: Lighter than FastAPI, but no auto-generated OpenAPI or Pydantic integration. More boilerplate. +- **http.server (stdlib)**: Zero deps, but manual routing, no middleware, no OpenAPI — too much hand-rolling. + +## R2: Audit Log Migration + +**Decision**: Move `action_log.py` from `filozzy-mcp` into `github-projects-client` as `audit_log.py`, with minor enhancements. + +**Rationale**: The spec requires that all mutations are logged regardless of client (MCP, curl, programmatic). The API layer is the single mutation gateway, so it's the natural place for logging. Since the API server lives inside `github-projects-client`, the audit log module belongs there too. + +**Changes from current implementation**: +- **Caller identity**: Current log records `tool` and `params` but not who initiated the request. The API layer can extract identity from the bearer token (GitHub user associated with the PAT) and include it in log entries. This may require a single GitHub API call (`GET /user`) on first use, cached for the session. +- **Log path**: Configurable via environment variable (same pattern: `ACTION_LOG_PATH` with a sensible default). +- **Format**: Same append-only JSONL. No schema change needed beyond adding `caller` field. + +**Alternatives considered**: +- Keeping logging in MCP and having MCP call the API for mutations: Defeats the purpose — MCP shouldn't be in the data path. +- Logging in the client library itself: Too low-level. The client is a general-purpose library; audit logging is an application concern. + +## R3: MCP Coordinator Refactor + +**Decision**: Strip all GitHub API calls from `filozzy-mcp/server.py`. Replace tools with a single `get_board_context` tool (or small set of tools) that returns board identity and API instructions. + +**Rationale**: The MCP layer's value is naming resolution (board name → org + project number) and LLM-friendly instructions (what endpoints exist, how to call them). It should not need GITHUB_TOKEN at all. + +**What the coordinator returns**: +- Board name, org, project number +- API base URL (from environment, e.g., `API_BASE_URL=http://localhost:8080`) +- Endpoint catalog: brief description of each endpoint, expected parameters, example curl commands +- Query syntax reference (the extensive filter docs currently in the `list_board_items` docstring) + +**What gets removed from MCP**: +- `_build_session()` and all `requests.Session` usage +- All tools that call `github-projects-client` directly +- `action_log.py` import and all `log_action` calls +- `GITHUB_TOKEN` from MCP environment config + +**What stays in MCP**: +- `GITHUB_ORG`, `GITHUB_PROJECT_NUMBER`, `BOARD_NAMES` (these are board identity, not API credentials) +- `API_BASE_URL` (new: tells the LLM where to reach the API) +- Board name resolution logic + +## R4: Authentication Flow + +**Decision**: Bearer token passthrough. The API server does not manage tokens — it receives a GitHub PAT in the `Authorization: Bearer ` header and passes it through to the `github-projects-client` library. + +**Rationale**: Simplest possible auth model. The LLM agent already has a GITHUB_TOKEN in its environment. The API server doesn't store it, cache it, or validate it beyond confirming the header is present. GitHub itself handles token validation when the client makes API calls. + +**Token validation approach**: The API checks for header presence before processing. If the token is invalid, GitHub's API will return 401, which the API surfaces as an authentication error to the caller. + +## R5: Response Format for Direct-to-Disk Use + +**Decision**: Reuse the existing format options (default JSONL, `json`, `compact`) from the current MCP tools, exposed as a `format` query parameter on list endpoints. + +**Rationale**: These formats were already designed for LLM consumption and disk storage. The compact columnar format is particularly well-suited for the direct-to-disk use case (40-60% smaller than full JSON). + +**Additional consideration**: The API should set `Content-Type: application/json` for all formats to enable proper pipe-to-file behavior with curl (`curl -s ... > file.json`). + +## R6: Where Does the API Server Run? + +**Decision**: The API server runs locally on the same machine as the LLM agent, started as a background process. It's configured in `.mcp.json` or started manually. + +**Rationale**: Localhost deployment keeps the architecture simple (no TLS, no network auth, no deployment infra). The server could eventually be deployed remotely, but that's out of scope for v1. The stateless design (bearer token per request, no server-side config beyond log path) means the same code works locally or remotely without changes. + +**Startup**: The API server is a Python package with an entry point (e.g., `github-projects-api` CLI command). It reads `HOST` and `PORT` from environment (defaulting to `127.0.0.1:8080`). diff --git a/specs/005-rest-api-layer/spec.md b/specs/005-rest-api-layer/spec.md new file mode 100644 index 0000000..0fe3d16 --- /dev/null +++ b/specs/005-rest-api-layer/spec.md @@ -0,0 +1,125 @@ +# Feature Specification: REST API Layer for GitHub Projects Client + +**Feature Branch**: `005-rest-api-layer` +**Created**: 2026-05-08 +**Status**: Draft +**Input**: User description: "Create a REST API layer on top of github-projects-client that exposes its capabilities (list items, get item, list fields, field options, set fields, bulk set fields) as stateless HTTP endpoints. Each request carries org, project_number, and a bearer token. The API includes audit logging (moved from filozzy-mcp action_log.jsonl). filozzy-mcp becomes a thin coordinator that knows board names, org, and project number, and tells the LLM how to curl the API directly — reads and writes bypass MCP context entirely." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Fetch Board Data Directly to Disk (Priority: P1) + +An LLM agent performing a board sweep needs to query 70+ board items. Today, that data flows through the MCP layer into the LLM's context window (tokenized once as input, then again as output when written to disk via the Write tool). With the REST API, the LLM instead receives a short endpoint description from the MCP coordinator and fetches the data directly to a file using a shell command, bypassing context entirely. + +**Why this priority**: This is the core value proposition. Board sweeps currently burn 30K+ tokens on data passthrough that involves zero reasoning. Eliminating this bottleneck is the primary motivation for the entire feature. + +**Independent Test**: Can be fully tested by starting the API server, making a query request with a bearer token, and verifying that the response contains correctly formatted board items that can be piped to a file. + +**Acceptance Scenarios**: + +1. **Given** the API server is running and a valid bearer token is provided, **When** the LLM issues a shell command to query board items with org and project number, **Then** the response contains the same data that the MCP tool would have returned, in a format suitable for saving directly to disk. +2. **Given** a board with 70+ items, **When** the LLM queries all items via the API and saves to disk, **Then** the LLM's context window contains only the shell command and a small confirmation — not the item data itself. +3. **Given** a query with filter parameters (e.g., status, assignee), **When** the request includes those filters, **Then** the API returns only matching items, consistent with existing filter/query behavior. + +--- + +### User Story 2 - Update Board Fields with Audit Trail (Priority: P2) + +An LLM agent needs to update board fields (e.g., move items to a new status, set cycle theme) during a sweep. These mutations should be logged for accountability. Today, audit logging lives in the MCP layer. With the REST API, logging moves into the API itself so that all mutations — whether initiated by an LLM via curl, by the MCP layer, or by any other client — are consistently logged. + +**Why this priority**: Mutations are less frequent than reads during sweeps, but the audit trail is critical for accountability. Moving logging to the API ensures no mutation goes unrecorded regardless of how it's triggered. + +**Independent Test**: Can be tested by sending a field update request to the API, verifying the field changed on the board, and confirming an audit log entry was written. + +**Acceptance Scenarios**: + +1. **Given** a valid bearer token and a board item reference, **When** a field update request is sent, **Then** the field is updated on the GitHub project board. +2. **Given** a batch of field updates (up to 25 items), **When** a bulk update request is sent, **Then** all fields are updated and each mutation is recorded in the audit log. +3. **Given** any mutation request, **When** the mutation succeeds, **Then** the audit log entry includes the item reference, field name, old value (if available), new value, timestamp, and the identity associated with the bearer token. + +--- + +### User Story 3 - MCP Coordinator Provides Board Context (Priority: P3) + +An LLM agent starts a board sweep session. It asks the MCP layer about the board. Instead of returning board data directly, the MCP layer responds with the board's configuration (org, project number, board name) and instructions for how to call the REST API — including the base URL and what endpoints are available. The LLM then uses this context to construct its own API calls. + +**Why this priority**: The MCP layer's role as a coordinator is important for usability (the LLM doesn't need to know org/project details upfront), but reads and writes can work without it — the LLM could be given the API URL and credentials directly. + +**Independent Test**: Can be tested by calling the MCP tool and verifying it returns board configuration and API usage instructions without returning any board item data. + +**Acceptance Scenarios**: + +1. **Given** the MCP server is configured with board names, org, and project number, **When** the LLM asks about a board, **Then** the MCP layer returns the board's identity (org, project number, display name) and the API base URL. +2. **Given** the MCP coordinator response, **When** the LLM constructs a curl command using the provided details, **Then** the command successfully retrieves data from the REST API. +3. **Given** the MCP server is configured with multiple board names, **When** the LLM asks about a specific board by name, **Then** the coordinator resolves the name to the correct org and project number. + +--- + +### User Story 4 - Discover Board Schema (Priority: P3) + +An LLM agent needs to understand what fields exist on the board, what options are available for single-select fields, and what iterations are active. The REST API exposes these as lightweight read endpoints that return small payloads suitable for direct inclusion in context. + +**Why this priority**: Schema discovery payloads are small enough that context passthrough is acceptable, but having them on the API keeps the interface consistent and allows direct-to-disk fetching for boards with unusually large field configurations. + +**Independent Test**: Can be tested by querying the fields endpoint and verifying it returns field names, types, and options. + +**Acceptance Scenarios**: + +1. **Given** a valid bearer token, org, and project number, **When** the fields endpoint is called, **Then** it returns all board fields with their names and types. +2. **Given** a single-select field name, **When** the field options endpoint is called, **Then** it returns all available options for that field. + +--- + +### Edge Cases + +- What happens when the bearer token is invalid or expired? The API returns a clear authentication error without leaking internal details. +- What happens when the org or project number doesn't exist? The API returns a descriptive "not found" error distinguishing between bad org vs. bad project number. +- What happens when a bulk update partially fails (e.g., 20 of 25 items succeed)? The API reports which items succeeded and which failed, and logs all outcomes in the audit log. +- What happens when the GitHub API rate limit is hit? The API surfaces the rate limit error with remaining/reset information so the caller can retry appropriately. +- What happens when the API server is unreachable but the MCP layer is running? The MCP coordinator can still provide board context, and reports the API as unavailable. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST expose board item listing as a stateless endpoint that accepts org, project number, bearer token, and optional query/filter parameters. +- **FR-002**: The system MUST expose single-item retrieval by item reference (repository#number or URL). +- **FR-003**: The system MUST expose field listing for a given board (org + project number). +- **FR-004**: The system MUST expose field option listing for single-select and iteration fields. +- **FR-005**: The system MUST expose single-item field updates that accept an item reference, field name, and new value. +- **FR-006**: The system MUST expose bulk field updates that accept multiple item-field-value triples in a single request. +- **FR-007**: The system MUST write an audit log entry for every mutation (single or bulk), including item reference, field name, new value, timestamp, and caller identity. +- **FR-008**: The system MUST authenticate every request using a bearer token and reject requests with missing or invalid tokens before processing. +- **FR-009**: The system MUST support the existing query/filter syntax including OR-condition queries. +- **FR-010**: The system MUST support pagination for list endpoints, returning a cursor that callers can use to fetch subsequent pages. +- **FR-011**: The system MUST return responses in a compact format optimized for saving to disk (minimal whitespace, no redundant metadata). +- **FR-012**: The MCP coordinator MUST return board configuration (org, project number, display name, API base URL) without returning board item data. +- **FR-013**: The MCP coordinator MUST NOT require a GitHub token in its own configuration — it delegates all GitHub API interaction to the REST API. +- **FR-014**: The system MUST surface GitHub API errors (rate limits, authentication failures, not found) with enough detail for callers to take corrective action. + +### Key Entities + +- **Board**: A GitHub Projects v2 project identified by org + project number. Has fields, items, and views. +- **Board Item**: An issue or pull request tracked on a board. Has field values, a reference (repo#number), and metadata (title, author, state, kind). +- **Field**: A board column (e.g., Status, Cycle Theme, Assignees). Has a type (single-select, text, number, iteration) and optionally a set of allowed values. +- **Audit Log Entry**: A record of a mutation. Captures what changed, when, and who initiated it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An LLM performing a full board sweep (70+ items) consumes less than 1,000 tokens of context for data retrieval, compared to 30,000+ tokens today. +- **SC-002**: All board data retrieved during a sweep is available on disk within 10 seconds of the LLM initiating the query, without passing through the LLM context window. +- **SC-003**: 100% of mutations (single and bulk) produce audit log entries — no mutation goes unrecorded regardless of which client initiates it. +- **SC-004**: The MCP coordinator's response to a board inquiry fits within 500 tokens, containing only configuration and instructions — no board item data. +- **SC-005**: Existing board sweep workflows (query, filter, update, report) can be completed using the new architecture with no loss of functionality compared to the current MCP-only approach. +- **SC-006**: The REST API handles the same query syntax and returns equivalent results to the current MCP tools, verified by running the same queries against both and comparing outputs. + +## Assumptions + +- The REST API server runs locally on the same machine as the LLM agent, so network latency is negligible and the LLM can reach it via localhost. +- The existing `github-projects-client` Python library is stable and its public API will not change as part of this work — the REST layer wraps it, not rewrites it. +- Bearer tokens are GitHub personal access tokens (PATs) with appropriate project scopes — the API passes them through to GitHub, it does not manage token lifecycle. +- The audit log format (append-only, one entry per mutation) is carried forward from the current implementation in filozzy-mcp, with the addition of caller identity. +- The MCP coordinator changes are scoped to filozzy-mcp only — no changes to the MCP protocol or other MCP servers. +- View URL resolution (parsing saved GitHub Project view URLs) is included in the API surface, consistent with the existing client capabilities. diff --git a/specs/005-rest-api-layer/tasks.md b/specs/005-rest-api-layer/tasks.md new file mode 100644 index 0000000..b62fe94 --- /dev/null +++ b/specs/005-rest-api-layer/tasks.md @@ -0,0 +1,210 @@ +# Tasks: REST API Layer for GitHub Projects Client + +**Input**: Design documents from `/specs/005-rest-api-layer/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/rest-api.md + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Create the server subpackage structure and configure dependencies + +- [X] T001 Create server subpackage directory structure: `github_projects_client/server/`, `github_projects_client/server/routes/`, and `__init__.py` files +- [X] T002 Update `github-projects-client/pyproject.toml`: add `fastapi` and `uvicorn[standard]` dependencies, add `github-projects-api` entry point under `[project.scripts]` +- [X] T003 Create FastAPI app skeleton in `github_projects_client/server/app.py`: app factory with metadata (title, description, version), route registration, uvicorn startup with HOST/PORT from environment + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core middleware and utilities that ALL endpoints depend on + +**CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 [P] Implement bearer token extraction as FastAPI dependency in `github_projects_client/server/auth.py`: create `get_token()` dependency using `Depends()` that extracts `Authorization: Bearer ` header, raises `HTTPException(401)` if missing +- [X] T005 [P] Implement Pydantic request/response models in `github_projects_client/server/models.py`: define models for all request bodies and response shapes per contracts/rest-api.md (ItemsResponse, CompactItemsResponse, MutationResponse, BulkMutationResponse, ErrorResponse, AuditLogEntry, etc.) — these drive the auto-generated OpenAPI schema +- [X] T006 [P] Implement compact format helper in `github_projects_client/server/formats.py`: port `_format_compact`, `_build_display_items` from `filozzy-mcp/filozzy_mcp/server.py` (standard JSON responses are handled by FastAPI's Pydantic serialization; only the columnar compact format needs custom logic) +- [X] T007 Implement error handlers in `github_projects_client/server/app.py`: register FastAPI exception handlers to map GitHub API errors (401, 404, rate limit) to consistent error JSON shape (`{"error": "...", "message": "...", "details": {}}`) per contracts/rest-api.md; include route registration via `app.include_router()` + +**Checkpoint**: Server starts, `GET /openapi.json` returns the auto-generated OpenAPI spec, `GET /docs` renders Swagger UI, returns 401 for unauthenticated requests, returns 404 for undefined routes with proper error JSON + +--- + +## Phase 3: User Story 1 - Fetch Board Data Directly to Disk (Priority: P1) MVP + +**Goal**: LLM agents can query board items via curl and pipe results directly to disk, bypassing MCP context entirely + +**Independent Test**: Start the API server, curl the items endpoint with a bearer token, verify response contains board items in the expected JSON/compact format, pipe to a file + +### Implementation for User Story 1 + +- [X] T008 [US1] Implement `GET /orgs/{org}/projects/{project_number}/items` in `github_projects_client/server/routes/items.py`: FastAPI route with `Query()` params (query, fields, format, per_page, cursor), `Depends(get_token)` for auth; build `requests.Session` from token; call `list_items()`; return Pydantic response model or compact format via formats.py +- [X] T009 [P] [US1] Implement `GET /orgs/{org}/projects/{project_number}/items/{item_ref}` in `github_projects_client/server/routes/items.py`: `Path()` param for item_ref (auto URL-decoded); call `get_item()`; return item dict or raise `HTTPException(404)` +- [X] T010 [US1] Implement `GET /orgs/{org}/projects/{project_number}/items/view` in `github_projects_client/server/routes/items.py`: `Query()` param for view_url; call `resolve_view_url()` then `list_items()`; return formatted response +- [X] T011 [US1] Create `APIRouter` in `github_projects_client/server/routes/items.py` and include it in app.py via `app.include_router()` + +**Checkpoint**: `curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8080/orgs/FilOzone/projects/14/items?format=compact" > board.json` works end-to-end. Pagination works. View URL resolution works. + +--- + +## Phase 4: User Story 2 - Update Board Fields with Audit Trail (Priority: P2) + +**Goal**: LLM agents can update board fields via curl, and every mutation is recorded in an audit log with caller identity + +**Independent Test**: Send a PUT request to update a field, verify the field changed on the board, verify an audit log entry was written with timestamp, caller, and field change details + +### Implementation for User Story 2 + +- [X] T012 [US2] Move and enhance audit log: copy `filozzy-mcp/filozzy_mcp/action_log.py` to `github_projects_client/audit_log.py`, add `caller` and `endpoint` fields to log entries, add `read_recent_entries()` function, configure log path via `ACTION_LOG_PATH` environment variable +- [X] T013 [US2] Implement `PUT /orgs/{org}/projects/{project_number}/items/{item_ref}/fields/{field_name}` in `github_projects_client/server/routes/mutations.py`: parse JSON body for `value`; call `set_field_value()`; write audit log entry with caller identity (from bearer token); return success/failure response per contract +- [X] T014 [US2] Implement `PUT /orgs/{org}/projects/{project_number}/fields/{field_name}/bulk` in `github_projects_client/server/routes/mutations.py`: parse JSON body for `item_refs` and `value`; call `set_field_value_bulk()`; write audit log entry per mutation; return per-item results with success/failure counts +- [X] T015 [US2] Implement `GET /orgs/{org}/projects/{project_number}/audit-log` in `github_projects_client/server/routes/mutations.py`: accept `count` query param; call `read_recent_entries()`; return entries array +- [X] T016 [US2] Register mutation routes in `github_projects_client/server/routes/__init__.py` + +**Checkpoint**: `curl -X PUT -H "Authorization: Bearer $TOKEN" -d '{"value":"⌨️ In Progress"}' "http://localhost:8080/orgs/FilOzone/projects/14/items/dealbot%23458/fields/Status"` updates the field and produces an audit log entry. Bulk updates work. Audit log endpoint returns recent entries. + +--- + +## Phase 5: User Story 4 - Discover Board Schema (Priority: P3) + +**Goal**: LLM agents can query board fields and field options via the API for schema discovery + +**Independent Test**: Curl the fields endpoint, verify it returns field names and types. Curl a field options endpoint, verify it returns valid options for a single-select field. + +### Implementation for User Story 4 + +- [X] T017 [P] [US4] Implement `GET /orgs/{org}/projects/{project_number}/fields` in `github_projects_client/server/routes/fields.py`: call `list_fields()`; return structured JSON with name, id, type per field (per contract) +- [X] T018 [US4] Implement `GET /orgs/{org}/projects/{project_number}/fields/{field_name}/options` in `github_projects_client/server/routes/fields.py`: call `list_field_options()`; return structured JSON differentiated by field type (single_select vs iteration) per contract +- [X] T019 [US4] Register fields routes in `github_projects_client/server/routes/__init__.py` + +**Checkpoint**: `curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8080/orgs/FilOzone/projects/14/fields"` returns all board fields. Field options endpoint returns valid options for Status, Cycle Theme, etc. + +--- + +## Phase 6: User Story 3 - MCP Coordinator Refactor (Priority: P3) + +**Goal**: filozzy-mcp becomes a thin coordinator that provides board context and API usage instructions without making any GitHub API calls + +**Independent Test**: Start the refactored MCP server (without GITHUB_TOKEN), call the coordinator tool, verify it returns board identity, API base URL, and endpoint documentation — not board data + +### Implementation for User Story 3 + +- [X] T020 [US3] Create `get_board_context` tool in `filozzy-mcp/filozzy_mcp/server.py`: return board name, org, project number, API base URL (from `API_BASE_URL` env var), link to OpenAPI spec (`{API_BASE_URL}/openapi.json`), endpoint catalog with descriptions and example curl commands, query syntax reference +- [X] T021 [US3] Remove all GitHub API tools from `filozzy-mcp/filozzy_mcp/server.py`: remove `list_board_items`, `list_board_view_items`, `get_board_item`, `list_board_fields`, `list_board_field_options`, `set_board_item_field`, `bulk_set_board_item_field`, `get_action_log` +- [X] T022 [US3] Remove GitHub API dependencies from `filozzy-mcp/filozzy_mcp/server.py`: remove `_build_session()`, all `requests.Session` usage, all `github_projects_client` imports, all `action_log` imports +- [X] T023 [US3] Remove `GITHUB_TOKEN` from MCP environment config: update `filozzy-mcp/filozzy_mcp/server.py` to not require `GITHUB_TOKEN`, add `API_BASE_URL` to configuration +- [X] T024 [US3] Delete `filozzy-mcp/filozzy_mcp/action_log.py` (moved to `github_projects_client/audit_log.py` in T012) +- [X] T025 [US3] Update `filozzy-mcp/pyproject.toml`: remove `github-projects-client` and `requests` from dependencies (no longer needed) +- [X] T026 [US3] Update `filozzy-mcp/tests/`: remove or update `test_format.py` (formatting moved to API), update `test_integration.py` to test coordinator tool instead of data tools + +**Checkpoint**: `filozzy-mcp` starts without `GITHUB_TOKEN`. The `get_board_context` tool returns board identity and API instructions. No MCP tool returns board item data. `.mcp.json` no longer passes `GITHUB_TOKEN` to the MCP server. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, validation, and cleanup + +- [X] T027 [P] Update `github-projects-client/README.md`: document the new REST API server, how to start it, link to `/docs` for interactive API explorer +- [X] T028 [P] Update `filozzy-mcp/README.md`: document the coordinator role, new `get_board_context` tool, removed tools +- [X] T029 Update `.mcp.json`: remove `GITHUB_TOKEN` from filozzy config, add `API_BASE_URL` +- [X] T030 Validate OpenAPI spec completeness: start the server, fetch `/openapi.json`, verify all endpoints from contracts/rest-api.md are present with correct parameters, request/response schemas, and error codes +- [ ] T031 Run quickstart.md validation: start the API server, execute all curl examples from `specs/005-rest-api-layer/quickstart.md`, verify responses match expected shapes +- [X] T032 Verify `github-project-export` still works: run existing export workflow to confirm the client library's public API is unaffected by adding the server subpackage + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Foundational — core read endpoints +- **US2 (Phase 4)**: Depends on Foundational — can run in parallel with US1 +- **US4 (Phase 5)**: Depends on Foundational — can run in parallel with US1 and US2 +- **US3 (Phase 6)**: Depends on US1, US2, US4 being complete (need the API server working before gutting MCP) +- **Polish (Phase 7)**: Depends on all user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: No dependencies on other stories. MVP deliverable. +- **US2 (P2)**: No dependencies on other stories (audit_log.py is self-contained). Can parallel with US1. +- **US4 (P3)**: No dependencies on other stories. Can parallel with US1 and US2. +- **US3 (P3)**: Depends on API server being functional (US1 + US2 + US4). Cannot remove MCP tools until the API replacement is verified. + +### Within Each User Story + +- Routes depend on auth.py, formats.py, and app.py from Foundational phase +- Mutation routes depend on audit_log.py (T012 must complete before T013-T015) +- Route registration tasks depend on the route implementation they register + +### Parallel Opportunities + +- T004 and T005 can run in parallel (auth.py and formats.py are independent files) +- T008 and T009 can run in parallel (different route handlers, but same file — split if needed) +- US1, US2, and US4 can all start after Foundational completes (if team capacity allows) +- T017 can run in parallel with any US1 or US2 task +- T027 and T028 can run in parallel (different README files) + +--- + +## Parallel Example: User Story 1 + +```text +# After Foundational phase completes, launch in parallel: +Task T008: Implement GET /items (list) in server/routes/items.py +Task T009: Implement GET /items/{item_ref} in server/routes/items.py + +# Then sequentially: +Task T010: Implement GET /items/view (depends on T008 pattern) +Task T011: Register items routes +``` + +## Parallel Example: After Foundational + +```text +# Three stories can start simultaneously: +Story US1: T008 (list items endpoint) +Story US2: T012 (audit log migration) +Story US4: T017 (fields endpoint) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL — blocks all stories) +3. Complete Phase 3: User Story 1 (list/get items) +4. **STOP and VALIDATE**: `curl` board items, pipe to file, verify format +5. This alone delivers the primary value: board data bypasses LLM context + +### Incremental Delivery + +1. Setup + Foundational → Server starts, auth works +2. Add US1 (reads) → LLM can fetch board data to disk (MVP!) +3. Add US2 (mutations) → LLM can update fields with audit trail +4. Add US4 (schema) → LLM can discover board structure +5. Add US3 (MCP refactor) → Complete architecture shift, MCP becomes coordinator +6. Each story adds value without breaking previous stories + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story is independently completable and testable (except US3 which requires the API server) +- The existing `github_projects_client` public API (`__init__.py` exports) must not change — `github-project-export` depends on it +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently