Skip to content

Commit fdcf28d

Browse files
ecPabloCopilot
andauthored
feat: add predecessors package logic ported from create-proposal-pr composite action (#474)
We are moving the files related to opcounts and predecessor calculation to this package to allow better reusability across all the GH workflows as this is something that might be used in different mcms related pipelines. Main API's Exposed #### Opcount computation related **ComputeHighestOpCountsFromPredecessors**: Returns the newly computed opcounts for the given PR and list of predecessors. ```go func ComputeHighestOpCountsFromPredecessors( lggr logger.Logger, newProposalData ProposalsOpData, predecessors []PRView, ) map[mcmstypes.ChainSelector]uint64 ``` **ApplyHighestOpCountsToProposal**: Applies the new opcounts provided to the proposal and saves the proposal to the filesystem ```go func ApplyHighestOpCountsToProposal( lggr logger.Logger, proposalPath string, newOpCounts map[mcmstypes.ChainSelector]uint64, ) ``` **ParseProposalOpsData**: small helper to extract just the opcount related information into an easier to access struct. ```go func ParseProposalOpsData(ctx context.Context, filePath string) (ProposalsOpData, error) ``` --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 126609e commit fdcf28d

File tree

8 files changed

+1038
-0
lines changed

8 files changed

+1038
-0
lines changed

.changeset/mighty-ways-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
add predecessors and opcount calculation logic to proposalutils package.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package proposalutils
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// MatchesProposalPath is a simple filter for proposal JSON files in the expected dir.
10+
func MatchesProposalPath(domain, environment, p string) bool {
11+
p = filepath.ToSlash(p)
12+
prefix := fmt.Sprintf("domains/%s/%s/proposals/", domain, environment)
13+
14+
return strings.HasPrefix(p, prefix) && strings.HasSuffix(p, ".json")
15+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package predecessors
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
10+
11+
"github.com/smartcontractkit/mcms"
12+
13+
mcmstypes "github.com/smartcontractkit/mcms/types"
14+
)
15+
16+
// ComputeHighestOpCountsFromPredecessors looks at the given predecessors,
17+
// and for each chain present in 'newProposalData', computes the new starting
18+
// op count as: current StartingOpCount (from the new proposal) + SUM of the
19+
// number of ops across ALL predecessors that share the same chain selector
20+
// AND the same MCM address. We assume that the predecessors are sorted
21+
// from oldest to newest, so we can log them in that order.
22+
// Returns:
23+
// - newStartPerChain: map[chain] = baseline + sum(ops)
24+
func ComputeHighestOpCountsFromPredecessors(
25+
lggr logger.Logger,
26+
newProposalData ProposalsOpData,
27+
predecessors []PRView,
28+
) map[mcmstypes.ChainSelector]uint64 {
29+
out := make(map[mcmstypes.ChainSelector]uint64, len(newProposalData))
30+
31+
lggr.Infof("Computing new starting op counts from %d predecessors...", len(predecessors))
32+
33+
for sel, cur := range newProposalData {
34+
lggr.Infof("New proposal data - chain %d: MCM=%s start=%d ops=%d", uint64(sel), cur.MCMAddress, cur.StartingOpCount, cur.OpsCount)
35+
baseline := cur.StartingOpCount
36+
var extra uint64
37+
38+
for _, pred := range predecessors {
39+
other, ok := pred.ProposalData[sel]
40+
if !ok || !sameMCM(cur.MCMAddress, other.MCMAddress) {
41+
continue
42+
}
43+
44+
start := other.StartingOpCount
45+
if start < baseline {
46+
// Stale predecessor: its proposed start is below current on-chain baseline.
47+
// Ignore to avoid double-counting or lowering.
48+
lggr.Warnf("Skipping stale predecessor PR#%d on chain %d: pred.start=%d < baseline (ie onchain-value)=%d",
49+
pred.Number, uint64(sel), start, baseline)
50+
51+
continue
52+
}
53+
54+
extra += other.OpsCount
55+
lggr.Infof("Counting predecessor PR#%d on chain %d: baseline=%d start=%d +ops=%d",
56+
pred.Number, uint64(sel), baseline, start, other.OpsCount)
57+
}
58+
59+
out[sel] = baseline + extra
60+
}
61+
62+
// Handle case where a predecessor PR is merged between PR creation and execution:
63+
// If the latest predecessor's op count is higher than the computed sum, adjust to match.
64+
if len(predecessors) > 0 {
65+
for sel, cur := range newProposalData {
66+
// get most recent predecessor with same MCM address
67+
latestPredecessor := predecessors[len(predecessors)-1]
68+
other, ok := latestPredecessor.ProposalData[sel]
69+
if !ok || !sameMCM(cur.MCMAddress, other.MCMAddress) {
70+
continue
71+
}
72+
newStart := other.StartingOpCount + other.OpsCount
73+
if newStart > out[sel] {
74+
lggr.Warnf("sum of predecessors' ops on chain %d is %d, but latest predecessor PR#%d has ops=%d, adjusting new start from %d to %d. This can happen when a predecessor gets merged while this action is running, but is not executed on-chain", uint64(sel), out[sel]-cur.StartingOpCount, latestPredecessor.Number, other.OpsCount, out[sel], newStart)
75+
out[sel] = newStart
76+
}
77+
}
78+
}
79+
80+
lggr.Infof("Computed new starting op counts: %v", out)
81+
82+
return out
83+
}
84+
85+
// ApplyHighestOpCountsToProposal updates the proposal file with the new starting op counts.
86+
func ApplyHighestOpCountsToProposal(
87+
lggr logger.Logger,
88+
proposalPath string,
89+
newOpCounts map[mcmstypes.ChainSelector]uint64,
90+
) error {
91+
// 1) load via mcms
92+
prop, err := mcms.LoadProposal(mcmstypes.KindTimelockProposal, proposalPath)
93+
if err != nil {
94+
return fmt.Errorf("load proposal: %w", err)
95+
}
96+
97+
// 2) cast to concrete type to mutate ChainMetadata
98+
tp, ok := prop.(*mcms.TimelockProposal)
99+
if !ok {
100+
return fmt.Errorf("expected *mcms.TimelockProposal, got %T", prop)
101+
}
102+
103+
// 3) bump starting op counts
104+
changed := false
105+
for sel, end := range newOpCounts {
106+
meta, ok := tp.ChainMetadata[sel]
107+
if !ok {
108+
continue
109+
}
110+
newStart := end
111+
if meta.StartingOpCount < newStart {
112+
lggr.Infof("Updated startingOpCount: chain %d → %d", uint64(sel), end)
113+
meta.StartingOpCount = newStart
114+
tp.ChainMetadata[sel] = meta
115+
changed = true
116+
} else {
117+
lggr.Warnf("Not updating startingOpCount for chain %d: current=%d, proposed=%d",
118+
uint64(sel), meta.StartingOpCount, newStart)
119+
}
120+
}
121+
122+
if !changed {
123+
lggr.Infof("No startingOpCount changes needed for %s", proposalPath)
124+
return nil
125+
}
126+
127+
// 4) write back using mcms helper
128+
f, err := os.OpenFile(proposalPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0o600)
129+
if err != nil {
130+
return fmt.Errorf("open for write: %w", err)
131+
}
132+
defer f.Close()
133+
134+
if err := mcms.WriteTimelockProposal(f, tp); err != nil {
135+
return fmt.Errorf("write proposal: %w", err)
136+
}
137+
138+
return nil
139+
}
140+
141+
// ParseProposalOpsData uses mcms for a local file path (current proposal) to get op counts.
142+
func ParseProposalOpsData(ctx context.Context, filePath string) (ProposalsOpData, error) {
143+
proposal, err := mcms.LoadProposal(mcmstypes.KindTimelockProposal, filePath)
144+
if err != nil {
145+
return nil, fmt.Errorf("load proposal from %s: %w", filePath, err)
146+
}
147+
// convert to timelock proposal
148+
tp, ok := proposal.(*mcms.TimelockProposal)
149+
if !ok {
150+
return nil, fmt.Errorf("expected *mcms.TimelockProposal, got %T", proposal)
151+
}
152+
153+
// Use conversion-aware counts
154+
counts, err := tp.OperationCounts(ctx)
155+
if err != nil {
156+
return nil, fmt.Errorf("converted operation counts: %w", err)
157+
}
158+
159+
data := make(ProposalsOpData, len(proposal.ChainMetadatas()))
160+
for chain, meta := range proposal.ChainMetadatas() {
161+
data[chain] = McmOpData{
162+
MCMAddress: strings.TrimSpace(meta.MCMAddress),
163+
StartingOpCount: meta.StartingOpCount,
164+
OpsCount: counts[chain],
165+
}
166+
}
167+
168+
return data, nil
169+
}
170+
171+
// sameMCM is a tiny helper to check mcm addresses are equal
172+
func sameMCM(a, b string) bool {
173+
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
174+
}

0 commit comments

Comments
 (0)