diff --git a/docs/architecture/software-factory-design.md b/docs/architecture/software-factory-design.md index af68f374..e4fab9b5 100644 --- a/docs/architecture/software-factory-design.md +++ b/docs/architecture/software-factory-design.md @@ -1420,7 +1420,7 @@ write access. | # | If | State | Next actor | |---|---|---|---| -| 1 | A linked pull request merged with a non-empty diff in its present life and closed the issue, as the issue's timeline names it the closer, or merged while the issue stood closed; never by its references as they stand now, which an edit after the merge can change (P-46, issue 267). Never an admitted outsider's merge, whose author may add a closing keyword after the approval: it reads `declined`, `closed_by_outsider_merge`, and a person decides, after the contradictions below are read (P-48, issue 280). One that closed nothing, of an issue still open, waits quietly within `max_close_lag_seconds` of the merge, `merge_closing`, since GitHub closes the issue a second after the merge and records the close two (P-33), and is read past after it. Unless, when the first such merge landed, a person had closed the issue in this life, anyone but a writer had reopened it, or a Hold stood on the issue (whoever placed it, or hands-off a person did not place) with no person's hands-off beside it: then the State a person put it in stays, `declined` or `held`, and the reason names the contradiction, `merged_while_declined` or `merged_while_held` (§4.5, issue 227). So does a State only a person ends that the facts place at that merge, read before the Hold: a person's hands-off on the issue no person has removed since, `not_served`, `merged_while_hands_off` ("A platform close keeps the State a person set on an issue held or not served", issue 522). Only the issue's own labels are read at the merge: a linked draft's Hold or hands-off is not, since when it became linked carries no time (decision 0141). The Consumer entry, whose configuration has no history, a left-out label or type, and when a pull request became linked carry no time in the facts and read as before, so that no terminal State is left by them ("Trial P-61: an issue's type changes and a pull request linked by hand, each timed on the issue's timeline", issue 551; "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the merge (after the first cut)", issue 552) | `done` | None | +| 1 | A linked pull request merged with a non-empty diff in its present life and closed the issue, as the issue's timeline names it the closer, or merged while the issue stood closed; never by its references as they stand now, which an edit after the merge can change (P-46, issue 267). Never an admitted outsider's merge, whose author may add a closing keyword after the approval: it reads `declined`, `closed_by_outsider_merge`, and a person decides, after the contradictions below are read (P-48, issue 280). One that closed nothing, of an issue still open, waits quietly within `max_close_lag_seconds` of the merge, `merge_closing`, since GitHub closes the issue a second after the merge and records the close two (P-33), and is read past after it. Unless, when the first such merge landed, a person had closed the issue in this life, anyone but a writer had reopened it, or a Hold stood on the issue (whoever placed it, or hands-off a person did not place) with no person's hands-off beside it: then the State a person put it in stays, `declined` or `held`, and the reason names the contradiction, `merged_while_declined` or `merged_while_held` (§4.5, issue 227). So does a State only a person ends that the facts place at that merge, read before the Hold: a person's hands-off on the issue no person has removed since, `not_served`, `merged_while_hands_off` ("A platform close keeps the State a person set on an issue held or not served", issue 522). Only the issue's own labels are read at the merge: a linked draft's Hold or hands-off is not, since when it became linked carries no time (decision 0141). Two pull requests linked by hand before the first merge's second, neither closed before it nor unlinked since, an outsider's only where a person had admitted it by then, were two links at it, and keep it `held`, `merged_while_held`, until fewer than two stand, an unlinking releasing it for good as a Hold's removal does (who can unlink is for trial P-61 to show); one linked in that second, or by a closing keyword alone, reads as before, so the Factory's merge beside another in its second is done ("Trial P-61: an issue's type changes and a pull request linked by hand, each timed on the issue's timeline", issue 551). The Consumer entry, whose configuration has no history, a left-out label or type, and when a pull request became linked by a closing keyword carry no time row 1 reads, and read as before, so that no terminal State is left by them ("Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the merge (after the first cut)", issue 552) | `done` | None | | 2 | Closed in any other way, an empty merge and a commit's closing keyword among them. It stays so if anyone but a person with write access reopens it | `declined` | None | | 3 | The Consumer entry does not serve it; or it, or its linked draft, carries `factory:hands-off` | `not_served` | A person | | 4 | It carries `factory:hold`, whoever added it; or its linked draft pull request does; or more than one linked pull request is open; or its Run ceiling is reached while it wants a Run (decision 0003) | `held` | As the Hold states | diff --git a/docs/decisions/0141-a-linked-drafts-flag-is-read-from-its-timeline-and-no-linked-drafts-label-holds-an-issue-at-its-merge.md b/docs/decisions/0141-a-linked-drafts-flag-is-read-from-its-timeline-and-no-linked-drafts-label-holds-an-issue-at-its-merge.md index f314af4d..41c8d64a 100644 --- a/docs/decisions/0141-a-linked-drafts-flag-is-read-from-its-timeline-and-no-linked-drafts-label-holds-an-issue-at-its-merge.md +++ b/docs/decisions/0141-a-linked-drafts-flag-is-read-from-its-timeline-and-no-linked-drafts-label-holds-an-issue-at-its-merge.md @@ -1,6 +1,6 @@ # 0141. A linked draft's flag is read from its timeline, and no linked draft's label holds an issue at its merge -Status: accepted. Replaces `docs/decisions/0139-a-platform-close-keeps-the-state-a-person-set-on-an-issue-held-or-not-served.md`, and keeps all of it but what it reads of a linked draft at the merge, which it overturns: that a Hold on a linked draft, hands-off anyone but a person placed on one, and a person's hands-off on one that lets a Hold pass, are read from the draft's flag and the link as they stand now. The Factory Builder's rulings of 2026-11-04 on "Facts: the times of a link, a left-out label and an issue's type, so that row 1 reads them at the merge" (issue 542), of "Epic: slice 1, first approval. A Release on next reviews a pull request to this repo, and the machine user's approval stands" (issue 182): its split three ways, into this change, "Trial P-61: an issue's type changes and a pull request linked by hand, each timed on the issue's timeline" (issue 551) and "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the merge (after the first cut)" (issue 552); and ruling (ii) on this change's reads. +Status: replaced by `docs/decisions/NNNN-two-pull-requests-linked-by-hand-before-an-issues-merge-leave-it-held-and-its-type-changes-are-read-whole.md`, which holds the rule in force. ## Decision diff --git a/docs/decisions/NNNN-two-pull-requests-linked-by-hand-before-an-issues-merge-leave-it-held-and-its-type-changes-are-read-whole.md b/docs/decisions/NNNN-two-pull-requests-linked-by-hand-before-an-issues-merge-leave-it-held-and-its-type-changes-are-read-whole.md new file mode 100644 index 00000000..dbf5432f --- /dev/null +++ b/docs/decisions/NNNN-two-pull-requests-linked-by-hand-before-an-issues-merge-leave-it-held-and-its-type-changes-are-read-whole.md @@ -0,0 +1,31 @@ +# NNNN. Two pull requests linked by hand before an issue's merge leave it held, and its type changes are read whole + +Status: accepted. Replaces `docs/decisions/0141-a-linked-drafts-flag-is-read-from-its-timeline-and-no-linked-drafts-label-holds-an-issue-at-its-merge.md`, and keeps all of it but what it says carries no time of a link by hand and of an issue's type, which it overturns: a link by hand carries its time, and two such links before the first merge's second keep the issue `held`. It amends decision 0132's row "What stays", whose contradictions it joins. The Factory Builder's rulings of 2026-11-09 on "Trial P-61: an issue's type changes and a pull request linked by hand, each timed on the issue's timeline" (issue 551): its plan, and the narrowing of decision 0132's same second. + +## Decision + +| What | Decided | Because | +|---|---|---| +| The fact | Each linked pull request in `issue-facts/1` carries `draft_events`: each conversion to a draft and each marking ready on its timeline, with its time, in the timeline's order, which is the order they happened. One opened as a draft has none for it; each changes the flag, so the two alternate; and the last is its flag now. A flag that disagrees with its last event, two of one kind in a row, a time out of order, or an event that cannot be read defers, `linked_unreadable`; two in one second, in order, are read | `trials/P-33.json`: a canary pull request marked ready, converted to a draft and marked ready again has each as its own event, a second or two apart; one left a draft from its opening has none. An unreadable fact never yields an action, and an author converting and marking ready in one second must not stall the issue (Claude's security read, Codex) | +| The facts of a link by hand and a type | Each linked pull request carries `link_events`: each link of it to the issue by hand, in the issue's Development box, and each unlinking, with its time, in the timeline's order. A closing keyword's link raises none, and one with none carries no time. The issue carries `type_events`: each setting, change and removal of its type, the type before and after, with its time. Each is read whole: links alternate, the first a link, none before the pull request opened; each type change follows from the last, and the last leaves the type now. One that cannot be read, or breaks that order, defers, `linked_unreadable` or `type_unreadable`; two in one second, in order, are read | An unreadable fact never yields an action. The trial the Status names asks GitHub for each as its own timed event; until its fixture is recorded the port's reading is not grounded, and the issue world links nothing by hand and sets no type | +| Two links at the merge | Two pull requests linked by hand before the first merge's second, neither closed before it, were two links at it: the issue stood `held` (row 4), and the merge keeps it so, `merged_while_held`, naming the two, whichever was linked first and whether the queue merged one or both in that second. Each is read as it stood at the merge: an outsider's only where a person had admitted it by then, so no admission after the merge moves a done issue. One unlinked in the merge's second or after it lets the merge read on for good, as a Hold's removal does: a link made again since moves nothing, so no one moves a done issue back and forth. Link events carry no actor, so who unlinked it is not read: until trial P-61 shows who can unlink, an App or a pull request's author that can would end the hold, a change from held to done the Lifecycle declares only a person's. That gap is open, and tracked by the trial. A person's hands-off that stood beside them at the merge excuses them, as it excuses a Hold: the issue stood not served then, row 3 before row 4. The rule is read after the issue's own Hold, as a contradiction of decision 0132, and so before an admitted outsider's merge, which with two such links reads `held`, not `declined`; either way a person decides. A pull request listed twice defers, `linked_unreadable`, since it would count as two links | Done from `held` is a change by the platform the Lifecycle does not declare; record 0141's "What carries no time" left it open for want of a link's time, which a link by hand now carries. A pull request's class is read as it stands now, so one whose author gained write access after the merge counts; granting it is the Maintainer's act. Who can link and unlink by hand is for trial P-61 to show | +| The same second | A link made in the first merge's second, a closing keyword's link with no time, a link made after the merge, one undone before it, and a pull request closed before it are no second link at it: decision 0132's Factory's or person's merge beside another in its second is still `done`. That same-second rule now covers only a rival link made in that second or carrying no time | In one second no order can be told, and a keyword's link carries no time until "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the merge (after the first cut)" (issue 552) | +| How the flag reads at a time | A linked pull request was a draft at a time where its flag now, with every event after that second undone, says so, or where it was one at any moment of that second; before its first event it was what that event undid. Nothing in the Kernel reads it yet: row 1 needs, beside the flag, when the pull request became linked, which "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the merge (after the first cut)" (issue 552) is to give for a closing keyword's link | So the fact is ready the day a link's time can be proven, and its reading is decided now, not then | +| Row 1 at the merge | Row 1 reads only the issue's own labels at the first merge: a person's hands-off on the issue keeps `not_served`, `merged_while_hands_off`; a Hold on the issue, or hands-off no person placed, keeps `held`, `merged_while_held`, unless a person's hands-off on the issue stood beside it. No linked pull request's label is read there | No fact with no time moves a terminal State, in either direction: the Factory Builder's ruling (A) on "A platform close keeps the State a person set on an issue held or not served" (issue 522), held whole by ruling (ii) here. When a pull request became linked carries no time: one opened before the merge may have been named by a keyword an edit added after it (`trials/P-46.json`), so its labels, read at the merge, could move a `done` issue to `held` or `not_served`, and read as they stand now, its flag could too | +| What row 1 no longer reads | A Hold on a linked draft, hands-off anyone but a person placed on one, and a person's hands-off on one that let a Hold on the issue pass, each of which decisions 0028 and 0139 read from the draft's flag and the link as they stand now | The flag now was itself a fact with no time; taking it out completes ruling (A) rather than weakening it | +| What carries no time | When a pull request became linked by a closing keyword, the Consumer entry, whose configuration has no history, and a left-out label, whose list is the entry's as it stands now. An issue's type changes carry their times, but row 1 does not read the type at the merge while the entry, which names the types it leaves out, has no history. So the queue's merge under a Hold or hands-off on a linked draft, on an issue the entry leaves out, or from two links one of which is a keyword's alone may still leave `held` or `not_served` by the platform, a change the Lifecycle does not declare; issue 552 gives a keyword's link the time of the first pass that saw it, and leaves the entry, as ruled, until a Consumer needs it. A rival's close is read as it stands now: GitHub clears it when the pull request is reopened, so a reopening after the merge of one closed before it would move a done issue to held, until an unlinking ends it for good. That is open: "Facts: a linked pull request's close and reopening, so that row 1 reads a rival as it stood at the merge" (issue 672) | GitHub dates a keyword's link at the first mention of the issue, not at the keyword (`trials/P-48.json`); a left-out label or type read at the merge against today's list would bring back what ruling (b) on "A platform close keeps the State a person set on an issue held or not served" (issue 522) took out | +| The exploration | The queue merges the Factory's and an admitted outsider's ready pull request from a Hold or a person's hands-off on the issue itself, and from two links each made by hand, never under a linked draft's labels or from two links one of which is a keyword's alone; it records each change of a draft's flag and each link by hand | Each of those is placed at the merge; the rest wait on a link's time | +| What stays | Everything else record 0141 decides, as it words it, and with it decision 0139, decision 0028 and decision 0132 as 0141 keeps them, 0132's same second narrowed as above; and 0141's Boundaries, each held by the check it names there, with those below | Only what carries no time of a link by hand and a type is overturned | + +## Why + +Record 0141 stopped reading a linked draft's labels at the merge because when a pull request became linked carried no time, and left two links at the merge, a type and the Consumer entry as they stood. An issue's timeline gives a link by hand and a type change each as its own event with its time, as the trial the Status names asks, so a link by hand can be placed at the merge: two of them keep the issue held. A keyword's link, a draft's labels and the entry still wait. + +## Boundaries + +| Boundary | Check | +|---|---| +| Each linked pull request carries its links by hand and unlinkings, each with its time, in the timeline's order, alternating, the first a link, none before it opened; one in its opening second, and an unlinking and a link in one second, are read; one that cannot be read, or breaks that order, defers, and so does a pull request listed twice | `tests/test_issue.py::test_an_unreadable_linked_pull_request_defers`, `kernel/issue.py`, mutant: "a pull request's link events are not read", mutant: "link events that are no list are read", mutant: "a link event may be anything", mutant: "the first link event may be an unlinking", mutant: "a link may be made before the pull request opened", mutant: "a link in the opening second is refused", mutant: "link events may be out of order", mutant: "a link event may repeat the last", mutant: "a pull request listed twice is read" | +| An issue carries its type changes, each the type before and after with its time, each a change, each from the last, the last the type now; one that cannot be read, or breaks that chain, defers | `tests/test_issue.py::test_an_issues_type_changes_are_read_whole_or_defer`, `kernel/issue.py`, mutant: "an issue's type events are not read", mutant: "type events that are no list are read", mutant: "a type event's time may be anything", mutant: "a type event's names may be anything", mutant: "type events may be out of order", mutant: "a type event may not follow the last", mutant: "the last type event may leave another type", mutant: "a type change that changes nothing is read" | +| Two pull requests linked by hand before the first merge's second, neither closed before it, keep the issue held, `merged_while_held`, naming the two, whichever was linked first and when two queue merges land in one second; a rival linked in that second, with no time, after the merge, undone before it or closed before it, and the merge's own link made in its second, leave it done (decision 0132); an outsider's admitted after the merge is no link at it, and an unlinking since, after the merge or in its second, lets the merge read on for good, a link made again after it moving nothing; a person's hands-off beside them at the merge, removed since, excuses them | `tests/test_issue.py::test_two_links_by_hand_before_the_merge_leave_the_issue_held`, `kernel/issue.py`, mutant: "two links by hand at the merge are not read", mutant: "one link by hand is two at the merge", mutant: "a link in the merge's second stands before it", mutant: "an unlinking before the merge leaves the link", mutant: "a link with no time stands before the merge", mutant: "a pull request closed before the merge is a link at it", mutant: "a pull request closed in the merge's second is no link at it", mutant: "an admission after the merge counts toward two links", mutant: "a link unlinked since the merge still holds", mutant: "a link made again after an unlinking since the merge holds again", mutant: "an unlinking in the merge's second leaves the link", mutant: "a person's hands-off beside two links does not excuse them" | +| The queue's merge from held by two links each made by hand is explored and makes no change of State by the platform from held | `tests/test_transitions.py::test_the_queues_merge_from_held_or_not_served_keeps_the_state`, `kernel/issue.py`, mutant: "two links by hand at the merge are read as before, in the exploration" | diff --git a/examples/invalid/issue-facts.a-close-time-in-words.json b/examples/invalid/issue-facts.a-close-time-in-words.json index 1b19f4c4..6fa7c0ff 100644 --- a/examples/invalid/issue-facts.a-close-time-in-words.json +++ b/examples/invalid/issue-facts.a-close-time-in-words.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.a-draft-event-in-words.json b/examples/invalid/issue-facts.a-draft-event-in-words.json index 13548d2c..089c2ded 100644 --- a/examples/invalid/issue-facts.a-draft-event-in-words.json +++ b/examples/invalid/issue-facts.a-draft-event-in-words.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [{"action": "drafted", "at": "2026-10-11T12:01:00Z"}] + "draft_events": [{"action": "drafted", "at": "2026-10-11T12:01:00Z"}], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.a-label-event-without-who-made-it.json b/examples/invalid/issue-facts.a-label-event-without-who-made-it.json index 2c77fedb..0c172e08 100644 --- a/examples/invalid/issue-facts.a-label-event-without-who-made-it.json +++ b/examples/invalid/issue-facts.a-label-event-without-who-made-it.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -49,7 +50,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -78,7 +80,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.a-link-with-no-opening.json b/examples/invalid/issue-facts.a-link-with-no-opening.json index 974b1bfb..7f411dc2 100644 --- a/examples/invalid/issue-facts.a-link-with-no-opening.json +++ b/examples/invalid/issue-facts.a-link-with-no-opening.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -53,7 +54,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -82,7 +84,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.a-linked-pull-request-without-its-class.json b/examples/invalid/issue-facts.a-linked-pull-request-without-its-class.json index 31f9f249..2a6f33c2 100644 --- a/examples/invalid/issue-facts.a-linked-pull-request-without-its-class.json +++ b/examples/invalid/issue-facts.a-linked-pull-request-without-its-class.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -53,7 +54,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -82,7 +84,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.a-reopening-time-as-a-number.json b/examples/invalid/issue-facts.a-reopening-time-as-a-number.json index 86afe51a..efcca24a 100644 --- a/examples/invalid/issue-facts.a-reopening-time-as-a-number.json +++ b/examples/invalid/issue-facts.a-reopening-time-as-a-number.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.an-author-without-its-kind.json b/examples/invalid/issue-facts.an-author-without-its-kind.json index 0557b810..44c67f37 100644 --- a/examples/invalid/issue-facts.an-author-without-its-kind.json +++ b/examples/invalid/issue-facts.an-author-without-its-kind.json @@ -12,6 +12,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -53,7 +54,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -82,7 +84,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.carries-prose.json b/examples/invalid/issue-facts.carries-prose.json index bc62929b..ea872630 100644 --- a/examples/invalid/issue-facts.carries-prose.json +++ b/examples/invalid/issue-facts.carries-prose.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.carries-the-title.json b/examples/invalid/issue-facts.carries-the-title.json index 32340fe8..4ad3c322 100644 --- a/examples/invalid/issue-facts.carries-the-title.json +++ b/examples/invalid/issue-facts.carries-the-title.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.no-branch-head.json b/examples/invalid/issue-facts.no-branch-head.json index bda8c1e7..93486acd 100644 --- a/examples/invalid/issue-facts.no-branch-head.json +++ b/examples/invalid/issue-facts.no-branch-head.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.no-completeness.json b/examples/invalid/issue-facts.no-completeness.json index 6c3adb31..4543ce22 100644 --- a/examples/invalid/issue-facts.no-completeness.json +++ b/examples/invalid/issue-facts.no-completeness.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.no-count-of-acts.json b/examples/invalid/issue-facts.no-count-of-acts.json index 4342322d..b6aa3c4a 100644 --- a/examples/invalid/issue-facts.no-count-of-acts.json +++ b/examples/invalid/issue-facts.no-count-of-acts.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.reopened-last-by-in-words.json b/examples/invalid/issue-facts.reopened-last-by-in-words.json index 78f28b10..bc47b97a 100644 --- a/examples/invalid/issue-facts.reopened-last-by-in-words.json +++ b/examples/invalid/issue-facts.reopened-last-by-in-words.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/issue-facts.time-in-another-format.json b/examples/invalid/issue-facts.time-in-another-format.json index a0924dc1..45dd6dbc 100644 --- a/examples/invalid/issue-facts.time-in-another-format.json +++ b/examples/invalid/issue-facts.time-in-another-format.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -54,7 +55,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -83,7 +85,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-context-without-the-pause.json b/examples/invalid/pass.a-context-without-the-pause.json index cb575801..95e6a474 100644 --- a/examples/invalid/pass.a-context-without-the-pause.json +++ b/examples/invalid/pass.a-context-without-the-pause.json @@ -261,6 +261,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -302,7 +303,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -331,7 +333,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-count-of-active-runs-below-zero.json b/examples/invalid/pass.a-count-of-active-runs-below-zero.json index a36ffb39..48f45d15 100644 --- a/examples/invalid/pass.a-count-of-active-runs-below-zero.json +++ b/examples/invalid/pass.a-count-of-active-runs-below-zero.json @@ -262,6 +262,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -303,7 +304,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -332,7 +334,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-decision-without-its-facts.json b/examples/invalid/pass.a-decision-without-its-facts.json index 4bf0ee56..874592b2 100644 --- a/examples/invalid/pass.a-decision-without-its-facts.json +++ b/examples/invalid/pass.a-decision-without-its-facts.json @@ -83,6 +83,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -124,7 +125,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -153,7 +155,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-head-that-is-no-commit.json b/examples/invalid/pass.a-head-that-is-no-commit.json index ee9a71db..20a730db 100644 --- a/examples/invalid/pass.a-head-that-is-no-commit.json +++ b/examples/invalid/pass.a-head-that-is-no-commit.json @@ -264,6 +264,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -305,7 +306,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -334,7 +336,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-left-out-code-in-words.json b/examples/invalid/pass.a-left-out-code-in-words.json index 6b45ed7a..41157f9c 100644 --- a/examples/invalid/pass.a-left-out-code-in-words.json +++ b/examples/invalid/pass.a-left-out-code-in-words.json @@ -264,6 +264,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -305,7 +306,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -334,7 +336,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-run-id-in-another-form.json b/examples/invalid/pass.a-run-id-in-another-form.json index 78a54659..e8e1d01b 100644 --- a/examples/invalid/pass.a-run-id-in-another-form.json +++ b/examples/invalid/pass.a-run-id-in-another-form.json @@ -262,6 +262,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -303,7 +304,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -332,7 +334,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.a-start-in-words.json b/examples/invalid/pass.a-start-in-words.json index 2c3ebe1b..87a1e787 100644 --- a/examples/invalid/pass.a-start-in-words.json +++ b/examples/invalid/pass.a-start-in-words.json @@ -262,6 +262,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -303,7 +304,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -332,7 +334,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.an-unidentified-run-without-its-creation.json b/examples/invalid/pass.an-unidentified-run-without-its-creation.json index 18eadab1..b565f32e 100644 --- a/examples/invalid/pass.an-unidentified-run-without-its-creation.json +++ b/examples/invalid/pass.an-unidentified-run-without-its-creation.json @@ -267,6 +267,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -308,7 +309,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -337,7 +339,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.no-release.json b/examples/invalid/pass.no-release.json index f7fd6fab..50f27a34 100644 --- a/examples/invalid/pass.no-release.json +++ b/examples/invalid/pass.no-release.json @@ -261,6 +261,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -302,7 +303,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -331,7 +333,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/invalid/pass.no-start.json b/examples/invalid/pass.no-start.json index a24fe59d..ae0c00e1 100644 --- a/examples/invalid/pass.no-start.json +++ b/examples/invalid/pass.no-start.json @@ -261,6 +261,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -302,7 +303,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -331,7 +333,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/issue-facts.json b/examples/issue-facts.json index 14392875..c3bf436e 100644 --- a/examples/issue-facts.json +++ b/examples/issue-facts.json @@ -13,6 +13,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -63,7 +64,8 @@ "action": "converted_to_draft", "at": "2026-10-11T09:50:00Z" } - ] + ], + "link_events": [] }, { "number": 140, @@ -92,7 +94,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/pass.an-unnamed-run.json b/examples/pass.an-unnamed-run.json index 5a2ea14c..0f69df48 100644 --- a/examples/pass.an-unnamed-run.json +++ b/examples/pass.an-unnamed-run.json @@ -269,6 +269,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -310,7 +311,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -339,7 +341,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/examples/pass.json b/examples/pass.json index 5cefd3b5..fa84d7dd 100644 --- a/examples/pass.json +++ b/examples/pass.json @@ -264,6 +264,7 @@ "can_write": true }, "type": "Task", + "type_events": [], "label_names": [ "kernel", "factory:triage" @@ -305,7 +306,8 @@ "empty": false, "label_events": [], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] }, { "number": 140, @@ -334,7 +336,8 @@ } ], "closed_issue_at": null, - "draft_events": [] + "draft_events": [], + "link_events": [] } ], "role_comments": [ diff --git a/kernel/issue.py b/kernel/issue.py index bd5959b5..41d15d2d 100644 --- a/kernel/issue.py +++ b/kernel/issue.py @@ -40,6 +40,9 @@ DRAFTED = "converted_to_draft" READIED = "ready_for_review" DRAFT_ACTIONS = ("converted_to_draft", "ready_for_review") +# The issue's timeline events that link a pull request to it by hand and unlink it (trial P-61), as the facts name them. +CONNECTED = "connected" +LINK_ACTIONS = ("connected", "disconnected") # The kinds a declared Factory App may be read as: the Bot account of an App, a Lens's or any other. APP_KINDS = ("app", "lens", "queue") # What GitHub says of an issue: open, or closed. @@ -186,7 +189,29 @@ def readable_link(entry, config: dict) -> bool: closer = entry.get("closed_issue_at", "") if closer is not None and not (is_time(closer) and merged_on is not None and merged_on <= closer): return False - return readable_draft_events(entry) and readable_events(entry.get("label_events"), config) + return readable_draft_events(entry) and readable_link_events(entry) and readable_events(entry.get("label_events"), config) + + +def readable_link_events(entry: dict) -> bool: + """Whether a pull request's links to the issue by hand and unlinkings can be read: each one or the other, with its + time, in the order the issue's timeline lists them, two in one second among them; each a change of the link, so the + two alternate and the first is a link; none before the pull request opened, which nothing can link. None at all is + a link by a closing keyword, which raises no event (trials/P-33.json), and is read as carrying no time (issue 551).""" + events = entry.get("link_events") + if not isinstance(events, list): + return False + if not all(isinstance(ev, dict) and ev.get("action") in LINK_ACTIONS and is_time(ev.get("at")) for ev in events): + return False + if events and (events[0]["action"] != CONNECTED or entry["opened_at"] > events[0]["at"]): + return False + return not any(later["at"] < ev["at"] or later["action"] == ev["action"] for ev, later in zip(events, events[1:])) + + +def linked_by_hand_before(entry: dict, when: str) -> bool: + """Whether a pull request stood linked to the issue by hand before `when`'s second: its last link or unlinking + before it is a link. One linked in that second, or by a closing keyword alone, carries no order against it.""" + before = [ev for ev in entry["link_events"] if ev["at"] < when] + return bool(before) and before[-1]["action"] == CONNECTED def readable_draft_events(entry: dict) -> bool: @@ -227,7 +252,28 @@ def readable_names(names) -> bool: def readable_type(facts: dict) -> bool: """The issue's type, a name or null where it has none, given and never defaulted: the Consumer entry may leave it out.""" - return "type" in facts and (facts["type"] is None or (isinstance(facts["type"], str) and facts["type"] != "")) + if not ("type" in facts and (facts["type"] is None or (isinstance(facts["type"], str) and facts["type"] != ""))): + return False + return readable_type_events(facts) + + +def readable_type_events(facts: dict) -> bool: + """Whether the changes of the issue's type can be read: each the type before and after, a name or null for none, + and a change, with its time, in the order the timeline lists them; each from the type the last left, and the last + leaving the type as it is now (issue 551). Row 1 does not read them at the merge while the Consumer entry has no history.""" + events = facts.get("type_events") + if not isinstance(events, list): + return False + names = ("from", "to") + if not all(isinstance(ev, dict) and is_time(ev.get("at")) and all( + name in ev and (ev[name] is None or (isinstance(ev[name], str) and ev[name] != "")) for name in names) + for ev in events): + return False + if any(later["at"] < ev["at"] or later["from"] != ev["to"] for ev, later in zip(events, events[1:])): + return False + if any(ev["from"] == ev["to"] for ev in events): # each changes the type, as a link event changes the link + return False + return not events or events[-1]["to"] == facts["type"] def readable_issues(config: dict) -> bool: @@ -481,6 +527,9 @@ def derive(facts: dict, runs: list, config: dict) -> dict: return out(None, "label_unreadable", []) if not (isinstance(facts.get("linked"), list) and all(readable_link(p, config) for p in facts["linked"])): return out(None, "linked_unreadable", []) + # Each pull request once: one listed twice would count as two links (issue 551). + if len({p["number"] for p in facts["linked"]}) != len(facts["linked"]): + return out(None, "linked_unreadable", []) if not (is_time(facts.get("life_started_at")) and facts.get("reopened_last_by") in REOPENED_BY and readable_reopening(facts)): return out(None, "life_unreadable", []) @@ -530,17 +579,30 @@ def derive(facts: dict, runs: list, config: dict) -> dict: # terminal State is left by a fact with no time: the issue's own label events, and the Hold. A person's # hands-off on the issue stands until a person removes it, and the merge then reads on; one removed since still # lets a Hold beside it pass, as the Factory leaves a queued item to merge under it. A linked draft's labels are - # not read: its flag at the merge can be read from its timeline, but when it became linked cannot, so neither - # its Hold nor its hands-off is placed at the merge (decision 0141). The Consumer entry, whose configuration has - # no history, a left-out label or type, and two links carry no time in the facts and read as before: "Trial - # P-61: an issue's type changes and a pull request linked by hand, each timed on the issue's timeline" (issue - # 551) and "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the + # not read: its flag at the merge can be read from its timeline, but when it became linked by a keyword cannot, + # so neither its Hold nor its hands-off is placed at the merge (decision 0141). The Consumer entry, whose + # configuration has no history, and a left-out label or type carry no time that row 1 reads, and read as + # before: "Facts: a closing keyword's link timed by the first pass that saw it, and the Consumer entry at the # merge (after the first cut)" (issue 552). past = as_of(facts, first) if hands_off_stood(facts, first): return out(t.NOT_SERVED, "merged_while_hands_off", merges) if stopped_before(facts, first) and not placed_by_a_person(standing_labels(past), past): return out(t.HELD, "merged_while_held", merges) + # Two pull requests linked by hand before the first merge's second, neither closed before it, were two links at + # it: the issue stood held (row 4), and the merge leaves it so (issue 551). Each is read as it stood at the + # merge: an outsider's only where a person had admitted it by then, so no admission after the merge moves a + # done issue. One unlinked at or after the merge lets the merge read on for good, as a Hold's removal does: a + # link made again since moves nothing. Who unlinked it carries no actor until trial P-61 shows who can. A link + # in the merge's second, or one with no time, a closing keyword's, reads as before: the Factory's merge beside + # another in its second is done (decision 0132). + stood = [str(p["number"]) for p in facts["linked"] if is_linked(as_of(p, first)) and linked_by_hand_before(p, first) + and not any(ev["action"] != CONNECTED and ev["at"] >= first for ev in p["link_events"]) + and (p["closed_at"] is None or p["closed_at"] >= first)] + # A person's hands-off that stood beside them excuses them, as it excuses a Hold: the issue stood not served then + # (row 3 before row 4), and the hands-off, removed since, lets the merge read on. + if len(stood) > 1 and not placed_by_a_person(standing_labels(past), past): + return out(t.HELD, "merged_while_held", stood) # Done only where the Factory's merge or a person's, in the first merge's second, closed the issue; where # only an outsider's did, a person decides whether the work is done (issue 280). closers = [p for p in merged if p["merged_at"] == first and p["closed_issue_at"] is not None] diff --git a/schemas/issue-facts.schema.json b/schemas/issue-facts.schema.json index ceab13b2..78728500 100644 --- a/schemas/issue-facts.schema.json +++ b/schemas/issue-facts.schema.json @@ -10,6 +10,7 @@ "closed_first_at", "author", "type", + "type_events", "label_names", "assignees", "life_started_at", @@ -79,6 +80,38 @@ ], "minLength": 1 }, + "type_events": { + "description": "Each setting, change and removal of the issue's type on its timeline, IssueTypeAddedEvent, IssueTypeChangedEvent and IssueTypeRemovedEvent, as the type before and after, null for none, with its time, in the order the timeline lists them. Each follows from the last, and the last leaves the type as it is now. Row 1 does not read the type at the merge while the Consumer entry has no history (the Factory Builder's ruling (b) on issue 541); its time is carried for when it does (grounding pending trial P-61, issue 551)", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "to", + "at" + ], + "properties": { + "from": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "to": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "at": { + "$ref": "#/$defs/time" + } + } + } + }, "label_names": { "description": "The name of every label the issue carries now, which the Consumer entry may leave out. The control labels are read from label_events alone", "type": "array", @@ -149,7 +182,8 @@ "empty", "label_events", "closed_issue_at", - "draft_events" + "draft_events", + "link_events" ], "properties": { "number": { @@ -237,6 +271,29 @@ } } } + }, + "link_events": { + "description": "Each link of it to the issue by hand, in the issue's Development box, and each unlinking, as the issue's timeline gives them, ConnectedEvent and DisconnectedEvent, with its time, in the order the timeline lists them. A link by a closing keyword raises none (trials/P-33.json): one with no event is read as before, with no time. Each changes the link, so the two alternate and the first is a link, none before it opened. Row 1 reads two pull requests linked so before the first merge's second as two links at it (grounding pending trial P-61, issue 551)", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "at" + ], + "properties": { + "action": { + "enum": [ + "connected", + "disconnected" + ] + }, + "at": { + "$ref": "#/$defs/time" + } + } + } } } } diff --git a/sim/grounding.py b/sim/grounding.py index 9f61569d..0510c4df 100644 --- a/sim/grounding.py +++ b/sim/grounding.py @@ -268,6 +268,13 @@ ("a_draft_change_is_timed", "A pull request's timeline gives each conversion to a draft and each marking ready as an " "event of its own, with its time to the second; one opened as a draft has no event for it", ("P-33",), "trials/P-33.json", None), + # Trial P-61 (issue 551) asks these; until its fixture is recorded the world links nothing by hand and sets no type, + # so it gives none of either. + ("a_link_by_hand_is_timed", "An issue's timeline gives each link of a pull request by hand, in its Development box, " + "and each unlinking, as an event of its own with its time to the second; a link by a closing keyword raises none", + ("P-61",), None, None), + ("a_type_change_is_timed", "An issue's timeline gives each setting, change and removal of its type as an event of " + "its own with its time to the second, and the type before and after", ("P-61",), None, None), # trials/P-46.json: an edit to a merged pull request's description after the merge adds the issue its new keyword # names to closingIssuesReferences, and closes nothing: the issue stays open and its timeline gains no close; undoing # the edit takes it out again. trials/P-33.json: the merge's own close of the issue names the pull request as the @@ -449,6 +456,11 @@ }, "who_changed_the_draft_flag_is_read": {"P-33": "Can who last changed the draft flag be read?"}, "a_draft_change_is_timed": {"P-33": "Can who last changed the draft flag be read?"}, + "a_link_by_hand_is_timed": { + "P-61": "each link of a pull request by hand in its Development box and its unlinking, as an event of its own " + "with its time to the second", + }, + "a_type_change_is_timed": {"P-61": "Does an issue's timeline give each change of its type"}, "a_merge_closes_the_issue_it_names_and_an_edit_after_it_closes_nothing": { "P-33": "Can the issue a pull request closes be read from a draft, an open, a merged and a closed-unmerged " "pull request alike", diff --git a/sim/issues.py b/sim/issues.py index b29ec938..afdd3102 100644 --- a/sim/issues.py +++ b/sim/issues.py @@ -186,7 +186,8 @@ def facts(self) -> dict: # GitHub: an_issues_close_and_reopening_say_when "closed_first_at": None if self.closed is None else World.at(self.closed), "author": dict(self.author), - "type": self.type, "label_names": list(self.label_names), + # GitHub: a_type_change_is_timed + "type": self.type, "type_events": [], "label_names": list(self.label_names), "assignees": [], # GitHub: a_reopening_names_its_actor "life_started_at": World.at(self.life_started), "reopened_last_by": self.reopened_last_by, @@ -197,6 +198,7 @@ def facts(self) -> dict: "label_events": copy.deepcopy(self.events), # GitHub: a_closing_reference_is_read_from_any_pull_request # GitHub: who_changed_the_draft_flag_is_read + # GitHub: a_link_by_hand_is_timed # GitHub: a_merge_closes_the_issue_it_names_and_an_edit_after_it_closes_nothing "linked": copy.deepcopy(self.linked), "role_comments": copy.deepcopy(self.comments), @@ -515,7 +517,7 @@ def ending(self, run: dict): self.linked.append({"number": number, "class": "agent", "opener": dict(AUTHOR_APP), "open": True, "draft": True, "opened_at": self.now(), "merged_at": None, "closed_at": None, "empty": False, "label_events": [], "closed_issue_at": None, - "draft_events": []}) + "draft_events": [], "link_events": []}) self.branch_head = "%040x" % (0xabc0 + number) self.moves() self.note("run %s: opened the draft #%d" % (run["run"], number)) @@ -557,7 +559,7 @@ def platform(self): self.linked.append({"number": 200 + len(self.linked), "class": "outside", "opener": dict(STRANGER), "open": True, "draft": self.rng.random() < 0.5, "opened_at": self.now(), "merged_at": None, "closed_at": None, "empty": False, "label_events": [], "closed_issue_at": None, - "draft_events": []}) + "draft_events": [], "link_events": []}) self.reached.append(2) self.note("a stranger's pull request names the issue") self.caused(STRANGER_BY) @@ -702,7 +704,7 @@ def outsider_acts(self) -> str: self.linked.append({"number": number, "class": "agent", "opener": dict(AUTHOR_APP), "open": False, "draft": False, "opened_at": merged, "merged_at": merged, "closed_at": merged, "empty": False, "label_events": [], "closed_issue_at": None, - "draft_events": []}) + "draft_events": [], "link_events": []}) self.late.append(number) self.reached.append(6) self.note("an edit after its merge made #%d name the issue" % number) @@ -737,7 +739,7 @@ def on_the_pull_request(self, who: dict): self.linked.append({"number": 300 + len(self.linked), "class": "maintainer", "opener": dict(who), "open": True, "draft": self.rng.random() < 0.5, "opened_at": self.now(), "merged_at": None, "closed_at": None, "empty": False, "label_events": [], "closed_issue_at": None, - "draft_events": []}) + "draft_events": [], "link_events": []}) self.writer_acted() self.note("%s opened a pull request of their own" % who["name"]) return diff --git a/tests/test_issue.py b/tests/test_issue.py index 80328dc7..27c192db 100644 --- a/tests/test_issue.py +++ b/tests/test_issue.py @@ -69,11 +69,12 @@ def unlabelled(label: str, by=None, minute: int = 2) -> dict: def pr(number=11, by=None, cls="maintainer", is_open=True, draft=False, merged=None, closed=None, empty=False, - events=(), opened=0, closed_issue="merge", drafts=()) -> dict: + events=(), opened=0, closed_issue="merge", drafts=(), links=()) -> dict: """A pull request that closes the issue. By default the Maintainer's, opened with the issue, open and ready. A merged one closed then, and closed the issue in its second, as the issue's timeline records it (P-33); `closed_issue=None` is one the issue's timeline never names, as one whose reference an edit added (P-46). `drafts` - are its conversions to a draft and its markings ready, as (action, minute), the last of which is its flag now.""" + are its conversions to a draft and its markings ready, as (action, minute), the last of which is its flag now; `links` + its links to the issue by hand and unlinkings, as (action, minute), none by default, as a closing keyword's.""" if merged is not None and closed is None: closed = merged if closed_issue == "merge": @@ -82,7 +83,8 @@ def pr(number=11, by=None, cls="maintainer", is_open=True, draft=False, merged=N "draft": draft, "opened_at": at(opened), "merged_at": None if merged is None else at(merged), "closed_at": None if closed is None else at(closed), "empty": empty, "label_events": list(events), "closed_issue_at": None if closed_issue is None else at(closed_issue), - "draft_events": [{"action": action, "at": at(minute)} for action, minute in drafts]} + "draft_events": [{"action": action, "at": at(minute)} for action, minute in drafts], + "link_events": [{"action": action, "at": at(minute)} for action, minute in links]} def agents(**over) -> dict: @@ -98,7 +100,7 @@ def strangers(admitted=False, **over) -> dict: def facts(**over) -> dict: """An open issue the Maintainer opened at minute 0, with no label and no pull request, read in full.""" - f = {"schema": "issue-facts/1", "item": dict(ITEM), "state": "open", "author": dict(WRITER), "type": None, "label_names": [], "assignees": [], + f = {"schema": "issue-facts/1", "item": dict(ITEM), "state": "open", "author": dict(WRITER), "type": None, "type_events": [], "label_names": [], "assignees": [], "life_started_at": at(0), "reopened_last_by": "never", "content_id": THIS, "content_changed_at": at(0), "label_events": [], "linked": [], "role_comments": [], "last_writer_action_at": at(0), "last_writer_comment_at": None, "acts_since_writer": 0, "branch_head": None, @@ -324,6 +326,18 @@ def test_an_unreadable_linked_pull_request_defers(self): dict(good, draft_events=["ready_for_review"]), dict(good, opened_at=at(2), draft_events=[{"action": "converted_to_draft", "at": at(1)}, {"action": "ready_for_review", "at": at(3)}]), + # Its links by hand and unlinkings (issue 551): each one or the other with its time, alternating, the + # first a link, none before it opened, in order. + dict(good, link_events=None), dict(good, link_events=["connected"]), + dict(good, link_events=[{"action": "linked", "at": at(3)}]), + dict(good, link_events=[{"action": "connected", "at": "2026-10-11"}]), + dict(good, link_events=[{"action": "connected"}]), + dict(good, link_events=[{"action": "disconnected", "at": at(3)}]), + dict(good, link_events=[{"action": "connected", "at": at(3)}, {"action": "connected", "at": at(4)}]), + dict(good, link_events=[{"action": "connected", "at": at(4)}, {"action": "disconnected", "at": at(3)}]), + dict(good, opened_at=at(2), link_events=[{"action": "connected", "at": at(1)}]), + dict(good, link_events=[{"action": "connected", "at": "2026-10-11T12:30:00"}]), + dict(good, link_events=[{"action": "connected", "at": at(3)}, {"action": "linked", "at": at(4)}]), ] no_class = dict(good) del no_class["class"] @@ -332,11 +346,35 @@ def test_an_unreadable_linked_pull_request_defers(self): d = derive(f) self.assertEqual((d["state"], d["reason"]), (None, "linked_unreadable"), entry) self.assertEqual(derive(facts(linked=None))["reason"], "linked_unreadable") + # One pull request listed twice, which would be two links (issue 551). + self.assertEqual(derive(facts(linked=[pr(), pr()]))["reason"], "linked_unreadable") # The other side: the first number, closed unmerged, merged, and a stranger's pull request are read. self.assertEqual(state(facts(linked=[pr(number=1)])), "in_review") self.assertEqual(state(facts(linked=[pr(is_open=False, closed=5)])), "new") self.assertEqual(state(facts(linked=[pr(is_open=False, merged=5)])), "done") self.assertEqual(state(facts(linked=[strangers()])), "new") + # A link by hand in its opening second, unlinked and linked again in one second, is read (issue 551). + both = pr(links=[("connected", 0), ("disconnected", 3), ("connected", 3)]) + self.assertEqual(state(facts(linked=[both])), "in_review") + + def test_an_issues_type_changes_are_read_whole_or_defer(self): # issue 551 + # Each change of the type, the type before and after with its time, each from the last, the last the type now. + task, bug = {"from": None, "to": "Task", "at": at(2)}, {"from": "Task", "to": "Bug", "at": at(3)} + for over in ({"type_events": None}, {"type_events": ["Task"]}, {"type": "Task", "type_events": [dict(task, at="soon")]}, + {"type": "Task", "type_events": [{"to": "Task", "at": at(2)}]}, + {"type": "Task", "type_events": [dict(task, to="")]}, + {"type": "Bug", "type_events": [dict(bug, at=at(1)), task]}, + {"type": "Bug", "type_events": [task, dict(bug, **{"from": "Feature"})]}, + {"type": "Bug", "type_events": [task]}, {"type": None, "type_events": [task]}, + {"type": "Task", "type_events": [dict(task, **{"from": 5})]}, + {"type": "Bug", "type_events": [dict(task, at=at(3)), dict(bug, at=at(2))]}, + {"type": "Task", "type_events": [task, dict(task, **{"from": "Task", "at": at(2)})]}): + d = derive(facts(**over)) + self.assertEqual((d["state"], d["reason"]), (None, "type_unreadable"), over) + # The other side: none with no type, the chain to the type now, and a removal that leaves none. + for over in ({}, {"type": "Bug", "type_events": [task, bug]}, + {"type": None, "type_events": [task, {"from": "Task", "to": None, "at": at(3)}]}): + self.assertEqual(derive(facts(**over))["reason"], "acceptance_due", over) def test_draft_events_in_the_opening_second_and_after_the_close_are_read(self): # issue 554 # Opened as a draft and marked ready in that second: a chronology, read whole. One after the close is read too: @@ -884,6 +922,53 @@ def test_an_outsiders_merge_never_makes_an_issue_done(self): # issue 280, trial self.assertEqual(derive(facts(linked=[dict(outsider, closed_issue_at=None)], **closed))["reason"], "merged_while_declined") + def test_two_links_by_hand_before_the_merge_leave_the_issue_held(self): # issue 551, decision 0132 + # Two pull requests linked by hand before the first merge's second, neither closed before it, were two links at + # it: the issue stood held, and the merge leaves it held, whichever was linked first. + closed = dict(state="closed", closed_first_at=at(5)) + own = agents(number=11, is_open=False, merged=5, links=[("connected", 2)]) + for rival in (pr(number=12, links=[("connected", 3)]), pr(number=12, links=[("connected", 1)]), + pr(number=12, is_open=False, closed=5, links=[("connected", 3)]), + pr(number=12, links=[("connected", 1), ("disconnected", 2), ("connected", 4)])): + d = derive(facts(linked=[own, rival], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("held", "merged_while_held", ["11", "12"]), rival) + # Two queue merges in one second, each linked by hand before it, leave it held too. + other = agents(number=12, is_open=False, merged=5, links=[("connected", 3)]) + d = derive(facts(linked=[own, other], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("held", "merged_while_held", ["11", "12"])) + # The other side: a rival linked in the merge's second, with no time (a closing keyword's), linked after the + # merge, unlinked before it, or closed before it, is no second link at it; nor is the merge's own link made in + # its second. The Factory's merge is done (decision 0132). + for rival in (pr(number=12, links=[("connected", 5)]), pr(number=12), pr(number=12, links=[("connected", 6)]), + pr(number=12, links=[("connected", 3), ("disconnected", 4)]), + # Unlinked before the merge and linked again after it: no link at it. + pr(number=12, links=[("connected", 3), ("disconnected", 4), ("connected", 7)]), + pr(number=12, is_open=False, closed=4, links=[("connected", 3)])): + d = derive(facts(linked=[own, rival], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("done", "merged", ["11"]), rival) + late = agents(number=11, is_open=False, merged=5, links=[("connected", 5)]) + d = derive(facts(linked=[late, pr(number=12, links=[("connected", 3)])], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("done", "merged", ["11"])) + # Each is read as it stood at the merge: an outsider's admitted by then holds it, one admitted after does not, so + # no admission after the merge moves a done issue. + admitted = strangers(number=13, events=[labelled(ADMIT, minute=4)], links=[("connected", 3)]) + d = derive(facts(linked=[own, admitted], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("held", "merged_while_held", ["11", "13"])) + later = strangers(number=13, events=[labelled(ADMIT, minute=9)], links=[("connected", 3)]) + d = derive(facts(linked=[own, later], **closed)) + self.assertEqual((d["state"], d["reason"], d["evidence"]), ("done", "merged", ["11"])) + # A person's hands-off that stood beside them at the merge, removed since, excuses them, as it excuses a Hold. + excused = dict(closed, label_events=[labelled(HANDS_OFF, minute=4), unlabelled(HANDS_OFF, minute=6)]) + d = derive(facts(linked=[own, pr(number=12, links=[("connected", 3)])], **excused)) + self.assertEqual((d["state"], d["reason"]), ("done", "merged")) + # An unlinking of the rival after the merge, or in its second, lets the merge read on for good, as a Hold's + # removal does: linked again since, it holds nothing, so no one moves a done issue back and forth. + for links, held in (([("connected", 3), ("disconnected", 7)], False), ([("connected", 3), ("disconnected", 5)], False), + ([("connected", 3), ("disconnected", 7), ("connected", 8)], False), + ([("connected", 3), ("disconnected", 7), ("connected", 8), ("disconnected", 9), ("connected", 10)], False)): + d = derive(facts(linked=[own, pr(number=12, links=links)], **closed)) + self.assertEqual(d["state"], "held" if held else "done", links) + def test_an_empty_merge_is_not_done(self): self.assertEqual(state(facts(linked=[pr(is_open=False, merged=5, empty=True)])), "new") self.assertEqual(state(facts(linked=[pr(is_open=False, merged=5, empty=False)])), "done") diff --git a/tests/test_issue_simulator.py b/tests/test_issue_simulator.py index 3a5d9ab7..9c043618 100644 --- a/tests/test_issue_simulator.py +++ b/tests/test_issue_simulator.py @@ -55,7 +55,7 @@ def agents_draft(w: IssueWorld) -> dict: """The Factory's draft pull request, linked to the issue.""" return {"number": 100, "class": "agent", "opener": dict(issues.AUTHOR_APP), "open": True, "draft": True, "opened_at": w.now(), "merged_at": None, "closed_at": None, "empty": False, "label_events": [], - "closed_issue_at": None, "draft_events": []} + "closed_issue_at": None, "draft_events": [], "link_events": []} def after(trace: list, first: str, then: str) -> bool: diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 55076034..364356ac 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -1200,17 +1200,29 @@ def change(item): return a_writers(change) -def opens_own(draft: bool): - """A person opens a pull request of their own that closes the issue, a draft or ready; one at a time.""" +def opens_own(draft: bool, by_hand: bool = False): + """A person opens a pull request of their own that closes the issue, a draft or ready; one at a time. By hand, it + is linked in the issue's Development box as it opens, and its link carries that time (issue 551).""" def change(item): f = item["facts"] if any(p["open"] and p["class"] == "maintainer" for p in f["linked"]): return None - f["linked"].append(dict(issue_facts.pr(number=30 + len(f["linked"]), draft=draft), opened_at=now(item))) + f["linked"].append(dict(issue_facts.pr(number=30 + len(f["linked"]), draft=draft), opened_at=now(item), + link_events=[{"action": issue.CONNECTED, "at": now(item)}] if by_hand else [])) return item return a_writers(change) +def links_by_hand(item: dict): + """A person links the Factory's open pull request to the issue by hand as well as by its closing keyword, which + carries no time: the link then carries one (issue 551).""" + live = [p for p in item["facts"]["linked"] if p["open"] and p["class"] == "agent" and not p["link_events"]] + if not live: + return None + live[0]["link_events"].append({"action": issue.CONNECTED, "at": now(item)}) + return item + + def closes_issue(item: dict): """A person closes the issue: the first close of its present life, since no change here reopens one.""" if item["facts"]["state"] != "open": @@ -1236,13 +1248,17 @@ def queue_merges_issue(item: dict, kind: str = "agent"): may name in its description, after the approval, an issue the Controller does not watch, so it merges while that issue is new too (P-48, issue 280). It merges too where row 1 reads the State at the merge, which then stays, since only a person ends it (issue 522): held by a Hold on the issue, the race of a pass that has not yet dequeued; and - not served by a person's hands-off on the issue, under which the Factory leaves a queued item to merge. Under a - linked draft's Hold or hands-off, from two links or on an issue the Consumer entry leaves out, row 1 waits on the - time a link was made (decision 0141; issues 551 and 552).""" + not served by a person's hands-off on the issue, under which the Factory leaves a queued item to merge; and held + by two links each made by hand, which carry their times (issue 551). Under a linked draft's Hold or hands-off, from + two links one of which is a closing keyword's alone, or on an issue the Consumer entry leaves out, row 1 waits on + the time a link was made (decision 0141, issue 552).""" live = [p for p in item["facts"]["linked"] if p["open"] and p["class"] == kind and not p["draft"]] d = issue_kernel(item) + by_hand = [p for p in item["facts"]["linked"] if p["open"] and p["link_events"] + and p["link_events"][-1]["action"] == issue.CONNECTED] kept = (d["reason"] == "human_hold" and issue_stands(item, t.LABEL_HOLD)) or \ - (d["reason"] == "hands_off" and issue_stands(item, t.LABEL_HANDS_OFF)) + (d["reason"] == "hands_off" and issue_stands(item, t.LABEL_HANDS_OFF)) or \ + (d["reason"] == "linked_twice" and len(by_hand) > 1) if not live or not (d["state"] in (("in_review", "new") if kind == "outside" else ("in_review",)) or kept): return None live[0].update(open=False, closed_at=now(item), merged_at=now(item), closed_issue_at=closes_the_issue(item)) @@ -1343,6 +1359,8 @@ def change(item): Move("a person merges the linked pull request with an empty diff", "human", linked_pull_request("empty")), Move("a person opens a draft of their own", "human", opens_own(True)), Move("a person opens a pull request of their own", "human", opens_own(False)), + Move("a person opens a pull request of their own, linked by hand", "human", opens_own(False, by_hand=True)), + Move("a person links the Factory's pull request by hand", "human", a_writers(links_by_hand)), Move("the author App marks its draft ready", "factory", app_marks_ready), Move("the queue merges the Factory's pull request", "platform", queue_merges_issue), Move("the queue merges an admitted outsider's pull request", "platform", @@ -1439,6 +1457,11 @@ def test_the_queues_merge_from_held_or_not_served_keeps_the_state(self): # issu # person's removal of hands-off then lets the merge read on to done. made, reached = issue_explored() self.assertLessEqual({("not_served", "merged_while_hands_off"), ("held", "merged_while_held")}, reached) + # It merges from held by two links each made by hand too, and the State stays (issue 551): no platform exit from + # held, as asserted below. Held by two links is reached before the merge as well, so this alone does not show the + # merge; the mutant "two links by hand at the merge are read as before, in the exploration" does, which only a + # merge from it kills. + self.assertIn(("held", "linked_twice"), reached) self.assertIn(("merged_while_hands_off", "merged"), made[("not_served", "done", "human")]) self.assertEqual([m for m in made if m[0] in (t.HELD, t.NOT_SERVED) and m[2] == "platform"], []) diff --git a/tools/mutants.py b/tools/mutants.py index 2e555c39..39ed7ac0 100644 --- a/tools/mutants.py +++ b/tools/mutants.py @@ -268,7 +268,7 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ("an issue's unreadable type is trusted", ISSUE_PY, " if not readable_type(facts):\n", " if False:\n", ISSUE_TESTS), ("an issue's missing type is read as none", ISSUE_PY, - ' return "type" in facts and (', " return (", ISSUE_TESTS), + ' if not ("type" in facts and (', " if not ((", ISSUE_TESTS), ("an issues block that cannot be read is trusted", ISSUE_PY, " if not readable_issues(config):\n", " if False:\n", ISSUE_TESTS), ("an issues block's date is not read", ISSUE_PY, @@ -308,7 +308,7 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: '"other" and facts["reopened_last_at"] <= first:', '"other":', MERGE_UNDER_STOP_TESTS), ('a merge while a Hold stands is done', ISSUE_PY, ' if stopped_before(facts, first) and not placed_by_a_person(standing_labels(past), past):\n', ' if False:\n', MERGE_UNDER_STOP_TESTS), ("a merge under a person's hands-off is a Hold", ISSUE_PY, ' if stopped_before(facts, first) and not placed_by_a_person(standing_labels(past), past):\n', ' if stopped_before(facts, first):\n', MERGE_UNDER_STOP_TESTS), - ("a person's hands-off placed after the merge excuses the Hold that stood", ISSUE_PY, 'not placed_by_a_person(standing_labels(past), past):', 'not a_persons_hands_off(labels, facts, drafts):', MERGE_UNDER_STOP_TESTS), + ("a person's hands-off placed after the merge excuses the Hold that stood", ISSUE_PY, 'stopped_before(facts, first) and not placed_by_a_person(standing_labels(past), past):', 'stopped_before(facts, first) and not a_persons_hands_off(labels, facts, drafts):', MERGE_UNDER_STOP_TESTS), ("a person's hands-off lifted since the merge excuses nothing", ISSUE_PY, ' past = as_of(facts, first)\n if hands_off_stood', ' past = facts\n if hands_off_stood', MERGE_UNDER_STOP_TESTS), ("a person's hands-off placed in the merge's second did not stand at it", ISSUE_PY, ' if ev["label"] in t.CONTROL_LABELS and ev["at"] <= when])\n', @@ -346,6 +346,99 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ' if stopped_before(facts, first) and not any(placed_by_a_person(standing_labels(as_of(q, first)), as_of(q, first))\n' ' for q in [facts] + [p for p in links if p["opened_at"] <= first]):\n', ("tests.test_issue.Done.test_a_merge_read_while_a_person_closed_or_held_the_issue_is_never_done",)), + # Issue 551: a link by hand and a type change, each read whole with its time, and two links by hand before the + # first merge's second leave the issue held (decision 0132's same second aside). + ("a pull request's link events are not read", ISSUE_PY, + 'readable_draft_events(entry) and readable_link_events(entry) and', 'readable_draft_events(entry) and', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('link events that are no list are read', ISSUE_PY, + ' events = entry.get("link_events")\n if not isinstance(events, list):\n return False\n', ' events = entry.get("link_events") or []\n', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('a link event may be anything', ISSUE_PY, + 'isinstance(ev, dict) and ev.get("action") in LINK_ACTIONS and is_time(ev.get("at"))', 'isinstance(ev, dict) and "at" in ev', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('the first link event may be an unlinking', ISSUE_PY, + 'events[0]["action"] != CONNECTED or ', '', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('a link may be made before the pull request opened', ISSUE_PY, + ' or entry["opened_at"] > events[0]["at"])', ')', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('a link in the opening second is refused', ISSUE_PY, + 'entry["opened_at"] > events[0]["at"]', 'entry["opened_at"] >= events[0]["at"]', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('link events may be out of order', ISSUE_PY, + 'return not any(later["at"] < ev["at"] or later["action"] == ev["action"] for', 'return not any(later["action"] == ev["action"] for', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('a link event may repeat the last', ISSUE_PY, + 'return not any(later["at"] < ev["at"] or later["action"] == ev["action"] for', 'return not any(later["at"] < ev["at"] for', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ("an issue's type events are not read", ISSUE_PY, + ' return readable_type_events(facts)\n', ' return True\n', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('type events that are no list are read', ISSUE_PY, + ' events = facts.get("type_events")\n if not isinstance(events, list):\n return False\n', ' events = facts.get("type_events") or []\n', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ("a type event's time may be anything", ISSUE_PY, + 'isinstance(ev, dict) and is_time(ev.get("at")) and all(', 'isinstance(ev, dict) and all(', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ("a type event's names may be anything", ISSUE_PY, + 'name in ev and (ev[name] is None or (isinstance(ev[name], str) and ev[name] != "")) for name in names)', 'name in ev for name in names)', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('type events may be out of order', ISSUE_PY, + 'if any(later["at"] < ev["at"] or later["from"] != ev["to"]', 'if any(later["from"] != ev["to"]', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('a type event may not follow the last', ISSUE_PY, + 'if any(later["at"] < ev["at"] or later["from"] != ev["to"]', 'if any(later["at"] < ev["at"]', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('the last type event may leave another type', ISSUE_PY, + ' return not events or events[-1]["to"] == facts["type"]\n', ' return True\n', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('two links by hand at the merge are not read', ISSUE_PY, + ' if len(stood) > 1 and not placed_by_a_person(standing_labels(past), past):\n return out(t.HELD, "merged_while_held", stood)\n', '', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('two links by hand at the merge are read as before, in the exploration', ISSUE_PY, + ' if len(stood) > 1 and not placed_by_a_person(standing_labels(past), past):\n return out(t.HELD, "merged_while_held", stood)\n', + ' if False:\n return out(t.HELD, "merged_while_held", stood)\n', + ('tests.test_transitions.AnyLifecycle.test_the_queues_merge_from_held_or_not_served_keeps_the_state',)), + ('one link by hand is two at the merge', ISSUE_PY, + ' if len(stood) > 1 and', ' if len(stood) > 0 and', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ("a link in the merge's second stands before it", ISSUE_PY, + 'before = [ev for ev in entry["link_events"] if ev["at"] < when]', 'before = [ev for ev in entry["link_events"] if ev["at"] <= when]', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('an unlinking before the merge leaves the link', ISSUE_PY, + 'return bool(before) and before[-1]["action"] == CONNECTED', 'return bool(before)', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('a link with no time stands before the merge', ISSUE_PY, + 'return bool(before) and before[-1]["action"] == CONNECTED', 'return not before or before[-1]["action"] == CONNECTED', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('a pull request closed before the merge is a link at it', ISSUE_PY, + ' and (p["closed_at"] is None or p["closed_at"] >= first)]', ']', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ("a pull request closed in the merge's second is no link at it", ISSUE_PY, + 'p["closed_at"] >= first)]', 'p["closed_at"] > first)]', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('an admission after the merge counts toward two links', ISSUE_PY, + 'if is_linked(as_of(p, first))', 'if is_linked(p)', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('a link unlinked since the merge still holds', ISSUE_PY, + ' and not any(ev["action"] != CONNECTED and ev["at"] >= first for ev in p["link_events"])\n', ' and True\n', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('an unlinking in the merge\'s second leaves the link', ISSUE_PY, + 'ev["action"] != CONNECTED and ev["at"] >= first for ev in p["link_events"])', 'ev["action"] != CONNECTED and ev["at"] > first for ev in p["link_events"])', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('a link made again after an unlinking since the merge holds again', ISSUE_PY, + 'and not any(ev["action"] != CONNECTED and ev["at"] >= first for ev in p["link_events"])', 'and p["link_events"][-1]["action"] == CONNECTED', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), + ('a pull request listed twice is read', ISSUE_PY, + ' if len({p["number"] for p in facts["linked"]}) != len(facts["linked"]):\n return out(None, "linked_unreadable", [])\n', '', + ('tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers',)), + ('a type change that changes nothing is read', ISSUE_PY, + ' if any(ev["from"] == ev["to"] for ev in events): # each changes the type, as a link event changes the link\n return False\n', '', + ('tests.test_issue.Unreadable.test_an_issues_type_changes_are_read_whole_or_defer',)), + ('a person\'s hands-off beside two links does not excuse them', ISSUE_PY, + ' and not placed_by_a_person(standing_labels(past), past):\n return out(t.HELD, "merged_while_held", stood)', ':\n return out(t.HELD, "merged_while_held", stood)', + ('tests.test_issue.Done.test_two_links_by_hand_before_the_merge_leave_the_issue_held',)), # Issue 554: a draft event before its pull request opened is no chronology. ("a draft event before the pull request opened is read", ISSUE_PY, ' if events and events[0]["at"] < entry["opened_at"]:\n return False\n', '', @@ -361,9 +454,9 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ' if events and (events[0]["at"] < entry["opened_at"] or (entry["closed_at"] is not None and events[-1]["at"] > entry["closed_at"])):\n', ("tests.test_issue.Unreadable.test_draft_events_in_the_opening_second_and_after_the_close_are_read",)), ("two draft events of one kind in a row are read", ISSUE_PY, - ' or later["action"] == ev["action"] for ev, later', ' for ev, later', ("tests.test_issue.Done.test_a_linked_drafts_labels_leave_a_done_issue_done",)), + ' or later["action"] == ev["action"] for ev, later in zip(events, events[1:])):\n return False\n return not events or (events[-1]', ' for ev, later in zip(events, events[1:])):\n return False\n return not events or (events[-1]', ("tests.test_issue.Done.test_a_linked_drafts_labels_leave_a_done_issue_done",)), ("draft events out of order are read", ISSUE_PY, - 'any(later["at"] < ev["at"] or later["action"]', 'any(later["action"]', ("tests.test_issue.Done.test_a_linked_drafts_labels_leave_a_done_issue_done",)), + ' if any(later["at"] < ev["at"] or later["action"]', ' if any(later["action"]', ("tests.test_issue.Done.test_a_linked_drafts_labels_leave_a_done_issue_done",)), ("the last draft event is the latest by time", ISSUE_PY, ' return not events or (events[-1]["action"] == DRAFTED) == entry["draft"]\n', ' return not events or (max(events, key=lambda ev: ev["at"])["action"] == DRAFTED) == entry["draft"]\n', ("tests.test_issue.Done.test_a_linked_drafts_labels_leave_a_done_issue_done",)), @@ -372,8 +465,8 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: ("a draft event with no time is read", ISSUE_PY, ' and ev.get("action") in DRAFT_ACTIONS and is_time(ev.get("at"))', ' and ev.get("action") in DRAFT_ACTIONS', ("tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers",)), ("a linked pull request's draft events are not read", ISSUE_PY, - ' return readable_draft_events(entry) and readable_events(entry.get("label_events"), config)\n', - ' return readable_events(entry.get("label_events"), config)\n', ("tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers",)), + ' return readable_draft_events(entry) and readable_link_events(entry) and readable_events(entry.get("label_events"), config)\n', + ' return readable_link_events(entry) and readable_events(entry.get("label_events"), config)\n', ("tests.test_issue.Unreadable.test_an_unreadable_linked_pull_request_defers",)), # Issue 522: a platform close keeps the State a person set, not served or held, where the facts place it at the # merge; what carries no time leaves no terminal State. ("a person's hands-off at the merge is passed", ISSUE_PY, @@ -411,8 +504,8 @@ def moved_before(rel: str, block: str, anchor: str) -> tuple: " if hands_off_stood(facts, first):\n", ("tests.test_issue.Done.test_a_done_issue_stays_done_whatever_changes_after_the_merge_that_carries_no_time",)), ("two links at the merge are read from the links as they stand now", ISSUE_PY, - " return out(t.HELD, \"merged_while_held\", merges)\n # Done only where", - " return out(t.HELD, \"merged_while_held\", merges)\n" + " return out(t.HELD, \"merged_while_held\", stood)\n # Done only where", + " return out(t.HELD, \"merged_while_held\", stood)\n" " if any(p[\"opened_at\"] <= first and p[\"merged_at\"] != first for p in links):\n" " return out(t.HELD, \"merged_while_held\", merges)\n # Done only where", ("tests.test_issue.Done.test_a_done_issue_stays_done_whatever_changes_after_the_merge_that_carries_no_time",)),