diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md new file mode 100644 index 0000000..efc4c7c --- /dev/null +++ b/.claude/skills/docs/SKILL.md @@ -0,0 +1,116 @@ +--- +name: docs +description: Update, review, or build the Sphinx documentation for Democrasite +disable-model-invocation: true +argument-hint: "[what to update or check, e.g. 'review for accuracy' or 'add activitypub section']" +--- + +## Task +$ARGUMENTS + +## Documentation structure + +``` +docs/ +├── conf.py — Sphinx config (theme, extensions, Django setup) +├── index.rst — Master toctree (README, CONTRIBUTING, howto, users, webiscite, api/) +├── README.rst — Project overview (mirrors root README) +├── CONTRIBUTING.rst — Dev setup and contribution guide +├── howto.rst — Instructions for building and writing docs +├── users.rst — Users app overview (minimal; relies on autodoc) +├── webiscite.rst — Core app narrative (pipeline, bill lifecycle) +└── api/ — Auto-generated RST files (do not edit manually) + ├── democrasite.rst + ├── democrasite.webiscite.rst + ├── democrasite.webiscite.models.rst + ├── democrasite.webiscite.managers.rst + ├── democrasite.webiscite.tasks.rst + ├── democrasite.webiscite.webhooks.rst + ├── democrasite.webiscite.constitution.rst + ├── democrasite.webiscite.views.rst + ├── democrasite.webiscite.api.rst + ├── democrasite.users.rst + ├── democrasite.activitypub.rst + └── … (one file per module) +``` + +## Key make commands + +All run from the `docs/` directory (or with `make -C docs `): + +| Command | What it does | +|---|---| +| `make apidocs` | Regenerate all `docs/api/*.rst` from source using sphinx-apidoc. Run this whenever Python modules are added or removed. | +| `make livehtml` | Build docs and serve with live reload at http://localhost:9000. Requires the docs Docker container (`docker compose -f docker-compose.docs.yml up`). | +| `make clean` | Remove `_build/` and `api/` directories for a fresh build. | +| `make html` | One-off HTML build into `_build/html/`. | + +To serve with live reload locally (in Docker): +``` +docker compose -f docker-compose.docs.yml up +``` + +To regenerate API docs locally (sphinx-apidoc must be installed): +``` +make -C docs apidocs +``` + +## What is auto-generated vs. manual + +- **`docs/api/`** — fully auto-generated by `make apidocs` (sphinx-apidoc). Never edit these files directly; edit the Python docstrings instead, then re-run `make apidocs`. +- **`docs/webiscite.rst`** and **`docs/users.rst`** — manually maintained narrative docs. Keep these in sync with code changes (especially `webiscite.rst` for the PR pipeline and bill lifecycle). +- **`docs/CONTRIBUTING.rst`** and **`docs/howto.rst`** — manually maintained. + +## Docstring style + +Use Google-style docstrings (supported via the Napoleon extension). Example: + +```python +def my_function(arg: int) -> str: + """One-line summary. + + Longer description if needed. + + Args: + arg: Description of the argument. + + Returns: + Description of the return value. + + Raises: + ValueError: When and why. + """ +``` + +Line length: 88 characters (enforced by ruff). Wrap long docstring lines to stay within this limit. + +## Steps for common tasks + +### Reviewing docs for accuracy +1. Read `docs/webiscite.rst` and cross-check function/class references against actual source code +2. Check that docstrings in `democrasite/webiscite/managers.py`, `models.py`, `tasks.py`, `webhooks.py`, and `constitution.py` have correct parameter names and return descriptions +3. Run `make apidocs` if modules have been added or removed since the last regeneration +4. Run `just lint` to confirm no ruff line-length violations were introduced + +### Adding or updating narrative docs +1. Edit the relevant `.rst` file in `docs/` (not under `docs/api/`) +2. Use Sphinx cross-references for code identifiers: + - `:class:\`~democrasite.webiscite.models.Bill\`` — class link + - `:func:\`~democrasite.webiscite.tasks.submit_bill\`` — function link + - `:meth:\`~democrasite.webiscite.webhooks.PullRequestHandler.opened\`` — method link +3. Verify the build: `docker compose -f docker-compose.docs.yml up` + +### Adding a new Python module +1. Write a module-level docstring at the top of the file +2. Run `make apidocs` to regenerate `docs/api/` — this creates the new RST file and adds it to the relevant package toctree automatically +3. Commit the updated `docs/api/` files alongside the new module + +### Updating docs after refactoring +1. Update any cross-references in `docs/webiscite.rst` or `docs/users.rst` that point to renamed/removed identifiers +2. Update affected docstrings in source files +3. Run `make apidocs` if the module structure changed (files added/removed) +4. Run `just lint` to verify no line-length violations + +## Published docs +- ReadTheDocs URL: https://cookiestocracy.readthedocs.io/en/latest/ +- Builds automatically on push to the main branch diff --git a/democrasite/activitypub/models.py b/democrasite/activitypub/models.py index 3bf28ed..9a8dc0b 100644 --- a/democrasite/activitypub/models.py +++ b/democrasite/activitypub/models.py @@ -1,3 +1,5 @@ +"""Models for the activitypub app.""" + from django.contrib.auth import get_user_model from django.db import models from django.urls import reverse diff --git a/democrasite/webiscite/managers.py b/democrasite/webiscite/managers.py index ee2c9fb..2b5886e 100644 --- a/democrasite/webiscite/managers.py +++ b/democrasite/webiscite/managers.py @@ -1,3 +1,5 @@ +"""Managers for the webiscite app models.""" + from logging import WARNING from typing import TYPE_CHECKING from typing import Any @@ -16,16 +18,14 @@ class PullRequestManager[T](models.Manager): def create_from_github(self, pr: dict[str, Any]) -> T: - """Create a :class:`~democrasite.webiscite.models.PullRequest` and optionally a - :class:`~democrasite.webiscite.models.Bill` instance from a GitHub pull request + """Create or update a :class:`~democrasite.webiscite.models.PullRequest` from + a GitHub pull request payload Args: - pr_args: The parameters for the ``PullRequest`` - bill_args: The parameters for the ``Bill`` or None + pr: The pull request data from the GitHub API Returns: - A tuple containing the new or updated pull request and new bill instance, if - applicable + The new or updated pull request instance """ pull_request, created = self.update_or_create( number=pr["number"], @@ -47,6 +47,12 @@ def create_from_github(self, pr: dict[str, Any]) -> T: class BillManager[T](models.Manager): def get_queryset(self): + """Return a queryset with pull_request pre-fetched and vote percentages added. + + All Bill querysets include ``total_votes``, ``yes_percent``, and + ``no_percent`` + annotations, and are ordered by creation date. + """ return ( super() .get_queryset() @@ -80,6 +86,16 @@ def get_queryset(self): def annotate_user_vote( self, user: User, queryset: models.QuerySet["Bill"] | None = None ): + """Annotate a queryset with the given user's vote on each bill. + + Args: + user: The user whose vote to annotate + queryset: The queryset to annotate; defaults to ``self.get_queryset()`` + + Returns: + The queryset annotated with a ``user_vote`` field + (``True``/``False``/``None``) + """ if queryset is None: queryset = self.get_queryset() diff --git a/democrasite/webiscite/webhooks.py b/democrasite/webiscite/webhooks.py index 4bcc24f..62312ad 100644 --- a/democrasite/webiscite/webhooks.py +++ b/democrasite/webiscite/webhooks.py @@ -1,6 +1,6 @@ """Views for processing webhooks. -Each service that sends webhooks should have its own function-based view.""" +Each service that sends webhooks should have its own class-based view.""" import hmac import json @@ -151,10 +151,10 @@ def _validate_header(headers: request.HttpHeaders) -> HttpResponseBadRequest | N """Validate the headers of a request from a webhook Args: - headers (dict): The headers from the request to validate + headers: The headers from the request to validate Returns: - str: Error message if the headers are invalid, otherwise an empty string + A bad request response if required headers are missing, otherwise ``None`` """ header_signature = headers.get("x-hub-signature-256") if header_signature is None: @@ -177,11 +177,12 @@ def _validate_signature( """Validate the signature of a request from a webhook Args: - header_signature (str): The signature from the request to validate - request_body (bytes): The body of the request to validate + header_signature: The HMAC signature from the request headers + request_body: The raw body of the request to validate Returns: - str: Error message if the signature is invalid, otherwise an empty string + An error response if the signature is invalid or uses an unsupported + digest, otherwise ``None`` """ digest_name, signature = header_signature.split("=") if digest_name != "sha256": diff --git a/docs/webiscite.rst b/docs/webiscite.rst index da40588..73feb61 100644 --- a/docs/webiscite.rst +++ b/docs/webiscite.rst @@ -15,23 +15,27 @@ Pull Requests Pull Request Processing Pipeline -------------------------------- -When a pull request is created on `GitHub`_, a `webhook`_ makes a request to -the GitHub :func:`webhook view `. +When a pull request is created on `GitHub`_, a `webhook`_ makes a POST request +to the :class:`~democrasite.webiscite.webhooks.GithubWebhookView`, which +validates the request signature and dispatches to the appropriate handler. -This method parses the data from the request and calls -:func:`~democrasite.webiscite.tasks.process_pull` -to handle the request. In the event a pull request was opened or reopened, -:func:`~democrasite.webiscite.tasks.pr_opened` is called. +For pull request events, the request is handled by +:class:`~democrasite.webiscite.webhooks.PullRequestHandler`. When a pull +request is opened or reopened, its +:meth:`~democrasite.webiscite.webhooks.PullRequestHandler.opened` method +creates a :class:`~democrasite.webiscite.models.PullRequest` instance. If the user who created the pull request has a democrasite account, a new :class:`~democrasite.webiscite.models.Bill` is created with the information from the pull request and made visible on the -homepage immediately. - -A task to execute :func:`~democrasite.webiscite.tasks.submit_bill` -is also put in the celery queue to execute once the voting period ends. The -function verifies that the pull request is open and unedited and then counts -the votes for and against that Bill. +homepage immediately. A :class:`~django_celery_beat.models.PeriodicTask` is +also scheduled to execute :func:`~democrasite.webiscite.tasks.submit_bill` +once the voting period ends. + +:func:`~democrasite.webiscite.tasks.submit_bill` verifies that the pull +request is still open and that its SHA has not changed since the bill was +created (i.e. the pull request has not been edited), then counts the votes for +and against that Bill. If the votes for the Bill pass the threshold, the pull request is merged into the master branch on Github and automatically deployed, officially making it