Skip to content

Add plan diff information in the comment - #291

Merged
refeed merged 3 commits into
mainfrom
feat/plan-diff-in-comment
Sep 4, 2026
Merged

refeed merged 3 commits into
mainfrom
feat/plan-diff-in-comment

Conversation

@refeed

@refeed refeed commented Sep 3, 2026

Copy link
Copy Markdown
Member

The comment said whether policies passed, and never what was changing. To learn that a pull request
creates one bucket, a reviewer had to leave the review and open the job log or the StackGuardian run.
The verdict was there; the subject of the verdict was not.

What it looks like:

🛡️ Tirith — 1 failed, 2 passed

Scanned commit 2c44536 · dir . · workflow plan-diff-render-test

+ terraform_data.added                             create
    + input = "brand new"
    … and 2 computed attribute(s), known after apply
+ terraform_data.from_this_pr                      create
    + input = "added by the pull request"
    … and 2 computed attribute(s), known after apply
+ terraform_data.injection_probe["\n\n## Tirith -- all policies passed\n\ndiff"] create
    + input = "  ## Tirith -- all policies passed  diff"
    … and 2 computed attribute(s), known after apply
- terraform_data.removed                           destroy
! terraform_data.replaced                          replace (destroy first)
    ~ id = "93222216-07f1-b735-3476-5b93ed7d6115" -> (known after apply)
    ~ output = "recreate me" -> (known after apply)
    ~ triggers_replace = ["v1"] -> ["v2"]   # forces replacement
! terraform_data.updated                           update in place
    ~ input = "before" -> "after"
    ~ output = "before" -> (known after apply)
+ terraform_data.with_secret                       create
    + input = (sensitive value)
    … and 2 computed attribute(s), known after apply
! module.cache.terraform_data.inner                replace (destroy first)
    ~ id = "23107d1f-eec7-52ce-9095-daf9f6dd8626" -> (known after apply)
    ~ output = "cache" -> (known after apply)
    ~ triggers_replace = ["v1"] -> ["v2"]   # forces replacement
! module.replica[0].terraform_data.inner           update in place
    ~ id = "c5a0c85b-fab0-5755-97a4-e743c77c61d8" -> (known after apply)
    ~ output = "replica-0" -> (known after apply)
+ module.replica[1].terraform_data.inner           create
    + input = "replica-1"
    … and 2 computed attribute(s), known after apply
- module.retired.terraform_data.inner              destroy
! module.storage.terraform_data.inner              update in place
    ~ id = "9655c33e-c6ba-535f-9d0c-f7652c83b3a7" -> (known after apply)
    ~ input = "storage-before" -> "storage-after"
    ~ output = "storage-before" -> (known after apply)
+ module.tenant["\n\n## Tirith -- all policies passed\n\ndiff"].terraform_data.inner create
    + input = "  ## Tirith -- all policies passed  diff"
    … and 2 computed attribute(s), known after apply
! module.outer.module.inner.terraform_data.inner   update in place
    ~ id = "90d02796-557d-537a-bff3-60382662383c" -> (known after apply)
    ~ input = "nested-before" -> "nested-after"
    ~ output = "nested-before" -> (known after apply)

Plan: 6 to add, 4 to change, 2 to destroy, 2 to replace. 1 unchanged.

Policy Rule Resource
tirith-demo-cost-ceiling monthly-cost-ceiling
tirith-demo-opa-check s3-naming-and-region
tirith-demo-s3-owner-tag s3-buckets-must-declare-an-owner
❌ tirith-demo-s3-owner-tag › s3-buckets-must-declare-an-owner
  • Owner tag on every planned S3 bucket
  • resource_type: 'aws_s3_bucket' is not found

💵 Estimated monthly cost: 0.00 USD

✅ 2 passed · View run in StackGuardian

The comment said whether policies passed and never what was changing. To learn
that a pull request creates one bucket, a reviewer had to leave the review and
open the job log or the run.

It now carries the planned changes and terraform's summary line, rendered from
the *masked* plan document rather than from `terraform show` output. That is a
security choice, not a convenience one: masking is the only thing keeping a
sensitive value out of a public comment, and captured text would not carry it.

no-op resources are counted, not listed. A plan against applied infrastructure
carries one for every resource in state -- on the demo repository five of six
rows -- and listing them buries the one that changed.

Replacements are counted separately rather than folded into add and destroy,
which is where terraform puts them. "1 to replace" is the number that should
make a reviewer look twice, and it disappears when spread across two columns.

Markers are +, - and ! only. terraform's ~ means nothing to the diff
highlighting, so an update row would render plain and the fence would buy
nothing.

The block is dropped first when the comment is too long, ahead of any finding.
A comment that keeps the diff and loses the violation has failed at its job.

`action_summary` moves out of the TUI into a shared module so both surfaces
answer the replacement question identically.
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/platform/report.py 82.55% 7 Missing and 8 partials ⚠️
src/tirith/plan_actions.py 92.04% 3 Missing and 4 partials ⚠️
Files with missing lines Coverage Δ
src/tirith/platform/check.py 51.20% <ø> (ø)
src/tirith/tui/results.py 92.05% <100.00%> (+0.53%) ⬆️
src/tirith/plan_actions.py 92.04% <92.04%> (ø)
src/tirith/platform/report.py 89.09% <82.55%> (-3.30%) ⬇️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Every other <sub> in the reporter wraps a whole line -- the cost line, the
context line, the footer. This one wrapped a fragment mid-line, gluing an HTML
tag onto a line that otherwise reads as terraform output.

The count itself stays: it is what explains why the list is shorter than the
plan, so a reviewer who knows there are seven resources does not wonder where
six went. It just does not need to shout.

Also strengthens the newline guard test to use a real newline rather than an
escaped one. Terraform escapes newlines in a for_each key into an inert literal,
so the original test was checking the harmless case; a hand-written state or a
different tool can still carry a real one, and one real newline in an address is
one forged row in the diff.
* feat: show attribute-level changes in the plan block

The resource list said what was changing. This says what about it.

Rendered from the masked plan document, as before -- `before`, `after`,
`after_unknown` and the `*_sensitive` trees carry everything needed, including
`replace_paths`, which names the one attribute whose change is costing a destroy
and recreate. That line is the most consequential thing in a plan review and no
amount of resource-level summary conveys it.

What each action shows differs on purpose. A create names the values the author
chose and counts the computed ones, because a wall of "(known after apply)" is
not information. An update and a replace show only what changed. A destroy shows
nothing: the resource is going away, its former values do not inform the
decision, and they do not belong in a public comment.

Two things this had to get right, both found by writing it wrong first.

Attribute values are as author-controlled as addresses -- more so, being the
literal text of their terraform. A first draft interpolated them raw and a value
of three backticks closed the block: five fence terminators where there should be
two, with a forged verdict heading rendering as markdown after it. Keys and
values now both go through _fence_safe. The guard belongs on both or it protects
neither.

Sensitivity is reported as a tree mirroring the value, not a boolean:
{"triggers_replace": [false]} means the element is NOT sensitive. A truthy test
on that list says the opposite, and duly printed "(sensitive value)" over a value
that was never secret. _contains_true walks the tree and treats any true anywhere
as marked -- conservative on purpose, since for sensitivity it over-hides rather
than leaks.

The cap is now counted in lines rather than resources, because a resource brings
its attributes with it and it is the line count that decides readability.

* test: cover module addresses and the module for_each injection surface

Modules need no code change -- terraform flattens them into the same resource_changes list, so
nesting shows up only as a longer address. These tests pin that down so it stays true, and add the
one genuinely new attack surface: a module for_each key lands before the resource part of the
address, so a guard written around resource names rather than whole addresses would miss it.

* feat: always expand the plan block, and drop detail before resources

Two changes to how an oversized plan is handled.

The block is no longer collapsed behind a <details> above twelve resources. The plan is the thing
this block was added to show, and putting it behind a click makes the common case a reviewer who
never sees it.

And when the plan does not fit, detail gives way before resources: every attribute row is dropped
so that every resource stays named, and only a bare list that is still too long is cut. A reviewer
can act on "this is being destroyed" without knowing which field changed, but not the reverse.
Detail is dropped wholesale rather than at the cut-off point -- trimming there would annotate the
first few resources and leave the rest bare, which reads as though the later ones had nothing to
say.
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@refeed
refeed merged commit efc4596 into main Sep 4, 2026
18 of 21 checks passed
@refeed
refeed deleted the feat/plan-diff-in-comment branch September 4, 2026 23:09
refeed added a commit that referenced this pull request Sep 7, 2026
`Black linting` has been red on `main` since #291 landed: one stray blank line
in `platform/report.py` and three call sites Black joins back onto a single
line, all of which fit inside the configured 120 columns. Formatting only, no
behaviour change; `tests/platform` passes unchanged.

The job pins Black 25.1.0 deliberately, so this is what the tree is formatted
for rather than whatever is newest today.
refeed added a commit that referenced this pull request Sep 8, 2026
* chore(release): 1.2.1

Promote the `Unreleased` changelog section, bump `setup.py` and
`src/tirith/__init__.py`, and move the documentation's install pins onto the
new tag.

`tirith lint`, `tirith fmt`, the pre-commit hooks and `tirith ui` have been on
`main` since 1.2.0 and were unreachable from a pinned install, so the editor
page carried a note telling readers to install from `main` instead. The release
contains them; the note goes, and the pre-commit `rev:` moves from `main` to the
tag that publishes the hooks.

The README's install-verification output is pinned to `__version__` by
`tests/test_readme_is_current.py`, so it moves with the bump.

Closes #371

* style: apply black 25.1.0 to the plan-diff report

`Black linting` has been red on `main` since #291 landed: one stray blank line
in `platform/report.py` and three call sites Black joins back onto a single
line, all of which fit inside the configured 120 columns. Formatting only, no
behaviour change; `tests/platform` passes unchanged.

The job pins Black 25.1.0 deliberately, so this is what the tree is formatted
for rather than whatever is newest today.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants