diff --git a/App/AppModel.swift b/App/AppModel.swift index cd7d3a6..6d5a290 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -529,6 +529,11 @@ final class AppModel { SessionResources.ranked(resources(for: id), kind: .pullRequest) } + /// The session's issues, most relevant first. + func issues(of id: Agent.ID) -> [SessionResource] { + SessionResources.ranked(resources(for: id), kind: .issue) + } + /// Opens the session with one of its links in its browser, such as a pull request a notification is about. func openLink(_ url: URL, in id: Agent.ID) { open(id) diff --git a/App/ChecksMonitor.swift b/App/ChecksMonitor.swift index 65acad0..dc037b1 100644 --- a/App/ChecksMonitor.swift +++ b/App/ChecksMonitor.swift @@ -1,13 +1,16 @@ import AppKit import ShepherdrCore -/// Watches the CI checks of the pull requests of the session on screen, through the GitHub CLI: -/// one GraphQL query for all of them, every 15 seconds while checks run and every 2 minutes once -/// they're done, paused while the Mac sleeps. A notification tells you when checks finish. +/// Watches the CI checks of the pull requests of the session on screen, and whether its issues are +/// still open, through the GitHub CLI: one GraphQL query for its pull requests and one for its +/// issues, every 15 seconds while checks run and every 2 minutes once they're done, paused while +/// the Mac sleeps. A notification tells you when checks finish. @MainActor @Observable final class ChecksMonitor { /// The last known checks of each pull request, by resource key. private(set) var checks: [String: PullRequestChecks] = [:] + /// The last known state of each issue, by resource key. + private(set) var issues: [String: IssueState] = [:] @ObservationIgnored weak var model: AppModel? @ObservationIgnored private let gitHub = GitHubLookup() @ObservationIgnored private var watched: Agent.ID? @@ -67,6 +70,7 @@ final class ChecksMonitor { private func poll(_ id: Agent.ID) async -> Poll { guard !isAsleep, let model else { return .settled } + await pollIssues(id, model: model) // Open pull requests only: merged and closed ones are done. let pulls = model.pullRequests(of: id).filter { $0.github != nil && checks[$0.key]?.isOpen != false }.prefix(10) guard !pulls.isEmpty else { return .settled } @@ -81,8 +85,36 @@ final class ChecksMonitor { return found.values.contains { $0.isOpen && $0.isRunning } ? .running : .settled } + /// Open issues only, like pull requests: closed ones are done. + private func pollIssues(_ id: Agent.ID, model: AppModel) async { + let open = model.issues(of: id).filter { $0.github != nil && issues[$0.key]?.isOpen != false }.prefix(10) + guard !open.isEmpty, let found = await gitHub.issueStates(of: Array(open)) else { return } + issues.merge(found) { _, latest in latest } + } + /// One mark for several pull requests. func state(of pulls: [SessionResource]) -> PullRequestChecks.State? { PullRequestChecks.combined(pulls.compactMap { checks[$0.key] }) } + + /// How a pull request or issue ended: landed (merged, or completed) or dropped (closed without + /// merging, not planned, a duplicate). + enum Outcome { case landed, dropped } + + /// Nil while it's open or GitHub hasn't said. + func outcome(of resource: SessionResource) -> Outcome? { + if let pull = checks[resource.key], !pull.isOpen { return pull.isMerged ? .landed : .dropped } + return switch issues[resource.key] { + case .completed: .landed + case .notPlanned, .duplicate: .dropped + case .open, nil: nil + } + } + + /// How several pull requests ended: nil while any may still be open, landed if any merged. + func outcome(of pulls: [SessionResource]) -> Outcome? { + let outcomes = pulls.map(outcome(of:)) + guard !outcomes.isEmpty, !outcomes.contains(nil) else { return nil } + return outcomes.contains(.landed) ? .landed : .dropped + } } diff --git a/App/ResourcesPanel.swift b/App/ResourcesPanel.swift index 027b4a8..55d2dd0 100644 --- a/App/ResourcesPanel.swift +++ b/App/ResourcesPanel.swift @@ -48,11 +48,10 @@ struct ResourcesPanel: View { private func row(_ resource: SessionResource) -> some View { let isOpen = workspace.browser.isVisible && workspace.browser.selected?.url == resource.url - let isMerged = checks.checks[resource.key]?.isMerged == true return Button { workspace.open(resource) } label: { HStack(alignment: .firstTextBaseline, spacing: 7) { Text(Self.glyph(resource.kind)).font(Theme.mono(10.5, .bold)) - .foregroundStyle(isMerged ? Theme.lilac : Self.tint(resource.kind)).frame(width: 12) + .foregroundStyle(tint(of: resource)).frame(width: 12) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 5) { Text(resource.name).font(Theme.mono(11, .medium)).lineLimit(1).truncationMode(.middle) @@ -86,7 +85,7 @@ struct ResourcesPanel: View { private func help(for resource: SessionResource) -> String { var lines: [String] = [] if let title = resource.title { lines.append(title) } - if checks.checks[resource.key]?.isMerged == true { lines.append("Merged") } + if let status = status(of: resource) { lines.append(status) } if let summary = checks.checks[resource.key]?.summary, !summary.isEmpty { lines.append("Checks: " + summary) } lines.append(resource.url.absoluteString) lines.append("Mentioned \(resource.mentions)× · opened \(resource.opens)×") @@ -101,11 +100,28 @@ struct ResourcesPanel: View { } } - private static func tint(_ kind: SessionResource.Kind) -> Color { - switch kind { - case .pullRequest: Theme.phosphor - case .issue: Theme.amber - case .artifact: Theme.cyan + /// Its kind's color while open; lilac once it landed, as GitHub marks merged pull requests and + /// completed issues; dimmed once dropped. + private func tint(of resource: SessionResource) -> Color { + switch checks.outcome(of: resource) { + case .landed: Theme.lilac + case .dropped: Theme.dim + case nil: + switch resource.kind { + case .pullRequest: Theme.phosphor + case .issue: Theme.amber + case .artifact: Theme.cyan + } + } + } + + private func status(of resource: SessionResource) -> String? { + if let pull = checks.checks[resource.key], !pull.isOpen { return pull.isMerged ? "Merged" : "Closed without merging" } + return switch checks.issues[resource.key] { + case .completed: "Closed as completed" + case .notPlanned: "Closed as not planned" + case .duplicate: "Closed as a duplicate" + case .open, nil: nil } } } diff --git a/App/SessionSidebar.swift b/App/SessionSidebar.swift index e673c72..9bc7a13 100644 --- a/App/SessionSidebar.swift +++ b/App/SessionSidebar.swift @@ -460,9 +460,15 @@ private struct SessionRowView: View { private func pullsButton(_ pulls: [SessionResource]) -> some View { let label = pulls.count == 1 ? Self.number(of: pulls[0]) : "\(pulls.count) PRs" + // Lilac once its pull requests landed, as in Resources: the session's work is in. + let tint = switch model.checksMonitor.outcome(of: pulls) { + case .landed: Theme.lilac + case .dropped: Theme.dim + case nil: Theme.phosphor + } return Button { model.openPullRequests(of: row.id) } label: { HStack(spacing: 4) { - Text("⇄ " + label).foregroundStyle(Theme.phosphor.opacity(0.85)) + Text("⇄ " + label).foregroundStyle(tint.opacity(0.85)) ChecksMark(state: model.checksMonitor.state(of: pulls)) } .contentShape(Rectangle()) diff --git a/Sources/ShepherdrCore/Domain/IssueState.swift b/Sources/ShepherdrCore/Domain/IssueState.swift new file mode 100644 index 0000000..9a7ecdb --- /dev/null +++ b/Sources/ShepherdrCore/Domain/IssueState.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Where a GitHub issue stands: open, or closed for one of GitHub's three reasons. +public enum IssueState: Equatable, Sendable { + case open, completed, notPlanned, duplicate + + public var isOpen: Bool { self == .open } +} + +extension GitHubLookup { + /// The states of several GitHub issues in one GraphQL query, by resource key. Nil when GitHub can't answer. + public func issueStates(of issues: [SessionResource]) async -> [String: IssueState]? { + let numbered = issues.compactMap { issue in issue.github.map { (key: issue.key, github: $0) } } + .filter { Self.isPlainName($0.github.owner) && Self.isPlainName($0.github.repository) } + guard let executable, !numbered.isEmpty, + let output = try? await runner.run(executable: executable, + arguments: ["api", "graphql", "-f", "query=\(Self.issueStatesQuery(numbered.map(\.github)))"], + timeout: 30) else { return nil } + // An issue GitHub can't find, or a number that is a pull request, fails the command but not the others' answers. + return Self.parseIssueStates(output.stdout, keys: numbered.map(\.key)) + } + + static func issueStatesQuery(_ issues: [(owner: String, repository: String, number: Int)]) -> String { + let fields = issues.enumerated().map { index, issue in + "i\(index): repository(owner: \"\(issue.owner)\", name: \"\(issue.repository)\") { issue(number: \(issue.number)) { state stateReason } }" + } + return "query { \(fields.joined(separator: " ")) }" + } + + /// Issues GitHub couldn't find are left out. + static func parseIssueStates(_ data: Data, keys: [String]) -> [String: IssueState]? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let answers = root["data"] as? [String: Any] else { return nil } + var result: [String: IssueState] = [:] + for (index, key) in keys.enumerated() { + guard let repository = answers["i\(index)"] as? [String: Any], + let issue = repository["issue"] as? [String: Any] else { continue } + let reason = issue["stateReason"] as? String + let state: IssueState = if issue["state"] as? String == "OPEN" { .open } + else if reason == "NOT_PLANNED" { .notPlanned } + else if reason == "DUPLICATE" { .duplicate } + else { .completed } + result[key] = state + } + return result + } +} diff --git a/Sources/ShepherdrCore/Domain/PullRequestChecks.swift b/Sources/ShepherdrCore/Domain/PullRequestChecks.swift index 985064a..a32090c 100644 --- a/Sources/ShepherdrCore/Domain/PullRequestChecks.swift +++ b/Sources/ShepherdrCore/Domain/PullRequestChecks.swift @@ -113,7 +113,7 @@ extension GitHubLookup { return result } - private static func isPlainName(_ name: String) -> Bool { + static func isPlainName(_ name: String) -> Bool { name.range(of: #"^[A-Za-z0-9_.-]+$"#, options: .regularExpression) != nil } } diff --git a/Tests/ShepherdrCoreTests/IssueStateTests.swift b/Tests/ShepherdrCoreTests/IssueStateTests.swift new file mode 100644 index 0000000..1e37050 --- /dev/null +++ b/Tests/ShepherdrCoreTests/IssueStateTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import ShepherdrCore + +struct IssueStateTests { + private func issue(_ link: String) -> SessionResource { SessionResource(url: URL(string: link)!)! } + + @Test func oneQueryAsksAboutEveryIssue() async { + let answer = """ + {"data":{ + "i0":{"issue":{"state":"OPEN","stateReason":null}}, + "i1":{"issue":{"state":"CLOSED","stateReason":"COMPLETED"}}, + "i2":{"issue":{"state":"CLOSED","stateReason":"NOT_PLANNED"}}, + "i3":{"issue":{"state":"CLOSED","stateReason":"DUPLICATE"}}, + "i4":{"issue":null}}, + "errors":[{"type":"NOT_FOUND","message":"Could not resolve to an Issue with the number of 5."}]} + """ + // gh fails when part of the query does, such as a number that is a pull request, but still prints the rest. + let runner = RecordingRunner([output(answer, stderr: "gh: Could not resolve to an Issue with the number of 5.", code: 1)]) + let lookup = GitHubLookup(runner: runner, executable: URL(fileURLWithPath: "/usr/bin/true")) + let issues = ["https://github.com/theam/shepherdr/issues/1", "https://github.com/theam/shepherdr/issues/2", + "https://github.com/theam/shepherdr/issues/3", "https://github.com/theam/shepherdr/issues/4", + "https://github.com/a/b/issues/5"].map(issue) + let states = await lookup.issueStates(of: issues) + #expect(states?[issues[0].key] == .open && states?[issues[0].key]?.isOpen == true) + #expect(states?[issues[1].key] == .completed) + #expect(states?[issues[2].key] == .notPlanned) + #expect(states?[issues[3].key] == .duplicate && states?[issues[3].key]?.isOpen == false) + #expect(states?[issues[4].key] == nil) + let query = await runner.recordedArguments().first?.last ?? "" + #expect(query.contains(#"i0: repository(owner: "theam", name: "shepherdr") { issue(number: 1) { state stateReason } }"#)) + } + + @Test func nothingIsAskedWithoutTheCLIOrForOddNames() async { + let runner = RecordingRunner([]) + #expect(await GitHubLookup(runner: runner, executable: nil).issueStates(of: [issue("https://github.com/a/b/issues/1")]) == nil) + if let odd = SessionResource(url: URL(string: "https://github.com/a%22%7D/b/issues/1")!) { + #expect(await GitHubLookup(runner: runner, executable: URL(fileURLWithPath: "/usr/bin/true")).issueStates(of: [odd]) == nil) + } + #expect(await runner.recordedArguments().isEmpty) + } +}