|
| 1 | +package badgeurl |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "net/url" |
| 6 | + "os" |
| 7 | + "sort" |
| 8 | + "strings" |
| 9 | + |
| 10 | + "github.com/goccy/go-yaml" |
| 11 | +) |
| 12 | + |
| 13 | +const ( |
| 14 | + // DefaultBadge lets bestpractices.dev prompt the user to choose a target |
| 15 | + // baseline section after opening the generated edit URL. |
| 16 | + DefaultBadge = "choose" |
| 17 | + defaultMaxURLLength = 2000 |
| 18 | + defaultJustificationSize = 240 |
| 19 | +) |
| 20 | + |
| 21 | +var ( |
| 22 | + // These are the BPB sections this utility knows how to target from |
| 23 | + // Privateer's current OSPS baseline mapping. |
| 24 | + supportedBadgeSections = map[string]struct{}{ |
| 25 | + "choose": {}, |
| 26 | + "baseline-1": {}, |
| 27 | + "baseline-2": {}, |
| 28 | + "baseline-3": {}, |
| 29 | + } |
| 30 | +) |
| 31 | + |
| 32 | +// Options configures badge URL generation. |
| 33 | +// |
| 34 | +// IncludeJustifications is a tri-state field: nil applies the package default |
| 35 | +// of including justifications, while non-nil values explicitly enable or |
| 36 | +// disable them. |
| 37 | +type Options struct { |
| 38 | + Badge string |
| 39 | + IncludeJustifications *bool |
| 40 | +} |
| 41 | + |
| 42 | +type resultsFile struct { |
| 43 | + Payload struct { |
| 44 | + Config *payloadConfig `yaml:"config"` |
| 45 | + RestData *struct { |
| 46 | + Config *payloadConfig `yaml:"config"` |
| 47 | + } `yaml:"restdata"` |
| 48 | + } `yaml:"payload"` |
| 49 | + EvaluationSuites []evaluationSuite `yaml:"evaluation-suites"` |
| 50 | +} |
| 51 | + |
| 52 | +type payloadConfig struct { |
| 53 | + Vars map[string]string `yaml:"vars"` |
| 54 | +} |
| 55 | + |
| 56 | +type evaluationSuite struct { |
| 57 | + ControlEvaluations controlEvaluations `yaml:"control-evaluations"` |
| 58 | +} |
| 59 | + |
| 60 | +type controlEvaluations struct { |
| 61 | + Evaluations []controlEvaluation `yaml:"evaluations"` |
| 62 | +} |
| 63 | + |
| 64 | +type controlEvaluation struct { |
| 65 | + AssessmentLogs []assessmentLog `yaml:"assessment-logs"` |
| 66 | +} |
| 67 | + |
| 68 | +type assessmentLog struct { |
| 69 | + Requirement struct { |
| 70 | + EntryID string `yaml:"entry-id"` |
| 71 | + } `yaml:"requirement"` |
| 72 | + Result string `yaml:"result"` |
| 73 | + Message string `yaml:"message"` |
| 74 | + Applicability []string `yaml:"applicability"` |
| 75 | +} |
| 76 | + |
| 77 | +type proposalUnit struct { |
| 78 | + key string |
| 79 | + encoded string |
| 80 | +} |
| 81 | + |
| 82 | +// GenerateFromFile reads a serialized Privateer results file and returns one |
| 83 | +// or more Best Practices Badge automation proposal URLs. |
| 84 | +func GenerateFromFile(path string, options Options) ([]string, error) { |
| 85 | + content, err := os.ReadFile(path) |
| 86 | + if err != nil { |
| 87 | + return nil, fmt.Errorf("read results file: %w", err) |
| 88 | + } |
| 89 | + |
| 90 | + return Generate(content, options) |
| 91 | +} |
| 92 | + |
| 93 | +// Generate converts a serialized Privateer results document into one or more |
| 94 | +// Best Practices Badge automation-proposals URLs. |
| 95 | +// Reference: https://github.com/coreinfrastructure/best-practices-badge/blob/main/docs/automation-proposals.md |
| 96 | +func Generate(content []byte, options Options) ([]string, error) { |
| 97 | + options = normalizeOptions(options) |
| 98 | + if err := validateOptions(options); err != nil { |
| 99 | + return nil, err |
| 100 | + } |
| 101 | + |
| 102 | + var results resultsFile |
| 103 | + if err := yaml.Unmarshal(content, &results); err != nil { |
| 104 | + return nil, fmt.Errorf("parse results YAML: %w", err) |
| 105 | + } |
| 106 | + |
| 107 | + repoURL, err := extractRepositoryURL(results) |
| 108 | + if err != nil { |
| 109 | + return nil, err |
| 110 | + } |
| 111 | + |
| 112 | + units := collectProposalUnits(results, options) |
| 113 | + if len(units) == 0 { |
| 114 | + return nil, fmt.Errorf("no supported Best Practices Badge links could be generated from the results") |
| 115 | + } |
| 116 | + |
| 117 | + baseURL := buildBaseURL(repoURL, options.Badge) |
| 118 | + return buildURLs(baseURL, units) |
| 119 | +} |
| 120 | + |
| 121 | +func normalizeOptions(options Options) Options { |
| 122 | + if strings.TrimSpace(options.Badge) == "" { |
| 123 | + options.Badge = DefaultBadge |
| 124 | + } |
| 125 | + if options.IncludeJustifications == nil { |
| 126 | + options.IncludeJustifications = boolPtr(true) |
| 127 | + } |
| 128 | + return options |
| 129 | +} |
| 130 | + |
| 131 | +func validateOptions(options Options) error { |
| 132 | + if _, ok := supportedBadgeSections[options.Badge]; !ok { |
| 133 | + return fmt.Errorf("invalid badge %q: must be one of choose, baseline-1, baseline-2, baseline-3", options.Badge) |
| 134 | + } |
| 135 | + return nil |
| 136 | +} |
| 137 | + |
| 138 | +// extractRepositoryURL finds the repository identity that BPB uses to look up |
| 139 | +// the target project before applying proposal fields. |
| 140 | +func extractRepositoryURL(results resultsFile) (string, error) { |
| 141 | + // Privateer results may carry config in more than one serialized payload |
| 142 | + // location depending on how the scanner wrote the output. |
| 143 | + configs := []*payloadConfig{ |
| 144 | + results.Payload.Config, |
| 145 | + } |
| 146 | + if results.Payload.RestData != nil { |
| 147 | + configs = append(configs, results.Payload.RestData.Config) |
| 148 | + } |
| 149 | + |
| 150 | + for _, cfg := range configs { |
| 151 | + if cfg == nil { |
| 152 | + continue |
| 153 | + } |
| 154 | + owner := strings.TrimSpace(cfg.Vars["owner"]) |
| 155 | + repo := strings.TrimSpace(cfg.Vars["repo"]) |
| 156 | + if owner != "" && repo != "" { |
| 157 | + return fmt.Sprintf("https://github.com/%s/%s", owner, repo), nil |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + return "", fmt.Errorf("could not determine repository URL from results payload") |
| 162 | +} |
| 163 | + |
| 164 | +// collectProposalUnits walks the evaluation logs, filters them to the target |
| 165 | +// badge scope, and turns supported findings into stable query-string fragments. |
| 166 | +func collectProposalUnits(results resultsFile, options Options) []proposalUnit { |
| 167 | + allowedLevels := levelsForBadge(options.Badge) |
| 168 | + seen := map[string]struct{}{} |
| 169 | + units := make([]proposalUnit, 0) |
| 170 | + |
| 171 | + for _, suite := range results.EvaluationSuites { |
| 172 | + for _, evaluation := range suite.ControlEvaluations.Evaluations { |
| 173 | + for _, log := range evaluation.AssessmentLogs { |
| 174 | + // Requirement IDs can appear more than once across suites. Keep the |
| 175 | + // first supported occurrence so the output is stable and non-duplicated. |
| 176 | + requirementID := strings.TrimSpace(log.Requirement.EntryID) |
| 177 | + if requirementID == "" { |
| 178 | + continue |
| 179 | + } |
| 180 | + if _, ok := seen[requirementID]; ok { |
| 181 | + continue |
| 182 | + } |
| 183 | + if !isApplicable(log.Applicability, allowedLevels) { |
| 184 | + continue |
| 185 | + } |
| 186 | + |
| 187 | + status, ok := mapResult(log.Result) |
| 188 | + if !ok { |
| 189 | + continue |
| 190 | + } |
| 191 | + |
| 192 | + key := badgeFieldName(requirementID) |
| 193 | + parts := []string{fmt.Sprintf("%s_status=%s", key, url.QueryEscape(status))} |
| 194 | + if *options.IncludeJustifications { |
| 195 | + justification := sanitizeJustification(log.Message) |
| 196 | + if justification != "" { |
| 197 | + parts = append(parts, fmt.Sprintf("%s_justification=%s", key, url.QueryEscape(justification))) |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + units = append(units, proposalUnit{ |
| 202 | + key: key, |
| 203 | + encoded: strings.Join(parts, "&"), |
| 204 | + }) |
| 205 | + seen[requirementID] = struct{}{} |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + sort.Slice(units, func(i, j int) bool { |
| 211 | + return units[i].key < units[j].key |
| 212 | + }) |
| 213 | + |
| 214 | + return units |
| 215 | +} |
| 216 | + |
| 217 | +// levelsForBadge maps a BPB section to the Privateer OSPS maturity levels |
| 218 | +// whose findings should be included in the generated link. |
| 219 | +func levelsForBadge(badge string) map[string]struct{} { |
| 220 | + if badge == DefaultBadge { |
| 221 | + // "choose" defers section choice to BPB, so include all applicable levels. |
| 222 | + return nil |
| 223 | + } |
| 224 | + |
| 225 | + levels := map[string]struct{}{} |
| 226 | + for _, level := range []string{"Maturity Level 1", "Maturity Level 2", "Maturity Level 3"} { |
| 227 | + levels[level] = struct{}{} |
| 228 | + if badge == "baseline-1" && level == "Maturity Level 1" { |
| 229 | + break |
| 230 | + } |
| 231 | + if badge == "baseline-2" && level == "Maturity Level 2" { |
| 232 | + break |
| 233 | + } |
| 234 | + if badge == "baseline-3" && level == "Maturity Level 3" { |
| 235 | + break |
| 236 | + } |
| 237 | + } |
| 238 | + return levels |
| 239 | +} |
| 240 | + |
| 241 | +func isApplicable(applicability []string, allowedLevels map[string]struct{}) bool { |
| 242 | + if allowedLevels == nil || len(applicability) == 0 { |
| 243 | + return true |
| 244 | + } |
| 245 | + for _, level := range applicability { |
| 246 | + if _, ok := allowedLevels[level]; ok { |
| 247 | + return true |
| 248 | + } |
| 249 | + } |
| 250 | + return false |
| 251 | +} |
| 252 | + |
| 253 | +// mapResult converts Privateer's control result vocabulary into BPB's status |
| 254 | +// vocabulary and drops unsupported states. |
| 255 | +func mapResult(result string) (string, bool) { |
| 256 | + switch strings.TrimSpace(strings.ToLower(result)) { |
| 257 | + case "passed": |
| 258 | + return "Met", true |
| 259 | + case "failed": |
| 260 | + return "Unmet", true |
| 261 | + case "notapplicable", "not applicable", "n/a": |
| 262 | + return "N/A", true |
| 263 | + default: |
| 264 | + return "", false |
| 265 | + } |
| 266 | +} |
| 267 | + |
| 268 | +func badgeFieldName(requirementID string) string { |
| 269 | + replacer := strings.NewReplacer("-", "_", ".", "_") |
| 270 | + return strings.ToLower(replacer.Replace(requirementID)) |
| 271 | +} |
| 272 | + |
| 273 | +// sanitizeJustification keeps reviewer context short and URL-safe so it can be |
| 274 | +// embedded directly into a BPB proposal link. |
| 275 | +func sanitizeJustification(message string) string { |
| 276 | + cleaned := strings.TrimSpace(message) |
| 277 | + if cleaned == "" { |
| 278 | + return "" |
| 279 | + } |
| 280 | + // Keep reviewer context compact while preserving evidence details such as |
| 281 | + // URLs; QueryEscape handles the actual URL encoding later. |
| 282 | + cleaned = strings.ReplaceAll(cleaned, "\n", " ") |
| 283 | + cleaned = strings.ReplaceAll(cleaned, "\r", " ") |
| 284 | + cleaned = strings.Join(strings.Fields(cleaned), " ") |
| 285 | + runes := []rune(cleaned) |
| 286 | + if len(runes) > defaultJustificationSize { |
| 287 | + cleaned = strings.TrimSpace(string(runes[:defaultJustificationSize])) |
| 288 | + } |
| 289 | + return cleaned |
| 290 | +} |
| 291 | + |
| 292 | +func boolPtr(value bool) *bool { |
| 293 | + return &value |
| 294 | +} |
| 295 | + |
| 296 | +func buildBaseURL(repoURL string, badge string) string { |
| 297 | + return fmt.Sprintf( |
| 298 | + "https://www.bestpractices.dev/projects?as=edit§ion=%s&url=%s", |
| 299 | + url.QueryEscape(badge), |
| 300 | + url.QueryEscape(repoURL), |
| 301 | + ) |
| 302 | +} |
| 303 | + |
| 304 | +// buildURLs emits one link when it fits within the default URL budget and |
| 305 | +// otherwise batches proposal fragments into multiple links that can be applied |
| 306 | +// in order. |
| 307 | +func buildURLs(baseURL string, units []proposalUnit) ([]string, error) { |
| 308 | + urls := make([]string, 0, 1) |
| 309 | + current := baseURL |
| 310 | + hasUnits := false |
| 311 | + |
| 312 | + for _, unit := range units { |
| 313 | + candidate := current + "&" + unit.encoded |
| 314 | + if len(candidate) > defaultMaxURLLength { |
| 315 | + if !hasUnits { |
| 316 | + return nil, fmt.Errorf("a single Best Practices Badge proposal entry exceeds %d characters; disable justifications or shorten the source evidence text", defaultMaxURLLength) |
| 317 | + } |
| 318 | + urls = append(urls, current) |
| 319 | + current = baseURL + "&" + unit.encoded |
| 320 | + hasUnits = true |
| 321 | + continue |
| 322 | + } |
| 323 | + current = candidate |
| 324 | + hasUnits = true |
| 325 | + } |
| 326 | + |
| 327 | + if hasUnits { |
| 328 | + urls = append(urls, current) |
| 329 | + } |
| 330 | + |
| 331 | + return urls, nil |
| 332 | +} |
0 commit comments