Skip to content

Commit cbe6586

Browse files
committed
fix: run checks when policies attach, drop duplicated approval reasons, trim the markdown hint
1 parent 0ca0940 commit cbe6586

7 files changed

Lines changed: 131 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Fixed
6+
7+
- Pre-merge checks sat at **pending** until somebody pressed Re-run. A change request is created before its policies are matched, and every built-in check is policy-scoped, so the run at creation found no checks to run. Attaching or detaching a policy now runs them.
8+
- The approval panel repeated each rule's shortfall in prose below a table that already showed the same counts. The page now shows only what the table cannot: a rejection, stale reviews, or no rules applying at all. The merge gate and the REST API still get the full text, having no table to read.
9+
- The review form's Markdown hint listed examples the live preview already demonstrates.
10+
311
## 0.1.0 - 2026-08-27
412

513
First release. Policy-driven change control and mandatory review for NetBox branches.

‎netbox_change_control/policy.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,24 @@ def outstanding(self):
7575

7676
def reasons(self):
7777
"""
78-
Human readable explanations of why the request is not satisfied.
78+
Every reason the request is not satisfied, including each rule's shortfall.
79+
80+
For callers with no rule table in front of them: the merge gate's refusal message,
81+
the REST API, a log line.
82+
"""
83+
return self.other_reasons() + [
84+
f'Rule "{rule.rule.name}" needs {rule.outstanding} more approval(s) of {rule.required}.'
85+
for rule in self.rules
86+
if not rule.satisfied
87+
]
88+
89+
def other_reasons(self):
90+
"""
91+
Only the reasons a rule table does not already show.
92+
93+
The change request page lists every rule with its count, so repeating "needs 1 more
94+
approval of 1" underneath says the same thing twice. A rejection, a stale review or
95+
the absence of any rule is not visible in that table, so it belongs here.
7996
"""
8097
messages = []
8198
for review in self.rejections:
@@ -85,11 +102,6 @@ def reasons(self):
85102
messages.append(f'The branch changed after these reviews were submitted, so they no longer count: {names}.')
86103
if not self.rules:
87104
messages.append('No policy rules apply to this change request.')
88-
for rule in self.rules:
89-
if not rule.satisfied:
90-
messages.append(
91-
f'Rule "{rule.rule.name}" needs {rule.outstanding} more approval(s) of {rule.required}.'
92-
)
93105
return messages
94106

95107

‎netbox_change_control/signal_receivers.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,22 @@ def emit_on_review_submitted(sender, instance, created, **kwargs):
189189

190190
@receiver([post_save, post_delete], sender=ChangeRequestPolicy)
191191
def refresh_on_policy_binding_change(sender, instance, **kwargs):
192-
_refresh(instance.change_request_id)
192+
"""
193+
Attaching or detaching a policy changes which checks apply, not just who must approve.
194+
195+
Every built-in check is policy-scoped, and a change request is created before its
196+
policies are matched, so the run at creation sees no policies and therefore no checks.
197+
Without running them here the panel sits at "pending" until somebody presses Re-run.
198+
"""
199+
if _is_being_deleted(instance.change_request_id):
200+
return
201+
change_request = ChangeRequest.objects.filter(pk=instance.change_request_id).first()
202+
if change_request is None:
203+
return
204+
205+
# Status first: run_checks may auto-merge, and it should decide against a current status.
206+
refresh_status(change_request)
207+
run_checks(change_request)
193208

194209

195210
def _refresh(change_request_id):

‎netbox_change_control/templates/netbox_change_control/changerequest.html‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,13 @@ <h2 class="card-header d-flex justify-content-between align-items-center">
147147
<tr><td colspan="2" class="text-muted">{% trans "No policy rules apply yet. Submit the request for review." %}</td></tr>
148148
{% endfor %}
149149
</table>
150-
{% if not evaluation.satisfied %}
150+
{% with notes=evaluation.other_reasons %}
151+
{% if notes %}
151152
<div class="card-footer small text-muted">
152-
{% for reason in evaluation.reasons %}<div>{{ reason }}</div>{% endfor %}
153+
{% for note in notes %}<div>{{ note }}</div>{% endfor %}
153154
</div>
154155
{% endif %}
156+
{% endwith %}
155157
<div class="card-footer p-0">
156158
<table class="table table-hover attr-table mb-0">
157159
<tr><th colspan="2" class="small text-uppercase text-muted">{% trans "Pre-merge checks" %}</th></tr>

‎netbox_change_control/templates/netbox_change_control/inc/review_form.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
placeholder="{% trans 'Comment' %}">{{ own_review.comment|default:'' }}</textarea>
2020
<div class="form-text">
2121
<i class="mdi mdi-information-outline" aria-hidden="true"></i>
22-
{% trans "Markdown syntax is supported: **bold**, `code`, lists, and links." %}
22+
{% trans "Markdown syntax is supported." %}
2323
</div>
2424
</div>
2525
<button type="submit" class="btn btn-success">

