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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Mainline/Mainline/ ← Source root
│ ├── SensitivePathMatcher.swift ← Pure path/branch-name heuristic classifier
│ ├── TriageClassifier.swift ← Pure needsHuman predicate engine
│ ├── PRSearchFilter.swift ← Pure search matcher (number / PR-URL / free-text); `matches(_:query:)` + `runSelfChecks()` #if DEBUG
│ ├── PRListExport.swift ← Pure Markdown formatter for a section's PRs (URL + CI/review/draft/conflict/threads/size/branch per PR), copied by the hover button on deck section headers; `PRListExportChecks.run()` #if DEBUG
│ ├── InboxMuteEngine.swift ← Pure glob matcher + four mute-rule predicates (pattern/botAuthor/label/outsideFocus); InboxMuteConfig + per-org OrgFocusConfig value structs; muteVerdict takes the PR's `org`; runSelfChecks() #if DEBUG
│ ├── ScopeStore.swift ← @MainActor derives org/repo scopes from PR list; drives badge
│ ├── StackEngine.swift ← Pure stacked-PR detection: chains where `B.baseRefName == A.headRefName` (same repo) become a `Stack` (bottom→top); `detect(_:)` + `Index` (`stack(containing:)` / `isStacked` / `position(of:)`); `StackEngineChecks.run()` #if DEBUG
Expand All @@ -62,7 +63,7 @@ Mainline/Mainline/ ← Source root
├── InboxSettingsView.swift ← Inbox noise-filter settings: Review Readiness (four reviewer "not ready → Waiting" toggles: conflict / failing CI / unresolved threads / approved-by-me, all default ON), mute patterns, muteBotAuthors toggle, per-org Review Focus (org sub-blocks nested INSIDE the one Review Focus card via `orgFocusBlock`, each with a Remove button; authors+teams keyed by lowercased org, derived from `manager.knownOrgs` + saved config + an add-org field), muteLabels
├── KeyboardShortcutsView.swift ← Configurable deck/peek shortcuts UI: per-action `InAppShortcutRecorder`, clash detection, Reset All button
├── MenuBarIconView.swift ← Dynamic badge: MenuBarBadge enum → SF Symbol + tint
├── TriageDeckView.swift ← Keyboard triage (J/K/Space/↵/E/M/T/R/S/N/X/V/D/Q/C/P/F/⌘Z) + TriageAction enum + per-row context menus (`rowContextMenu` for deck + Postponed rows, `doneRowContextMenu` for Done rows; see "Write actions") + first-responder KeyCaptureView; deck keys are user-configurable via settings.shortcutBindings; Inbox mode: role sections (yourPRs / needsYourReview) + collapsed Muted group; Q toggles per-PR manual mute (settings.inboxMuteOverrides); T marks draft PR ready for review (draft-only, write-gated, plain toast); P pins/unpins the focused PR (floats it to the top of its group, `settings.pinnedNodeIds`); F opens the flat search-results view (`searchMode`)
├── TriageDeckView.swift ← Keyboard triage (J/K/Space/↵/E/M/T/R/S/N/X/V/D/Q/C/P/F/⌘Z) + TriageAction enum + per-row context menus (`rowContextMenu` for deck + Postponed rows, `doneRowContextMenu` for Done rows; see "Write actions") + hover-revealed section-header copy button (`copyPRList` → `PRListExport`) + first-responder KeyCaptureView; deck keys are user-configurable via settings.shortcutBindings; Inbox mode: role sections (yourPRs / needsYourReview) + collapsed Muted group; Q toggles per-PR manual mute (settings.inboxMuteOverrides); T marks draft PR ready for review (draft-only, write-gated, plain toast); P pins/unpins the focused PR (floats it to the top of its group, `settings.pinnedNodeIds`); F opens the flat search-results view (`searchMode`)
├── PRPeekView.swift ← Space peek: instant glance card + async files list
├── UndoToastView.swift ← Batched undo toast stack
├── TelemetryOptInBanner.swift← Dismissable Privacy-pane opt-in banner (consent-versioned)
Expand Down Expand Up @@ -235,15 +236,15 @@ There is **no XCTest target**. The testing surface is `#if DEBUG` self-check
functions on pure types, all invoked from `AppDelegate.applicationDidFinishLaunching`:
`InboxMuteEngine.runSelfChecks()`, `PRSearchFilter.runSelfChecks()`, `PRClassificationChecks.run()`,
`AttentionPolicyChecks.run()`, `NotificationRoutingChecks.run()`, `StackEngineChecks.run()`,
`PanelBackdropChecks.run()`. Add new pure logic's
`PanelBackdropChecks.run()`, `PRListExportChecks.run()`. Add new pure logic's
assertions to one of these (or a sibling enum in the same file) rather than introducing a
test framework. Keeping the decision table pure — `PREvent.migratedPolicy(from:storedVersion:)`,
`NotificationService.classify(...)` — is what makes it assertable at all.

### Write actions
Write actions (Approve, Merge, Request Changes, Mark Ready, Convert to Draft, Close, Reopen) are gated by `settings.writeActionsEnabled` (default OFF). Each is a `WriteAction` case handled in `PRManager.performAction` (GraphQL mutation in `GitHubClient` → `recordWriteAction` telemetry → `presentActionFailure` on error → `triggerSingleRefresh`). Always show an `NSAlert` confirmation before calling the GitHub API — via `TriageDeckView.dispatchVerb` + `confirmCopy`. The two deliberate exceptions are the draft-state pair, Mark Ready and Convert to Draft, which fire immediately with a plain toast because each reverses the other. Close is menu-only and never gets a default key.

**Row menus.** Every row kind has a right-click menu. Deck rows AND Postponed rows share `rowContextMenu(for:)` — GitHub write verbs, triage verbs (Later ↔ Resume when `snoozeStore.isSnoozed`), view/open (plus Open on GitHub when `prOpenTarget == .linear`), copy (link, branch), then Close PR (destructive role) or Reopen PR for a closed-unmerged pin. Done rows use the smaller `doneRowContextMenu(for:)` (open, copy, Reopen for closed-unmerged). A new triage verb belongs in `TriageAction` + `handleTriageAction` + the relevant menu, so the right-click menu stays the discoverable home for every action.
**Row menus.** Every row kind has a right-click menu. Deck rows AND Postponed rows share `rowContextMenu(for:)` — GitHub write verbs, triage verbs (Later ↔ Resume when `snoozeStore.isSnoozed`), view/open (plus Open on GitHub when `prOpenTarget == .linear`), copy (link, branch), then Close PR (destructive role) or Reopen PR for a closed-unmerged pin. Done rows use the smaller `doneRowContextMenu(for:)` (open, copy, Reopen for closed-unmerged). Section headers (actionability groups and the Inbox role headers) reveal a copy button on hover — also a "Copy PR List" right-click item — that copies the section's PRs, stack members included, as a Markdown list via `PRListExport` (`TriageDeckView.copyPRList`; hover state lives in a per-header `HoverReveal` so it never re-renders the deck). A new triage verb belongs in `TriageAction` + `handleTriageAction` + the relevant menu, so the right-click menu stays the discoverable home for every action.

