${escapeHtml(it)}" }
+ ?: muted(
+ if (opening) IssueHubBundle["detail.noDescription"] else IssueHubBundle["detail.timeline.emptyComment"],
+ muted,
+ )
+ // Indented to start under its own header rather than out beside the icons.
+ return event(icon, header + edit, muted) + """Renders this.
",""")) + + assertEquals("Renders *this*.", dto.body) + assertEquals("Renders this.
", dto.bodyHtml) + } + + /** List responses use the default media type, so rows decode with no rendered body at all. */ + @Test + fun `a response without the rendered body still decodes`() { + val dto = json.decodeFromStringStill broken.
", + "html_url": "https://github.test/octocat/hello-world/issues/17#issuecomment-1" + } + """, + ) as IssueTimelineItem.Comment + + assertEquals("octocat", item.actor?.login) + assertEquals("2026-07-03T10:00:00Z", item.at) + assertEquals("Still *broken*.", item.body) + assertEquals("Still broken.
", item.bodyHtml) + // An unedited comment is stamped as updated the moment it is written. + assertFalse(item.edited) + } + + @Test + fun `a later update marks a comment as edited`() { + val item = + item( + """ + { + "event": "commented", + "user": {"login": "octocat"}, + "created_at": "2026-07-03T10:00:00Z", + "updated_at": "2026-07-03T11:00:00Z", + "body": "Fixed the typo." + } + """, + ) as IssueTimelineItem.Comment + + assertTrue(item.edited) + } + + @Test + fun `closing carries the reason and the actor who did it`() { + val item = + item( + """ + { + "event": "closed", + "actor": {"login": "maintainer"}, + "created_at": "2026-07-04T09:00:00Z", + "state_reason": "not_planned" + } + """, + ) as IssueTimelineItem.StateChange + + assertEquals(IssueState.CLOSED, item.state) + assertEquals("not_planned", item.reason) + assertEquals("maintainer", item.actor?.login) + } + + @Test + fun `reopening comes back as a state change without a reason`() { + val item = + item("""{"event": "reopened", "actor": {"login": "octocat"}, "created_at": "2026-07-05T09:00:00Z"}""") + as IssueTimelineItem.StateChange + + assertEquals(IssueState.OPEN, item.state) + assertNull(item.reason) + } + + @Test + fun `label entries distinguish adding from removing`() { + val added = + item( + """{"event": "labeled", "actor": {"login": "bot"}, "created_at": "2026-07-04T09:00:00Z", "label": {"name": "bug", "color": "d73a4a"}}""", + ) as IssueTimelineItem.LabelChange + val removed = + item( + """{"event": "unlabeled", "actor": {"login": "bot"}, "created_at": "2026-07-04T09:01:00Z", "label": {"name": "bug"}}""", + ) as IssueTimelineItem.LabelChange + + assertTrue(added.added) + assertEquals("bug", added.label.name) + assertEquals("d73a4a", added.label.color) + assertFalse(removed.added) + } + + @Test + fun `assignee entries name the assignee, not just the actor`() { + val item = + item( + """ + { + "event": "assigned", + "actor": {"login": "maintainer"}, + "assignee": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z" + } + """, + ) as IssueTimelineItem.AssigneeChange + + assertEquals("maintainer", item.actor?.login) + assertEquals("octocat", item.assignee.login) + assertTrue(item.added) + } + + /** The timeline names a milestone by title only, so there is no number to map. */ + @Test + fun `milestone entries fall back to the unknown number`() { + val item = + item( + """{"event": "milestoned", "actor": {"login": "octocat"}, "created_at": "2026-07-04T09:00:00Z", "milestone": {"title": "v1.0"}}""", + ) as IssueTimelineItem.MilestoneChange + + assertEquals("v1.0", item.milestone.title) + assertEquals(IssueMilestone.NUMBER_UNKNOWN, item.milestone.number) + } + + @Test + fun `a rename keeps both titles`() { + val item = + item( + """ + { + "event": "renamed", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "rename": {"from": "Broke", "to": "Crash on save"} + } + """, + ) as IssueTimelineItem.Renamed + + assertEquals("Broke", item.from) + assertEquals("Crash on save", item.to) + } + + @Test + fun `a cross reference unwraps the issue it points at`() { + val item = + item( + """ + { + "event": "cross-referenced", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "source": { + "type": "issue", + "issue": { + "number": 21, + "title": "Follow-up fix", + "state": "open", + "html_url": "https://github.test/octocat/hello-world/pull/21", + "created_at": "2026-07-04T08:00:00Z", + "updated_at": "2026-07-04T08:30:00Z", + "pull_request": {"url": "https://api.github.test/repos/octocat/hello-world/pulls/21"} + } + } + } + """, + ) as IssueTimelineItem.CrossReferenced + + assertEquals("#21", item.displayNumber) + assertEquals("Follow-up fix", item.title) + assertEquals("https://github.test/octocat/hello-world/pull/21", item.url) + assertTrue(item.isPullRequest) + } + + /** GitHub hands back the API address, which renders JSON; the link has to reach the web page. */ + @Test + fun `a commit reference links to the commit page, not the API`() { + val item = + item( + """ + { + "event": "referenced", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "commit_id": "0123456789abcdef", + "commit_url": "https://api.github.com/repos/octocat/hello-world/commits/0123456789abcdef" + } + """, + ) as IssueTimelineItem.Referenced + + assertEquals("0123456789abcdef", item.commitSha) + assertEquals("https://github.com/octocat/hello-world/commit/0123456789abcdef", item.commitUrl) + } + + @Test + fun `a commit reference without a link still names the commit`() { + val item = + item("""{"event": "referenced", "created_at": "2026-07-04T09:00:00Z", "commit_id": "0123456789abcdef"}""") + as IssueTimelineItem.Referenced + + assertEquals("0123456789abcdef", item.commitSha) + assertNull(item.commitUrl) + } + + /** An entry we have no case for still shows up, rather than being dropped on the floor. */ + @Test + fun `an unmodelled entry keeps the provider's own name for it`() { + val item = + item("""{"event": "pinned", "actor": {"login": "octocat"}, "created_at": "2026-07-04T09:00:00Z"}""") + as IssueTimelineItem.Unknown + + assertEquals("pinned", item.kind) + } + + @Test + fun `entries without a payload or a date are dropped`() { + // `committed` entries are dated by the commit author instead of GitHub. + assertNull(item("""{"event": "committed", "message": "Fix it"}""")) + assertNull(item("""{"event": "labeled", "created_at": "2026-07-04T09:00:00Z"}""")) + assertNull(item("""{"event": "renamed", "created_at": "2026-07-04T09:00:00Z"}""")) + } + + @Test + fun `a whole page decodes even when entries disagree about their fields`() { + val page = + json.decodeFromStringRenders this.
")) + + assertEquals(1, cards.size) + val opening = cards.single() + assertEquals("octocat", opening.actor?.login) + assertTrue(opening.opensTheIssue) + val comment = opening.items.single() as IssueTimelineItem.Comment + assertEquals("Renders this.
", comment.bodyHtml) + assertEquals("2026-07-01T00:00:00Z", comment.at) + } + + @Test + fun `an issue nobody touched is still one card`() { + val cards = issueThread(issue(body = null), null) + + assertEquals(1, cards.size) + assertNull((cards.single().items.single() as IssueTimelineItem.Comment).body) + } + + @Test + fun `a run of entries by one account collapses into a single card`() { + val cards = + issueThread( + issue(), + detail( + label("bot", "2026-07-02T09:00:00Z", "bug"), + label("bot", "2026-07-02T09:01:00Z", "triage"), + label("bot", "2026-07-02T09:02:00Z", "help wanted"), + ), + ) + + // The description's card, then one card covering all three labels. + assertEquals(2, cards.size) + assertEquals("bot", cards[1].actor?.login) + assertEquals(3, cards[1].items.size) + assertFalse(cards[1].opensTheIssue) + } + + @Test + fun `entries by the description's author join its card`() { + val cards = issueThread(issue(), detail(label("octocat", "2026-07-02T09:00:00Z", "bug"))) + + assertEquals(1, cards.size) + assertEquals(2, cards.single().items.size) + assertTrue(cards.single().opensTheIssue) + } + + /** Grouping must not reorder: a card covers one continuous stretch, not everything an account did. */ + @Test + fun `an account coming back after someone else gets a second card`() { + val cards = + issueThread( + issue(), + detail( + label("bot", "2026-07-02T09:00:00Z", "bug"), + label("maintainer", "2026-07-02T10:00:00Z", "triage"), + label("bot", "2026-07-02T11:00:00Z", "wontfix"), + ), + ) + + assertEquals(listOf("octocat", "bot", "maintainer", "bot"), cards.map { it.actor?.login }) + } + + @Test + fun `only the first card opens the issue`() { + val cards = issueThread(issue(), detail(label("bot", "2026-07-02T09:00:00Z", "bug"))) + + assertEquals(listOf(true, false), cards.map { it.opensTheIssue }) + } + + private fun issue(body: String? = "Renders *this*.") = + Issue( + id = 17, + displayNumber = "#17", + title = "Filter issues", + state = IssueState.OPEN, + body = body, + author = IssueActor("octocat"), + url = "https://github.test/octocat/hello-world/issues/17", + createdAt = "2026-07-01T00:00:00Z", + updatedAt = "2026-07-02T00:00:00Z", + ) + + private fun detail(vararg timeline: IssueTimelineItem) = IssueDetail(issue(), timeline = timeline.toList()) + + private fun label( + who: String, + at: String, + name: String, + ) = IssueTimelineItem.LabelChange(IssueActor(who), at, IssueLabel(name), added = true) +}