Skip to content
Open
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 Sources/Nirux/Model/WorkspaceState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ final class WorkspaceState {
var prInfo: PRInfo? {
didSet { if prInfo?.url != oldValue?.url || prInfo?.state != "OPEN" { prFeedback = nil } }
}
/// The last pull request found on this branch: the card keeps its
/// title and author while a commit refetches `prInfo`.
private(set) var lastPullRequest: PRInfo?
/// The open PR's feedback (docs/pr-feedback-inbox.md), read after each
/// PR refresh. Cleared when the PR changes or stops being open.
var prFeedback: PRFeedback?
Expand Down Expand Up @@ -230,6 +233,7 @@ extension WorkspaceState {
if !sameRevision || (context?.identity.isDirty == true && Self.isTerminalPullRequest(prInfo)) {
prInfo = nil
}
if previousContext?.branch != context?.branch { lastPullRequest = nil }
diffStats = nil
if !titleIsManual, let branch = context?.branch, !branch.isEmpty {
title = branch
Expand Down Expand Up @@ -259,6 +263,7 @@ extension WorkspaceState {
prInfo != info
else { return false }
prInfo = info
if let info { lastPullRequest = info }
return true
}

Expand Down
51 changes: 46 additions & 5 deletions Sources/Nirux/Util/PRDetect.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import os

enum PRDetect {
private struct GitHubCLIContext {
Expand Down Expand Up @@ -51,11 +52,39 @@ enum PRDetect {
context.branch == branch
else { return .failure }

return fetch(
let result = fetch(
branch: branch,
ghPath: ghPath,
context: context
)
guard case .success(let context, var info?) = result,
let host = context.upstreamRepository?.host
else { return result }
if let author = info.otherAuthor {
// The gh user unknown: no mark, rather than one on their own branches.
let viewer = viewerLogin(ghPath: ghPath, host: host)
if viewer == nil || viewer == author { info.otherAuthor = nil }
}
return .success(context: context, info: info)
}

private static let viewerLogins = OSAllocatedUnfairLock(initialState: [String: (login: String?, at: Date)]())

/// The gh user on `host`, asked once per launch; nil while gh can't
/// say, asked again ten minutes later rather than at every refresh.
private static func viewerLogin(ghPath: String, host: String) -> String? {
if let known = viewerLogins.withLock({ $0[host] }),
known.login != nil || Date().timeIntervalSince(known.at) < 600 {
return known.login
}
let result = GitHubCLIBoardClient.runGH(
ghPath, arguments: ["api", "user", "--hostname", host, "--jq", ".login"], timeout: 10
)
let login = result.flatMap { $0.terminationStatus == 0 ? String(data: $0.standardOutput, encoding: .utf8) : nil }?
.trimmingCharacters(in: .whitespacesAndNewlines)
let known = (login: login?.isEmpty == false ? login : nil, at: Date())
viewerLogins.withLock { $0[host] = known }
return known.login
}

static func fetch(
Expand Down Expand Up @@ -131,7 +160,7 @@ enum PRDetect {
"number", "state", "headRefOid", "headRepositoryOwner",
"headRepository", "isDraft", "statusCheckRollup",
"reviewDecision", "mergeable", "url", "additions",
"deletions", "changedFiles"
"deletions", "changedFiles", "title", "author"
].joined(separator: ",")
let result = BoundedProcess.run(
executableURL: URL(fileURLWithPath: cliContext.executablePath),
Expand Down Expand Up @@ -196,10 +225,18 @@ enum PRDetect {
url: candidate["url"] as? String ?? "",
additions: candidate["additions"] as? Int,
deletions: candidate["deletions"] as? Int,
changedFiles: candidate["changedFiles"] as? Int
changedFiles: candidate["changedFiles"] as? Int,
title: candidate["title"] as? String,
otherAuthor: author(of: candidate)
)
}

/// A bot is no teammate: no mark.
private static func author(of candidate: [String: Any]) -> String? {
guard let author = candidate["author"] as? [String: Any], author["is_bot"] as? Bool != true else { return nil }
return author["login"] as? String
}

private static func repository(for candidate: [String: Any]) -> GitHubRepository? {
guard let owner = candidate["headRepositoryOwner"] as? [String: Any],
let login = owner["login"] as? String,
Expand Down Expand Up @@ -242,7 +279,7 @@ enum PRDetect {
return .failure
}
guard let output = gitOutput(
arguments: noIndexRefresh + ["diff", "--shortstat"],
arguments: noIndexRefresh + ["diff", "--shortstat"] + excludingHandovers,
cwd: cwd,
gitPath: gitPath
) else { return .failure }
Expand All @@ -253,14 +290,18 @@ enum PRDetect {
private static func diffPaths(cwd: String) -> [String] {
// User-initiated: unlike the background shortstat, this may refresh
// the index, or it would list touched-but-unchanged files.
guard let output = gitOutput(arguments: ["diff", "--name-only"], cwd: cwd) else { return [] }
guard let output = gitOutput(arguments: ["diff", "--name-only"] + excludingHandovers, cwd: cwd) else { return [] }
return output
.split(separator: "\n")
.map(String.init)
.filter { !$0.isEmpty }
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}

/// A mission's handover is the agent's note, not the work: a repo that
/// tracks one would show it changed in every new worktree.
private static let excludingHandovers = ["--"] + BranchReview.Handover.names.map { ":(top,exclude)\($0)" }

/// `git diff` refreshes and rewrites `.git/index` even under
/// GIT_OPTIONAL_LOCKS=0. `--shortstat` still leaves stat-only changes
/// out without the refresh (`--name-only` would not).
Expand Down
15 changes: 14 additions & 1 deletion Sources/Nirux/Views/NiruxShellView+SidebarGit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ extension NiruxShellView {
blocker: workspace.blocker, phase: workspace.effectivePhase,
lastSummary: workspace.lastSummary, lastActivityAt: workspace.lastActivityAt,
reviewBadges: workspace.reviewBadges,
mergedCleanup: mergedCleanupOffer(workspaceIndex: index))
mergedCleanup: mergedCleanupOffer(workspaceIndex: index),
branchLabel: sidebarBranchLabel(of: workspace, among: visibleIndices),
lastPullRequest: workspace.lastPullRequest)
}
tickHiddenSpaceAgents(visibleIndices: visibleIndices, foregroundProcesses: foregroundProcesses)
updateColumnHeaders(infos: infos, foregroundProcesses: foregroundProcesses, now: now)
Expand Down Expand Up @@ -88,6 +90,17 @@ extension NiruxShellView {
refreshProjectBoards(snapshot: snapshot, now: now, foregroundProcesses: foregroundProcesses)
}

/// Line 2's branch, without the prefix it shares with the listed
/// worktrees of its repo (the inactive ones are folded away).
private func sidebarBranchLabel(of workspace: WorkspaceState, among visibleIndices: [Int]) -> String? {
guard let branch = workspace.gitBranch else { return nil }
guard let repository = workspace.gitContext?.upstreamRepository else { return branch }
let siblings = visibleIndices.map { workspaces[$0] }
.filter { !$0.isInactive && $0.gitContext?.upstreamRepository == repository }
.compactMap(\.gitBranch)
return SidebarCardLayout.distinctiveBranch(branch, siblings: siblings)
}

/// Every column's foreground process, shown or not, and what follows
/// from it before statuses are read: session bindings of a replaced
/// agent, stuck agents (the one in another space is the one nobody
Expand Down
10 changes: 10 additions & 0 deletions Sources/Nirux/Views/SidebarCardLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ struct SidebarCardLayout {
var contentMaxX: CGFloat { cardX + cardWidth - Metrics.cardPaddingX }
var indentX: CGFloat { contentX + Metrics.cardIndent }

/// The branch without the longest prefix it shares with a sibling's,
/// cut after that prefix's last `/` or `-`: worktrees of one repo tell
/// apart by their ends (`tomiir/payouts-ledger` → `ledger`).
static func distinctiveBranch(_ branch: String, siblings: [String]) -> String {
let shared = siblings.filter { $0 != branch }.map { branch.commonPrefix(with: $0).count }.max() ?? 0
guard let separator = branch.prefix(shared).lastIndex(where: { $0 == "/" || $0 == "-" }) else { return branch }
let rest = branch[branch.index(after: separator)...]
return rest.isEmpty ? branch : String(rest)
}

/// Between the feedback and the pull request, and the pull request and
/// its checks.
static func linkGaps(_ links: [SidebarCardLink]) -> CGFloat {
Expand Down
19 changes: 19 additions & 0 deletions Sources/Nirux/Views/SidebarModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ struct PRInfo: Hashable, Sendable {
let additions: Int?
let deletions: Int?
let changedFiles: Int?
var title: String?
/// Who opened it, when that isn't the gh user: the card marks a
/// teammate's branch. `PRDetect` clears it for the user's own, and when
/// it can't tell who the user is.
var otherAuthor: String?

var failedCheckUrl: String? {
checks.lazy.filter { $0.result == .failure }.compactMap(\.url).first
Expand Down Expand Up @@ -238,6 +243,20 @@ struct WorkspaceInfo: Hashable {
var reviewBadges: ReviewBadges? = nil
/// What the card shows after "#N merged"; nil shows nothing.
var mergedCleanup: MergedCleanupOffer?
/// Line 2's branch, without what visible worktrees of its repo share
/// (`SidebarCardLayout.distinctiveBranch`); nil prints `gitBranch`.
var branchLabel: String?
/// `WorkspaceState.lastPullRequest`: names the card, marks a teammate's.
var lastPullRequest: PRInfo?

/// Line 1: the PR's title while the workspace is titled with its
/// branch (a name set by hand differs and stays), without the
/// `type(scope): ` every PR of a repo shares.
var cardTitle: String {
guard title == gitBranch, let pullRequestTitle = lastPullRequest?.title else { return title }
let name = pullRequestTitle.replacing(/^\w+(\([^)]*\))?!?:\s+/, with: "")
return name.isEmpty ? pullRequestTitle : name
}

var sidebarAction: (text: String, isBlocker: Bool)? {
if let blocker = normalizedContextText(blocker) {
Expand Down
27 changes: 22 additions & 5 deletions Sources/Nirux/Views/SidebarWorkspaceCardRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ final class SidebarWorkspaceCardRenderer {
let titleX = layout.contentX + Metrics.stateDotSize + Theme.Space.sm
let isQuiet = !workspace.isActive && [.idle, .done].contains(layout.state)
let title = textLabel(
workspace.title, font: Theme.Font.title,
workspace.cardTitle, font: Theme.Font.title,
color: isQuiet ? Theme.Color.textSecondary : Theme.Color.textPrimary
)
title.toolTip = titleToolTip()
Expand All @@ -141,7 +141,7 @@ final class SidebarWorkspaceCardRenderer {
/// The title's tooltip: what the card doesn't print — the phase, the
/// purpose, the last summary, another card's next step.
private func titleToolTip() -> String {
var lines = [workspace.title, "Phase: \(workspace.phase.displayName)"]
var lines = [workspace.cardTitle, "Phase: \(workspace.phase.displayName)"]
if let purpose = workspace.purpose { lines.append("Purpose: \(purpose)") }
if let summary = workspace.lastSummary { lines.append("Last: \(summary)") }
// The selected card prints the next step, every card its blocker.
Expand Down Expand Up @@ -213,9 +213,26 @@ final class SidebarWorkspaceCardRenderer {
maxX = diff.frame.minX - Theme.Space.sm
}
if let branchName = workspace.gitBranch {
let branch = textLabel(branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary)
var x = layout.indentX
// A teammate's branch: their initial. Nothing on the user's own.
if let author = workspace.lastPullRequest?.otherAuthor, let initial = author.first {
let badge = SidebarBadgeView(
text: initial.uppercased(), textColor: Theme.Color.textSecondary,
fillColor: Theme.Color.fillPressed, font: Theme.Font.label
)
badge.cornerRadius = Theme.Radius.chip
badge.frame = NSRect(x: x, y: rowY, width: Metrics.branchRowHeight, height: Metrics.branchRowHeight)
badge.toolTip = "Opened by \(author)"
badge.setAccessibilityRole(.staticText)
badge.setAccessibilityLabel("Opened by \(author)")
append(badge)
x = badge.frame.maxX + Theme.Space.xs
}
let branch = textLabel(workspace.branchLabel ?? branchName, font: Theme.Font.mono, color: Theme.Color.textSecondary)
// A long branch keeps both ends.
branch.lineBreakMode = .byTruncatingMiddle
branch.toolTip = branchName
branch.frame = NSRect(x: layout.indentX, y: rowY, width: max(0, maxX - layout.indentX), height: Metrics.branchRowHeight)
branch.frame = NSRect(x: x, y: rowY, width: max(0, maxX - x), height: Metrics.branchRowHeight)
append(branch)
}
}
Expand Down Expand Up @@ -470,7 +487,7 @@ final class SidebarWorkspaceCardRenderer {
))

let titleX = x + 12 + Theme.Space.sm
let title = textLabel(workspace.title, font: Theme.Font.body, color: Theme.Color.textSecondary)
let title = textLabel(workspace.cardTitle, font: Theme.Font.body, color: Theme.Color.textSecondary)
title.toolTip = titleToolTip()
let height = ceil(title.fittingSize.height)
title.frame = NSRect(x: titleX, y: midY - height / 2, width: max(0, badge.frame.minX - Theme.Space.sm - titleX), height: height)
Expand Down
51 changes: 51 additions & 0 deletions Tests/NiruxTests/PRDetectTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -688,3 +688,54 @@ private func fakeGitHubCLIScript(_ configuration: FakeGitHubCLIConfiguration) ->
kill "$watchdog_pid" 2>/dev/null || true
"""#
}

// The card: its title, its teammate mark, its diff.
extension PRDetectTests {
func testPullRequestInfoReadsTitleAndAuthor() {
let info = PRDetect.pullRequestInfo(from: [
"number": 1174, "state": "OPEN", "title": "Ledger entries for payouts", "author": ["login": "tomiir"]
])
XCTAssertEqual(info.title, "Ledger entries for payouts")
XCTAssertEqual(info.otherAuthor, "tomiir")

let bot = PRDetect.pullRequestInfo(from: ["author": ["login": "app/dependabot", "is_bot": true]])
XCTAssertNil(bot.otherAuthor, "a bot is no teammate")
}

func testDiffStatsLeaveHandoversOut() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }

try runGit(["init", "-q"], at: directory)
try FileManager.default.createDirectory(
at: directory.appendingPathComponent("sub"), withIntermediateDirectories: true
)
for name in [".claude-handover.md", ".codex-handover.md", "tracked.txt", "sub/kept.txt"] {
try "clean\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8)
}
try runGit(["add", "."], at: directory)
try runGit([
"-c", "user.name=Nirux Tests",
"-c", "user.email=nirux@example.test",
"commit", "-qm", "initial"
], at: directory)

for name in [".claude-handover.md", ".codex-handover.md"] {
try "next steps\nmore\n".write(to: directory.appendingPathComponent(name), atomically: true, encoding: .utf8)
}
// From a subdirectory too: the handovers sit at the top level.
let subdirectory = directory.appendingPathComponent("sub").path
guard case .observed(_, let handoversOnly) = PRDetect.diffStats(cwd: subdirectory) else {
return XCTFail("Expected observed diff stats")
}
XCTAssertNil(handoversOnly)

try "changed\n".write(to: directory.appendingPathComponent("tracked.txt"), atomically: true, encoding: .utf8)
guard case .observed(_, let stats) = PRDetect.diffStats(cwd: subdirectory) else {
return XCTFail("Expected observed diff stats")
}
XCTAssertEqual(stats, "1 file changed, 1 insertion(+), 1 deletion(-)")
}
}
Loading
Loading