### Keyboard events in MenuBarExtra
`.onKeyPress` requires macOS 14+ (deployment target is 13). A local `NSEvent` monitor only fires while the app is *active*, and clicking the menu bar icon does NOT activate an accessory app — so key nav silently died on click-open. Instead `TriageDeckView` embeds a zero-size first-responder `NSView` (`KeyCaptureView`) that overrides `keyDown`: it receives keys whenever the popover window is key, independent of app-active state. It also handles Esc-to-close (the first responder otherwise swallows the popover's built-in Esc dismissal).
Expand Down
4 changes: 4 additions & 0 deletions Mainline.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
HP000000000000000000000B1 /* HelpSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HP000000000000000000000A1 /* HelpSettingsView.swift */; };
LG000000000000000000000B1 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LG000000000000000000000A1 /* LaunchAtLoginService.swift */; };
PS000000000000000000000B1 /* PRSearchFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = PS000000000000000000000A1 /* PRSearchFilter.swift */; };
PX000000000000000000000B1 /* PRListExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = PX000000000000000000000A1 /* PRListExport.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -89,6 +90,7 @@
HP000000000000000000000A1 /* HelpSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpSettingsView.swift; sourceTree = "<group>"; };
LG000000000000000000000A1 /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = "<group>"; };
PS000000000000000000000A1 /* PRSearchFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRSearchFilter.swift; sourceTree = "<group>"; };
PX000000000000000000000A1 /* PRListExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRListExport.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -158,6 +160,7 @@
IB000000000000000000000A1 /* InboxMuteEngine.swift */,
LG000000000000000000000A1 /* LaunchAtLoginService.swift */,
PS000000000000000000000A1 /* PRSearchFilter.swift */,
PX000000000000000000000A1 /* PRListExport.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -299,6 +302,7 @@
HP000000000000000000000B1 /* HelpSettingsView.swift in Sources */,
LG000000000000000000000B1 /* LaunchAtLoginService.swift in Sources */,
PS000000000000000000000B1 /* PRSearchFilter.swift in Sources */,
PX000000000000000000000B1 /* PRListExport.swift in Sources */,
OT00000000000000000000B1 /* TelemetryService.swift in Sources */,
OT00000000000000000000B2 /* TelemetryOptInBanner.swift in Sources */,
OT00000000000000000000B3 /* TelemetryDetailsSheet.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Mainline/Mainline/MainlineApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
PollCarryForwardChecks.run()
StackEngineChecks.run()
PanelBackdropChecks.run()
PRListExportChecks.run()
#endif

// Listen for open-settings requests from MenuBarView
Expand Down
96 changes: 96 additions & 0 deletions Mainline/Mainline/Services/PRListExport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation

/// Pure formatter that turns a section's PRs into a Markdown list for the clipboard.
///
/// The output is shaped to be pasted into an agent ("which of these are ready to
/// merge?"), so every PR carries its URL plus the state the deck already shows —
/// CI, review decision, draft, conflicts, unresolved threads, size, branch — as
/// plain words rather than icons. No I/O; `PRListExportChecks` asserts it.
enum PRListExport {

/// Just the list, no heading — the user pastes it under their own prompt.
/// - Parameter prs: the section's PRs, in display order.
static func markdown(prs: [PRSnapshot]) -> String {
var lines: [String] = []
for pr in prs {
lines.append("- [\(pr.repoFullName)#\(pr.number)](\(pr.htmlUrl)) \(pr.title)")
lines.append(" - \(details(pr).joined(separator: " · "))")
}
return lines.joined(separator: "\n")
}

/// The per-PR status line, most decision-relevant facts first.
static func details(_ pr: PRSnapshot) -> [String] {
var parts: [String] = []
if !pr.author.isEmpty { parts.append("author: @\(pr.author)") }
if pr.merged {
parts.append("merged")
} else if pr.closed {
parts.append("closed")
} else if pr.isDraft {
parts.append("draft")
}
parts.append("CI: \(ciLabel(pr.ciStatus))")
parts.append("review: \(reviewLabel(pr.reviewDecision))")
if pr.mergeable == false { parts.append("merge conflict") }
if pr.unresolvedThreadCount > 0 {
let noun = pr.unresolvedThreadCount == 1 ? "thread" : "threads"
parts.append("\(pr.unresolvedThreadCount) unresolved \(noun)")
}
parts.append("+\(pr.linesAdded) −\(pr.linesDeleted)")
if !pr.headRefName.isEmpty {
let base = pr.baseRefName.isEmpty ? "" : " → \(pr.baseRefName)"
parts.append("branch: `\(pr.headRefName)`\(base)")
}
return parts
}

private static func ciLabel(_ status: CIStatus) -> String {
switch status {
case .success: return "passing"
case .failure: return "failing"
case .error: return "error"
case .pending: return "pending"
case .unknown: return "none"
}
}

private static func reviewLabel(_ decision: ReviewDecision?) -> String {
switch decision {
case .approved: return "approved"
case .changesRequested: return "changes requested"
case .reviewRequired: return "review required"
case nil: return "none"
}
}
}

#if DEBUG
enum PRListExportChecks {
static func run() {
let green = PRSnapshot(
nodeId: "a", number: 12, title: "feat: thing", htmlUrl: "https://github.com/o/r/pull/12",
repoFullName: "o/r", isDraft: false, state: "open", reviewDecision: .approved,
ciStatus: .success, reviewState: .approved, commentCount: 0, updatedAt: "",
author: "me", requestedReviewers: [], mergeable: true,
headRefName: "feat/thing", baseRefName: "main", linesAdded: 10, linesDeleted: 2
)
let blocked = PRSnapshot(
nodeId: "b", number: 7, title: "fix: other", htmlUrl: "https://github.com/o/r/pull/7",
repoFullName: "o/r", isDraft: true, state: "open", reviewDecision: .changesRequested,
ciStatus: .failure, reviewState: .changesRequested, commentCount: 0, updatedAt: "",
author: "", requestedReviewers: [], mergeable: false,
unresolvedThreadCount: 1
)

let out = PRListExport.markdown(prs: [green, blocked])
let lines = out.components(separatedBy: "\n")
assert(lines.count == 4, "two lines per PR, no heading")
assert(lines[0] == "- [o/r#12](https://github.com/o/r/pull/12) feat: thing", "row links repo#number to the URL")
assert(lines[1] == " - author: @me · CI: passing · review: approved · +10 −2 · branch: `feat/thing` → main",
"green PR details")
assert(lines[3] == " - draft · CI: failing · review: changes requested · merge conflict · 1 unresolved thread · +0 −0",
"blocked PR: draft/conflict/thread flags; empty author and branch omitted")
}
}
#endif
2 changes: 1 addition & 1 deletion Mainline/Mainline/Services/TelemetryService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ final class TelemetryService {
/// "open_in_browser" | "diff_preview" | "copy_branch" | "open_preview" | "tab_switch" |
/// "scope_filter_change" | "toggle_drafts" | "inbox_mute" | "inbox_unmute" |
/// "undo" | "refresh" | "multi_select_toggle" | "mark_ready" | "for_me_filter_change" |
/// "convert_to_draft" | "copy_link"
/// "convert_to_draft" | "copy_link" | "copy_pr_list"
func recordTriageInteraction(_ interaction: String) {
guard MainlineSettings.shared.telemetryEnabled else { return }
ensureSetup()
Expand Down
Loading
Loading