Skip to content

Commit bcf51e5

Browse files
authored
fix: match dashboard hasDiff and exclude deleted resources from fixes (#6)
* fix: match dashboard's hasDiff and exclude deleted resources from fixes Switches the cost section visibility (hasDiff) and per-project diff check to key off DiffBreakdown.Resources rather than per-resource Action, matching the dashboard's projectHasDiff. ProjectResult.Resources is no longer read anywhere in the comment package and has been removed. Also filters fixed-issue counts to exclude resources that no longer appear in any current policy result (passing or failing) — claiming a "fix" for a resource that has disappeared, or whose policy is no longer applicable, is misleading. * fix: align comment formatting with dashboard - formatCost: negative values render as `-$X` (sign before currency symbol) and integer parts get thousand-separators (e.g. `-$1,616`). - formatCarbonWithExample: take a pluralize flag; the run-level summary uses it so we say `avoids`/`emits` instead of `avoid`/`emit`. - formatNumber: rewrite to use the dashboard's "decimal places to surface the first non-zero digit" rule. Whole numbers render with no decimals (`16`), fractional values keep needed precision (`335.7`), and thousand-separators are applied. - processCostChangesAndBudgets: sort budget-tag-note keys before joining to fix non-deterministic ordering. - Template: blank lines around the governance sentence so HTML blocks that follow are recognised as block-level by GitHub's markdown parser.
1 parent 2a44c61 commit bcf51e5

28 files changed

Lines changed: 311 additions & 125 deletions

pkg/vcs/comment/budgets.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package comment
22

33
import (
44
"fmt"
5+
"sort"
56
"strings"
67

78
"github.com/infracost/go-proto/pkg/rat"
@@ -101,7 +102,11 @@ func (data *Data) processCostChangesAndBudgets(inputs *Inputs) {
101102
// Build tag note.
102103
var keys []string
103104
for k := range tagKeys {
104-
keys = append(keys, escapeAndFormatCode(k))
105+
keys = append(keys, k)
106+
}
107+
sort.Strings(keys)
108+
for i, k := range keys {
109+
keys[i] = escapeAndFormatCode(k)
105110
}
106111
inputs.BudgetTagNote = fmt.Sprintf(
107112
"Note: Tag-based actual costs are calculated using service provider cost data for the current budget period for all resources tagged with %s.",

pkg/vcs/comment/costs.go

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package comment
33
import (
44
"fmt"
55
"sort"
6+
"strings"
67

78
"github.com/infracost/go-proto/pkg/rat"
8-
"github.com/infracost/proto/gen/go/infracost/provider"
99
)
1010

1111
// processProjectCosts builds the project costs table entries, determines which
@@ -120,17 +120,11 @@ func (data *Data) usageCostsMessage() string {
120120
return fmt.Sprintf("*Usage costs can be estimated by updating %s, see %s for other options.", cloudSettingsStr, usageDocsStr)
121121
}
122122

123-
// projectHasDiff returns true if any resource in the project has a non-NOOP action.
123+
// projectHasDiff returns true if the project's pre-computed diff breakdown
124+
// contains any resource entries. Matches the dashboard's projectHasDiff in
125+
// dashboard/api/src/services/templates/helpers.ts.
124126
func projectHasDiff(project ProjectResult) bool {
125-
for _, r := range project.Resources {
126-
switch r.Action {
127-
case provider.ResourceAction_CREATE,
128-
provider.ResourceAction_MODIFY,
129-
provider.ResourceAction_DELETE:
130-
return true
131-
}
132-
}
133-
return false
127+
return project.DiffBreakdown != nil && len(project.DiffBreakdown.Resources) > 0
134128
}
135129

136130
// costChangeOpts controls formatting of cost change strings.
@@ -205,15 +199,51 @@ func formatPercentChange(oldCost, newCost *rat.Rat) string {
205199
// formatCost formats a cost value with currency symbol, matching the
206200
// dashboard's autoDecimals behavior: values with absolute value >= 1 (or zero)
207201
// are rounded to whole numbers, smaller values show 2 decimal places.
202+
// Negative values render with the sign before the currency symbol (e.g.
203+
// "-$1,616") and integer parts are formatted with thousand separators.
208204
// See: dashboard/api/src/utils/format.ts CurrencyFormatter.formatCost
209205
func formatCost(d *rat.Rat, currency string) string {
206+
sym := currencySymbol(currency)
210207
if d == nil || d.IsZero() {
211-
return currencySymbol(currency) + "0"
208+
return sym + "0"
209+
}
210+
abs := d.Abs()
211+
decimals := 0
212+
if abs.LessThan(rat.New(1)) {
213+
decimals = 2
214+
}
215+
formatted := withThousandSeparators(abs.StringFixed(decimals))
216+
if d.LessThan(rat.Zero) {
217+
return "-" + sym + formatted
218+
}
219+
return sym + formatted
220+
}
221+
222+
// withThousandSeparators inserts commas every three digits in the integer
223+
// portion of a numeric string. The input is expected to be a decimal string
224+
// like "1234567" or "1234.56".
225+
func withThousandSeparators(s string) string {
226+
intPart := s
227+
frac := ""
228+
if dot := strings.IndexByte(s, '.'); dot >= 0 {
229+
intPart = s[:dot]
230+
frac = s[dot:]
231+
}
232+
if len(intPart) <= 3 {
233+
return intPart + frac
212234
}
213-
if d.Abs().GreaterThanOrEqual(rat.New(1)) {
214-
return currencySymbol(currency) + d.StringFixed(0)
235+
var b strings.Builder
236+
first := len(intPart) % 3
237+
if first == 0 {
238+
first = 3
215239
}
216-
return currencySymbol(currency) + d.StringFixed(2)
240+
b.WriteString(intPart[:first])
241+
for i := first; i < len(intPart); i += 3 {
242+
b.WriteByte(',')
243+
b.WriteString(intPart[i : i+3])
244+
}
245+
b.WriteString(frac)
246+
return b.String()
217247
}
218248

219249
// safeSub returns a - b, handling nils as zero.
@@ -242,4 +272,4 @@ func truncateMiddle(s string, maxLen int) string {
242272
}
243273
half := (maxLen - 3) / 2
244274
return s[:half] + "..." + s[len(s)-half:]
245-
}
275+
}

pkg/vcs/comment/data.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,6 @@ type ProjectResult struct {
183183
// Available from runner's Project.Diff.
184184
DiffBreakdown *CostBreakdown
185185

186-
// Resources contains the current resources with their costs from the
187-
// provider output. Each resource has an Action field (CREATE, MODIFY,
188-
// DELETE, NOOP) indicating whether it changed between runs.
189-
Resources []*provider.Resource
190-
191186
// Diagnostics contains any parsing errors or warnings for this project.
192187
Diagnostics []*diagnostic.Diagnostic
193188

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package comment
2+
3+
import "github.com/infracost/proto/gen/go/infracost/provider"
4+
5+
// buildDeletedResources returns the set of resource IDs/addresses that were
6+
// failing in the previous run but no longer appear in any current policy
7+
// result (passing or failing) across finops, security, and tagging policies.
8+
//
9+
// Such a resource was either deleted, or stopped being applicable to every
10+
// policy that previously covered it. In either case it shouldn't be counted
11+
// as a "fix" in the comment — claiming we fixed an issue on a resource that
12+
// has disappeared (or whose policy has retired) is misleading.
13+
//
14+
// FinOps results key resources by parser-supplied ID; tagging results key by
15+
// address. They share the same map: keys are strings and any collision (when
16+
// a parser uses the address as the ID) is harmless.
17+
func (data *Data) buildDeletedResources() map[string]bool {
18+
current := make(map[string]bool)
19+
addFinopsCurrent(current, data.FinOpsPolicyResults)
20+
addFinopsCurrent(current, data.SecurityPolicyResults)
21+
for _, r := range data.TaggingPolicyResults {
22+
for _, fr := range r.FailingResources {
23+
current[fr.Address] = true
24+
}
25+
for _, pr := range r.PassingResources {
26+
current[pr.Address] = true
27+
}
28+
}
29+
30+
deleted := make(map[string]bool)
31+
addFinopsDeleted(deleted, current, data.PreviousFinOpsPolicyResults)
32+
addFinopsDeleted(deleted, current, data.PreviousSecurityPolicyResults)
33+
for _, r := range data.PreviousTaggingPolicyResults {
34+
for _, fr := range r.FailingResources {
35+
if !current[fr.Address] {
36+
deleted[fr.Address] = true
37+
}
38+
}
39+
}
40+
return deleted
41+
}
42+
43+
func addFinopsCurrent(current map[string]bool, results []*provider.FinopsPolicyResult) {
44+
for _, r := range results {
45+
for _, fr := range r.FailingResources {
46+
current[fr.Id] = true
47+
}
48+
for _, id := range r.PassingResourceIds {
49+
current[id] = true
50+
}
51+
}
52+
}
53+
54+
func addFinopsDeleted(deleted, current map[string]bool, previous []*provider.FinopsPolicyResult) {
55+
for _, r := range previous {
56+
for _, fr := range r.FailingResources {
57+
if !current[fr.Id] {
58+
deleted[fr.Id] = true
59+
}
60+
}
61+
}
62+
}

pkg/vcs/comment/fixed_issues.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import (
1414
func (data *Data) processFixedIssues(inputs *Inputs, finopsIndex, securityIndex policyFailureIndex, taggingIndex taggingFailureIndex) {
1515
counts := make([]FixedIssueCount, 0, len(data.PreviousFinOpsPolicyResults)+len(data.PreviousSecurityPolicyResults)+len(data.TaggingPolicyResults))
1616

17-
counts = append(counts, finopsFixedIssueCounts(data.PreviousFinOpsPolicyResults, finopsIndex)...)
18-
counts = append(counts, finopsFixedIssueCounts(data.PreviousSecurityPolicyResults, securityIndex)...)
19-
counts = append(counts, taggingFixedIssueCounts(data.TaggingPolicyResults, taggingIndex)...)
17+
deleted := data.buildDeletedResources()
18+
19+
counts = append(counts, finopsFixedIssueCounts(data.PreviousFinOpsPolicyResults, finopsIndex, deleted)...)
20+
counts = append(counts, finopsFixedIssueCounts(data.PreviousSecurityPolicyResults, securityIndex, deleted)...)
21+
counts = append(counts, taggingFixedIssueCounts(data.TaggingPolicyResults, taggingIndex, deleted)...)
2022

2123
// Sort by count descending, then policy name ascending.
2224
sort.Slice(counts, func(i, j int) bool {
@@ -44,8 +46,11 @@ func (data *Data) processFixedIssues(inputs *Inputs, finopsIndex, securityIndex
4446
}
4547

4648
// finopsFixedIssueCounts computes fixed issue counts for FinOps/security policies.
47-
// A fixed issue is a resource that was failing previously but is no longer failing.
48-
func finopsFixedIssueCounts(previousResults []*provider.FinopsPolicyResult, index policyFailureIndex) []FixedIssueCount {
49+
// A fixed issue is a resource that was failing previously, is no longer failing,
50+
// and still appears in some current policy result. Resources in the deleted set
51+
// (gone from every current policy's scope) are excluded so we don't claim a fix
52+
// for a resource that has disappeared.
53+
func finopsFixedIssueCounts(previousResults []*provider.FinopsPolicyResult, index policyFailureIndex, deleted map[string]bool) []FixedIssueCount {
4954
if len(previousResults) == 0 {
5055
return nil
5156
}
@@ -59,9 +64,13 @@ func finopsFixedIssueCounts(previousResults []*provider.FinopsPolicyResult, inde
5964
currentIDs := index.current[prev.PolicySlug]
6065
fixed := 0
6166
for _, r := range prev.FailingResources {
62-
if currentIDs == nil || !currentIDs[r.Id] {
63-
fixed++
67+
if currentIDs != nil && currentIDs[r.Id] {
68+
continue
69+
}
70+
if deleted[r.Id] {
71+
continue
6472
}
73+
fixed++
6574
}
6675

6776
if fixed > 0 {
@@ -76,8 +85,10 @@ func finopsFixedIssueCounts(previousResults []*provider.FinopsPolicyResult, inde
7685
}
7786

7887
// taggingFixedIssueCounts computes fixed issue counts for tagging policies.
79-
// A fixed issue is an address that was failing previously but is no longer failing.
80-
func taggingFixedIssueCounts(results []event.TaggingPolicyResult, index taggingFailureIndex) []FixedIssueCount {
88+
// A fixed issue is an address that was failing previously, is no longer
89+
// failing, and still appears in some current policy result. Addresses in the
90+
// deleted set are excluded — see finopsFixedIssueCounts for rationale.
91+
func taggingFixedIssueCounts(results []event.TaggingPolicyResult, index taggingFailureIndex, deleted map[string]bool) []FixedIssueCount {
8192
var counts []FixedIssueCount
8293

8394
for _, result := range results {
@@ -93,9 +104,13 @@ func taggingFixedIssueCounts(results []event.TaggingPolicyResult, index taggingF
93104
currentAddrs := index.current[result.TagPolicyID]
94105
fixed := 0
95106
for addr := range prevAddrs {
96-
if currentAddrs == nil || !currentAddrs[addr] {
97-
fixed++
107+
if currentAddrs != nil && currentAddrs[addr] {
108+
continue
109+
}
110+
if deleted[addr] {
111+
continue
98112
}
113+
fixed++
99114
}
100115

101116
if fixed > 0 {

pkg/vcs/comment/format_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package comment
2+
3+
import (
4+
"testing"
5+
6+
"github.com/infracost/go-proto/pkg/rat"
7+
)
8+
9+
func TestFormatCost(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
value *rat.Rat
13+
currency string
14+
want string
15+
}{
16+
{name: "nil", value: nil, currency: "USD", want: "$0"},
17+
{name: "zero", value: rat.Zero, currency: "USD", want: "$0"},
18+
{name: "small fraction", value: ratFrom("0.5"), currency: "USD", want: "$0.50"},
19+
{name: "small fraction negative", value: ratFrom("-0.5"), currency: "USD", want: "-$0.50"},
20+
{name: "whole number", value: rat.New(50), currency: "USD", want: "$50"},
21+
{name: "whole number negative", value: rat.New(-50), currency: "USD", want: "-$50"},
22+
{name: "thousands", value: rat.New(1616), currency: "USD", want: "$1,616"},
23+
{name: "thousands negative", value: rat.New(-1616), currency: "USD", want: "-$1,616"},
24+
{name: "millions", value: rat.New(1234567), currency: "USD", want: "$1,234,567"},
25+
{name: "millions negative", value: rat.New(-1234567), currency: "USD", want: "-$1,234,567"},
26+
{name: "EUR", value: rat.New(1500), currency: "EUR", want: "€1,500"},
27+
{name: "GBP negative", value: rat.New(-2500), currency: "GBP", want: "-£2,500"},
28+
}
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
if got := formatCost(tt.value, tt.currency); got != tt.want {
32+
t.Errorf("formatCost(%v, %q) = %q, want %q", tt.value, tt.currency, got, tt.want)
33+
}
34+
})
35+
}
36+
}
37+
38+
func TestFormatNumber(t *testing.T) {
39+
tests := []struct {
40+
v float64
41+
want string
42+
}{
43+
{v: 0, want: "0"},
44+
{v: 1, want: "1"},
45+
{v: 16, want: "16"},
46+
{v: 16.0, want: "16"},
47+
{v: 335.7, want: "335.7"},
48+
{v: 1234.5, want: "1,234.5"},
49+
{v: 1234567, want: "1,234,567"},
50+
{v: 0.5, want: "0.5"},
51+
{v: 0.05, want: "0.05"},
52+
{v: 0.005, want: "0.005"},
53+
}
54+
for _, tt := range tests {
55+
t.Run(tt.want, func(t *testing.T) {
56+
if got := formatNumber(tt.v); got != tt.want {
57+
t.Errorf("formatNumber(%v) = %q, want %q", tt.v, got, tt.want)
58+
}
59+
})
60+
}
61+
}
62+
63+
func ratFrom(s string) *rat.Rat {
64+
r, err := rat.NewFromString(s)
65+
if err != nil {
66+
panic(err)
67+
}
68+
return r
69+
}

0 commit comments

Comments
 (0)