Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/skills/new-bill-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The Bill system spans these files. Review each to determine if it needs changes:
- `config/settings/base.py` — WEBISCITE_* settings (quorum, majority thresholds, voting period, GitHub token/repo)

## Key patterns to follow
- Bill status choices: DRAFT, OPEN, APPROVED, REJECTED, FAILED, CLOSED
- Bill status choices: DRAFT, OPEN, APPROVED, AMENDED, REJECTED, FAILED, CLOSED
- Votes are M2M through Vote model with unique constraint on (bill, user)
- Bill.vote() toggles existing votes; raises ClosedBillVoteError if bill not OPEN
- Constitutional bills need WEBISCITE_SUPERMAJORITY (66.67%), normal need WEBISCITE_NORMAL_MAJORITY (50%)
Expand All @@ -62,6 +62,8 @@ The Bill system spans these files. Review each to determine if it needs changes:
- PullRequest.close() closes both open and draft bills
- The submit PeriodicTask is created disabled for draft bills; Bill.publish() enables it and resets last_run_at so the voting period starts from publication
- GitHub's `ready_for_review` webhook action triggers PullRequestHandler.ready_for_review(), which updates the PR and publishes the draft bill
- GitHub's `synchronize` webhook action (new commits pushed to PR) triggers PullRequestHandler.synchronize(), which updates the PR SHA and closes any open bill with AMENDED status; draft bills are not affected
- Bill.close() accepts an optional `status` parameter (default Bill.Status.CLOSED) to set a different terminal status (e.g. AMENDED)

## Steps
1. Read the relevant files from the checklist above
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.11 on 2026-02-24 20:20

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('webiscite', '0007_alter_bill__submit_task'),
]

operations = [
migrations.AlterField(
model_name='bill',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('approved', 'Approved'), ('amended', 'PR Amended'), ('rejected', 'Rejected'), ('failed', 'Not Enough Votes'), ('closed', 'PR Closed')], default='open', help_text='The current status of the bill', max_length=10),
),
migrations.AlterField(
model_name='historicalbill',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('approved', 'Approved'), ('amended', 'PR Amended'), ('rejected', 'Rejected'), ('failed', 'Not Enough Votes'), ('closed', 'PR Closed')], default='open', help_text='The current status of the bill', max_length=10),
),
]
5 changes: 3 additions & 2 deletions democrasite/webiscite/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class Status(models.TextChoices):
DRAFT = "draft", _("Draft")
OPEN = "open", _("Open")
APPROVED = "approved", _("Approved")
AMENDED = "amended", _("PR Amended") # PR updated with new commits
REJECTED = "rejected", _("Rejected")
FAILED = "failed", _("Not Enough Votes") # Failed to reach quorum
# Translators: PR is short for "pull request"
Expand Down Expand Up @@ -271,9 +272,9 @@ def _schedule_submit_task(self) -> None:
super().save()
self.log("Scheduled %s", self._submit_task.name)

def close(self) -> None:
def close(self, status: "Bill.Status" = Status.CLOSED) -> None:
"""Close the bill and disable its submit task"""
self.status = self.Status.CLOSED
self.status = status
self.save()
self.log("Closed")

Expand Down
11 changes: 11 additions & 0 deletions democrasite/webiscite/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ def test_close(self, bill: Bill):
assert bill._submit_task is not None # noqa: SLF001
assert bill._submit_task.enabled is False # noqa: SLF001

def test_close_amended(self, bill: Bill):
assert bill._submit_task is not None # noqa: SLF001
assert bill._submit_task.enabled is True # noqa: SLF001

bill.close(status=Bill.Status.AMENDED)

bill.refresh_from_db()
assert bill.status == Bill.Status.AMENDED
assert bill._submit_task is not None # noqa: SLF001
assert bill._submit_task.enabled is False # noqa: SLF001


class TestBillPublish:
def test_publish(self):
Expand Down
22 changes: 22 additions & 0 deletions democrasite/webiscite/tests/test_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,25 @@ def test_closed(self, mock_close, pr_handler: PullRequestHandler, bill: Bill):

assert response == (bill.pull_request, mock_close.return_value)
mock_close.assert_called_once()

def test_synchronize(self, pr_handler: PullRequestHandler):
bill = BillFactory.create(status=Bill.Status.OPEN)
pr = GithubPullRequestFactory.create(bill=bill)

pull_request, amended_bill = pr_handler.synchronize(pr)

assert pull_request is not None
assert amended_bill is not None
amended_bill.refresh_from_db()
assert amended_bill.status == Bill.Status.AMENDED
assert amended_bill._submit_task is not None # noqa: SLF001
assert amended_bill._submit_task.enabled is False # noqa: SLF001

def test_synchronize_no_open_bill(self, pr_handler: PullRequestHandler):
# Default GithubPullRequestFactory creates a closed bill (no active bill)
pr = GithubPullRequestFactory.create()

pull_request, bill = pr_handler.synchronize(pr)

assert pull_request is not None
assert bill is None
23 changes: 23 additions & 0 deletions democrasite/webiscite/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,29 @@ def ready_for_review(
bill.publish()
return (pull_request, bill)

def synchronize(self, pr: dict[str, Any]) -> tuple[PullRequest, Bill | None]:
"""Handle new commits pushed to an open pull request.

Updates the pull request with the new SHA and closes any active bill
as amended, since votes on the old version no longer apply.

Args:
pr: The parsed JSON object representing the pull request

Returns:
A tuple containing the updated pull request and the closed bill, if any
"""
pull_request = PullRequest.objects.create_from_github(pr)

try:
bill: Bill = pull_request.bill_set.get(status=Bill.Status.OPEN)
except Bill.DoesNotExist:
pull_request.log("No open bill found")
return (pull_request, None)

bill.close(status=Bill.Status.AMENDED)
return (pull_request, bill)

def closed(self, pr: dict[str, Any]) -> tuple[PullRequest | None, Bill | None]:
"""Disables the open bill associated with the pull request

Expand Down
7 changes: 7 additions & 0 deletions docs/webiscite.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,12 @@ 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
part of Democrasite.

If new commits are pushed to a pull request while a Bill is open for voting,
GitHub sends a ``synchronize`` event.
:meth:`~democrasite.webiscite.webhooks.PullRequestHandler.synchronize`
updates the stored pull request with the new SHA and closes the open Bill
with an ``AMENDED`` status, since the votes no longer reflect the current
code. Draft bills are not affected by synchronize events.

.. _GitHub: https://github.com/mfosterw/cookiestocracy
.. _webhook: https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks