Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 35 additions & 3 deletions App/ChecksMonitor.swift
Original file line number Diff line number Diff line change
@@ -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?
Expand Down Expand Up @@ -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 }
Expand All @@ -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
}
}
32 changes: 24 additions & 8 deletions App/ResourcesPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)×")
Expand All @@ -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
}
}
}
8 changes: 7 additions & 1 deletion App/SessionSidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
47 changes: 47 additions & 0 deletions Sources/ShepherdrCore/Domain/IssueState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/ShepherdrCore/Domain/PullRequestChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
42 changes: 42 additions & 0 deletions Tests/ShepherdrCoreTests/IssueStateTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading