|
7 | 7 | // prefix (for example "[0.34] Copy of ENGCP-906"), and appends "Fixes <id>" to |
8 | 8 | // that backport PR's body so the sub-issue is closed when the backport merges. |
9 | 9 | // |
| 10 | +// After a title match it also verifies the release attached to the sub-issue: |
| 11 | +// a missing release or a release on a different line than the backport target |
| 12 | +// produces a remedy warning, because a title match alone cannot catch a |
| 13 | +// sub-issue attached to the wrong release. Verification never blocks linking. |
| 14 | +// |
10 | 15 | // It is advisory: any failure is logged as a warning and the process still |
11 | 16 | // exits 0 so it never blocks the backport workflow. |
12 | 17 | package main |
@@ -51,6 +56,13 @@ type issueFamily struct { |
51 | 56 | Parent *issueWithChildren `json:"parent"` |
52 | 57 | } |
53 | 58 |
|
| 59 | +// releaseRef is a minimal Linear release attached to an issue. Version is |
| 60 | +// nullable in Linear's schema, so it decodes to "" when unset. |
| 61 | +type releaseRef struct { |
| 62 | + Name string `json:"name"` |
| 63 | + Version string `json:"version"` |
| 64 | +} |
| 65 | + |
54 | 66 | func main() { |
55 | 67 | // Workflow commands (::notice::, ::warning::) are only parsed when they |
56 | 68 | // start the line, so drop log's default date/time prefix; otherwise the |
@@ -143,6 +155,11 @@ func run() error { |
143 | 155 | continue |
144 | 156 | } |
145 | 157 |
|
| 158 | + // A title match alone cannot catch a sub-issue attached to the wrong |
| 159 | + // release, so verify the attachment. Advisory in every outcome: the |
| 160 | + // Fixes-line linking below proceeds unchanged. |
| 161 | + verifyRelease(linearToken, sub, version, target) |
| 162 | + |
146 | 163 | headBranch := backportHeadBranch(target, *sourcePR) |
147 | 164 | bp, err := findBackportPR(ctx, gh, *repoOwner, *repoName, headBranch, target) |
148 | 165 | if err != nil { |
@@ -254,6 +271,73 @@ func familyCandidates(f issueFamily) []issueRef { |
254 | 271 | return append(out, f.Children.Nodes...) |
255 | 272 | } |
256 | 273 |
|
| 274 | +var leadingVersionRe = regexp.MustCompile(`^\s*v?(\d+)\.(\d+)`) |
| 275 | + |
| 276 | +// lineFromVersionString extracts the X.Y release line from the leading version |
| 277 | +// in a string. "0.33.5 - Security Only" -> "0.33", "v1.2.3" -> "1.2", |
| 278 | +// "abc123" -> "". |
| 279 | +func lineFromVersionString(s string) string { |
| 280 | + m := leadingVersionRe.FindStringSubmatch(s) |
| 281 | + if len(m) < 3 { |
| 282 | + return "" |
| 283 | + } |
| 284 | + return m[1] + "." + m[2] |
| 285 | +} |
| 286 | + |
| 287 | +// releaseLine derives a release's X.Y line: from the version field when it is |
| 288 | +// set and parseable, else from the leading version in the release name. Empty |
| 289 | +// when neither carries a version (e.g. a commit-hash version and a free-form |
| 290 | +// name). |
| 291 | +func releaseLine(r releaseRef) string { |
| 292 | + if line := lineFromVersionString(r.Version); line != "" { |
| 293 | + return line |
| 294 | + } |
| 295 | + return lineFromVersionString(r.Name) |
| 296 | +} |
| 297 | + |
| 298 | +// verifyReleaseAttachment checks that a matched sub-issue carries a release on |
| 299 | +// the backport target's line. It returns nil when any attached release matches |
| 300 | +// the line, and a remedyWarning naming the fix otherwise. Pure so the three |
| 301 | +// outcomes are testable without HTTP. |
| 302 | +func verifyReleaseAttachment(sub issueRef, releases []releaseRef, line, target string) *remedyWarning { |
| 303 | + if len(releases) == 0 { |
| 304 | + return &remedyWarning{ |
| 305 | + problem: fmt.Sprintf("sub-issue %s matched backport target %s by title but has no release attached", sub.Identifier, target), |
| 306 | + remedy: fmt.Sprintf("attach the %s line's In Progress release to %s", line, sub.Identifier), |
| 307 | + } |
| 308 | + } |
| 309 | + var attached []string |
| 310 | + for _, r := range releases { |
| 311 | + rl := releaseLine(r) |
| 312 | + if rl == line { |
| 313 | + return nil |
| 314 | + } |
| 315 | + if rl == "" { |
| 316 | + rl = fmt.Sprintf("%q (no parseable version)", r.Name) |
| 317 | + } |
| 318 | + attached = append(attached, rl) |
| 319 | + } |
| 320 | + return &remedyWarning{ |
| 321 | + problem: fmt.Sprintf("sub-issue %s matched backport target %s by title but its attached release is on line %s, not %s", sub.Identifier, target, strings.Join(attached, ", "), line), |
| 322 | + remedy: fmt.Sprintf("attach the %s release to %s, or fix the sub-issue title if the release attachment is the correct one", line, sub.Identifier), |
| 323 | + } |
| 324 | +} |
| 325 | + |
| 326 | +// verifyRelease fetches the releases attached to a matched sub-issue and emits |
| 327 | +// a remedy warning when none is on the backport target's line. When the |
| 328 | +// releases query itself fails (API shape drift, permissions), it degrades to a |
| 329 | +// single plain warning; linking always proceeds. |
| 330 | +func verifyRelease(token string, sub issueRef, line, target string) { |
| 331 | + releases, err := getIssueReleases(token, sub.ID) |
| 332 | + if err != nil { |
| 333 | + warnf("could not verify the release attached to %s (linking continues): %v", sub.Identifier, err) |
| 334 | + return |
| 335 | + } |
| 336 | + if w := verifyReleaseAttachment(sub, releases, line, target); w != nil { |
| 337 | + w.emit() |
| 338 | + } |
| 339 | +} |
| 340 | + |
257 | 341 | var fixesRe = regexp.MustCompile(`(?i)\b(fix(es|ed)?|close[sd]?|resolve[sd]?)\s+#?`) |
258 | 342 |
|
259 | 343 | // bodyReferencesIssue reports whether a PR body already closes/fixes/resolves |
@@ -396,6 +480,34 @@ func getFamilyByID(token, id string) (*issueFamily, error) { |
396 | 480 | return &f, nil |
397 | 481 | } |
398 | 482 |
|
| 483 | +// getIssueReleases returns the releases attached to a Linear issue. |
| 484 | +func getIssueReleases(token, id string) ([]releaseRef, error) { |
| 485 | + const q = `query($id: String!) { |
| 486 | + issue(id: $id) { |
| 487 | + releases { nodes { name version } } |
| 488 | + } |
| 489 | +}` |
| 490 | + var resp struct { |
| 491 | + Data struct { |
| 492 | + Issue struct { |
| 493 | + Releases struct { |
| 494 | + Nodes []releaseRef `json:"nodes"` |
| 495 | + } `json:"releases"` |
| 496 | + } `json:"issue"` |
| 497 | + } `json:"data"` |
| 498 | + Errors []struct { |
| 499 | + Message string `json:"message"` |
| 500 | + } `json:"errors"` |
| 501 | + } |
| 502 | + if err := linearGraphQL(token, q, map[string]any{"id": id}, &resp); err != nil { |
| 503 | + return nil, err |
| 504 | + } |
| 505 | + if len(resp.Errors) > 0 { |
| 506 | + return nil, fmt.Errorf("linear: %s", resp.Errors[0].Message) |
| 507 | + } |
| 508 | + return resp.Data.Issue.Releases.Nodes, nil |
| 509 | +} |
| 510 | + |
399 | 511 | func linearGraphQL(token, query string, variables map[string]any, out any) error { |
400 | 512 | payload, err := json.Marshal(map[string]any{"query": query, "variables": variables}) |
401 | 513 | if err != nil { |
|
0 commit comments