Skip to content

Commit 12e85f7

Browse files
mfosterwclaude
andcommitted
Handle synchronize webhook and add AMENDED bill status
When new commits are pushed to a PR with an open bill, the bill is now closed with a new AMENDED status instead of being silently invalidated. The PullRequestHandler.synchronize() method updates the stored PR SHA and closes any open bill as amended; draft bills are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d7cfec9 commit 12e85f7

7 files changed

Lines changed: 92 additions & 3 deletions

File tree

.claude/skills/new-bill-feature/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The Bill system spans these files. Review each to determine if it needs changes:
4848
- `config/settings/base.py` — WEBISCITE_* settings (quorum, majority thresholds, voting period, GitHub token/repo)
4949

5050
## Key patterns to follow
51-
- Bill status choices: DRAFT, OPEN, APPROVED, REJECTED, FAILED, CLOSED
51+
- Bill status choices: DRAFT, OPEN, APPROVED, AMENDED, REJECTED, FAILED, CLOSED
5252
- Votes are M2M through Vote model with unique constraint on (bill, user)
5353
- Bill.vote() toggles existing votes; raises ClosedBillVoteError if bill not OPEN
5454
- Constitutional bills need WEBISCITE_SUPERMAJORITY (66.67%), normal need WEBISCITE_NORMAL_MAJORITY (50%)
@@ -62,6 +62,8 @@ The Bill system spans these files. Review each to determine if it needs changes:
6262
- PullRequest.close() closes both open and draft bills
6363
- 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
6464
- GitHub's `ready_for_review` webhook action triggers PullRequestHandler.ready_for_review(), which updates the PR and publishes the draft bill
65+
- 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
66+
- Bill.close() accepts an optional `status` parameter (default Bill.Status.CLOSED) to set a different terminal status (e.g. AMENDED)
6567

6668
## Steps
6769
1. Read the relevant files from the checklist above
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.2.11 on 2026-02-24 20:20
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('webiscite', '0007_alter_bill__submit_task'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='bill',
15+
name='status',
16+
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),
17+
),
18+
migrations.AlterField(
19+
model_name='historicalbill',
20+
name='status',
21+
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),
22+
),
23+
]

democrasite/webiscite/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ class Status(models.TextChoices):
130130
DRAFT = "draft", _("Draft")
131131
OPEN = "open", _("Open")
132132
APPROVED = "approved", _("Approved")
133+
AMENDED = "amended", _("PR Amended") # PR updated with new commits
133134
REJECTED = "rejected", _("Rejected")
134135
FAILED = "failed", _("Not Enough Votes") # Failed to reach quorum
135136
# Translators: PR is short for "pull request"
@@ -271,9 +272,9 @@ def _schedule_submit_task(self) -> None:
271272
super().save()
272273
self.log("Scheduled %s", self._submit_task.name)
273274

274-
def close(self) -> None:
275+
def close(self, status: "Bill.Status" = Status.CLOSED) -> None:
275276
"""Close the bill and disable its submit task"""
276-
self.status = self.Status.CLOSED
277+
self.status = status
277278
self.save()
278279
self.log("Closed")
279280

democrasite/webiscite/tests/test_models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,17 @@ def test_close(self, bill: Bill):
207207
assert bill._submit_task is not None # noqa: SLF001
208208
assert bill._submit_task.enabled is False # noqa: SLF001
209209

210+
def test_close_amended(self, bill: Bill):
211+
assert bill._submit_task is not None # noqa: SLF001
212+
assert bill._submit_task.enabled is True # noqa: SLF001
213+
214+
bill.close(status=Bill.Status.AMENDED)
215+
216+
bill.refresh_from_db()
217+
assert bill.status == Bill.Status.AMENDED
218+
assert bill._submit_task is not None # noqa: SLF001
219+
assert bill._submit_task.enabled is False # noqa: SLF001
220+
210221

211222
class TestBillPublish:
212223
def test_publish(self):

democrasite/webiscite/tests/test_webhooks.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,25 @@ def test_closed(self, mock_close, pr_handler: PullRequestHandler, bill: Bill):
217217

218218
assert response == (bill.pull_request, mock_close.return_value)
219219
mock_close.assert_called_once()
220+
221+
def test_synchronize(self, pr_handler: PullRequestHandler):
222+
bill = BillFactory.create(status=Bill.Status.OPEN)
223+
pr = GithubPullRequestFactory.create(bill=bill)
224+
225+
pull_request, amended_bill = pr_handler.synchronize(pr)
226+
227+
assert pull_request is not None
228+
assert amended_bill is not None
229+
amended_bill.refresh_from_db()
230+
assert amended_bill.status == Bill.Status.AMENDED
231+
assert amended_bill._submit_task is not None # noqa: SLF001
232+
assert amended_bill._submit_task.enabled is False # noqa: SLF001
233+
234+
def test_synchronize_no_open_bill(self, pr_handler: PullRequestHandler):
235+
# Default GithubPullRequestFactory creates a closed bill (no active bill)
236+
pr = GithubPullRequestFactory.create()
237+
238+
pull_request, bill = pr_handler.synchronize(pr)
239+
240+
assert pull_request is not None
241+
assert bill is None

democrasite/webiscite/webhooks.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ def ready_for_review(
115115
bill.publish()
116116
return (pull_request, bill)
117117

118+
def synchronize(self, pr: dict[str, Any]) -> tuple[PullRequest, Bill | None]:
119+
"""Handle new commits pushed to an open pull request.
120+
121+
Updates the pull request with the new SHA and closes any active bill
122+
as amended, since votes on the old version no longer apply.
123+
124+
Args:
125+
pr: The parsed JSON object representing the pull request
126+
127+
Returns:
128+
A tuple containing the updated pull request and the closed bill, if any
129+
"""
130+
pull_request = PullRequest.objects.create_from_github(pr)
131+
132+
try:
133+
bill: Bill = pull_request.bill_set.get(status=Bill.Status.OPEN)
134+
except Bill.DoesNotExist:
135+
pull_request.log("No open bill found")
136+
return (pull_request, None)
137+
138+
bill.close(status=Bill.Status.AMENDED)
139+
return (pull_request, bill)
140+
118141
def closed(self, pr: dict[str, Any]) -> tuple[PullRequest | None, Bill | None]:
119142
"""Disables the open bill associated with the pull request
120143

docs/webiscite.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,12 @@ If the votes for the Bill pass the threshold, the pull request is merged into
4141
the master branch on Github and automatically deployed, officially making it
4242
part of Democrasite.
4343

44+
If new commits are pushed to a pull request while a Bill is open for voting,
45+
GitHub sends a ``synchronize`` event.
46+
:meth:`~democrasite.webiscite.webhooks.PullRequestHandler.synchronize`
47+
updates the stored pull request with the new SHA and closes the open Bill
48+
with an ``AMENDED`` status, since the votes no longer reflect the current
49+
code. Draft bills are not affected by synchronize events.
50+
4451
.. _GitHub: https://github.com/mfosterw/cookiestocracy
4552
.. _webhook: https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks

0 commit comments

Comments
 (0)