‎netbox_change_control/tests/test_checks.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,50 @@ def test_a_builtin_only_runs_where_a_policy_asks(self):
754754
ChangeRequestPolicy.objects.create(change_request=cr, policy=policy)
755755
run_checks(cr)
756756
self.assertTrue(cr.checks.filter(name='threads-resolved').exists())
757+
758+
759+
class ChecksRunWhenPoliciesAttachTest(TestCase):
760+
"""
761+
A change request is created before its policies are matched, and every built-in check is
762+
policy-scoped. The run at creation therefore sees no policies and no checks, so unless
763+
attaching a policy runs them the panel sits at "pending" until somebody presses Re-run.
764+
"""
765+
766+
def setUp(self):
767+
self._saved = dict(_registry)
768+
_registry.clear()
769+
from netbox_change_control.checks import register_builtin_checks
770+
771+
register_builtin_checks(['has-changes'])
772+
self.requester = User.objects.create(username='attach-requester')
773+
self.policy = Policy.objects.create(name='Wants has-changes', checks=['has-changes'])
774+
775+
def tearDown(self):
776+
_registry.clear()
777+
_registry.update(self._saved)
778+
779+
def test_attaching_a_policy_runs_its_checks(self):
780+
cr = ChangeRequest.objects.create(
781+
branch=make_branch('attach', self._testMethodName),
782+
title='T',
783+
requester=self.requester,
784+
)
785+
self.assertFalse(cr.checks.exists(), 'no policy yet, so no check applies')
786+
787+
ChangeRequestPolicy.objects.create(change_request=cr, policy=self.policy)
788+
789+
check = cr.checks.get(name='has-changes')
790+
self.assertNotEqual(check.status, MergeCheckStatusChoices.PENDING)
791+
self.assertIsNotNone(check.completed)
792+
793+
def test_detaching_the_policy_removes_its_checks(self):
794+
cr = ChangeRequest.objects.create(
795+
branch=make_branch('detach', self._testMethodName),
796+
title='T',
797+
requester=self.requester,
798+
)
799+
ChangeRequestPolicy.objects.create(change_request=cr, policy=self.policy)
800+
self.assertTrue(cr.checks.filter(name='has-changes').exists())
801+
802+
ChangeRequestPolicy.objects.filter(change_request=cr).delete()
803+
self.assertFalse(cr.checks.filter(name='has-changes').exists())

‎netbox_change_control/tests/test_policy.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from netbox_change_control.choices import ChangeRequestStatusChoices, ReviewDecisionChoices
1212
from netbox_change_control.models import ChangeRequest, ChangeRequestPolicy, Policy, Review
1313
from netbox_change_control.policy import evaluate_change_request
14-
from netbox_change_control.tests.base import add_rule, approve, make_branch, make_policy
14+
from netbox_change_control.tests.base import ChangeControlTestCase, add_rule, approve, make_branch, make_policy
1515

1616

1717
class PolicyRuleEligibilityTest(TestCase):
@@ -246,3 +246,39 @@ def test_it_does_not_weaken_another_policy(self):
246246
approve(cr, engineer)
247247
cr.refresh_from_db()
248248
self.assertEqual(cr.status, ChangeRequestStatusChoices.APPROVED)
249+
250+
251+
class ReasonsAudienceTest(ChangeControlTestCase):
252+
"""
253+
The change request page lists every rule with its count, so repeating each shortfall in
254+
prose underneath said the same thing twice. The merge gate has no such table, so it still
255+
needs the full text.
256+
"""
257+
258+
branch_prefix = 'reasons'
259+
260+
def test_a_plain_shortfall_adds_nothing_to_the_page(self):
261+
evaluation = self.cr.evaluate()
262+
self.assertEqual(evaluation.other_reasons(), [])
263+
self.assertTrue(any('needs 1 more' in r for r in evaluation.reasons()))
264+
265+
def test_a_rejection_is_shown_because_no_rule_row_carries_it(self):
266+
Review.objects.create(
267+
change_request=self.cr,
268+
reviewer=self.reviewer,
269+
decision=ReviewDecisionChoices.REJECT,
270+
comment='not yet',
271+
)
272+
notes = self.cr.evaluate().other_reasons()
273+
self.assertEqual(len(notes), 1)
274+
self.assertIn('requested changes', notes[0])
275+
276+
def test_having_no_rules_at_all_is_shown(self):
277+
ChangeRequestPolicy.objects.filter(change_request=self.cr).delete()
278+
self.cr.refresh_from_db()
279+
self.assertIn('No policy rules apply', ' '.join(self.cr.evaluate().other_reasons()))
280+
281+
def test_the_merge_gate_still_names_every_unmet_rule(self):
282+
message = self.cr.merge_blocked_reason
283+
self.assertIn('not approved', message)
284+
self.assertTrue(any('needs' in r for r in self.cr.evaluate().reasons()))

0 commit comments

Comments
 (0)