|
56 | 56 | // ## What is asserted, and what is deliberately not |
57 | 57 | // |
58 | 58 | // Asserted: the target parse (which keyword spellings qualify, which forms do |
59 | | -// not, that a same-repo reference is skipped), and the OUTCOME of every exit -- |
| 59 | +// not, that a same-repo reference is skipped), the target KIND (#9711: a pull |
| 60 | +// request is also an issue to `issues.get`, so `owner/repo#N` naming a PR |
| 61 | +// reaches the loop like anything else -- the scenarios pin that it is REFUSED |
| 62 | +// rather than closed, and that the refusal happens before the already-closed |
| 63 | +// branch can leave a comment on somebody else's pull request), and the OUTCOME |
| 64 | +// of every exit -- |
60 | 65 | // which of `core.setFailed` / `core.warning` / a job summary fires, and which |
61 | 66 | // API calls were made. Those are the properties both cards are about. |
62 | 67 | // |
@@ -198,7 +203,21 @@ function makeDoubles({ body, token, issues = {}, prCommentError = null, summaryE |
198 | 203 | // `state_reason` is nullable on a real closed issue -- objectui#4478 |
199 | 204 | // answers `null` from this very endpoint -- so the default models that |
200 | 205 | // rather than inventing a value the API does not promise. |
201 | | - return { data: { state: t.state ?? 'open', state_reason: t.stateReason ?? null } }; |
| 206 | + // |
| 207 | + // `pull_request` is how the same endpoint says the number is a PULL |
| 208 | + // REQUEST, and it is modelled as ABSENCE rather than as `undefined` |
| 209 | + // because that is what was measured: objectstack-ai/objectstack#9716 (a |
| 210 | + // PR) answers `{ url, html_url, diff_url, patch_url, merged_at }`, and |
| 211 | + // #9711 (an issue) carries no such key at all. A fixture that always |
| 212 | + // spelled the key would let a truthiness test pass here that the real |
| 213 | + // API would fail. (#9711) |
| 214 | + return { |
| 215 | + data: { |
| 216 | + state: t.state ?? 'open', |
| 217 | + state_reason: t.stateReason ?? null, |
| 218 | + ...(t.pullRequest ? { pull_request: t.pullRequest } : {}), |
| 219 | + }, |
| 220 | + }; |
202 | 221 | }, |
203 | 222 | async listComments({ owner, repo, issue_number: n }) { |
204 | 223 | const key = `${owner}/${repo}#${n}`; |
@@ -384,6 +403,27 @@ const FOREIGN = ['objectstack-ai/objectui#456', 'my-org/some.repo#22', 'third/pa |
384 | 403 |
|
385 | 404 | /** The target the already-closed scenarios (L2, L6-L9) work on. */ |
386 | 405 | const CLOSED_TARGET = 'objectstack-ai/objectui#456'; |
| 406 | + |
| 407 | +/** The target the PULL REQUEST scenarios (L12, L13) work on (#9711). */ |
| 408 | +const PR_TARGET = 'my-org/some.repo#22'; |
| 409 | + |
| 410 | +/** |
| 411 | + * The `pull_request` key GitHub returns when an issue number is a pull request. |
| 412 | + * |
| 413 | + * Copied from a real response rather than invented: `GET /repos/ |
| 414 | + * objectstack-ai/objectstack/issues/9716` answers exactly these five fields, |
| 415 | + * and `.../issues/9711` -- an issue -- answers with the key absent. The loop |
| 416 | + * reads only its truthiness, but the shape is pinned so that a change in what |
| 417 | + * the script reads out of it (`html_url` reaches the job summary) fails here |
| 418 | + * instead of printing `undefined` into somebody's run. |
| 419 | + */ |
| 420 | +const PR_KEY = { |
| 421 | + url: 'https://api.github.com/repos/my-org/some.repo/pulls/22', |
| 422 | + html_url: 'https://github.com/my-org/some.repo/pull/22', |
| 423 | + diff_url: 'https://github.com/my-org/some.repo/pull/22.diff', |
| 424 | + patch_url: 'https://github.com/my-org/some.repo/pull/22.patch', |
| 425 | + merged_at: null, |
| 426 | +}; |
387 | 427 | const PR_URL = 'https://github.com/objectstack-ai/objectstack/pull/1234'; |
388 | 428 |
|
389 | 429 | /** |
@@ -730,6 +770,84 @@ export const SCENARIOS = [ |
730 | 770 | t(r.log.warning.length === 0, 'L11 run 2 warns about nothing -- an idempotent re-run is a normal outcome'), |
731 | 771 | ], |
732 | 772 | }, |
| 773 | + { |
| 774 | + id: 'L12', |
| 775 | + name: 'the target is an OPEN pull request -- refused out loud, never closed (#9711)', |
| 776 | + scenario: () => ({ |
| 777 | + body: MIXED_BODY, |
| 778 | + token: 'pat', |
| 779 | + issues: { [PR_TARGET]: { state: 'open', pullRequest: PR_KEY } }, |
| 780 | + }), |
| 781 | + check: (r, t) => [ |
| 782 | + t(r.calls.get.includes(PR_TARGET), 'L12 reads the target -- the kind is only knowable from the response'), |
| 783 | + t( |
| 784 | + !r.calls.update.some((u) => u.key === PR_TARGET), |
| 785 | + "L12 does NOT close the pull request -- GitHub's own keyword parser never closes one, and this " |
| 786 | + + 'workflow exists to carry that behaviour across repos, not to exceed it. A closed PR also loses ' |
| 787 | + + 'its merge-queue membership and any armed auto-merge in the same step, and neither returns by itself', |
| 788 | + ), |
| 789 | + t(!r.calls.comment.some((c) => c.key === PR_TARGET), 'L12 posts nothing on the foreign pull request either'), |
| 790 | + t( |
| 791 | + !r.calls.list.includes(PR_TARGET), |
| 792 | + 'L12 does not even list its comments -- the target KIND settles it before idempotency can matter', |
| 793 | + ), |
| 794 | + t( |
| 795 | + r.log.warning.some((w) => w.message.includes(PR_TARGET) && /PULL REQUEST/.test(w.message)), |
| 796 | + 'L12 ANNOUNCES the refusal and names the target -- a quiet `continue` here would have been the ' |
| 797 | + + "eleventh silent exit in a file whose ten known ones were each found by somebody reading it", |
| 798 | + ), |
| 799 | + t( |
| 800 | + r.log.failed.length === 1, |
| 801 | + `L12 fails the job: the merged body declares a close that can never fire, and this run is the only ` |
| 802 | + + `thing that will ever know; got ${r.log.failed.length}`, |
| 803 | + ), |
| 804 | + t((r.log.failed[0] ?? '').includes(PR_TARGET), 'L12 setFailed names the malformed target'), |
| 805 | + t( |
| 806 | + !/re-run this job once the cause is cleared/.test(r.log.failed[0] ?? ''), |
| 807 | + 'L12 verdict does not send the reader to re-run -- unlike every other red path here the refusal is ' |
| 808 | + + 'deterministic, and only the PR body can change it', |
| 809 | + ), |
| 810 | + t((r.log.summary[0] ?? '').includes(PR_TARGET), 'L12 summary carries it too, with the PR url'), |
| 811 | + t(r.calls.update.length === 2, `L12 still closes the other two targets -- isolation holds; got ${r.calls.update.length}`), |
| 812 | + t(r.threw === null, 'L12 does not let anything escape the script'), |
| 813 | + ], |
| 814 | + }, |
| 815 | + { |
| 816 | + id: 'L13', |
| 817 | + name: 'the target is a MERGED pull request -- refused BEFORE the already-closed branch comments on it (#9711)', |
| 818 | + scenario: () => ({ |
| 819 | + body: MIXED_BODY, |
| 820 | + token: 'pat', |
| 821 | + // A merged pull request answers `state: 'closed'` with |
| 822 | + // `state_reason: null` from the issues endpoint -- measured on |
| 823 | + // objectstack-ai/objectstack#9143. That is the very fixture L9 uses to |
| 824 | + // prove a closed ISSUE gets its backlink, which makes this scenario the |
| 825 | + // ORDERING test: a `pull_request` guard placed after the state branch |
| 826 | + // would sail past it and comment on somebody else's pull request. |
| 827 | + issues: { |
| 828 | + [PR_TARGET]: { |
| 829 | + state: 'closed', |
| 830 | + stateReason: null, |
| 831 | + comments: [], |
| 832 | + pullRequest: { ...PR_KEY, merged_at: '2026-08-16T14:56:58Z' }, |
| 833 | + }, |
| 834 | + }, |
| 835 | + }), |
| 836 | + check: (r, t) => [ |
| 837 | + t( |
| 838 | + !r.calls.comment.some((c) => c.key === PR_TARGET), |
| 839 | + 'L13 leaves NO backlink on a merged pull request -- the path L9 pins for a closed issue must not be reached here', |
| 840 | + ), |
| 841 | + t( |
| 842 | + !r.calls.list.includes(PR_TARGET), |
| 843 | + 'L13 never reaches the idempotency check, which is what proves the kind guard runs FIRST', |
| 844 | + ), |
| 845 | + t(!r.calls.update.some((u) => u.key === PR_TARGET), 'L13 does not touch its state'), |
| 846 | + t(r.log.failed.length === 1, `L13 fails the job for the same reason L12 does, got ${r.log.failed.length}`), |
| 847 | + t((r.log.failed[0] ?? '').includes(PR_TARGET), 'L13 setFailed names it'), |
| 848 | + t(r.calls.update.length === 2, `L13 still closes the other two, got ${r.calls.update.length}`), |
| 849 | + ], |
| 850 | + }, |
733 | 851 | ]; |
734 | 852 |
|
735 | 853 | // ── The battery ───────────────────────────────────────────────────────────── |
@@ -850,9 +968,11 @@ const MUTATIONS = [ |
850 | 968 | { |
851 | 969 | id: 'M1', |
852 | 970 | what: 'the post-loop verdict is downgraded back to a warning (the #9595 defect, restored)', |
853 | | - from: 'core.setFailed(\n `${failures.length} of ${targets.size}', |
854 | | - to: 'core.warning(\n `${failures.length} of ${targets.size}', |
855 | | - expect: ['L3', 'L4', 'L5'], |
| 971 | + from: "core.setFailed(verdict.join(' '));", |
| 972 | + to: "core.warning(verdict.join(' '));", |
| 973 | + // Since #9711 the verdict carries both red classes, so this one mutation |
| 974 | + // now has to be caught by an API refusal AND by a malformed target. |
| 975 | + expect: ['L3', 'L4', 'L5', 'L12', 'L13'], |
856 | 976 | }, |
857 | 977 | { |
858 | 978 | id: 'M2', |
@@ -922,6 +1042,27 @@ const MUTATIONS = [ |
922 | 1042 | to: 'failures.push({ key, reason });', |
923 | 1043 | expect: ['L10'], |
924 | 1044 | }, |
| 1045 | + { |
| 1046 | + id: 'M12', |
| 1047 | + what: 'the pull-request guard is dropped, so a keyword aimed at a foreign PR comments on it and CLOSES it (#9711)', |
| 1048 | + from: 'if (issue.pull_request) {', |
| 1049 | + to: 'if (false) {', |
| 1050 | + expect: ['L12', 'L13'], |
| 1051 | + }, |
| 1052 | + { |
| 1053 | + id: 'M13', |
| 1054 | + what: 'a refused pull-request target stops being recorded, so the run refuses it and still reports green', |
| 1055 | + from: 'malformed.push({ key, url: issue.pull_request.html_url });', |
| 1056 | + to: '', |
| 1057 | + expect: ['L12', 'L13'], |
| 1058 | + }, |
| 1059 | + { |
| 1060 | + id: 'M14', |
| 1061 | + what: 'the refusal is downgraded to an info line, so nothing annotates the run it happened in', |
| 1062 | + from: 'core.warning(\n `${key} is a PULL REQUEST', |
| 1063 | + to: 'core.info(\n `${key} is a PULL REQUEST', |
| 1064 | + expect: ['L12'], |
| 1065 | + }, |
925 | 1066 | { |
926 | 1067 | id: 'M7', |
927 | 1068 | what: 'the notice path loses its verdict (the #9594 half, restored to its defect)', |
|
0 commit comments