|
7 | 7 | evaluateLinkedIssueHardRules, |
8 | 8 | hasVerifiableOpenLinkedIssueReference, |
9 | 9 | loadLinkedIssueHardRules, |
| 10 | + mergeLinkedIssueHardRuleWithPersistedViolation, |
10 | 11 | resolveLinkedIssueHardRule, |
11 | 12 | resolveLinkedIssueHasOpenReference, |
12 | 13 | type LinkedIssueFacts, |
@@ -502,6 +503,65 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () = |
502 | 503 | }); |
503 | 504 | }); |
504 | 505 |
|
| 506 | +describe("mergeLinkedIssueHardRuleWithPersistedViolation (#linked-issue-hard-rule-persistence)", () => { |
| 507 | + const notPersisted = { violatedAt: undefined, reason: undefined }; |
| 508 | + |
| 509 | + it("returns the live result unchanged when it is ALREADY a violation (persisted memory adds nothing new)", () => { |
| 510 | + const live = { violated: true, reason: "Linked issue #9 is labeled `maintainer-only` — it is not open for community PRs unless assigned by a maintainer." }; |
| 511 | + expect(mergeLinkedIssueHardRuleWithPersistedViolation(live, notPersisted)).toBe(live); |
| 512 | + // A live violation's reason wins even when a DIFFERENT persisted reason also exists — freshest evidence. |
| 513 | + expect( |
| 514 | + mergeLinkedIssueHardRuleWithPersistedViolation(live, { violatedAt: "2026-06-01T00:00:00Z", reason: "a stale, different reason" }), |
| 515 | + ).toBe(live); |
| 516 | + }); |
| 517 | + |
| 518 | + it("passes through undefined (no rule applies) when nothing is persisted", () => { |
| 519 | + expect(mergeLinkedIssueHardRuleWithPersistedViolation(undefined, notPersisted)).toBeUndefined(); |
| 520 | + }); |
| 521 | + |
| 522 | + it("passes through a clean { violated: false } result unchanged when nothing is persisted", () => { |
| 523 | + const clean = { violated: false, reason: null }; |
| 524 | + expect(mergeLinkedIssueHardRuleWithPersistedViolation(clean, notPersisted)).toBe(clean); |
| 525 | + }); |
| 526 | + |
| 527 | + // REGRESSION (dodge 1): a contributor edits the PR body during the flag-then-close grace window to strip the |
| 528 | + // "Closes #N" reference. The next pass's live re-parse then sees zero linked issues, so resolveLinkedIssueHardRule |
| 529 | + // returns `undefined` -- exactly like this "live" input. Without the persisted memory, clearLinkedIssueFlag |
| 530 | + // would remove the pending-closure label as if the violation never happened. |
| 531 | + it("REGRESSION (body-edit-during-grace-window): a persisted violation is enforced even when the live re-parse now finds NO linked issues at all (undefined)", () => { |
| 532 | + const merged = mergeLinkedIssueHardRuleWithPersistedViolation(undefined, { |
| 533 | + violatedAt: "2026-06-01T12:00:00Z", |
| 534 | + reason: "Linked issue #9 is labeled `maintainer-only` — it is not open for community PRs unless assigned by a maintainer.", |
| 535 | + }); |
| 536 | + expect(merged).toEqual({ |
| 537 | + violated: true, |
| 538 | + reason: "Linked issue #9 is labeled `maintainer-only` — it is not open for community PRs unless assigned by a maintainer.", |
| 539 | + }); |
| 540 | + }); |
| 541 | + |
| 542 | + // REGRESSION (dodge 2): the linked issue's LIVE state changes between the violating pass and the verification |
| 543 | + // pass (e.g. the assignee is removed, or the maintainer-only label is dropped) -- resolveLinkedIssueHardRule |
| 544 | + // re-evaluates the SAME issue number cleanly and returns `{ violated: false, reason: null }`. Without the |
| 545 | + // persisted memory, this is indistinguishable from "never violated" and the flag is cleared. |
| 546 | + it("REGRESSION (live-issue-state-change-before-re-evaluation): a persisted violation is enforced even when the live re-parse now finds the SAME issue clean", () => { |
| 547 | + const merged = mergeLinkedIssueHardRuleWithPersistedViolation( |
| 548 | + { violated: false, reason: null }, |
| 549 | + { violatedAt: "2026-06-01T12:00:00Z", reason: "Linked issue #9 is already assigned to @claimed-dev — only the assignee or a maintainer can submit that work." }, |
| 550 | + ); |
| 551 | + expect(merged).toEqual({ |
| 552 | + violated: true, |
| 553 | + reason: "Linked issue #9 is already assigned to @claimed-dev — only the assignee or a maintainer can submit that work.", |
| 554 | + }); |
| 555 | + }); |
| 556 | + |
| 557 | + it("falls back to the generic reason when a persisted violation carries a null/missing reason", () => { |
| 558 | + expect(mergeLinkedIssueHardRuleWithPersistedViolation(undefined, { violatedAt: "2026-06-01T00:00:00Z", reason: null })).toEqual({ |
| 559 | + violated: true, |
| 560 | + reason: "the linked issue is not eligible for a community PR", |
| 561 | + }); |
| 562 | + }); |
| 563 | +}); |
| 564 | + |
505 | 565 | describe("hasVerifiableOpenLinkedIssueReference (#unlinked-issue-guardrail-followup — pure evaluator)", () => { |
506 | 566 | const found = (state: string): LinkedIssueFactsFetch => ({ status: "found", facts: { number: 1, state, labels: [], assignees: [], authorLogin: null } }); |
507 | 567 | const notFound: LinkedIssueFactsFetch = { status: "not_found" }; |
|
0 commit comments