diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3cf6a38 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build and test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + # The oldest toolchain the README promises, and the current one. + os: [macos-15, macos-26] + steps: + - uses: actions/checkout@v5 + + - name: Toolchain + run: | + xcodebuild -version + swift --version + + - name: Install XcodeGen + run: command -v xcodegen >/dev/null || brew install xcodegen + + - name: Scan for credentials + run: make scan + + # No Local.xcconfig here, so this is also the proof that a fresh clone + # builds with ad-hoc signing and nothing to set up. + - name: Build and test + run: make test + + - name: Release build + run: make release diff --git a/.gitignore b/.gitignore index e5b11ba..faba082 100644 --- a/.gitignore +++ b/.gitignore @@ -16,12 +16,13 @@ secrets.json token.txt # ── Xcode / SwiftPM build output ───────────────────────────────────── -# BarRepo.xcodeproj is generated from project.yml by `make generate`. +# Cleared.xcodeproj is generated from project.yml by `make generate`. *.xcodeproj/ *.xcworkspace/ xcuserdata/ DerivedData/ build/ +dist/ .build/ .swiftpm/ *.xcuserstate diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c4d42ac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +## 0.4.0 + +The release that makes Cleared something you can leave running. + +### Fixed + +- **Cleared did nothing until its icon was first clicked.** Polling started + from the panel, which macOS does not build until it is opened, so an app + launched at login watched nothing and announced nothing. It now starts with + the app. +- **A fresh clone did not build.** `project.yml` pointed at `Local.xcconfig`, + which is gitignored. The include is now optional. +- **Merge was offered on pull requests GitHub would refuse.** Readiness looked + only at checks and conflicts. It now also requires GitHub's own merge verdict, + so a pull request waiting on a required review, behind its base branch, or + blocked by a branch rule is listed under *In flight* with the reason. +- **A merge could include commits nobody had seen.** The merge is now pinned to + the commit Cleared last polled; a push in between is refused by GitHub. +- **The merge method setting failed outright on repositories that don't allow + it.** Those repositories now use their own default. +- **Start a workflow listed workflows that can't be started**, always sent no + inputs, and assumed the branch was `main`. It now lists only workflows with a + `workflow_dispatch` trigger, shows their declared inputs, and defaults to the + repository's default branch. +- **The "calls left" figure jumped around.** It mixed the REST and GraphQL + budgets; it now shows the REST budget, which is the one polling spends. +- **A revoked token caused an error banner on every poll.** Cleared now stops + and returns to the connect screen with an explanation. +- **Ignoring a repository could not be undone**, and pinned repositories had no + interface at all. +- **Switches in Settings showed no colour when on.** macOS draws its own + switch grey in any window that isn't key, which a menu bar panel often isn't. + They are now drawn by the app and use the system accent colour. +- "Notify on failed runs" had no effect. A newer banner could be dismissed by + the previous banner's timer. `⌘1`–`⌘9` were attached to rows rather than to + the Merge buttons, and every row past the ninth shared `⌘↩`. + +### Added + +- Launch at login, an app icon (shown in Settings too), and `make install`. +- *Clear* and *Re-run failed jobs* buttons on notifications. A hold's + notification is withdrawn once the hold is gone. +- Reject a held deployment, from a row's right-click menu. +- An optional two-click confirmation for Clear and Merge. +- Rate-limit handling: when GitHub says to stop, Cleared waits until the time + it was given. +- Repositories are rediscovered hourly, so new ones appear without a restart. +- Timestamps in the panel tick while it is open. +- Logging to the unified log under `net.hlnd.Cleared`. +- CI on pull requests, and a script that produces a notarized build. + +### Changed + +- The token only needs the `repo` scope. Earlier versions asked for `workflow` + as well, which nothing Cleared does requires. +- Failed runs older than 30 days are no longer listed, since GitHub won't + re-run them. +- Release builds use the hardened runtime. + +## 0.3.0 + +Initial public version, plus a fix so the panel sizes to its queue. diff --git a/Cleared.xcconfig b/Cleared.xcconfig new file mode 100644 index 0000000..8883576 --- /dev/null +++ b/Cleared.xcconfig @@ -0,0 +1,6 @@ +// Build settings shared by every configuration. +// +// Local.xcconfig is optional and gitignored. The `?` makes the include a +// no-op when the file is absent, so a fresh clone builds with ad-hoc signing +// and nothing to set up. See Local.xcconfig.example for what it can override. +#include? "Local.xcconfig" diff --git a/Makefile b/Makefile index 652f9a3..d8b6e04 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: generate build test run preview release scan clean +.PHONY: generate build test run install preview icon release dist scan clean + +RELEASE_APP := build/Build/Products/Release/Cleared.app +INSTALL_DIR ?= /Applications generate: ## Regenerate Cleared.xcodeproj from project.yml xcodegen generate @@ -14,19 +17,38 @@ release: generate -derivedDataPath build clean build run: release - open build/Build/Products/Release/Cleared.app + open $(RELEASE_APP) + +## Build Release, replace the copy in /Applications, and launch it. +## Launch at login only works from an installed copy. +install: release + @pkill -x Cleared 2>/dev/null || true + rm -rf "$(INSTALL_DIR)/Cleared.app" + ditto $(RELEASE_APP) "$(INSTALL_DIR)/Cleared.app" + open "$(INSTALL_DIR)/Cleared.app" + @echo "✓ Installed Cleared $$(defaults read "$(INSTALL_DIR)/Cleared.app/Contents/Info" CFBundleShortVersionString) to $(INSTALL_DIR)" ## Render the panel in a normal window against sample data. ## FIXTURE=full|overflow|empty +## Opens a second instance and only ever closes an earlier preview, so an +## installed copy that is running keeps watching. preview: build - @pkill -f 'Cleared.app/Contents/MacOS/Cleared' 2>/dev/null || true - @open "$$(xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Debug \ - -showBuildSettings 2>/dev/null | awk '/BUILT_PRODUCTS_DIR/{print $$3}')/Cleared.app" \ + @pkill -f 'Cleared.app/Contents/MacOS/Cleared --ui-preview' 2>/dev/null || true + @open -n "$$(xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Debug \ + -showBuildSettings 2>/dev/null | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p')/Cleared.app" \ --args --ui-preview $${FIXTURE:-full} +## Redraw the app icon at every size from Scripts/make-icon.swift. +icon: + swift Scripts/make-icon.swift + +## Build a Developer ID-signed, notarized zip in dist/. See Scripts/release.sh. +dist: + @./Scripts/release.sh + ## Fail loudly if anything token-shaped is about to be committed. scan: @./Scripts/scan-secrets.sh clean: - rm -rf build DerivedData Cleared.xcodeproj + rm -rf build dist DerivedData Cleared.xcodeproj diff --git a/README.md b/README.md index 1b03afc..c0da968 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ The queue is sorted by what blocks you, not by repository. A held production deploy is the top item no matter which repo it's in, because that's the one that costs real time. +Every button is one GitHub would honour. A pull request only gets **Merge** when +its checks are green *and* GitHub reports no review or branch rule in the way, +and the merge is pinned to the commit Cleared last saw — so a push that lands +between polls is refused, not merged unseen. **Start a workflow** lists only +workflows that declare `workflow_dispatch`, with the inputs each one's file +declares. + ## Why it exists Environment gates with required reviewers don't fail — they idle. A deploy sits @@ -31,14 +38,22 @@ Cleared puts that queue in the menu bar and makes clearing it one click. ## Install -Requires macOS 14+ and Xcode 16+ to build. +Requires macOS 14+, and Xcode 16+ with [XcodeGen](https://github.com/yonaskolb/XcodeGen) +(`brew install xcodegen`) to build. There is no signed download yet, so for now +Cleared is built from source: ```sh git clone https://github.com/hollandcloud/cleared.git cd cleared -make run # builds Release and launches it +make install # builds Release, copies it to /Applications, launches it ``` +Then turn on *Settings → Launch at login*. Cleared starts watching as soon as it +launches — you don't need to open the panel for holds to be noticed. + +`make run` builds and launches without installing, which is fine for a look +but can't be registered to launch at login. + Or open it in Xcode: ```sh @@ -46,13 +61,12 @@ make generate # writes Cleared.xcodeproj from project.yml open Cleared.xcodeproj ``` -`Cleared.xcodeproj` is generated by [XcodeGen](https://github.com/yonaskolb/XcodeGen) -(`brew install xcodegen`) and is deliberately not committed — `project.yml` is -the source of truth. +`Cleared.xcodeproj` is generated and deliberately not committed — `project.yml` +is the source of truth. ## Connecting -Cleared needs a GitHub token with the **`repo`** and **`workflow`** scopes. +Cleared needs a GitHub token with the **`repo`** scope. - **If you use the `gh` CLI:** click *Use my gh CLI login*. Cleared shells out to `gh auth token` once and stores the result. @@ -64,15 +78,43 @@ in the clear, and never logged — the only representation Cleared will ever display is a fingerprint like `ghp_…2345 · 40 chars`. To revoke: *Settings → Disconnect and forget token*, which deletes the Keychain -item. Revoke the token itself on GitHub. +item. Revoke the token itself on GitHub. If GitHub starts rejecting the token — +revoked, or expired — Cleared stops polling and returns to the connect screen +saying so, rather than failing quietly. + +### Why `repo` + +Approving a deployment, merging, re-running jobs and dispatching a workflow all +require `repo` on a classic token; GitHub offers nothing narrower. (`workflow` +is not needed — that scope is for editing workflow files, which Cleared never +does.) -### Why `repo` and `workflow` +If that's more access than you want a menu bar app to hold, use a fine-grained +token limited to the repositories you want Cleared to touch, with these +repository permissions: -`workflow` is what allows approving a pending deployment and dispatching a -workflow; `repo` covers reading private repositories and merging. GitHub does -not offer a narrower scope for either action. If that's more access than you -want a menu bar app to hold, use a fine-grained PAT limited to the specific -repositories you want Cleared to touch. +| Permission | Access | Used for | +|---|---|---| +| Actions | Read and write | Listing runs, re-running jobs, starting workflows | +| Deployments | Read and write | Approving or rejecting a held deployment | +| Contents | Read and write | Merging; reading workflow files for their inputs | +| Pull requests | Read | Listing your open pull requests | + +Organizations that enforce SAML single sign-on also need the token authorized +for that organization on GitHub. + +## Using it + +- **Click a row** to open it on GitHub. **Right-click** for *Copy Link* — and, + on a held deployment, *Reject…*, which cancels the waiting jobs with a reason. +- **⌘1–⌘9** merge the first nine ready pull requests, in the order shown. + **⌘R** refreshes, **⌘,** opens Settings, **⌘Q** quits. +- A **notification** appears when a deployment starts holding, with a *Clear* + button on it. It is withdrawn once the hold is gone, whoever cleared it. +- *Settings → Ask before clearing or merging* turns each button into a + two-click *Sure?* confirmation. It is off by default: one click is the point. +- Pull requests that can't be merged yet sit under **In flight** with the + reason — *needs review*, *checks running*, *behind base branch*, *conflicts*. ## How it stays inside the rate limit @@ -87,14 +129,20 @@ its own. Cleared avoids that: across every repository, rather than one REST call per repo. - **Backoff.** Polling slows by 4× while the menu is closed, and by a further 4× when fewer than 500 calls remain. +- **Stopping when told to.** If GitHub answers with a rate limit, Cleared sends + nothing further on that budget until the time GitHub named, instead of + retrying into it. -Remaining budget is shown in the footer. +Remaining budget is shown in the footer. When a poll comes back short — no +network, rate limited — the footer says why and how stale the queue is. ## Repository discovery Cleared ships with **no repository list**. On first connect it asks GitHub for repositories you've pushed to in the last 90 days (configurable) and watches -the 20 most recent. You can exclude any of them in Settings. +the 20 most recent, re-checking hourly so a new repository is picked up without +a restart. In Settings you can ignore any of them (and un-ignore them later), or +pin a repository so it stays watched however long it has been quiet. This is deliberate: it means the source of this public repository reveals nothing about which repositories anyone actually tracks, and low-traffic repos @@ -104,14 +152,35 @@ that see one PR a month get picked up without being added by hand. ```sh make build # debug build -make test # 17 tests, 8 suites +make test # unit tests +make install # Release build into /Applications make preview # render the panel against sample data make preview FIXTURE=overflow # ... with enough rows to scroll make preview FIXTURE=empty # ... the all-clear state +make icon # redraw the app icon from Scripts/make-icon.swift make scan # refuse to commit anything token-shaped make clean ``` +Pull requests run the same `make scan`, `make test` and a Release build on +macOS 15 and macOS 26. + +### Tests + +The tests never reach GitHub and never touch your Keychain. `StubGitHub` +stands in for the API, so the client's caching and rate-limit handling and the +model's behaviour — a revoked token, a laptop going offline, a merge GitHub +refuses — are exercised against scripted responses. This matters more than +usual here: the tests run hosted inside the app, so a test that signed out for +real would delete the token of whoever ran it. Token storage is injected for +exactly that reason. + +To watch what the running app is doing: + +```sh +log stream --predicate 'subsystem == "net.hlnd.Cleared"' --level debug +``` + ### Working on the panel Laying out the queue against live GitHub means waiting for a deployment to @@ -130,18 +199,36 @@ that case rather than pretending no token was ever saved. To make the signature stable, copy `Local.xcconfig.example` to `Local.xcconfig` (gitignored) and set an identity from `security find-identity -v -p codesigning`. +The file is optional: without it the build falls back to ad-hoc signing. Install the pre-commit secret scan with `./Scripts/install-hooks.sh`. +### Releasing + +A build that other people can download has to be signed with a Developer ID +and notarized, or Gatekeeper refuses to open it: + +```sh +DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)" \ +NOTARY_PROFILE=cleared-notary \ +make dist # writes dist/Cleared-.zip +``` + +`NOTARY_PROFILE` is a credential profile saved once with +`xcrun notarytool store-credentials`. The script signs with the hardened +runtime, submits to Apple, staples the ticket and asks Gatekeeper to confirm +before it reports success. + ### Layout ``` Sources/Cleared/ ├── Auth/ Keychain wrapper, token store, gh CLI import -├── GitHub/ Actor-isolated client (ETag cache, rate limits) + the four verbs +├── GitHub/ Actor-isolated client (ETag cache, rate limits), the four +│ verbs, and the reader for workflow_dispatch inputs ├── Models/ Sendable value types -├── Queue/ AppModel — polling lanes, actions, banner state -├── Support/ Settings, notifications +├── Queue/ AppModel — the polling loop, actions, banner state +├── Support/ Settings, notifications, launch at login, logging └── UI/ MenuBarExtra panel ``` diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5ec5b29 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..ef1c171 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..6b83d41 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..7e26b6a Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..fd0a567 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..6b83d41 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..bdc9fae Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..fd0a567 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..69314d1 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..bdc9fae Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..bcf7cd1 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..4aa7c53 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/Resources/Info.plist b/Resources/Info.plist index a494191..511d667 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -18,6 +18,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.developer-tools LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/Scripts/make-icon.swift b/Scripts/make-icon.swift new file mode 100755 index 0000000..0c77e74 --- /dev/null +++ b/Scripts/make-icon.swift @@ -0,0 +1,180 @@ +#!/usr/bin/env swift +// Draws Cleared's app icon and writes every size the asset catalog needs. +// +// swift Scripts/make-icon.swift +// +// The icon is drawn in code so it can be regenerated and reviewed like the +// rest of the source: an amber tile — the colour of a held deployment — with +// an air traffic control tower on it. "Cleared" is the tower's word: nothing +// leaves the gate until it says so. The tower is drawn here rather than +// borrowed from SF Symbols, which has no such glyph and which Apple's licence +// does not allow in an app icon anyway. + +import AppKit + +let output = URL(fileURLWithPath: CommandLine.arguments.count > 1 + ? CommandLine.arguments[1] + : "Resources/Assets.xcassets/AppIcon.appiconset") + +/// Draws the icon into a square of `size` pixels. +func draw(size: CGFloat, in context: CGContext) { + let unit = size / 1024 + context.clear(CGRect(x: 0, y: 0, width: size, height: size)) + + // The macOS icon grid: an 824pt tile centred on a 1024pt canvas. + let tile = CGRect(x: 100 * unit, y: 100 * unit, width: 824 * unit, height: 824 * unit) + let shape = CGPath(roundedRect: tile, cornerWidth: 185 * unit, cornerHeight: 185 * unit, transform: nil) + + context.saveGState() + context.setShadow( + offset: CGSize(width: 0, height: -10 * unit), + blur: 24 * unit, + color: NSColor.black.withAlphaComponent(0.28).cgColor + ) + context.addPath(shape) + context.setFillColor(NSColor(srgbRed: 0.85, green: 0.62, blue: 0.16, alpha: 1).cgColor) + context.fillPath() + context.restoreGState() + + context.saveGState() + context.addPath(shape) + context.clip() + let colors = [ + NSColor(srgbRed: 0.97, green: 0.76, blue: 0.27, alpha: 1).cgColor, + NSColor(srgbRed: 0.80, green: 0.52, blue: 0.10, alpha: 1).cgColor, + ] as CFArray + let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors, locations: [0, 1])! + context.drawLinearGradient( + gradient, + start: CGPoint(x: tile.midX, y: tile.maxY), + end: CGPoint(x: tile.midX, y: tile.minY), + options: [] + ) + context.restoreGState() + + // The tower: a stem with a wide, flat head. Every part is at least a + // pixel across at 16 pixels, so the silhouette survives the smallest size. + let centre: CGFloat = 512 + + /// A four-sided shape symmetric about the centre line, by its two widths. + func band(from bottom: CGFloat, width bottomWidth: CGFloat, to top: CGFloat, width topWidth: CGFloat) -> CGPath { + let path = CGMutablePath() + path.addLines(between: [ + CGPoint(x: (centre - bottomWidth / 2) * unit, y: bottom * unit), + CGPoint(x: (centre + bottomWidth / 2) * unit, y: bottom * unit), + CGPoint(x: (centre + topWidth / 2) * unit, y: top * unit), + CGPoint(x: (centre - topWidth / 2) * unit, y: top * unit), + ]) + path.closeSubpath() + return path + } + + func slab(from bottom: CGFloat, to top: CGFloat, width: CGFloat, radius: CGFloat) -> CGPath { + CGPath( + roundedRect: CGRect(x: (centre - width / 2) * unit, y: bottom * unit, width: width * unit, height: (top - bottom) * unit), + cornerWidth: radius * unit, + cornerHeight: radius * unit, + transform: nil + ) + } + + // One path, filled once, so the parts cast a single shadow between them. + let tower = CGMutablePath() + tower.addPath(slab(from: 214, to: 268, width: 300, radius: 16)) // plinth + tower.addPath(band(from: 262, width: 168, to: 548, width: 128)) // shaft + tower.addPath(band(from: 534, width: 132, to: 566, width: 330)) // collar under the cab + tower.addPath(band(from: 560, width: 330, to: 706, width: 420)) // cab, leaning outward + tower.addPath(slab(from: 700, to: 748, width: 456, radius: 14)) // roof + tower.addPath(slab(from: 742, to: 800, width: 64, radius: 8)) // mast + tower.addEllipse(in: CGRect(x: (centre - 34) * unit, y: 782 * unit, width: 68 * unit, height: 68 * unit)) // beacon + + context.saveGState() + context.setShadow( + offset: CGSize(width: 0, height: -6 * unit), + blur: 14 * unit, + color: NSColor(srgbRed: 0.45, green: 0.27, blue: 0.02, alpha: 0.45).cgColor + ) + context.addPath(tower) + context.setFillColor(NSColor.white.cgColor) + context.fillPath() + context.restoreGState() + + // The cab's glass: a band of the tile's own amber showing through, which + // follows the slant of the cab walls. Left out at 16 pixels, where it is + // about a pixel tall and only greys the cab; a solid head reads better. + func cabWidth(at y: CGFloat) -> CGFloat { 330 + (420 - 330) * (y - 560) / (706 - 560) } + let sill: CGFloat = 598 + let lintel: CGFloat = 668 + let inset: CGFloat = 56 + if size >= 32 { + context.addPath(band(from: sill, width: cabWidth(at: sill) - inset, to: lintel, width: cabWidth(at: lintel) - inset)) + context.setFillColor(NSColor(srgbRed: 0.72, green: 0.45, blue: 0.07, alpha: 1).cgColor) + context.fillPath() + } + + // Mullions, only where there are pixels to draw them with. + if size >= 128 { + context.setFillColor(NSColor.white.cgColor) + for offset in [-96, 0, 96] as [CGFloat] { + // Lean each one with the wall it is nearest, like the real thing. + let lean = offset / 330 * (420 - 330) / 2 * (lintel - sill) / (706 - 560) + let mullion = CGMutablePath() + mullion.addLines(between: [ + CGPoint(x: (centre + offset - 8) * unit, y: sill * unit), + CGPoint(x: (centre + offset + 8) * unit, y: sill * unit), + CGPoint(x: (centre + offset + lean + 8) * unit, y: lintel * unit), + CGPoint(x: (centre + offset + lean - 8) * unit, y: lintel * unit), + ]) + mullion.closeSubpath() + context.addPath(mullion) + } + context.fillPath() + } +} + +func png(pixels: Int) -> Data { + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixels, + pixelsHigh: pixels, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )! + let context = NSGraphicsContext(bitmapImageRep: rep)! + draw(size: CGFloat(pixels), in: context.cgContext) + context.flushGraphics() + return rep.representation(using: .png, properties: [:])! +} + +let slots: [(points: Int, scale: Int)] = [ + (16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2), +] + +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + +var images: [[String: String]] = [] +for slot in slots { + let name = "icon_\(slot.points)x\(slot.points)\(slot.scale == 2 ? "@2x" : "").png" + try png(pixels: slot.points * slot.scale).write(to: output.appendingPathComponent(name)) + images.append([ + "filename": name, + "idiom": "mac", + "scale": "\(slot.scale)x", + "size": "\(slot.points)x\(slot.points)", + ]) +} + +let contents: [String: Any] = ["images": images, "info": ["author": "xcode", "version": 1]] +let json = try JSONSerialization.data(withJSONObject: contents, options: [.prettyPrinted, .sortedKeys]) +try json.write(to: output.appendingPathComponent("Contents.json")) + +let catalog: [String: Any] = ["info": ["author": "xcode", "version": 1]] +try JSONSerialization.data(withJSONObject: catalog, options: [.prettyPrinted, .sortedKeys]) + .write(to: output.deletingLastPathComponent().appendingPathComponent("Contents.json")) + +print("✓ wrote \(slots.count) sizes to \(output.path)") diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..75330bf --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Builds the zip that goes on a GitHub release: signed with a Developer ID, +# notarized by Apple, and stapled, so it opens on someone else's Mac without a +# Gatekeeper warning. +# +# DEVELOPER_ID="Developer ID Application: Your Name (TEAMID)" \ +# NOTARY_PROFILE=cleared-notary \ +# make dist +# +# DEVELOPER_ID a certificate from `security find-identity -v -p codesigning` +# NOTARY_PROFILE a credential profile saved once with +# `xcrun notarytool store-credentials ` +# +# Leave NOTARY_PROFILE unset to stop after signing. That zip runs on this Mac +# but Gatekeeper will refuse it anywhere else, so do not publish it. +set -euo pipefail + +: "${DEVELOPER_ID:?Set DEVELOPER_ID to a Developer ID Application certificate — see the top of this script.}" +NOTARY_PROFILE=${NOTARY_PROFILE:-} + +cd "$(git rev-parse --show-toplevel)" + +team=${DEVELOPER_ID##*(} +team=${team%)} +version=$(sed -n 's/.*MARKETING_VERSION: *"\(.*\)".*/\1/p' project.yml) +app=build/Build/Products/Release/Cleared.app +zip=dist/Cleared-$version.zip + +echo "▸ Building Cleared $version" +xcodegen generate >/dev/null +xcodebuild -project Cleared.xcodeproj -scheme Cleared -configuration Release \ + -derivedDataPath build \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$DEVELOPER_ID" \ + DEVELOPMENT_TEAM="$team" \ + OTHER_CODE_SIGN_FLAGS=--timestamp \ + clean build >/dev/null + +echo "▸ Checking the signature" +codesign --verify --strict --deep "$app" +# Notarization rejects a build without the hardened runtime, and says so only +# after the upload. Fail here instead. +if ! codesign --display --verbose=2 "$app" 2>&1 | grep -q 'flags=.*runtime'; then + echo "✖ $app was not signed with the hardened runtime." >&2 + exit 1 +fi + +mkdir -p dist +rm -f "$zip" +ditto -c -k --keepParent "$app" "$zip" + +if [ -z "$NOTARY_PROFILE" ]; then + echo "▸ NOTARY_PROFILE is not set — skipping notarization." + echo " $zip is signed but NOT notarized. Do not publish it." + exit 0 +fi + +echo "▸ Notarizing (this waits on Apple, usually a minute or two)" +xcrun notarytool submit "$zip" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple "$app" + +# The ticket is stapled to the app, so the zip has to be made again around it. +rm -f "$zip" +ditto -c -k --keepParent "$app" "$zip" + +echo "▸ Asking Gatekeeper what it thinks" +spctl --assess --type execute --verbose=2 "$app" + +echo "✓ $zip" +shasum -a 256 "$zip" diff --git a/Sources/Cleared/Auth/Keychain.swift b/Sources/Cleared/Auth/Keychain.swift index 489843d..f06d618 100644 --- a/Sources/Cleared/Auth/Keychain.swift +++ b/Sources/Cleared/Auth/Keychain.swift @@ -34,13 +34,15 @@ enum Keychain { switch status { case errSecSuccess: return - case errSecItemNotFound: + default: + // Not found — or found but closed to this build, which is what a + // rebuild with a new signature leaves behind. Either way the fix + // is a fresh item, so clear anything in the way and add one. + if status != errSecItemNotFound { SecItemDelete(query as CFDictionary) } var insert = query insert.merge(attributes) { current, _ in current } let addStatus = SecItemAdd(insert as CFDictionary, nil) guard addStatus == errSecSuccess else { throw Failure.unexpectedStatus(addStatus) } - default: - throw Failure.unexpectedStatus(status) } } diff --git a/Sources/Cleared/Auth/TokenStore.swift b/Sources/Cleared/Auth/TokenStore.swift index aaa0c15..f51b7e0 100644 --- a/Sources/Cleared/Auth/TokenStore.swift +++ b/Sources/Cleared/Auth/TokenStore.swift @@ -51,12 +51,33 @@ struct TokenStore: Sendable { } } +/// Where the token is kept. The app uses the Keychain. Tests run hosted inside +/// the app, so they supply their own — otherwise a sign-out test would delete +/// the real token of whoever ran it. +struct TokenVault: Sendable { + var current: @Sendable () -> String? + var save: @Sendable (String) throws -> Void + var clear: @Sendable () -> Void + + static let keychain = TokenVault( + current: { TokenStore.current() }, + save: { try TokenStore.save($0) }, + clear: { TokenStore.clear() } + ) +} + /// Imports the token from an existing `gh auth login`, so connecting Cleared is /// one click for anyone who already uses the GitHub CLI. enum GHCLIImporter { + /// A menu bar app does not inherit a shell's PATH, so the usual install + /// locations are checked directly. private static let candidatePaths = [ "/opt/homebrew/bin/gh", "/usr/local/bin/gh", + "/opt/local/bin/gh", + "/run/current-system/sw/bin/gh", + NSHomeDirectory() + "/.nix-profile/bin/gh", + NSHomeDirectory() + "/.local/bin/gh", "/usr/bin/gh", ] @@ -67,12 +88,17 @@ enum GHCLIImporter { .map { URL(fileURLWithPath: $0) } } - static func importToken() throws -> String { + /// Runs off the main actor: `gh` can take a moment to answer, and the + /// panel should keep drawing while it does. + static func importToken() async throws -> String { guard let gh = locate() else { throw TokenStore.TokenError.ghNotFound } + return try await Task.detached(priority: .userInitiated) { try readToken(from: gh) }.value + } + private static func readToken(from gh: URL) throws -> String { let process = Process() process.executableURL = gh - process.arguments = ["auth", "token"] + process.arguments = ["auth", "token", "--hostname", "github.com"] process.standardInput = FileHandle.nullDevice let out = Pipe() diff --git a/Sources/Cleared/ClearedApp.swift b/Sources/Cleared/ClearedApp.swift index 25b6bea..750378d 100644 --- a/Sources/Cleared/ClearedApp.swift +++ b/Sources/Cleared/ClearedApp.swift @@ -2,35 +2,64 @@ import SwiftUI @main struct ClearedApp: App { -#if DEBUG - @NSApplicationDelegateAdaptor(PreviewLauncher.self) private var launcher -#endif - @State private var model = AppModel() + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate var body: some Scene { MenuBarExtra { - MenuPanel(model: model) - .task { await model.start() } + MenuPanel(model: delegate.model) } label: { - MenuBarLabel(model: model) + MenuBarLabel(model: delegate.model) } .menuBarExtraStyle(.window) } } -#if DEBUG -/// `open Cleared.app --args --ui-preview` renders the panel in an ordinary -/// window against sample data. +/// Owns the model, so polling starts when the app launches. /// -/// Laying out the queue against real GitHub state means waiting for a -/// deployment to actually be held — the rare event the whole app exists to -/// catch — so the panel would otherwise be near-impossible to iterate on. -final class PreviewLauncher: NSObject, NSApplicationDelegate { - private var window: NSWindow? +/// A window-style `MenuBarExtra` does not build its content until the icon is +/// first clicked. Starting from the panel would mean an app launched at login +/// watches nothing — and announces nothing — until someone opens it, which is +/// the opposite of what a hold notifier is for. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let model = AppModel() +#if DEBUG + private var previewWindow: NSWindow? +#endif func applicationDidFinishLaunching(_ notification: Notification) { - guard CommandLine.arguments.contains("--ui-preview") else { return } +#if DEBUG + if CommandLine.arguments.contains("--ui-preview") { + openPreview() + return + } +#endif + // The unit tests run hosted inside the app. They must never read the + // Keychain, prompt for notifications, or reach GitHub. + guard !AppEnvironment.isRunningTests else { return } + + Notifier.shared.attach(to: model) + Task { await model.start() } + } +} +enum AppEnvironment { + static let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + + /// "0.4.0" — read from the bundle so the User-Agent and the Settings + /// footer can never drift from `MARKETING_VERSION`. + static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" +} + +#if DEBUG +extension AppDelegate { + /// `open Cleared.app --args --ui-preview` renders the panel in an ordinary + /// window against sample data. + /// + /// Laying out the queue against real GitHub state means waiting for a + /// deployment to actually be held — the rare event the whole app exists to + /// catch — so the panel would otherwise be near-impossible to iterate on. + private func openPreview() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: Theme.panelWidth + 48, height: 820), styleMask: [.titled, .closable, .resizable], @@ -44,7 +73,7 @@ final class PreviewLauncher: NSObject, NSApplicationDelegate { window.makeKeyAndOrderFront(nil) NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) - self.window = window + previewWindow = window } } diff --git a/Sources/Cleared/GitHub/GitHubAPI.swift b/Sources/Cleared/GitHub/GitHubAPI.swift index 8b01c42..8e4e729 100644 --- a/Sources/Cleared/GitHub/GitHubAPI.swift +++ b/Sources/Cleared/GitHub/GitHubAPI.swift @@ -48,6 +48,21 @@ private struct MergeResult: Decodable, Sendable { let message: String? } +private struct DispatchResult: Decodable, Sendable { + let html_url: String? +} + +/// The signed-in account, and what its token is allowed to do. +struct Viewer: Sendable, Hashable { + let login: String + /// Scopes on a classic token. Nil for a fine-grained token, which reports none. + let scopes: [String]? + + /// False only when GitHub positively reports a classic token without + /// `repo`. Every write Cleared makes needs it. + var canWrite: Bool { scopes?.contains("repo") ?? true } +} + // MARK: - Read + write operations extension GitHubClient { @@ -66,9 +81,12 @@ extension GitHubClient { var approvals: [PendingApproval] = [] for run in page.workflow_runs { + // Cached too: a run that sits at a gate for an hour would otherwise + // cost a request every poll for an answer that has not changed. let deployments: [PendingDeploymentDTO] = try await get( [PendingDeploymentDTO].self, - "repos/\(repo.owner)/\(repo.name)/actions/runs/\(run.id)/pending_deployments" + "repos/\(repo.owner)/\(repo.name)/actions/runs/\(run.id)/pending_deployments", + useCache: true ) // Only surface gates this user can actually clear. A run held for // someone else is noise, not an action. @@ -92,20 +110,15 @@ extension GitHubClient { /// Clears a held deployment. This is the action the whole app exists for. func approve(_ approval: PendingApproval, comment: String = "Cleared from the menu bar") async throws { - struct Body: Encodable, Sendable { - let environment_ids: [Int] - let state: String - let comment: String - } - try await rest( - "POST", - "repos/\(approval.repo.owner)/\(approval.repo.name)/actions/runs/\(approval.runID)/pending_deployments", - body: Body(environment_ids: approval.environmentIDs, state: "approved", comment: comment) - ) + try await review(approval, state: "approved", comment: comment) } - /// Rejects a held deployment. + /// Rejects a held deployment, which cancels the jobs waiting on it. func reject(_ approval: PendingApproval, comment: String) async throws { + try await review(approval, state: "rejected", comment: comment) + } + + private func review(_ approval: PendingApproval, state: String, comment: String) async throws { struct Body: Encodable, Sendable { let environment_ids: [Int] let state: String @@ -114,7 +127,7 @@ extension GitHubClient { try await rest( "POST", "repos/\(approval.repo.owner)/\(approval.repo.name)/actions/runs/\(approval.runID)/pending_deployments", - body: Body(environment_ids: approval.environmentIDs, state: "rejected", comment: comment) + body: Body(environment_ids: approval.environmentIDs, state: state, comment: comment) ) } @@ -127,6 +140,10 @@ extension GitHubClient { struct Owner: Decodable, Sendable { let login: String } let name: String let owner: Owner + let squashMergeAllowed: Bool + let mergeCommitAllowed: Bool + let rebaseMergeAllowed: Bool + let viewerDefaultMergeMethod: String? } struct Commits: Decodable, Sendable { struct CommitNode: Decodable, Sendable { @@ -138,15 +155,21 @@ extension GitHubClient { } let nodes: [CommitNode] } - let number: Int - let title: String - let url: String - let isDraft: Bool - let headRefName: String - let updatedAt: Date - let mergeable: String - let repository: Repo - let commits: Commits + // Optional because an inline fragment yields an empty object + // for a search hit that is not a pull request. + let number: Int? + let title: String? + let url: String? + let isDraft: Bool? + let headRefName: String? + let headRefOid: String? + let updatedAt: Date? + let mergeable: String? + let mergeStateStatus: String? + let reviewDecision: String? + let isInMergeQueue: Bool? + let repository: Repo? + let commits: Commits? } let search: Search } @@ -156,8 +179,12 @@ extension GitHubClient { search(query: $q, type: ISSUE, first: \(limit)) { nodes { ... on PullRequest { - number title url isDraft headRefName updatedAt mergeable - repository { name owner { login } } + number title url isDraft headRefName headRefOid updatedAt + mergeable mergeStateStatus reviewDecision isInMergeQueue + repository { + name owner { login } + squashMergeAllowed mergeCommitAllowed rebaseMergeAllowed viewerDefaultMergeMethod + } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } @@ -172,36 +199,59 @@ extension GitHubClient { ) return payload.search.nodes.compactMap { node -> PullRequestSummary? in - guard let node, let url = URL(string: node.url) else { return nil } - let rollup = node.commits.nodes.first?.commit.statusCheckRollup?.state + guard let node, + let number = node.number, + let repository = node.repository, + let url = node.url.flatMap(URL.init(string:)) + else { return nil } + + let rollup = node.commits?.nodes.first?.commit.statusCheckRollup?.state let checks: CheckState = switch rollup { case "SUCCESS": .success case "PENDING", "EXPECTED": .pending case "FAILURE", "ERROR": .failure default: .none } + + var allowed: [MergeMethod] = [] + if repository.squashMergeAllowed { allowed.append(.squash) } + if repository.mergeCommitAllowed { allowed.append(.merge) } + if repository.rebaseMergeAllowed { allowed.append(.rebase) } + return PullRequestSummary( - repo: RepoRef(owner: node.repository.owner.login, name: node.repository.name), - number: node.number, - title: node.title, - branch: node.headRefName, - isDraft: node.isDraft, + repo: RepoRef(owner: repository.owner.login, name: repository.name), + number: number, + title: node.title ?? "", + branch: node.headRefName ?? "—", + isDraft: node.isDraft ?? false, checkState: checks, - mergeable: node.mergeable.lowercased(), + mergeable: (node.mergeable ?? "UNKNOWN").lowercased(), htmlURL: url, - updatedAt: node.updatedAt + updatedAt: node.updatedAt ?? .distantPast, + headSHA: node.headRefOid ?? "", + mergeState: MergeState(graphQL: node.mergeStateStatus), + reviewDecision: ReviewDecision(graphQL: node.reviewDecision), + isInMergeQueue: node.isInMergeQueue ?? false, + allowedMethods: allowed, + repoDefaultMethod: MergeMethod(graphQL: repository.viewerDefaultMergeMethod) ) } } - /// Squash-merges. Squash is the default because a squash commit takes the PR - /// title, which is what semantic-release reads to pick the next version. - func merge(_ pr: PullRequestSummary, method: String = "squash") async throws { - struct Body: Encodable, Sendable { let merge_method: String } + /// Merges a pull request at the commit Cleared last saw. + /// + /// Sending the head SHA is what makes one click safe: if someone pushed + /// since the last poll, the checks shown as green did not run on what would + /// be merged, and GitHub answers 409 instead of merging it. + func merge(_ pr: PullRequestSummary, method: MergeMethod = .squash) async throws { + struct Body: Encodable, Sendable { + let merge_method: String + let sha: String? + } let response = try await rest( "PUT", "repos/\(pr.repo.owner)/\(pr.repo.name)/pulls/\(pr.number)/merge", - body: Body(merge_method: method) + body: Body(merge_method: method.rawValue, sha: pr.headSHA.isEmpty ? nil : pr.headSHA) ) let result = try Self.decode(MergeResult.self, from: response.data) if result.merged == false { @@ -233,25 +283,88 @@ extension GitHubClient { try await rest("POST", "repos/\(run.repo.owner)/\(run.repo.name)/actions/runs/\(run.runID)/rerun-failed-jobs") } - /// Workflows in a repository that declare `on: workflow_dispatch`. - func dispatchableWorkflows(in repo: RepoRef) async throws -> [DispatchableWorkflow] { - let page: WorkflowsPage = try await get( + /// Workflows in a repository that declare `on: workflow_dispatch`, with + /// the inputs each one takes. + /// + /// The REST listing knows a workflow's name and whether it is enabled, but + /// not what triggers it. The triggers are only in the files, so one GraphQL + /// request reads the whole `.github/workflows` directory alongside it. + func workflowCatalog(in repo: RepoRef) async throws -> WorkflowCatalog { + struct Payload: Decodable, Sendable { + struct Repository: Decodable, Sendable { + struct Branch: Decodable, Sendable { let name: String } + struct Tree: Decodable, Sendable { + struct Entry: Decodable, Sendable { + struct Blob: Decodable, Sendable { let text: String? } + let path: String + let object: Blob? + } + let entries: [Entry]? + } + let defaultBranchRef: Branch? + let object: Tree? + } + let repository: Repository? + } + + let query = """ + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + object(expression: "HEAD:.github/workflows") { + ... on Tree { entries { path object { ... on Blob { text } } } } + } + } + } + """ + + async let listing: WorkflowsPage = get( WorkflowsPage.self, "repos/\(repo.owner)/\(repo.name)/actions/workflows?per_page=100", useCache: true ) - return page.workflows - .filter { $0.state == "active" } - .map { DispatchableWorkflow(repo: repo, workflowID: $0.id, name: $0.name, path: $0.path) } + async let files: Payload = graphQL(Payload.self, query: query, variables: ["owner": repo.owner, "name": repo.name]) + let (page, payload) = try await (listing, files) + + var sources: [String: String] = [:] + for entry in payload.repository?.object?.entries ?? [] { + if let text = entry.object?.text { sources[entry.path] = text } + } + + let workflows = page.workflows.compactMap { workflow -> DispatchableWorkflow? in + guard workflow.state == "active", + let source = sources[workflow.path], + let inputs = WorkflowFile.dispatchInputs(in: source) + else { return nil } + return DispatchableWorkflow( + repo: repo, + workflowID: workflow.id, + name: workflow.name, + path: workflow.path, + inputs: inputs + ) + } + return WorkflowCatalog( + defaultBranch: payload.repository?.defaultBranchRef?.name ?? "main", + workflows: workflows.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + ) } - func dispatch(_ workflow: DispatchableWorkflow, ref: String, inputs: [String: String]) async throws { + /// Starts a workflow and returns the page for the run it created, when + /// GitHub reports one. + @discardableResult + func dispatch(_ workflow: DispatchableWorkflow, ref: String, inputs: [String: String]) async throws -> URL? { struct Body: Encodable, Sendable { let ref: String; let inputs: [String: String] } - try await rest( + let response = try await rest( "POST", "repos/\(workflow.repo.owner)/\(workflow.repo.name)/actions/workflows/\(workflow.workflowID)/dispatches", body: Body(ref: ref, inputs: inputs) ) + // Older API versions answer 204 with no body at all. + guard !response.data.isEmpty, let result = try? Self.decode(DispatchResult.self, from: response.data) else { + return nil + } + return result.html_url.flatMap(URL.init(string:)) } /// Repositories the signed-in user has pushed to recently. @@ -266,7 +379,8 @@ extension GitHubClient { for page in 1...3 { let repos: [RepoDTO] = try await get( [RepoDTO].self, - "user/repos?affiliation=owner,organization_member,collaborator&sort=pushed&direction=desc&per_page=100&page=\(page)" + "user/repos?affiliation=owner,organization_member,collaborator&sort=pushed&direction=desc&per_page=100&page=\(page)", + useCache: true ) if repos.isEmpty { break } for repo in repos where !repo.archived && (repo.disabled != true) { @@ -287,9 +401,10 @@ extension GitHubClient { .map(\.0) } - func viewerLogin() async throws -> String { + func viewer() async throws -> Viewer { struct User: Decodable, Sendable { let login: String } - let user: User = try await get(User.self, "user") - return user.login + let response = try await rest("GET", "user") + let user = try Self.decode(User.self, from: response.data) + return Viewer(login: user.login, scopes: response.scopes) } } diff --git a/Sources/Cleared/GitHub/GitHubClient.swift b/Sources/Cleared/GitHub/GitHubClient.swift index f593fbd..bb665b4 100644 --- a/Sources/Cleared/GitHub/GitHubClient.swift +++ b/Sources/Cleared/GitHub/GitHubClient.swift @@ -1,8 +1,10 @@ import Foundation -enum GitHubError: Error, LocalizedError, Sendable { +enum GitHubError: Error, LocalizedError, Sendable, Equatable { case notAuthenticated case http(status: Int, message: String) + /// GitHub asked Cleared to stop for a while. `until` is when it may resume. + case rateLimited(until: Date) case transport(String) case decoding(String) @@ -13,20 +15,32 @@ enum GitHubError: Error, LocalizedError, Sendable { case .http(let status, let message): switch status { case 401: return "GitHub rejected the token. Reconnect in Settings." - case 403: return message.contains("rate limit") - ? "Rate limited. Cleared will back off and retry." - : "That token lacks permission. It needs the repo and workflow scopes." + case 403: + if message.localizedCaseInsensitiveContains("rate limit") { + return "Rate limited. Cleared will back off and retry." + } + if message.localizedCaseInsensitiveContains("SAML") { + return "That organization requires single sign-on. Authorize the token for it on GitHub." + } + return "That token lacks permission. It needs the repo scope." case 404: return "Not found — the token may not have access to that repository." - case 405, 409: return message - case 422: return message + case 405, 409, 422: return message default: return "GitHub returned \(status). \(message)" } + case .rateLimited(let until): + let minutes = max(1, Int((until.timeIntervalSinceNow / 60).rounded(.up))) + return "Rate limited. Cleared will retry in \(minutes)m." case .transport(let detail): return "Couldn't reach GitHub. \(detail)" case .decoding(let detail): return "Unexpected response from GitHub. \(detail)" } } + + var isUnauthorized: Bool { + if case .http(status: 401, _) = self { return true } + return false + } } /// Talks to GitHub. Actor-isolated so the ETag cache and rate-limit reading @@ -38,24 +52,46 @@ enum GitHubError: Error, LocalizedError, Sendable { /// time, so a quiet repository costs nothing at all. actor GitHubClient { private let session: URLSession + private let token: @Sendable () -> String? + private let now: @Sendable () -> Date private var cache: [String: (etag: String, data: Data)] = [:] - private(set) var rateLimit: RateLimit? + /// REST and GraphQL draw on separate budgets, so they are tracked apart. + private var rateLimits: [String: RateLimit] = [:] + private var blockedUntil: [String: Date] = [:] + + private enum Resource { + static let core = "core" + static let graphQL = "graphql" + } - init(session: URLSession? = nil) { + init( + session: URLSession? = nil, + token: @escaping @Sendable () -> String? = { TokenStore.current() }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.token = token + self.now = now if let session { self.session = session } else { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 20 config.waitsForConnectivity = false - config.httpAdditionalHeaders = ["User-Agent": "Cleared/0.3 (+https://github.com/hollandcloud/cleared)"] + config.httpAdditionalHeaders = [ + "User-Agent": "Cleared/\(AppEnvironment.version) (+https://github.com/hollandcloud/cleared)", + ] self.session = URLSession(configuration: config) } } - func currentRateLimit() -> RateLimit? { rateLimit } + /// The REST budget — the one polling spends, and the one worth showing. + func currentRateLimit() -> RateLimit? { rateLimits[Resource.core] } - func clearCache() { cache.removeAll() } + func clearCache() { + cache.removeAll() + rateLimits.removeAll() + blockedUntil.removeAll() + } // MARK: - REST @@ -64,6 +100,9 @@ actor GitHubClient { let status: Int /// True when GitHub answered 304 and this is the previously cached body. let notModified: Bool + /// The scopes a classic token carries. Nil for fine-grained tokens, + /// which do not report any. + let scopes: [String]? } /// Issues a REST request, reusing an ETag when one is known for this path. @@ -74,11 +113,19 @@ actor GitHubClient { body: (any Encodable & Sendable)? = nil, useCache: Bool = false ) async throws -> Response { - guard let token = TokenStore.current() else { throw GitHubError.notAuthenticated } + guard let token = token() else { throw GitHubError.notAuthenticated } guard let url = URL(string: "https://api.github.com/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) else { throw GitHubError.transport("Bad URL for \(path)") } + // Once GitHub has said stop, keep asking nothing until it said to + // resume. Retrying into a secondary limit is how a token gets flagged. + let resource = path == "graphql" ? Resource.graphQL : Resource.core + if let until = blockedUntil[resource] { + if until > now() { throw GitHubError.rateLimited(until: until) } + blockedUntil[resource] = nil + } + var request = URLRequest(url: url) request.httpMethod = method request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -107,10 +154,19 @@ actor GitHubClient { throw GitHubError.transport("Malformed response") } - readRateLimit(from: http) + readRateLimit(from: http, fallbackResource: resource) + let scopes = http.value(forHTTPHeaderField: "X-OAuth-Scopes").map { header in + header.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } if http.statusCode == 304, let cached = cache[cacheKey] { - return Response(data: cached.data, status: 304, notModified: true) + return Response(data: cached.data, status: 304, notModified: true, scopes: scopes) + } + + if let until = rateLimitRelease(from: http, data: data) { + blockedUntil[resource] = until + Log.github.notice("rate limited on \(resource, privacy: .public); resuming in \(Int(until.timeIntervalSince(self.now())))s") + throw GitHubError.rateLimited(until: until) } guard (200..<300).contains(http.statusCode) else { @@ -121,7 +177,7 @@ actor GitHubClient { cache[cacheKey] = (etag, data) } - return Response(data: data, status: http.statusCode, notModified: false) + return Response(data: data, status: http.statusCode, notModified: false, scopes: scopes) } func get(_ type: T.Type, _ path: String, useCache: Bool = false) async throws -> T { @@ -152,12 +208,36 @@ actor GitHubClient { // MARK: - Helpers - private func readRateLimit(from http: HTTPURLResponse) { + private func readRateLimit(from http: HTTPURLResponse, fallbackResource: String) { guard let remaining = http.value(forHTTPHeaderField: "X-RateLimit-Remaining").flatMap(Int.init), let limit = http.value(forHTTPHeaderField: "X-RateLimit-Limit").flatMap(Int.init), let reset = http.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Double.init) else { return } - rateLimit = RateLimit(remaining: remaining, limit: limit, resetsAt: Date(timeIntervalSince1970: reset)) + let resource = http.value(forHTTPHeaderField: "X-RateLimit-Resource") ?? fallbackResource + rateLimits[resource] = RateLimit(remaining: remaining, limit: limit, resetsAt: Date(timeIntervalSince1970: reset)) + } + + /// When GitHub is rate limiting this response, the moment it is safe to + /// try again; nil for every other response. + /// + /// A primary limit arrives as 403 or 429 with no budget remaining. A + /// secondary limit arrives as 403 or 429 with `Retry-After`, or with + /// neither header and only the message to go on. + private func rateLimitRelease(from http: HTTPURLResponse, data: Data) -> Date? { + guard http.statusCode == 403 || http.statusCode == 429 else { return nil } + + if let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init) { + return now().addingTimeInterval(max(1, retryAfter)) + } + if http.value(forHTTPHeaderField: "X-RateLimit-Remaining") == "0", + let reset = http.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Double.init) { + return max(Date(timeIntervalSince1970: reset), now().addingTimeInterval(1)) + } + if http.statusCode == 429 || Self.message(from: data).localizedCaseInsensitiveContains("rate limit") { + // GitHub's guidance when it gives no number: wait at least a minute. + return now().addingTimeInterval(60) + } + return nil } nonisolated static func decode(_ type: T.Type, from data: Data) throws -> T { diff --git a/Sources/Cleared/GitHub/WorkflowFile.swift b/Sources/Cleared/GitHub/WorkflowFile.swift new file mode 100644 index 0000000..acbacf3 --- /dev/null +++ b/Sources/Cleared/GitHub/WorkflowFile.swift @@ -0,0 +1,200 @@ +import Foundation + +/// Reads the one part of a workflow file Cleared cares about: whether it can be +/// started by hand, and with which inputs. +/// +/// GitHub's API lists workflows but not their triggers, so the only way to know +/// a workflow accepts `workflow_dispatch` is to read the file. This is not a +/// YAML parser. It understands block mappings, block and flow sequences of +/// scalars, quoting and comments — the shapes an `on:` block is written in — +/// and it only ever looks inside `on:`, so nothing a job does can confuse it. +enum WorkflowFile { + /// The inputs a manual run accepts, or nil when the workflow has no + /// `workflow_dispatch` trigger and cannot be started by hand at all. + static func dispatchInputs(in yaml: String) -> [WorkflowInput]? { + let lines = significantLines(of: yaml) + guard let onIndex = lines.firstIndex(where: { $0.indent == 0 && splitKey($0.text)?.key == "on" }), + let inline = splitKey(lines[onIndex].text)?.value + else { return nil } + + let trigger = "workflow_dispatch" + + // on: workflow_dispatch / on: [push, workflow_dispatch] / on: { workflow_dispatch: … } + if !inline.isEmpty { + if inline.hasPrefix("{") { return inline.contains(trigger) ? [] : nil } + return flowItems(inline).contains(trigger) ? [] : nil + } + + var index = onIndex + 1 + guard index < lines.count, lines[index].indent > 0 || lines[index].isSequenceItem else { return nil } + + switch parseValue(lines, &index) { + case .list(let events): + return events.contains(trigger) ? [] : nil + case .scalar: + return nil + case .map(let events): + guard let dispatch = events.first(where: { $0.key == trigger })?.value else { return nil } + guard case .map(let settings) = dispatch, + case .map(let declared)? = settings.first(where: { $0.key == "inputs" })?.value + else { return [] } + return declared.map { input(named: $0.key, from: $0.value) } + } + } + + private static func input(named name: String, from node: Node) -> WorkflowInput { + var fields: [String: Node] = [:] + if case .map(let pairs) = node { + for pair in pairs { fields[pair.key] = pair.value } + } + func text(_ key: String) -> String { + if case .scalar(let value)? = fields[key] { return value } + return "" + } + + let kind: WorkflowInput.Kind + switch text("type") { + case "boolean": kind = .boolean + case "number": kind = .number + case "environment": kind = .environment + case "choice": + if case .list(let options)? = fields["options"] { kind = .choice(options) } else { kind = .string } + default: kind = .string + } + + return WorkflowInput( + name: name, + description: text("description"), + kind: kind, + isRequired: text("required").lowercased() == "true", + defaultValue: text("default") + ) + } + + // MARK: - A deliberately small reader + + private indirect enum Node { + case scalar(String) + case list([String]) + case map([(key: String, value: Node)]) + } + + private struct Line { + let indent: Int + let text: String + var isSequenceItem: Bool { text == "-" || text.hasPrefix("- ") } + } + + private static func significantLines(of yaml: String) -> [Line] { + yaml.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline).compactMap { raw in + let uncommented = stripComment(String(raw)) + let text = uncommented.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { return nil } + return Line(indent: uncommented.prefix { $0 == " " }.count, text: text) + } + } + + /// Parses the block starting at `index`, leaving `index` on the first line + /// that does not belong to it. + private static func parseValue(_ lines: [Line], _ index: inout Int) -> Node { + let indent = lines[index].indent + + if lines[index].isSequenceItem { + var items: [String] = [] + while index < lines.count, lines[index].indent == indent, lines[index].isSequenceItem { + items.append(unquote(String(lines[index].text.dropFirst()).trimmingCharacters(in: .whitespaces))) + index += 1 + // An item that is itself a mapping spills onto deeper lines. + while index < lines.count, lines[index].indent > indent { index += 1 } + } + return .list(items) + } + + var pairs: [(key: String, value: Node)] = [] + while index < lines.count, lines[index].indent >= indent { + guard lines[index].indent == indent, let (key, value) = splitKey(lines[index].text) else { + index += 1 + continue + } + index += 1 + + if value.isEmpty { + // A sequence may sit at the same indent as the key that owns it. + let hasChild = index < lines.count + && (lines[index].indent > indent || (lines[index].indent == indent && lines[index].isSequenceItem)) + pairs.append((key, hasChild ? parseValue(lines, &index) : .scalar(""))) + } else if value.hasPrefix("|") || value.hasPrefix(">") { + var folded: [String] = [] + while index < lines.count, lines[index].indent > indent { + folded.append(lines[index].text) + index += 1 + } + pairs.append((key, .scalar(folded.joined(separator: " ")))) + } else if value.hasPrefix("[") { + pairs.append((key, .list(flowItems(value)))) + } else { + pairs.append((key, .scalar(unquote(value)))) + } + } + return .map(pairs) + } + + /// Splits `key: value` at the first colon that ends a key. + private static func splitKey(_ text: String) -> (key: String, value: String)? { + var quote: Character? + var index = text.startIndex + while index < text.endIndex { + let character = text[index] + if let open = quote { + if character == open { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ":" { + let next = text.index(after: index) + if next == text.endIndex || text[next] == " " { + let key = unquote(String(text[.. [String] { + var inner = value + if inner.hasPrefix("[") { inner.removeFirst() } + if inner.hasSuffix("]") { inner.removeLast() } + return inner.split(separator: ",") + .map { unquote($0.trimmingCharacters(in: .whitespaces)) } + .filter { !$0.isEmpty } + } + + private static func unquote(_ value: String) -> String { + guard value.count >= 2, let first = value.first, first == value.last, first == "\"" || first == "'" else { + return value + } + return String(value.dropFirst().dropLast()) + } + + /// Drops a trailing `# comment`. A `#` inside quotes, or one that is not + /// preceded by whitespace, is part of the value. + private static func stripComment(_ line: String) -> String { + var quote: Character? + var previous: Character = " " + for index in line.indices { + let character = line[index] + if let open = quote { + if character == open { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "#", previous == " " || previous == "\t" { + return String(line[.. MergeMethod { + if allowedMethods.isEmpty || allowedMethods.contains(preferred) { return preferred } + if let repoDefaultMethod, allowedMethods.contains(repoDefaultMethod) { return repoDefaultMethod } + return allowedMethods[0] } } @@ -97,16 +214,39 @@ struct FailedRun: Identifiable, Sendable, Hashable { var id: String { "\(repo.id)#\(runID)" } } +/// One `workflow_dispatch` input, as declared in the workflow file. +struct WorkflowInput: Identifiable, Sendable, Hashable { + enum Kind: Sendable, Hashable { + case string, number, boolean, environment + case choice([String]) + } + + let name: String + let description: String + let kind: Kind + let isRequired: Bool + let defaultValue: String + + var id: String { name } +} + /// A workflow that can be started by hand (`on: workflow_dispatch`). struct DispatchableWorkflow: Identifiable, Sendable, Hashable { let repo: RepoRef let workflowID: Int let name: String let path: String + var inputs: [WorkflowInput] = [] var id: String { "\(repo.id)#\(workflowID)" } } +/// What the dispatch sheet needs for one repository. +struct WorkflowCatalog: Sendable, Hashable { + let defaultBranch: String + let workflows: [DispatchableWorkflow] +} + struct RateLimit: Sendable, Hashable { let remaining: Int let limit: Int diff --git a/Sources/Cleared/Queue/AppModel.swift b/Sources/Cleared/Queue/AppModel.swift index 992e448..4fb39d6 100644 --- a/Sources/Cleared/Queue/AppModel.swift +++ b/Sources/Cleared/Queue/AppModel.swift @@ -4,10 +4,10 @@ import SwiftUI /// Everything the menu shows and every action it can take. /// -/// Two polling lanes run at different rates. The approval lane is fast because a -/// held deployment is the thing that costs real time; the merge lane is slower -/// because a pull request that just turned green will still be green in a -/// minute. Both back off when the menu is closed. +/// One polling loop drives the whole queue. It runs fast while the panel is +/// open and backs off while it is closed, and again when the rate-limit budget +/// runs low. Quiet repositories answer from the ETag cache, so the cadence +/// costs almost nothing when nothing is waiting. @MainActor @Observable final class AppModel { @@ -23,7 +23,6 @@ final class AppModel { private(set) var pullRequests: [PullRequestSummary] = [] private(set) var failures: [FailedRun] = [] private(set) var repos: [RepoRef] = [] - private(set) var workflows: [DispatchableWorkflow] = [] // Presentation state private(set) var phase: Phase = .needsToken @@ -32,23 +31,50 @@ final class AppModel { private(set) var lastRefresh: Date? private(set) var isRefreshing = false private(set) var busyItems: Set = [] + /// Why the last poll came back short. Shown in the footer rather than as a + /// banner: a laptop that is offline for an hour should not stack up alerts. + private(set) var pollProblem: String? + /// Why the connect screen is showing again, when it is not a first run. + private(set) var connectNotice: String? + /// Set when the token can read but GitHub reports it cannot write. + private(set) var scopeWarning: String? var banner: Banner? - var menuIsOpen = false { didSet { if menuIsOpen { Task { await refresh() } } } } + private(set) var menuIsOpen = false struct Banner: Identifiable, Equatable { enum Kind { case success, failure } let id = UUID() let kind: Kind let text: String + /// Where the result of the action can be seen, when there is somewhere. + var link: URL? } - private let client = GitHubClient() + private let client: GitHubClient + private let settings: Settings + private let vault: TokenVault private var pollTask: Task? - private let settings = Settings.shared + private var hasStarted = false + private var lastDiscovery: Date? + /// Repositories that answered 403 or 404 for Actions. Skipped until the + /// next discovery so a repo Cleared cannot read is not asked every poll. + private var unreadable: Set = [] /// Set by the preview harness so seeded state is never overwritten by a /// live refresh (which, with no token, would blank the rate limit). private var isPreview = false + /// New repositories are picked up without a restart. + private static let discoveryInterval: TimeInterval = 3_600 + /// GitHub refuses to re-run anything older than this, so there is no point + /// offering the button. + private static let rerunWindow: TimeInterval = 30 * 86_400 + + init(client: GitHubClient = GitHubClient(), settings: Settings = .shared, vault: TokenVault = .keychain) { + self.client = client + self.settings = settings + self.vault = vault + } + // MARK: - Derived /// Pull requests that nothing is blocking — checks green, no conflicts, not a draft. @@ -67,35 +93,48 @@ final class AppModel { // MARK: - Lifecycle + /// Called once, when the app launches. func start() async { - await Notifier.shared.requestAuthorizationIfNeeded() - guard TokenStore.current() != nil else { + guard !hasStarted, !isPreview else { return } + hasStarted = true + Log.app.notice("starting") + guard vault.current() != nil else { phase = .needsToken return } - phase = .connecting await connect() - startPolling() } func connect() async { + phase = .connecting do { - login = try await client.viewerLogin() + let viewer = try await client.viewer() + login = viewer.login + scopeWarning = viewer.canWrite + ? nil + : "This token is missing the repo scope, so clearing, merging and re-running will be refused." try await discoverRepos() + connectNotice = nil phase = .ready + Log.app.notice("connected; watching \(self.repos.count) repositories") + startPolling() + // Not awaited: the first run shows a system prompt, and the queue + // should not wait on it. + Task { await Notifier.shared.requestAuthorizationIfNeeded() } await refresh() + } catch let error as GitHubError where error.isUnauthorized { + tokenRejected() } catch { + Log.app.error("connect failed: \(error.localizedDescription, privacy: .public)") phase = .failed(error.localizedDescription) } } func signIn(token: String) async { do { - try TokenStore.save(token) + try vault.save(token) await client.clearCache() - phase = .connecting await connect() - startPolling() } catch { phase = .failed(error.localizedDescription) } @@ -103,7 +142,7 @@ final class AppModel { func signInFromGHCLI() async { do { - let token = try GHCLIImporter.importToken() + let token = try await GHCLIImporter.importToken() await signIn(token: token) } catch { banner = Banner(kind: .failure, text: error.localizedDescription) @@ -111,34 +150,83 @@ final class AppModel { } func signOut() { + vault.clear() + resetSession() + connectNotice = nil + phase = .needsToken + Task { await client.clearCache() } + Log.app.notice("signed out") + } + + /// GitHub answered 401: the token was revoked or has expired. Polling on + /// would only repeat the failure, so go back to the connect screen and say why. + private func tokenRejected() { + Log.app.notice("token rejected by GitHub") + resetSession() + connectNotice = "GitHub rejected the saved token — it was revoked or has expired. Connect again to carry on." + phase = .needsToken + } + + private func resetSession() { pollTask?.cancel() pollTask = nil - TokenStore.clear() approvals = [] pullRequests = [] failures = [] repos = [] + unreadable = [] login = nil - phase = .needsToken + rateLimit = nil + lastRefresh = nil + lastDiscovery = nil + pollProblem = nil + scopeWarning = nil + Notifier.shared.reset() } private func startPolling() { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { - guard let self else { return } - let base = await MainActor.run { self.settings.approvalInterval } - let multiplier = await MainActor.run { self.menuIsOpen ? 1 : self.settings.backgroundMultiplier } - let low = await MainActor.run { self.rateLimit?.isLow ?? false } - let delay = base * Double(multiplier) * (low ? 4 : 1) - + guard let delay = self?.pollDelay else { return } try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled else { return } - await self.refresh() + await self?.refresh() } } } + /// The panel came on screen. Always refreshes, even if it was already + /// thought to be open: showing a stale queue is the worse mistake. + func panelDidOpen() { + let wasOpen = menuIsOpen + menuIsOpen = true + guard case .ready = phase, !isPreview else { return } + if !wasOpen { + Log.app.debug("panel opened") + // Restart the loop so the faster cadence applies now, not after + // the long background sleep already in progress. + startPolling() + } + Task { await refresh() } + } + + func panelDidClose() { + guard menuIsOpen else { return } + menuIsOpen = false + guard case .ready = phase, !isPreview else { return } + Log.app.debug("panel closed") + startPolling() + } + + /// Seconds until the next poll: the configured interval, slowed while + /// nobody is looking, and slowed again when the budget is nearly spent. + var pollDelay: TimeInterval { + let background = menuIsOpen ? 1 : settings.backgroundMultiplier + let scarcity = (rateLimit?.isLow ?? false) ? 4 : 1 + return settings.approvalInterval * Double(background * scarcity) + } + // MARK: - Refresh func discoverRepos() async throws { @@ -153,6 +241,19 @@ final class AppModel { merged.append(repo) } repos = merged.filter { !excluded.contains($0.id) } + unreadable = [] + lastDiscovery = Date() + } + + /// Re-reads the repository list after a setting that shapes it changes. + func rediscover() async { + guard case .ready = phase, !isPreview else { return } + do { + try await discoverRepos() + await refresh() + } catch { + banner = Banner(kind: .failure, text: error.localizedDescription) + } } func refresh() async { @@ -161,64 +262,108 @@ final class AppModel { isRefreshing = true defer { isRefreshing = false } + if let lastDiscovery, Date().timeIntervalSince(lastDiscovery) > Self.discoveryInterval { + // Best effort: a failed rediscovery keeps the list it already has. + try? await discoverRepos() + } + async let approvalResult = fetchApprovals() async let pullRequestResult = fetchPullRequests() async let failureResult = fetchFailures() - let (newApprovals, newPRs, newFailures) = await (approvalResult, pullRequestResult, failureResult) - if let newApprovals { approvals = newApprovals.sorted { $0.waitingSince < $1.waitingSince } } - if let newPRs { pullRequests = newPRs } - if let newFailures { failures = newFailures } + let problems = [newApprovals.problem, newPRs.problem, newFailures.problem].compactMap { $0 } + if problems.contains(where: { ($0 as? GitHubError)?.isUnauthorized == true }) { + tokenRejected() + return + } + // Signing out mid-poll must not repopulate the queue it just emptied. + guard case .ready = phase else { return } + if let items = newApprovals.items { approvals = items.sorted { $0.waitingSince < $1.waitingSince } } + if let items = newPRs.items { pullRequests = items } + if let items = newFailures.items { failures = items } + + pollProblem = problems.first?.localizedDescription rateLimit = await client.currentRateLimit() lastRefresh = Date() - await Notifier.shared.announceHolds(approvals) + Log.poll.debug("poll: \(self.approvals.count) holding, \(self.readyToMerge.count) ready, \(self.failures.count) failed, problem: \(self.pollProblem ?? "none", privacy: .public)") + + Notifier.shared.announceHolds(approvals) + Notifier.shared.announceFailures(failures) + } + + /// What one lane of a poll produced. `items` is nil when the lane failed + /// outright and the previous contents should stay on screen. + private struct Fetched: Sendable { + var items: [Item]? + var problem: (any Error)? + } + + /// True for a failure that will repeat for every repository, so there is + /// no point asking the rest: no network, no budget, or no valid token. + private func endsPoll(_ error: any Error) -> Bool { + switch error as? GitHubError { + case .transport, .rateLimited, .notAuthenticated: true + case .http(let status, _): status == 401 + case .decoding, nil: false + } + } + + private func isUnreadable(_ error: any Error) -> Bool { + if case .http(let status, _)? = error as? GitHubError { return status == 403 || status == 404 } + return false } - private func fetchApprovals() async -> [PendingApproval]? { + private func fetchApprovals() async -> Fetched { var collected: [PendingApproval] = [] - var sawError: String? + var problem: (any Error)? // Sequential on purpose: quiet repositories answer 304 in single-digit // milliseconds, and a burst of parallel writes to the ETag cache buys // nothing but a thundering herd against secondary rate limits. - for repo in repos { + for repo in repos where !unreadable.contains(repo.id) { do { collected.append(contentsOf: try await client.pendingApprovals(in: repo)) - } catch let error as GitHubError { - if case .http(let status, _) = error, status == 404 || status == 403 { continue } - sawError = error.localizedDescription } catch { - sawError = error.localizedDescription + if isUnreadable(error) { + // Actions is off, or the token cannot see it. Not an error + // worth showing; just stop asking. + unreadable.insert(repo.id) + continue + } + problem = error + if endsPoll(error) { return Fetched(items: nil, problem: error) } } } - if let sawError, collected.isEmpty { - banner = Banner(kind: .failure, text: sawError) - return nil - } - return collected + return Fetched(items: collected, problem: problem) } - private func fetchPullRequests() async -> [PullRequestSummary]? { + private func fetchPullRequests() async -> Fetched { do { - return try await client.myOpenPullRequests() + return Fetched(items: try await client.myOpenPullRequests()) } catch { - return nil + return Fetched(items: nil, problem: error) } } - private func fetchFailures() async -> [FailedRun]? { - guard settings.showFailures else { return [] } + private func fetchFailures() async -> Fetched { + guard settings.showFailures else { return Fetched(items: []) } + let cutoff = Date().addingTimeInterval(-Self.rerunWindow) var collected: [FailedRun] = [] - for repo in repos.prefix(6) { - if let runs = try? await client.recentFailures(in: repo, limit: 3) { - collected.append(contentsOf: runs) + for repo in repos.prefix(6) where !unreadable.contains(repo.id) { + do { + collected.append(contentsOf: try await client.recentFailures(in: repo, limit: 3)) + } catch { + // The approvals lane reports problems; this one only needs to + // know when to stop. + if endsPoll(error) { return Fetched(items: nil) } } } - return collected + let recent = collected + .filter { $0.finishedAt >= cutoff } .sorted { $0.finishedAt > $1.finishedAt } .prefix(5) - .map { $0 } + return Fetched(items: Array(recent)) } // MARK: - Actions @@ -227,13 +372,25 @@ final class AppModel { await perform(id: approval.id, success: "Cleared · \(approval.runName)") { try await self.client.approve(approval) self.approvals.removeAll { $0.id == approval.id } + return nil + } + } + + func reject(_ approval: PendingApproval, comment: String) async { + let reason = comment.trimmingCharacters(in: .whitespacesAndNewlines) + await perform(id: approval.id, success: "Rejected · \(approval.runName)") { + try await self.client.reject(approval, comment: reason.isEmpty ? "Rejected from the menu bar" : reason) + self.approvals.removeAll { $0.id == approval.id } + return nil } } func merge(_ pr: PullRequestSummary) async { + let method = pr.mergeMethod(preferring: settings.mergeMethod) await perform(id: pr.id, success: "Merged · \(pr.repo.name) #\(pr.number)") { - try await self.client.merge(pr, method: self.settings.mergeMethod) + try await self.client.merge(pr, method: method) self.pullRequests.removeAll { $0.id == pr.id } + return nil } } @@ -241,11 +398,33 @@ final class AppModel { await perform(id: run.id, success: "Re-running · \(run.name)") { try await self.client.rerunFailedJobs(run) self.failures.removeAll { $0.id == run.id } + return nil } } - func loadWorkflows(for repo: RepoRef) async { - workflows = (try? await client.dispatchableWorkflows(in: repo)) ?? [] + /// The Clear button on a notification. The hold is looked up fresh: by the + /// time someone clicks, it may already have been cleared elsewhere. + func clearHold(withID id: String) async { + guard let approval = approvals.first(where: { $0.id == id }) else { + banner = Banner(kind: .failure, text: "That deployment is no longer waiting.") + return + } + await clear(approval) + } + + func rerunFailure(withID id: String) async { + guard let run = failures.first(where: { $0.id == id }) else { + banner = Banner(kind: .failure, text: "That run is no longer listed as failed.") + return + } + await rerun(run) + } + + func workflowCatalog(for repo: RepoRef) async throws -> WorkflowCatalog { +#if DEBUG + if isPreview { return SampleData.catalog } +#endif + return try await client.workflowCatalog(in: repo) } func dispatch(_ workflow: DispatchableWorkflow, ref: String, inputs: [String: String]) async { @@ -254,18 +433,23 @@ final class AppModel { } } - private func perform(id: String, success: String, _ work: @escaping () async throws -> Void) async { + /// Runs one action, shows what happened, and re-reads the queue. `work` + /// returns a link to the result when GitHub hands one back. + private func perform(id: String, success: String, _ work: @escaping () async throws -> URL?) async { + // The preview has no GitHub behind it; its buttons are for looking at. + guard !isPreview, !busyItems.contains(id) else { return } busyItems.insert(id) defer { busyItems.remove(id) } do { - try await work() - banner = Banner(kind: .success, text: success) + let link = try await work() + banner = Banner(kind: .success, text: success, link: link) // Re-read soon: clearing a gate usually starts a run worth showing. Task { [weak self] in try? await Task.sleep(for: .seconds(2)) await self?.refresh() } } catch { + Log.app.error("action failed: \(error.localizedDescription, privacy: .public)") banner = Banner(kind: .failure, text: error.localizedDescription) } } diff --git a/Sources/Cleared/Queue/SampleData.swift b/Sources/Cleared/Queue/SampleData.swift index 2ea30ca..74274d3 100644 --- a/Sources/Cleared/Queue/SampleData.swift +++ b/Sources/Cleared/Queue/SampleData.swift @@ -46,6 +46,7 @@ enum SampleData { pr(repos[1], 117, "refactor(web): collapse the settings form into one grid", .pending, "mergeable"), pr(repos[2], 55, "feat(infra): self-hosted runner pool", .failure, "mergeable"), pr(repos[0], 409, "docs: rewrite the contributing guide", .success, "conflicting"), + pr(repos[2], 54, "feat(infra): rotate the deploy keys", .success, "mergeable", review: .reviewRequired), ] } @@ -70,9 +71,40 @@ enum SampleData { ] } + /// A repository's startable workflows, covering every kind of input the + /// dispatch sheet has to draw. + static var catalog: WorkflowCatalog { + WorkflowCatalog( + defaultBranch: "main", + workflows: [ + DispatchableWorkflow( + repo: repos[0], + workflowID: 1, + name: "Deploy", + path: ".github/workflows/deploy.yml", + inputs: [ + WorkflowInput( + name: "environment", + description: "Where to deploy.", + kind: .choice(["staging", "production"]), + isRequired: true, + defaultValue: "staging" + ), + WorkflowInput(name: "version", description: "Tag to ship, e.g. v1.2.3.", kind: .string, isRequired: true, defaultValue: ""), + WorkflowInput(name: "dry_run", description: "Plan only; change nothing.", kind: .boolean, isRequired: false, defaultValue: "true"), + WorkflowInput(name: "replicas", description: "", kind: .number, isRequired: false, defaultValue: "3"), + ] + ), + DispatchableWorkflow(repo: repos[0], workflowID: 2, name: "Nightly rebuild", path: ".github/workflows/nightly.yml"), + DispatchableWorkflow(repo: repos[0], workflowID: 3, name: "Rotate credentials", path: ".github/workflows/rotate.yml"), + ] + ) + } + private static func pr( _ repo: RepoRef, _ number: Int, _ title: String, - _ checks: CheckState, _ mergeable: String + _ checks: CheckState, _ mergeable: String, + review: ReviewDecision? = nil ) -> PullRequestSummary { PullRequestSummary( repo: repo, @@ -83,7 +115,9 @@ enum SampleData { checkState: checks, mergeable: mergeable, htmlURL: URL(string: "https://github.com/\(repo.fullName)/pull/\(number)")!, - updatedAt: Date().addingTimeInterval(-Double(number) * 60) + updatedAt: Date().addingTimeInterval(-Double(number) * 60), + mergeState: review == .reviewRequired ? .blocked : .clean, + reviewDecision: review ) } } diff --git a/Sources/Cleared/Support/Log.swift b/Sources/Cleared/Support/Log.swift new file mode 100644 index 0000000..56605d8 --- /dev/null +++ b/Sources/Cleared/Support/Log.swift @@ -0,0 +1,14 @@ +import os + +/// Unified-log channels, readable with +/// `log stream --predicate 'subsystem == "net.hlnd.Cleared"' --level debug`. +/// +/// The token never goes near these. Repository names are marked private so +/// they are redacted in any log collected off the machine. +enum Log { + private static let subsystem = "net.hlnd.Cleared" + + static let app = Logger(subsystem: subsystem, category: "app") + static let poll = Logger(subsystem: subsystem, category: "poll") + static let github = Logger(subsystem: subsystem, category: "github") +} diff --git a/Sources/Cleared/Support/LoginItem.swift b/Sources/Cleared/Support/LoginItem.swift new file mode 100644 index 0000000..f691cff --- /dev/null +++ b/Sources/Cleared/Support/LoginItem.swift @@ -0,0 +1,23 @@ +import Foundation +import ServiceManagement + +/// Launch at login. A hold notifier that only runs when someone remembers to +/// open it has the same blind spot as the browser tab it replaces. +enum LoginItem { + static var isEnabled: Bool { SMAppService.mainApp.status == .enabled } + + /// macOS registers the app by where it lives, so a copy run straight out + /// of a build folder would register a path the next build deletes. + static var isInstalled: Bool { + let path = Bundle.main.bundlePath + return path.hasPrefix("/Applications/") || path.hasPrefix(NSHomeDirectory() + "/Applications/") + } + + static func setEnabled(_ enabled: Bool) throws { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } +} diff --git a/Sources/Cleared/Support/Notifier.swift b/Sources/Cleared/Support/Notifier.swift index 6e3badc..8f6d9f0 100644 --- a/Sources/Cleared/Support/Notifier.swift +++ b/Sources/Cleared/Support/Notifier.swift @@ -1,16 +1,86 @@ +import AppKit import Foundation import UserNotifications +/// Tracks which items have already been announced, so each one notifies once +/// no matter how many polls observe it. +struct AnnouncementLedger: Sendable { + private var seen: Set? + + /// Returns the keys not seen on the previous call, then remembers `keys`. + /// + /// `announceFirstSight` decides what happens on the very first call: a + /// deployment already held at launch still needs you, but a run that failed + /// before Cleared started is not news. + mutating func fresh(in keys: [String], announceFirstSight: Bool) -> [String] { + defer { seen = Set(keys) } + guard let seen else { return announceFirstSight ? keys : [] } + return keys.filter { !seen.contains($0) } + } + + /// Keys that were present last time and are gone now. + func departed(from keys: [String]) -> [String] { + guard let seen else { return [] } + return Array(seen.subtracting(keys)) + } +} + /// Local notifications for things that enter the hold queue. Cleared only /// notifies about work that is blocked on this user — never general activity. @MainActor -final class Notifier { +final class Notifier: NSObject { static let shared = Notifier() + private enum Category { + static let hold = "net.hlnd.Cleared.hold" + static let failure = "net.hlnd.Cleared.failure" + } + + private enum Action { + static let clear = "net.hlnd.Cleared.clear" + static let rerun = "net.hlnd.Cleared.rerun" + } + + private enum Info { + static let item = "item" + static let url = "url" + } + + private weak var model: AppModel? + /// Nil under test, so a test run can never post, clear, or prompt. + private var center: UNUserNotificationCenter? { + AppEnvironment.isRunningTests ? nil : .current() + } private var authorized = false - private var announced: Set = [] + private var holds = AnnouncementLedger() + private var failures = AnnouncementLedger() + + /// Registers the actions a notification can carry and routes clicks back + /// to the model. A hold notification has a Clear button for the same + /// reason the panel does: telling you something is waiting and then making + /// you go find it is the problem Cleared exists to fix. + func attach(to model: AppModel) { + self.model = model + guard let center else { return } + center.delegate = self + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Category.hold, + actions: [UNNotificationAction(identifier: Action.clear, title: "Clear")], + intentIdentifiers: [] + ), + UNNotificationCategory( + identifier: Category.failure, + actions: [UNNotificationAction(identifier: Action.rerun, title: "Re-run failed jobs")], + intentIdentifiers: [] + ), + ]) + } func requestAuthorizationIfNeeded() async { + guard !AppEnvironment.isRunningTests else { return } + // Fetched here rather than through `center`: a value read off this + // actor cannot be handed to the system's async call under Swift 6.1. let center = UNUserNotificationCenter.current() do { authorized = try await center.requestAuthorization(options: [.alert, .sound]) @@ -19,33 +89,98 @@ final class Notifier { } } - /// Fires once per held deployment, no matter how many polls observe it. - func announceHolds(_ approvals: [PendingApproval]) async { + /// Fires once per held deployment, and withdraws the notification once the + /// hold is gone — a "Holding" alert for a gate someone already cleared is + /// worse than no alert. + func announceHolds(_ approvals: [PendingApproval]) { + let keys = approvals.map(\.announcementKey) + let departed = holds.departed(from: keys) + let fresh = Set(holds.fresh(in: keys, announceFirstSight: true)) + + if !departed.isEmpty { + center?.removeDeliveredNotifications(withIdentifiers: departed) + } guard authorized, Settings.shared.notifyOnHold else { return } - let current = Set(approvals.map(\.id)) - let fresh = approvals.filter { !announced.contains($0.id) } - announced = current - for approval in fresh { - await post( - id: approval.id, + for approval in approvals where fresh.contains(approval.announcementKey) { + post( + id: approval.announcementKey, + category: Category.hold, title: "Holding · \(approval.repo.name)", - body: "\(approval.runName) is waiting on \(approval.environmentNames)." + body: "\(approval.runName) is waiting on \(approval.environmentNames).", + item: approval.id, + url: approval.htmlURL ) } } - func announceFailure(_ run: FailedRun) async { + /// Fires once per run that fails while Cleared is watching. + func announceFailures(_ runs: [FailedRun]) { + let fresh = Set(failures.fresh(in: runs.map(\.id), announceFirstSight: false)) guard authorized, Settings.shared.notifyOnFailure else { return } - await post(id: run.id, title: "Failed · \(run.repo.name)", body: "\(run.name) on \(run.branch)") + + for run in runs where fresh.contains(run.id) { + post( + id: run.id, + category: Category.failure, + title: "Failed · \(run.repo.name)", + body: "\(run.name) on \(run.branch)", + item: run.id, + url: run.htmlURL + ) + } + } + + /// Forgets everything, so signing in as someone else starts clean. + func reset() { + holds = AnnouncementLedger() + failures = AnnouncementLedger() + center?.removeAllDeliveredNotifications() } - private func post(id: String, title: String, body: String) async { + private func post(id: String, category: String, title: String, body: String, item: String, url: URL) { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default + content.categoryIdentifier = category + content.userInfo = [Info.item: item, Info.url: url.absoluteString] let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) - try? await UNUserNotificationCenter.current().add(request) + center?.add(request) + } + + private func handle(action: String, item: String?, url: URL?) async { + switch action { + case Action.clear: + if let item { await model?.clearHold(withID: item) } + case Action.rerun: + if let item { await model?.rerunFailure(withID: item) } + case UNNotificationDefaultActionIdentifier: + if let url { NSWorkspace.shared.open(url) } + default: + break + } + } +} + +extension Notifier: UNUserNotificationCenterDelegate { + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let info = response.notification.request.content.userInfo + let action = response.actionIdentifier + let item = info[Info.item] as? String + let url = (info[Info.url] as? String).flatMap(URL.init(string:)) + await handle(action: action, item: item, url: url) + } + + /// The system withholds a banner from whichever app is active. Without + /// this, a hold that lands while the panel is open would arrive silently. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] } } diff --git a/Sources/Cleared/Support/Settings.swift b/Sources/Cleared/Support/Settings.swift index 2322805..62ec262 100644 --- a/Sources/Cleared/Support/Settings.swift +++ b/Sources/Cleared/Support/Settings.swift @@ -20,7 +20,7 @@ final class Settings { static let notifyOnHold = "notifyOnHold" static let notifyOnFailure = "notifyOnFailure" static let showFailures = "showFailures" - static let confirmMerge = "confirmBeforeMerge" + static let confirmActions = "confirmActions" } private let defaults: UserDefaults @@ -36,7 +36,7 @@ final class Settings { Key.notifyOnHold: true, Key.notifyOnFailure: false, Key.showFailures: true, - Key.confirmMerge: false, + Key.confirmActions: false, ]) } @@ -72,9 +72,14 @@ final class Settings { set { withMutation(keyPath: \.pinnedRepos) { defaults.set(newValue, forKey: Key.pinned) } } } - var mergeMethod: String { - get { access(keyPath: \.mergeMethod); return defaults.string(forKey: Key.mergeMethod) ?? "squash" } - set { withMutation(keyPath: \.mergeMethod) { defaults.set(newValue, forKey: Key.mergeMethod) } } + /// The method to merge with where a repository allows it. A repository + /// that does not falls back to its own default. + var mergeMethod: MergeMethod { + get { + access(keyPath: \.mergeMethod) + return defaults.string(forKey: Key.mergeMethod).flatMap(MergeMethod.init(rawValue:)) ?? .squash + } + set { withMutation(keyPath: \.mergeMethod) { defaults.set(newValue.rawValue, forKey: Key.mergeMethod) } } } var notifyOnHold: Bool { @@ -92,16 +97,36 @@ final class Settings { set { withMutation(keyPath: \.showFailures) { defaults.set(newValue, forKey: Key.showFailures) } } } - var confirmBeforeMerge: Bool { - get { access(keyPath: \.confirmBeforeMerge); return defaults.bool(forKey: Key.confirmMerge) } - set { withMutation(keyPath: \.confirmBeforeMerge) { defaults.set(newValue, forKey: Key.confirmMerge) } } + /// Clear and Merge take a second click when this is on. Off by default: + /// one click is the point, and both buttons only appear when GitHub + /// already agrees the action is allowed. + var confirmActions: Bool { + get { access(keyPath: \.confirmActions); return defaults.bool(forKey: Key.confirmActions) } + set { withMutation(keyPath: \.confirmActions) { defaults.set(newValue, forKey: Key.confirmActions) } } } - func toggleExclusion(_ repo: RepoRef) { + func setExcluded(_ excluded: Bool, _ repoID: String) { var set = excludedRepos - if set.contains(repo.id) { set.remove(repo.id) } else { set.insert(repo.id) } + if excluded { set.insert(repoID) } else { set.remove(repoID) } excludedRepos = set } + + /// Pins a repository so it is watched however quiet it has been. Returns + /// false when the text is not `owner/name`. + @discardableResult + func pin(_ fullName: String) -> Bool { + guard let repo = RepoRef(fullName: fullName.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + if !pinnedRepos.contains(where: { $0.caseInsensitiveCompare(repo.id) == .orderedSame }) { + pinnedRepos.append(repo.id) + } + // Pinning something that was ignored means it is wanted after all. + setExcluded(false, repo.id) + return true + } + + func unpin(_ repoID: String) { + pinnedRepos.removeAll { $0 == repoID } + } } extension TimeInterval { diff --git a/Sources/Cleared/UI/AccentSwitch.swift b/Sources/Cleared/UI/AccentSwitch.swift new file mode 100644 index 0000000..6a0985d --- /dev/null +++ b/Sources/Cleared/UI/AccentSwitch.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// A switch that shows the system accent colour when it is on, whatever state +/// its window is in. +/// +/// AppKit draws its own switch grey in any window that is not key, and a menu +/// bar panel often is not — so in Settings "on" and "off" differed only by +/// where the knob sat. This one is drawn here, and takes its colour from the +/// accent the user picked in System Settings. +struct AccentSwitchStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + LabeledContent { + AccentSwitch(isOn: configuration.$isOn) + } label: { + configuration.label + } + // Presented to VoiceOver as the ordinary toggle it stands in for. + .accessibilityRepresentation { + Toggle(isOn: configuration.$isOn) { configuration.label } + } + } +} + +private struct AccentSwitch: View { + @Binding var isOn: Bool + @Environment(\.isEnabled) private var isEnabled + + private let track = CGSize(width: 36, height: 20) + private let knob = CGSize(width: 22, height: 16) + + var body: some View { + Button { + isOn.toggle() + } label: { + Capsule() + .fill(isOn ? Color(nsColor: .controlAccentColor) : Color.primary.opacity(0.18)) + .frame(width: track.width, height: track.height) + .overlay(alignment: isOn ? .trailing : .leading) { + Capsule() + .fill(.white) + .frame(width: knob.width, height: knob.height) + .shadow(color: .black.opacity(0.22), radius: 1, y: 0.5) + .padding(.horizontal, (track.height - knob.height) / 2) + } + .animation(.easeOut(duration: 0.15), value: isOn) + .opacity(isEnabled ? 1 : 0.45) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/Cleared/UI/ActionRow.swift b/Sources/Cleared/UI/ActionRow.swift index 86b0a78..0769312 100644 --- a/Sources/Cleared/UI/ActionRow.swift +++ b/Sources/Cleared/UI/ActionRow.swift @@ -1,5 +1,14 @@ import SwiftUI +/// Something a row can do besides its main button, offered on right-click. +struct RowAction: Identifiable { + let title: String + var isDestructive = false + let perform: () -> Void + + var id: String { title } +} + /// One line in the queue: a status dot, a description, and the button that /// resolves it. Every row in Cleared can be acted on — nothing is here to be /// admired, only to be cleared. @@ -9,6 +18,7 @@ struct ActionRow: View { let subtitle: String let isBusy: Bool let openURL: URL + var actions: [RowAction] = [] @ViewBuilder var trailing: Trailing @Environment(\.openURL) private var open @@ -19,6 +29,7 @@ struct ActionRow: View { Circle() .fill(dot) .frame(width: 7, height: 7) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { // Tail truncation, not middle: the front of a PR title carries @@ -39,6 +50,9 @@ struct ActionRow: View { .truncationMode(.middle) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isLink) + .accessibilityAction { open(openURL) } if isBusy { ProgressView() @@ -56,24 +70,57 @@ struct ActionRow: View { .contentShape(Rectangle()) .onHover { isHovering = $0 } .onTapGesture { open(openURL) } + .contextMenu { + ForEach(actions) { action in + Button(action.title, role: action.isDestructive ? .destructive : nil, action: action.perform) + } + if !actions.isEmpty { Divider() } + Button("Open on GitHub") { open(openURL) } + Button("Copy Link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(openURL.absoluteString, forType: .string) + } + } .help("\(title)\n\(subtitle)\n\nClick to open on GitHub") } } /// The button that does the thing. Amber for a hold, green for a merge. +/// +/// With `confirms` on, the first click arms the button and the second one +/// fires it. Arming lapses after a few seconds, so a button left armed cannot +/// be set off by a stray click later. struct ClearButton: View { let label: String let tint: Color + var confirms = false + var shortcut: KeyEquivalent? let action: () -> Void + @State private var isArmed = false + var body: some View { - Button(action: action) { - Text(label) + Button { + if confirms, !isArmed { + isArmed = true + } else { + isArmed = false + action() + } + } label: { + Text(isArmed ? "Sure?" : label) .font(.system(size: 11, weight: .semibold)) .frame(width: 52) } .buttonStyle(.borderedProminent) .controlSize(.small) - .tint(tint) + .tint(isArmed ? Theme.stop : tint) + .keyboardShortcut(shortcut.map { KeyboardShortcut($0, modifiers: .command) }) + .task(id: isArmed) { + guard isArmed else { return } + try? await Task.sleep(for: .seconds(3)) + if !Task.isCancelled { isArmed = false } + } + .accessibilityLabel(isArmed ? "Confirm \(label)" : label) } } diff --git a/Sources/Cleared/UI/ConnectView.swift b/Sources/Cleared/UI/ConnectView.swift index b681406..6d9074f 100644 --- a/Sources/Cleared/UI/ConnectView.swift +++ b/Sources/Cleared/UI/ConnectView.swift @@ -17,6 +17,16 @@ struct ConnectView: View { .fixedSize(horizontal: false, vertical: true) } + if let notice = model.connectNotice { + Label(notice, systemImage: "key.slash") + .font(.system(size: 11)) + .fixedSize(horizontal: false, vertical: true) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.hold.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + // A saved-but-unreadable token is not the same as no token, and // silently showing the sign-in form for it is a lie. if case .denied(let status) = TokenStore.status() { @@ -69,7 +79,7 @@ struct ConnectView: View { .textFieldStyle(.roundedBorder) .font(.system(size: 11, design: .monospaced)) .onSubmit(submit) - Text("Needs the repo and workflow scopes. Stored in your login Keychain — never written to disk in the clear.") + Text("Needs the repo scope. Stored in your login Keychain — never written to disk in the clear.") .font(.system(size: 10)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -114,6 +124,7 @@ struct FailureView: View { .fixedSize(horizontal: false, vertical: true) HStack { Button("Try again") { Task { await model.connect() } } + .keyboardShortcut(.defaultAction) .controlSize(.small) Button("Disconnect") { model.signOut() } .controlSize(.small) diff --git a/Sources/Cleared/UI/DispatchSheet.swift b/Sources/Cleared/UI/DispatchSheet.swift index c6f178a..145f252 100644 --- a/Sources/Cleared/UI/DispatchSheet.swift +++ b/Sources/Cleared/UI/DispatchSheet.swift @@ -1,15 +1,21 @@ import SwiftUI -/// Starts a workflow by hand. The ref defaults to main because that is what a -/// manual production deploy almost always targets. +/// Starts a workflow by hand. +/// +/// Only workflows that declare `workflow_dispatch` are listed, and each one +/// brings the inputs its file declares — so what this sheet offers is what +/// GitHub will accept, rather than a form that 422s on submit. struct DispatchSheet: View { let model: AppModel let repo: RepoRef @Environment(\.dismiss) private var dismiss + @State private var catalog: WorkflowCatalog? + @State private var loadError: String? @State private var selected: DispatchableWorkflow? - @State private var ref = "main" - @State private var isLoading = true + @State private var ref = "" + @State private var values: [String: String] = [:] + @State private var isStarting = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -20,80 +26,225 @@ struct DispatchSheet: View { .foregroundStyle(.secondary) } - if isLoading { - HStack(spacing: 6) { - ProgressView().controlSize(.small).scaleEffect(0.7) - Text("Reading workflows…").font(.system(size: 11)).foregroundStyle(.secondary) + if let catalog { + if catalog.workflows.isEmpty { + Text("Nothing in this repository can be started by hand. A workflow needs `workflow_dispatch` in its `on:` block.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 12) + } else { + workflowList(catalog.workflows) + form } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 20) - } else if model.workflows.isEmpty { - Text("No active workflows in this repository.") + } else if let loadError { + Label(loadError, systemImage: "exclamationmark.triangle") .font(.system(size: 11)) .foregroundStyle(.secondary) - .padding(.vertical, 16) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 12) } else { - ScrollView { - VStack(alignment: .leading, spacing: 1) { - ForEach(model.workflows) { workflow in - Button { - selected = workflow - } label: { - HStack(spacing: 6) { - Image(systemName: selected?.id == workflow.id ? "largecircle.fill.circle" : "circle") - .font(.system(size: 10)) - .foregroundStyle(selected?.id == workflow.id ? Theme.hold : .secondary) - VStack(alignment: .leading, spacing: 0) { - Text(workflow.name).font(.system(size: 11)).lineLimit(1) - Text(workflow.path) - .font(.system(size: 9, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - } - .contentShape(Rectangle()) - .padding(.vertical, 3) - .padding(.horizontal, 4) - } - .buttonStyle(.plain) - } - } - } - .frame(height: 190) - HStack(spacing: 6) { - Text("Ref").font(.system(size: 11)) - TextField("main", text: $ref) - .textFieldStyle(.roundedBorder) - .font(.system(size: 11, design: .monospaced)) + ProgressView().controlSize(.small).scaleEffect(0.7) + Text("Reading workflows…").font(.system(size: 11)).foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) } Divider() HStack { Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) Spacer() - Button("Run") { - if let selected { - Task { - await model.dispatch(selected, ref: ref, inputs: [:]) - dismiss() + Button("Run") { start() } + .buttonStyle(.borderedProminent) + .tint(Theme.hold) + .disabled(!canStart) + .keyboardShortcut(.defaultAction) + } + } + .padding(14) + .frame(width: 380) + .task { await load() } + } + + private func workflowList(_ workflows: [DispatchableWorkflow]) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 1) { + ForEach(workflows) { workflow in + Button { + select(workflow) + } label: { + HStack(spacing: 6) { + Image(systemName: selected?.id == workflow.id ? "largecircle.fill.circle" : "circle") + .font(.system(size: 10)) + .foregroundStyle(selected?.id == workflow.id ? Theme.hold : .secondary) + VStack(alignment: .leading, spacing: 0) { + Text(workflow.name).font(.system(size: 11)).lineLimit(1) + Text(workflow.path) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() } + .contentShape(Rectangle()) + .padding(.vertical, 3) + .padding(.horizontal, 4) } + .buttonStyle(.plain) + .accessibilityAddTraits(selected?.id == workflow.id ? .isSelected : []) } - .buttonStyle(.borderedProminent) - .tint(Theme.hold) - .disabled(selected == nil || ref.trimmingCharacters(in: .whitespaces).isEmpty) - .keyboardShortcut(.defaultAction) } } - .padding(14) - .frame(width: 380) - .task { - await model.loadWorkflows(for: repo) - isLoading = false + .frame(height: min(CGFloat(workflows.count) * 31, 155)) + } + + @ViewBuilder + private var form: some View { + LabeledField("Ref") { + TextField("branch or tag", text: $ref) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .accessibilityLabel("Ref") } + + if let inputs = selected?.inputs, !inputs.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(inputs) { input in + InputField(input: input, value: binding(for: input)) + } + } + } + .frame(maxHeight: 190) + } + } + + private var canStart: Bool { + guard let selected, !isStarting, !ref.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + return selected.inputs.allSatisfy { !$0.isRequired || !(values[$0.name] ?? "").isEmpty } + } + + private func binding(for input: WorkflowInput) -> Binding { + Binding(get: { values[input.name] ?? "" }, set: { values[input.name] = $0 }) + } + + private func load() async { + do { + let loaded = try await model.workflowCatalog(for: repo) + catalog = loaded + ref = loaded.defaultBranch + if loaded.workflows.count == 1 { select(loaded.workflows[0]) } + } catch { + loadError = error.localizedDescription + } + } + + /// Seeds each input with what the workflow file declares as its default, + /// so an untouched form sends exactly what a run from github.com would. + private func select(_ workflow: DispatchableWorkflow) { + selected = workflow + values = Dictionary(uniqueKeysWithValues: workflow.inputs.map { input in + switch input.kind { + case .boolean: + return (input.name, input.defaultValue.lowercased() == "true" ? "true" : "false") + case .choice(let options): + return (input.name, options.contains(input.defaultValue) ? input.defaultValue : (options.first ?? "")) + case .string, .number, .environment: + return (input.name, input.defaultValue) + } + }) + } + + private func start() { + guard let selected else { return } + isStarting = true + // An input left empty is omitted, which lets the workflow's own + // default apply rather than overriding it with an empty string. + let inputs = values.filter { !$0.value.isEmpty } + let target = ref.trimmingCharacters(in: .whitespaces) + Task { + await model.dispatch(selected, ref: target, inputs: inputs) + dismiss() + } + } +} + +private struct LabeledField: View { + let label: String + let caption: String + let field: Field + + init(_ label: String, caption: String = "", @ViewBuilder field: () -> Field) { + self.label = label + self.caption = caption + self.field = field() + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(label) + .font(.system(size: 11)) + .frame(width: 96, alignment: .leading) + .lineLimit(1) + .truncationMode(.middle) + .help(label) + field + } + if !caption.isEmpty { + Text(caption) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 102) + } + } + } +} + +/// One declared input, drawn as the control its type calls for. +private struct InputField: View { + let input: WorkflowInput + @Binding var value: String + + var body: some View { + LabeledField(input.name, caption: caption) { + switch input.kind { + case .boolean: + Toggle("", isOn: Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })) + .labelsHidden() + .toggleStyle(AccentSwitchStyle()) + .accessibilityLabel(spokenName) + Spacer() + case .choice(let options): + Picker("", selection: $value) { + ForEach(options, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + .controlSize(.small) + .accessibilityLabel(spokenName) + case .string, .number, .environment: + TextField(input.isRequired ? "required" : "optional", text: $value) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .accessibilityLabel(spokenName) + } + } + } + + /// The visible label sits beside the control, not on it, so each control + /// is named explicitly for VoiceOver. + private var spokenName: String { + input.isRequired ? "\(input.name), required" : input.name + } + + private var caption: String { + let needsValue = input.isRequired && value.isEmpty + if input.description.isEmpty { return needsValue ? "Required." : "" } + return needsValue ? "\(input.description) Required." : input.description } } diff --git a/Sources/Cleared/UI/MenuPanel.swift b/Sources/Cleared/UI/MenuPanel.swift index 7836440..39cf733 100644 --- a/Sources/Cleared/UI/MenuPanel.swift +++ b/Sources/Cleared/UI/MenuPanel.swift @@ -7,8 +7,11 @@ struct MenuPanel: View { @Bindable var model: AppModel @State private var showingSettings = false @State private var dispatchTarget: RepoRef? + @State private var rejectTarget: PendingApproval? @State private var queueHeight: CGFloat = 0 + private var settings: Settings { Settings.shared } + var body: some View { VStack(spacing: 0) { switch model.phase { @@ -23,14 +26,23 @@ struct MenuPanel: View { } } .frame(width: Theme.panelWidth) - .onAppear { model.menuIsOpen = true } - .onDisappear { model.menuIsOpen = false } + // Two signals for the same fact. SwiftUI's appear/disappear is the + // obvious one, but a menu bar panel is hidden rather than torn down, + // so the window's own visibility is watched as well. + .onAppear { model.panelDidOpen() } + .onDisappear { model.panelDidClose() } + .background(WindowVisibility { visible in + if visible { model.panelDidOpen() } else { model.panelDidClose() } + }) .sheet(isPresented: $showingSettings) { SettingsView(model: model) } .sheet(item: $dispatchTarget) { repo in DispatchSheet(model: model, repo: repo) } + .sheet(item: $rejectTarget) { approval in + RejectSheet(model: model, approval: approval) + } } private var loading: some View { @@ -49,91 +61,106 @@ struct MenuPanel: View { } ScrollView { - VStack(alignment: .leading, spacing: 2) { - if model.isAllClear && model.failures.isEmpty { - allClear - } + // "held 6m" and "checked 40s ago" are only true for a moment, so + // the rows are redrawn on a timer rather than only when data lands. + TimelineView(.periodic(from: .now, by: 20)) { _ in + rows + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .measureHeight() + } + // Grow with the queue, stop at maxQueueHeight, scroll past that. + .frame(height: min(max(queueHeight, 1), Theme.maxQueueHeight)) + .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(ContentHeightKey.self) { height in + queueHeight = height + } - if !model.approvals.isEmpty { - SectionHeader(title: "Holding", count: model.approvals.count, tint: Theme.hold) - ForEach(model.approvals) { approval in - ActionRow( - dot: Theme.hold, - title: "\(approval.repo.name) · \(approval.runName)", - subtitle: "\(approval.environmentNames) · \(approval.branch) · held \(approval.waitingSince.heldDuration)", - isBusy: model.isBusy(approval.id), - openURL: approval.htmlURL - ) { - ClearButton(label: "Clear", tint: Theme.hold) { - Task { await model.clear(approval) } - } + Divider() + TimelineView(.periodic(from: .now, by: 20)) { _ in + footer + } + } + + private var rows: some View { + VStack(alignment: .leading, spacing: 2) { + if model.isAllClear && model.failures.isEmpty { + allClear + } + + if !model.approvals.isEmpty { + SectionHeader(title: "Holding", count: model.approvals.count, tint: Theme.hold) + ForEach(model.approvals) { approval in + ActionRow( + dot: Theme.hold, + title: "\(approval.repo.name) · \(approval.runName)", + subtitle: "\(approval.environmentNames) · \(approval.branch) · held \(approval.waitingSince.heldDuration)", + isBusy: model.isBusy(approval.id), + openURL: approval.htmlURL, + actions: [RowAction(title: "Reject…", isDestructive: true) { rejectTarget = approval }] + ) { + ClearButton(label: "Clear", tint: Theme.hold, confirms: settings.confirmActions) { + Task { await model.clear(approval) } } } } + } - if !model.readyToMerge.isEmpty { - SectionHeader(title: "Ready to merge", count: model.readyToMerge.count, tint: Theme.go) - ForEach(Array(model.readyToMerge.enumerated()), id: \.element.id) { index, pr in - ActionRow( - dot: Theme.go, - title: "#\(pr.number) \(pr.title)", - subtitle: "\(pr.repo.fullName) · \(pr.branch)", - isBusy: model.isBusy(pr.id), - openURL: pr.htmlURL + if !model.readyToMerge.isEmpty { + SectionHeader(title: "Ready to merge", count: model.readyToMerge.count, tint: Theme.go) + ForEach(Array(model.readyToMerge.enumerated()), id: \.element.id) { index, pr in + ActionRow( + dot: Theme.go, + title: "#\(pr.number) \(pr.title)", + subtitle: "\(pr.repo.fullName) · \(pr.branch)", + isBusy: model.isBusy(pr.id), + openURL: pr.htmlURL + ) { + ClearButton( + label: "Merge", + tint: Theme.go, + confirms: settings.confirmActions, + shortcut: shortcut(for: index) ) { - ClearButton(label: "Merge", tint: Theme.go) { - Task { await model.merge(pr) } - } + Task { await model.merge(pr) } } - .keyboardShortcut(shortcut(for: index), modifiers: .command) } } + } - if !model.blockedPullRequests.isEmpty { - SectionHeader(title: "In flight", count: model.blockedPullRequests.count, tint: .secondary) - ForEach(model.blockedPullRequests) { pr in - ActionRow( - dot: pr.checkState == .failure ? Theme.stop : .secondary.opacity(0.5), - title: "#\(pr.number) \(pr.title)", - subtitle: "\(pr.repo.name) · \(pr.blockReason ?? "waiting")", - isBusy: false, - openURL: pr.htmlURL - ) { EmptyView() } - } + if !model.blockedPullRequests.isEmpty { + SectionHeader(title: "In flight", count: model.blockedPullRequests.count, tint: .secondary) + ForEach(model.blockedPullRequests) { pr in + ActionRow( + dot: pr.checkState == .failure ? Theme.stop : .secondary.opacity(0.5), + title: "#\(pr.number) \(pr.title)", + subtitle: "\(pr.repo.name) · \(pr.blockReason ?? "waiting")", + isBusy: false, + openURL: pr.htmlURL + ) { EmptyView() } } + } - if !model.failures.isEmpty { - SectionHeader(title: "Failed", count: model.failures.count, tint: Theme.stop) - ForEach(model.failures) { run in - ActionRow( - dot: Theme.stop, - title: "\(run.repo.name) · \(run.name)", - subtitle: "\(run.branch) · \(run.finishedAt.heldDuration) ago", - isBusy: model.isBusy(run.id), - openURL: run.htmlURL - ) { - Button("Re-run") { Task { await model.rerun(run) } } - .font(.system(size: 11)) - .buttonStyle(.bordered) - .controlSize(.small) - .frame(width: 52) - } + if !model.failures.isEmpty { + SectionHeader(title: "Failed", count: model.failures.count, tint: Theme.stop) + ForEach(model.failures) { run in + ActionRow( + dot: Theme.stop, + title: "\(run.repo.name) · \(run.name)", + subtitle: "\(run.branch) · \(run.finishedAt.heldDuration) ago", + isBusy: model.isBusy(run.id), + openURL: run.htmlURL + ) { + Button("Re-run") { Task { await model.rerun(run) } } + .font(.system(size: 11)) + .buttonStyle(.bordered) + .controlSize(.small) + .frame(width: 52) } } } - .padding(.horizontal, 6) - .padding(.vertical, 6) - .measureHeight() - } - // Grow with the queue, stop at maxQueueHeight, scroll past that. - .frame(height: min(max(queueHeight, 1), Theme.maxQueueHeight)) - .scrollBounceBehavior(.basedOnSize) - .onPreferenceChange(ContentHeightKey.self) { height in - queueHeight = height } - - Divider() - footer } private var allClear: some View { @@ -149,6 +176,7 @@ struct MenuPanel: View { } .frame(maxWidth: .infinity) .padding(.vertical, 26) + .accessibilityElement(children: .combine) } private var footer: some View { @@ -162,13 +190,17 @@ struct MenuPanel: View { Image(systemName: "arrow.clockwise").font(.system(size: 10)) } .buttonStyle(.plain) - .help("Refresh now") + .keyboardShortcut("r", modifiers: .command) + .help("Refresh now (⌘R)") + .accessibilityLabel("Refresh now") } Text(statusLine) .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(model.pollProblem == nil ? Color.secondary : Theme.hold) .lineLimit(1) + .truncationMode(.tail) + .help(statusLine) Spacer() @@ -177,7 +209,11 @@ struct MenuPanel: View { Button(repo.fullName) { dispatchTarget = repo } } } label: { - Image(systemName: "play.circle").font(.system(size: 10)) + // A Label rather than a bare Image: the menu takes its spoken + // name from here, and an image alone announces as "play". + Label("Start a workflow", systemImage: "play.circle") + .labelStyle(.iconOnly) + .font(.system(size: 10)) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) @@ -190,7 +226,9 @@ struct MenuPanel: View { Image(systemName: "gearshape").font(.system(size: 10)) } .buttonStyle(.plain) - .help("Settings") + .keyboardShortcut(",", modifiers: .command) + .help("Settings (⌘,)") + .accessibilityLabel("Settings") Button { NSApplication.shared.terminate(nil) @@ -198,14 +236,23 @@ struct MenuPanel: View { Image(systemName: "power").font(.system(size: 10)) } .buttonStyle(.plain) - .help("Quit Cleared") + .keyboardShortcut("q", modifiers: .command) + .help("Quit Cleared (⌘Q)") + .accessibilityLabel("Quit Cleared") } .padding(.horizontal, 10) .padding(.vertical, 7) } + /// When a poll came back short the footer says why, and how stale the + /// queue above it is — that is the only thing worth knowing at that point. private var statusLine: String { var parts: [String] = [] + if let problem = model.pollProblem { + parts.append(problem) + if let last = model.lastRefresh { parts.append("last checked \(last.heldDuration) ago") } + return parts.joined(separator: " · ") + } if let last = model.lastRefresh { parts.append("checked \(last.heldDuration) ago") } if let rate = model.rateLimit { parts.append("\(rate.remaining) calls left") @@ -214,9 +261,10 @@ struct MenuPanel: View { return parts.joined(separator: " · ") } - /// ⌘1–⌘9 on the merge queue, in the order shown. - private func shortcut(for index: Int) -> KeyEquivalent { - guard index < 9, let scalar = Unicode.Scalar(UInt32(49 + index)) else { return .return } + /// ⌘1–⌘9 on the merge queue, in the order shown. Rows past the ninth have + /// no shortcut. + private func shortcut(for index: Int) -> KeyEquivalent? { + guard index < 9, let scalar = Unicode.Scalar(UInt32(49 + index)) else { return nil } return KeyEquivalent(Character(scalar)) } } @@ -241,6 +289,9 @@ struct SectionHeader: View { .padding(.horizontal, 10) .padding(.top, 9) .padding(.bottom, 3) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityLabel("\(title), \(count)") } } @@ -248,6 +299,8 @@ struct BannerView: View { let banner: AppModel.Banner let dismiss: () -> Void + @Environment(\.openURL) private var open + var body: some View { HStack(spacing: 6) { Image(systemName: banner.kind == .success ? "checkmark.circle.fill" : "exclamationmark.circle.fill") @@ -257,15 +310,73 @@ struct BannerView: View { .font(.system(size: 11)) .lineLimit(2) Spacer() + if let link = banner.link { + Button("Open") { open(link) } + .buttonStyle(.link) + .font(.system(size: 11)) + } Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 8)) } .buttonStyle(.plain) + .accessibilityLabel("Dismiss") } .padding(.horizontal, 10) .padding(.vertical, 6) .background((banner.kind == .success ? Theme.go : Theme.stop).opacity(0.12)) - .task { - try? await Task.sleep(for: .seconds(4)) - dismiss() + // Keyed on the banner: a second banner arriving restarts the clock + // instead of inheriting what was left of the first one's. + .task(id: banner.id) { + // A failure, or a result with a link to follow, stays up longer. + let seconds: Double = banner.kind == .failure || banner.link != nil ? 8 : 4 + try? await Task.sleep(for: .seconds(seconds)) + if !Task.isCancelled { dismiss() } + } + } +} + +/// Turns a held deployment down. GitHub requires a comment, and the people +/// whose deploy just got cancelled deserve one. +struct RejectSheet: View { + let model: AppModel + let approval: PendingApproval + + @Environment(\.dismiss) private var dismiss + @State private var comment = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Reject this deployment?").font(.system(size: 13, weight: .semibold)) + Text("\(approval.repo.fullName) · \(approval.runName)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Text("The jobs waiting on \(approval.environmentNames) will be cancelled. This can't be undone from here — the workflow has to be run again.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + TextField("Why (shown on the run)", text: $comment, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11)) + .lineLimit(2...4) + + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Reject", role: .destructive) { + let reason = comment + dismiss() + Task { await model.reject(approval, comment: reason) } + } + .buttonStyle(.borderedProminent) + .tint(Theme.stop) + } } + .padding(14) + .frame(width: 360) } } diff --git a/Sources/Cleared/UI/SettingsView.swift b/Sources/Cleared/UI/SettingsView.swift index b9f2ab3..c770bfc 100644 --- a/Sources/Cleared/UI/SettingsView.swift +++ b/Sources/Cleared/UI/SettingsView.swift @@ -5,10 +5,20 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Bindable private var settings = Settings.shared @State private var pollSeconds: Double = Settings.shared.approvalInterval + @State private var launchAtLogin = LoginItem.isEnabled + @State private var loginItemError: String? + @State private var repoToPin = "" + @State private var pinRejected = false + @State private var rediscovery: Task? var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack { + HStack(spacing: 9) { + Image(nsImage: appIcon) + .resizable() + .interpolation(.high) + .frame(width: 30, height: 30) + .accessibilityHidden(true) Text("Cleared").font(.system(size: 13, weight: .semibold)) Spacer() if let login = model.login { @@ -16,101 +26,234 @@ struct SettingsView: View { } } .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 10) + .padding(.top, 12) + .padding(.bottom, 9) Divider() Form { - Section("Checking") { - VStack(alignment: .leading, spacing: 2) { - Slider(value: $pollSeconds, in: 10...300, step: 10) { - Text("Every \(Int(pollSeconds))s") - } - .onChange(of: pollSeconds) { _, new in settings.approvalInterval = new } - Text("Slows to every \(Int(pollSeconds) * settings.backgroundMultiplier)s while the menu is closed. Unchanged repositories answer from cache and cost nothing.") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } + general + checking + acting + repositories + account + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .toggleStyle(AccentSwitchStyle()) + + Divider() + HStack { + Text("Cleared \(AppEnvironment.version)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(width: 400, height: 600) + .onChange(of: settings.discoveryDays) { scheduleRediscovery() } + .onChange(of: settings.repoLimit) { scheduleRediscovery() } + } + + private var general: some View { + Section { + // A closure, not `set: setLaunchAtLogin`: handing Binding a method + // reference crashes the Swift 6.1–6.3 compilers in IR generation. + Toggle("Launch at login", isOn: Binding(get: { launchAtLogin }, set: { setLaunchAtLogin($0) })) + .disabled(!LoginItem.isInstalled && !launchAtLogin) + if let loginItemError { + caption(loginItemError) + } else if !LoginItem.isInstalled { + caption("Move Cleared to Applications to turn this on (make install does it). A copy in a build folder would register a path the next build removes.") + } + } + } - Toggle("Notify when something starts holding", isOn: $settings.notifyOnHold) - Toggle("Notify on failed runs", isOn: $settings.notifyOnFailure) - Toggle("Show failed runs in the queue", isOn: $settings.showFailures) + private var checking: some View { + Section("Checking") { + VStack(alignment: .leading, spacing: 2) { + Slider(value: $pollSeconds, in: 10...300, step: 10) { + Text("Every \(Int(pollSeconds))s") } + .onChange(of: pollSeconds) { _, new in settings.approvalInterval = new } + caption("Slows to every \(Int(pollSeconds) * settings.backgroundMultiplier)s while the menu is closed. Unchanged repositories answer from cache and cost nothing.") + } + + Toggle("Notify when something starts holding", isOn: $settings.notifyOnHold) + Toggle("Notify when a run fails", isOn: $settings.notifyOnFailure) + Toggle("Show failed runs in the queue", isOn: $settings.showFailures) + } + } + + private var acting: some View { + Section("Clearing and merging") { + Picker("Merge method", selection: $settings.mergeMethod) { + Text("Squash").tag(MergeMethod.squash) + Text("Merge commit").tag(MergeMethod.merge) + Text("Rebase").tag(MergeMethod.rebase) + } + .pickerStyle(.segmented) + caption("Squash keeps the PR title as the commit message, which is what semantic-release reads to pick the next version. A repository that doesn't allow this method uses its own default.") + + Toggle("Ask before clearing or merging", isOn: $settings.confirmActions) + caption("Off, one click acts. On, the button asks \"Sure?\" and a second click confirms.") + } + } + + private var repositories: some View { + Section("Repositories") { + Stepper("Active in the last \(settings.discoveryDays) days", value: $settings.discoveryDays, in: 7...365, step: 7) + Stepper("Watch at most \(settings.repoLimit)", value: $settings.repoLimit, in: 1...50) - Section("Merging") { - Picker("Method", selection: $settings.mergeMethod) { - Text("Squash").tag("squash") - Text("Merge commit").tag("merge") - Text("Rebase").tag("rebase") + if !model.repos.isEmpty { + repoList(model.repos.map(\.id)) { repo in + if settings.pinnedRepos.contains(repo) { + rowButton("Unpin") { settings.unpin(repo); rediscoverNow() } } - .pickerStyle(.segmented) - Text("Squash keeps the PR title as the commit message, which is what semantic-release reads to pick the next version.") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + rowButton("Ignore") { settings.setExcluded(true, repo); rediscoverNow() } } + } - Section("Repositories") { - Stepper("Active in the last \(settings.discoveryDays) days", value: $settings.discoveryDays, in: 7...365, step: 7) - Stepper("Watch at most \(settings.repoLimit)", value: $settings.repoLimit, in: 1...50) - Button("Rediscover now") { - Task { try? await model.discoverRepos() } - } - .controlSize(.small) - - if !model.repos.isEmpty { - ScrollView { - VStack(alignment: .leading, spacing: 1) { - ForEach(model.repos) { repo in - HStack { - Text(repo.fullName) - .font(.system(size: 10, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button("Ignore") { - settings.toggleExclusion(repo) - Task { try? await model.discoverRepos() } - } - .buttonStyle(.plain) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - } - } - } - } - .frame(maxHeight: 96) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + TextField("owner/name", text: $repoToPin) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .labelsHidden() + .onSubmit(pin) + Button("Pin", action: pin) + .controlSize(.small) + .disabled(repoToPin.trimmingCharacters(in: .whitespaces).isEmpty) + } + caption(pinRejected + ? "That isn't an owner/name." + : "Pin a repository to watch it however long it has been quiet.") + } + + let ignored = settings.excludedRepos.sorted() + if !ignored.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text("Ignored").font(.system(size: 10, weight: .medium)).foregroundStyle(.secondary) + repoList(ignored) { repo in + rowButton("Watch again") { settings.setExcluded(false, repo); rediscoverNow() } } } + } + + Button("Rediscover now", action: rediscoverNow) + .controlSize(.small) + } + } + + private var account: some View { + Section("Account") { + if let rate = model.rateLimit { + LabeledContent("Rate limit") { + Text("\(rate.remaining) of \(rate.limit)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(rate.isLow ? Theme.hold : .secondary) + } + } + if let warning = model.scopeWarning { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.system(size: 10)) + .foregroundStyle(Theme.hold) + .fixedSize(horizontal: false, vertical: true) + } + Button("Disconnect and forget token") { + model.signOut() + dismiss() + } + .foregroundStyle(Theme.stop) + .controlSize(.small) + } + } + + // MARK: - Pieces - Section("Account") { - if let rate = model.rateLimit { - LabeledContent("Rate limit") { - Text("\(rate.remaining) of \(rate.limit)") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(rate.isLow ? Theme.hold : .secondary) + /// Read from the app's own asset catalog. `NSApp.applicationIconImage` + /// goes through the system icon cache, which is keyed on the version and + /// keeps serving a previous build's icon until it happens to refresh. + private var appIcon: NSImage { + NSImage(named: "AppIcon") ?? NSApp.applicationIconImage + } + + private func caption(_ text: String) -> some View { + Text(text) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private func rowButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(title, action: action) + .buttonStyle(.plain) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + + private func repoList( + _ repos: [String], + @ViewBuilder trailing: @escaping (String) -> Trailing + ) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(repos, id: \.self) { repo in + HStack(spacing: 8) { + if settings.pinnedRepos.contains(repo) { + Image(systemName: "pin.fill") + .font(.system(size: 7)) + .foregroundStyle(.secondary) + .accessibilityLabel("Pinned") } + Text(repo) + .font(.system(size: 10, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + trailing(repo) } - Button("Disconnect and forget token") { - model.signOut() - dismiss() - } - .foregroundStyle(Theme.stop) - .controlSize(.small) } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + } + .frame(maxHeight: 96) + } - Divider() - HStack { - Spacer() - Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) - } - .padding(12) + // MARK: - Actions + + private func setLaunchAtLogin(_ wanted: Bool) { + do { + try LoginItem.setEnabled(wanted) + loginItemError = nil + } catch { + loginItemError = "Couldn't change the login item. \(error.localizedDescription)" + } + // Read back rather than trust the switch: the system has the last word. + launchAtLogin = LoginItem.isEnabled + } + + private func pin() { + pinRejected = !settings.pin(repoToPin) + guard !pinRejected else { return } + repoToPin = "" + rediscoverNow() + } + + private func rediscoverNow() { + rediscovery?.cancel() + rediscovery = Task { await model.rediscover() } + } + + /// A stepper fires on every click. Wait for the clicking to stop before + /// asking GitHub, rather than once per step. + private func scheduleRediscovery() { + rediscovery?.cancel() + rediscovery = Task { + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled else { return } + await model.rediscover() } - .frame(width: 400, height: 560) } } diff --git a/Sources/Cleared/UI/Theme.swift b/Sources/Cleared/UI/Theme.swift index 2b88a8f..f3c57e5 100644 --- a/Sources/Cleared/UI/Theme.swift +++ b/Sources/Cleared/UI/Theme.swift @@ -49,3 +49,49 @@ extension View { ) } } + +/// Reports when the window hosting a view is shown or hidden. +struct WindowVisibility: NSViewRepresentable { + let onChange: (Bool) -> Void + + func makeNSView(context: Context) -> Probe { Probe(onChange: onChange) } + + func updateNSView(_ probe: Probe, context: Context) { probe.onChange = onChange } + + final class Probe: NSView { + var onChange: (Bool) -> Void + private var observer: (any NSObjectProtocol)? + private var lastReported: Bool? + + init(onChange: @escaping (Bool) -> Void) { + self.onChange = onChange + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + if let window { + observer = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.report() } + } + } + report() + } + + private func report() { + let visible = window.map { $0.isVisible && $0.occlusionState.contains(.visible) } ?? false + guard visible != lastReported else { return } + lastReported = visible + onChange(visible) + } + } +} diff --git a/Tests/ClearedTests/AppModelTests.swift b/Tests/ClearedTests/AppModelTests.swift new file mode 100644 index 0000000..6eb57fa --- /dev/null +++ b/Tests/ClearedTests/AppModelTests.swift @@ -0,0 +1,335 @@ +import Testing +import Foundation +@testable import Cleared + +/// Settings backed by a throwaway defaults domain, so a test never reads or +/// writes the preferences of the person running it. +@MainActor +final class ScratchSettings { + let settings: Settings + private let domain = "ClearedTests.\(UUID().uuidString)" + + init() { + settings = Settings(defaults: UserDefaults(suiteName: domain)!) + } + + deinit { + UserDefaults.standard.removePersistentDomain(forName: domain) + } +} + +/// Holds a token in memory. Every model a test builds gets one of these, so +/// nothing in the suite can read, replace or delete a real Keychain entry. +final class MemoryVault: @unchecked Sendable { + private let lock = NSLock() + private var token: String? + + init(_ token: String? = nil) { + self.token = token + } + + var current: String? { lock.withLock { token } } + + var vault: TokenVault { + TokenVault( + current: { [self] in lock.withLock { token } }, + save: { [self] new in lock.withLock { token = new } }, + clear: { [self] in lock.withLock { token = nil } } + ) + } +} + +private func stamp(daysAgo: Double) -> String { + ISO8601DateFormatter().string(from: Date().addingTimeInterval(-daysAgo * 86_400)) +} + +/// A GitHub with two repositories, one deployment held in `web`, and one green +/// pull request. Individual tests override the parts they care about. +private func everydayGitHub(_ request: StubGitHub.Request) -> StubGitHub.Reply { + switch request.path { + case "/user": + return .json(#"{"login": "octocat"}"#, headers: ["X-OAuth-Scopes": "repo"]) + case "/graphql": + return .json(Fixture.search([Fixture.pullRequest(number: 7)])) + case let path where path.hasPrefix("/user/repos"): + return .json(Fixture.repos(["web", "api"], pushedAt: stamp(daysAgo: 1))) + case let path where path.contains("pending_deployments"): + return .json(Fixture.pendingDeployments([(11, "production", true)])) + case let path where path.contains("/acme/web/actions/runs?status=waiting"): + return .json(Fixture.runs([42], updatedAt: stamp(daysAgo: 0.01))) + default: + return .json(Fixture.runs([])) + } +} + +@MainActor +@Suite("App model") +struct AppModelTests { + private let github = StubGitHub() + private let scratch = ScratchSettings() + private let memory = MemoryVault("saved-token") + + private func makeModel() -> AppModel { + AppModel(client: github.client(), settings: scratch.settings, vault: memory.vault) + } + + private func connectedModel() async -> AppModel { + github.respond(everydayGitHub) + let model = makeModel() + await model.connect() + return model + } + + @Test("connecting fills the queue") + func connects() async { + let model = await connectedModel() + + #expect(model.phase == .ready) + #expect(model.login == "octocat") + #expect(model.repos.map(\.id) == ["acme/web", "acme/api"]) + #expect(model.approvals.map(\.id) == ["acme/web#42"]) + #expect(model.readyToMerge.map(\.number) == [7]) + #expect(model.pendingCount == 2) + #expect(model.pollProblem == nil) + #expect(model.scopeWarning == nil) + } + + @Test("a token GitHub rejects goes back to the connect screen and says why") + func rejectedToken() async { + github.respond { _ in .json(#"{"message": "Bad credentials"}"#, status: 401) } + let model = makeModel() + + await model.connect() + + #expect(model.phase == .needsToken) + #expect(model.connectNotice?.contains("rejected") == true) + } + + @Test("a token revoked mid-session empties the queue instead of polling on") + func revokedMidSession() async { + let model = await connectedModel() + github.respond { _ in .json(#"{"message": "Bad credentials"}"#, status: 401) } + + await model.refresh() + + #expect(model.phase == .needsToken) + #expect(model.approvals.isEmpty) + #expect(model.pullRequests.isEmpty) + #expect(model.connectNotice != nil) + } + + @Test("a read-only token connects but warns that actions will be refused") + func readOnlyToken() async { + github.respond { request in + request.path == "/user" + ? .json(#"{"login": "octocat"}"#, headers: ["X-OAuth-Scopes": "read:org"]) + : everydayGitHub(request) + } + let model = makeModel() + + await model.connect() + + #expect(model.phase == .ready) + #expect(model.scopeWarning?.contains("repo scope") == true) + } + + @Test("offline keeps the last queue on screen and stops asking after the first failure") + func offline() async { + let model = await connectedModel() + let before = github.requests(matching: "status=waiting").count + github.respond { _ in .offline } + + await model.refresh() + + #expect(model.phase == .ready) + #expect(model.approvals.map(\.id) == ["acme/web#42"]) + #expect(model.readyToMerge.count == 1) + #expect(model.pollProblem?.contains("Couldn't reach GitHub") == true) + // Two repositories are watched; the second is not tried once the first times out. + #expect(github.requests(matching: "status=waiting").count == before + 1) + } + + @Test("a problem clears itself once GitHub answers again") + func recovers() async { + let model = await connectedModel() + github.respond { _ in .offline } + await model.refresh() + github.respond(everydayGitHub) + + await model.refresh() + + #expect(model.pollProblem == nil) + } + + @Test("a repository that refuses Actions access is not asked again every poll") + func skipsUnreadableRepos() async { + github.respond { request in + request.path.contains("/acme/api/actions/runs") + ? .json(#"{"message": "Not Found"}"#, status: 404) + : everydayGitHub(request) + } + let model = makeModel() + await model.connect() + + await model.refresh() + await model.refresh() + + #expect(github.requests(matching: "/acme/api/actions/runs?status=waiting").count == 1) + #expect(model.approvals.count == 1) + #expect(model.pollProblem == nil) + } + + @Test("clearing approves the deployment and takes it off the queue") + func clears() async throws { + let model = await connectedModel() + let hold = try #require(model.approvals.first) + + await model.clear(hold) + + #expect(model.approvals.isEmpty) + #expect(model.banner?.kind == .success) + let sent = try #require(github.requests.last { $0.method == "POST" && $0.path.contains("pending_deployments") }) + #expect(sent.json["state"] as? String == "approved") + #expect(sent.json["environment_ids"] as? [Int] == [11]) + } + + @Test("a refused action leaves the item in place and shows GitHub's reason") + func failedActionKeepsItem() async throws { + let model = await connectedModel() + let pr = try #require(model.readyToMerge.first) + github.respond { request in + request.method == "PUT" + ? .json(#"{"message": "Head branch was modified. Review and try the merge again."}"#, status: 409) + : everydayGitHub(request) + } + + await model.merge(pr) + + #expect(model.readyToMerge.count == 1) + #expect(model.banner?.kind == .failure) + #expect(model.banner?.text.contains("Head branch was modified") == true) + } + + @Test("a notification's Clear button does nothing for a hold that is already gone") + func staleNotification() async { + let model = await connectedModel() + + await model.clearHold(withID: "acme/web#999") + + #expect(model.banner?.kind == .failure) + #expect(!github.requests.contains { $0.method == "POST" && $0.path.contains("pending_deployments") }) + #expect(model.approvals.count == 1) + } + + @Test("merging falls back to a method the repository allows") + func mergeMethodFallback() async throws { + scratch.settings.mergeMethod = .rebase + github.respond { request in + switch (request.method, request.path) { + case ("POST", "/graphql"): + return .json(Fixture.search([Fixture.pullRequest(number: 7, mergeCommit: false, rebase: false)])) + case ("PUT", _): + return .json(#"{"merged": true}"#) + default: + return everydayGitHub(request) + } + } + let model = makeModel() + await model.connect() + let pr = try #require(model.readyToMerge.first) + + await model.merge(pr) + + let sent = try #require(github.requests.last { $0.method == "PUT" }) + #expect(sent.json["merge_method"] as? String == "squash") + #expect(sent.json["sha"] as? String == "abc7") + #expect(model.pullRequests.isEmpty) + } + + @Test("failures too old for GitHub to re-run are not offered") + func hidesStaleFailures() async { + github.respond { request in + if request.path.contains("/acme/web/actions/runs?status=failure") { + return .json(Fixture.runs([1], updatedAt: stamp(daysAgo: 2))) + } + if request.path.contains("/acme/api/actions/runs?status=failure") { + return .json(Fixture.runs([2], updatedAt: stamp(daysAgo: 45))) + } + return everydayGitHub(request) + } + let model = makeModel() + + await model.connect() + + #expect(model.failures.map(\.runID) == [1]) + } + + @Test("ignored repositories are not watched; pinned ones always are") + func honoursPinsAndIgnores() async { + scratch.settings.setExcluded(true, "acme/api") + scratch.settings.pin("acme/quiet") + + let model = await connectedModel() + + #expect(model.repos.map(\.id) == ["acme/quiet", "acme/web"]) + } + + @Test("polls quickly while the panel is open and backs off when it closes") + func cadence() async { + let model = await connectedModel() + #expect(model.pollDelay == 120) + + model.panelDidOpen() + #expect(model.pollDelay == 30) + + model.panelDidClose() + #expect(model.pollDelay == 120) + } + + @Test("signing out forgets everything") + func signsOut() async { + let model = await connectedModel() + + model.signOut() + + #expect(model.phase == .needsToken) + #expect(model.login == nil) + #expect(model.approvals.isEmpty && model.pullRequests.isEmpty && model.repos.isEmpty) + #expect(model.connectNotice == nil) + #expect(memory.current == nil) + } + + @Test("starting with a saved token connects without anyone opening the panel") + func startsOnLaunch() async { + github.respond(everydayGitHub) + let model = makeModel() + + await model.start() + + #expect(model.phase == .ready) + #expect(model.approvals.count == 1) + } + + @Test("starting with no token waits on the connect screen and asks GitHub nothing") + func startsWithoutToken() async { + let model = AppModel(client: github.client(), settings: scratch.settings, vault: MemoryVault().vault) + + await model.start() + + #expect(model.phase == .needsToken) + #expect(model.connectNotice == nil) + #expect(github.requests.isEmpty) + } + + @Test("pasting a token stores it and connects") + func signsIn() async { + github.respond(everydayGitHub) + let empty = MemoryVault() + let model = AppModel(client: github.client(), settings: scratch.settings, vault: empty.vault) + + await model.signIn(token: "pasted-token") + + #expect(empty.current == "pasted-token") + #expect(model.phase == .ready) + } +} diff --git a/Tests/ClearedTests/ClearedTests.swift b/Tests/ClearedTests/ClearedTests.swift index faf573d..0dcb28b 100644 --- a/Tests/ClearedTests/ClearedTests.swift +++ b/Tests/ClearedTests/ClearedTests.swift @@ -23,7 +23,12 @@ struct MergeReadinessTests { private func pr( checks: CheckState = .success, mergeable: String = "mergeable", - draft: Bool = false + draft: Bool = false, + state: MergeState = .clean, + review: ReviewDecision? = nil, + queued: Bool = false, + allowed: [MergeMethod] = [], + repoDefault: MergeMethod? = nil ) -> PullRequestSummary { PullRequestSummary( repo: RepoRef(owner: "o", name: "r"), @@ -34,7 +39,12 @@ struct MergeReadinessTests { checkState: checks, mergeable: mergeable, htmlURL: URL(string: "https://github.com/o/r/pull/1")!, - updatedAt: Date() + updatedAt: Date(), + mergeState: state, + reviewDecision: review, + isInMergeQueue: queued, + allowedMethods: allowed, + repoDefaultMethod: repoDefault ) } @@ -68,6 +78,51 @@ struct MergeReadinessTests { #expect(!blocked.isReadyToMerge) #expect(blocked.blockReason == reason) } + + @Test("green checks are not enough when GitHub's own rules still block the merge", arguments: [ + (MergeState.blocked, ReviewDecision.reviewRequired as ReviewDecision?, "needs review"), + (MergeState.blocked, ReviewDecision.changesRequested, "changes requested"), + (MergeState.blocked, ReviewDecision.approved, "blocked by branch rules"), + (MergeState.blocked, nil, "blocked by branch rules"), + (MergeState.behind, nil, "behind base branch"), + (MergeState.dirty, nil, "conflicts"), + (MergeState.unknown, nil, "GitHub is still checking"), + ]) + func branchRulesBlock(state: MergeState, review: ReviewDecision?, reason: String) { + let blocked = pr(state: state, review: review) + #expect(!blocked.isReadyToMerge) + #expect(blocked.blockReason == reason) + } + + @Test("an approved pull request in a clean state is ready", arguments: [MergeState.clean, .hasHooks, .unstable]) + func approvedIsReady(state: MergeState) { + #expect(pr(state: state, review: .approved).isReadyToMerge) + } + + @Test("a pull request already in the merge queue has no merge button") + func queuedIsNotOffered() { + #expect(pr(queued: true).blockReason == "in merge queue") + } + + @Test("mergeability GitHub has not computed yet is not treated as mergeable") + func unknownMergeability() { + let pending = pr(mergeable: "unknown") + #expect(!pending.isReadyToMerge) + #expect(pending.blockReason == "GitHub is still checking") + } + + @Test("the preferred merge method is used wherever the repository allows it") + func prefersConfiguredMethod() { + #expect(pr(allowed: [.squash, .merge, .rebase]).mergeMethod(preferring: .rebase) == .rebase) + // Nothing known about the repository: trust the preference. + #expect(pr().mergeMethod(preferring: .merge) == .merge) + } + + @Test("a repository that forbids the preferred method gets its own default instead") + func fallsBackToRepoDefault() { + #expect(pr(allowed: [.merge, .rebase], repoDefault: .rebase).mergeMethod(preferring: .squash) == .rebase) + #expect(pr(allowed: [.merge], repoDefault: .squash).mergeMethod(preferring: .rebase) == .merge) + } } @Suite("Pending approval") @@ -91,6 +146,107 @@ struct PendingApprovalTests { #expect(item.environmentIDs == [1, 2]) #expect(item.id == "o/r#42") } + + @Test("the same run held at a different gate is a new thing to announce") + func announcementKeyTracksTheGate() { + let staging = approval(environments: [.init(id: 1, name: "staging")]) + let production = approval(environments: [.init(id: 2, name: "production")]) + #expect(staging.id == production.id) + #expect(staging.announcementKey != production.announcementKey) + #expect(staging.announcementKey == approval(environments: [.init(id: 1, name: "renamed")]).announcementKey) + } +} + +@Suite("Announcements") +struct AnnouncementLedgerTests { + @Test("a hold already waiting at launch is announced; an old failure is not") + func firstSight() { + var holds = AnnouncementLedger() + var failures = AnnouncementLedger() + #expect(holds.fresh(in: ["a", "b"], announceFirstSight: true) == ["a", "b"]) + #expect(failures.fresh(in: ["a", "b"], announceFirstSight: false).isEmpty) + } + + @Test("each item is announced once, however many polls see it") + func announcesOnce() { + var ledger = AnnouncementLedger() + _ = ledger.fresh(in: ["a"], announceFirstSight: true) + #expect(ledger.fresh(in: ["a"], announceFirstSight: true).isEmpty) + #expect(ledger.fresh(in: ["a", "b"], announceFirstSight: true) == ["b"]) + } + + @Test("something that leaves and comes back is announced again") + func reannouncesAfterLeaving() { + var ledger = AnnouncementLedger() + _ = ledger.fresh(in: ["a"], announceFirstSight: true) + _ = ledger.fresh(in: [], announceFirstSight: true) + #expect(ledger.fresh(in: ["a"], announceFirstSight: true) == ["a"]) + } + + @Test("reports what has gone, so its notification can be withdrawn") + func reportsDepartures() { + var ledger = AnnouncementLedger() + #expect(ledger.departed(from: ["a"]).isEmpty) + _ = ledger.fresh(in: ["a", "b"], announceFirstSight: true) + #expect(ledger.departed(from: ["b"]) == ["a"]) + } +} + +@MainActor +@Suite("Settings") +struct SettingsTests { + private let scratch = ScratchSettings() + private var settings: Settings { scratch.settings } + + @Test("ships with one-click actions and squash merges") + func defaults() { + #expect(settings.approvalInterval == 30) + #expect(settings.backgroundMultiplier == 4) + #expect(settings.mergeMethod == .squash) + #expect(settings.confirmActions == false) + #expect(settings.notifyOnHold) + #expect(settings.notifyOnFailure == false) + } + + @Test("the poll interval is kept inside sane bounds") + func clampsInterval() { + settings.approvalInterval = 1 + #expect(settings.approvalInterval == 10) + settings.approvalInterval = 9_000 + #expect(settings.approvalInterval == 300) + } + + @Test("the merge method survives a round trip") + func storesMergeMethod() { + settings.mergeMethod = .rebase + #expect(settings.mergeMethod == .rebase) + } + + @Test("an ignored repository can be watched again") + func ignoreIsReversible() { + settings.setExcluded(true, "acme/web") + #expect(settings.excludedRepos == ["acme/web"]) + settings.setExcluded(false, "acme/web") + #expect(settings.excludedRepos.isEmpty) + } + + @Test("pinning accepts owner/name, once, and un-ignores it") + func pins() { + settings.setExcluded(true, "acme/quiet") + #expect(settings.pin(" acme/quiet\n")) + #expect(settings.pin("ACME/Quiet")) + #expect(settings.pinnedRepos == ["acme/quiet"]) + #expect(settings.excludedRepos.isEmpty) + + settings.unpin("acme/quiet") + #expect(settings.pinnedRepos.isEmpty) + } + + @Test("pinning rejects anything that is not owner/name", arguments: ["", "nope", "a/b/c", "https://github.com/acme/web"]) + func rejectsBadPins(input: String) { + #expect(settings.pin(input) == false) + #expect(settings.pinnedRepos.isEmpty) + } } @Suite("Held duration") @@ -142,10 +298,22 @@ struct RateLimitTests { @Suite("Error messages") struct ErrorMessageTests { - @Test("a 403 about scopes tells you which scopes") + @Test("a 403 about scopes tells you which scope") func scopeMessage() { let error = GitHubError.http(status: 403, message: "Resource not accessible") - #expect(error.localizedDescription.contains("repo and workflow")) + #expect(error.localizedDescription.contains("repo scope")) + } + + @Test("a 403 from an SSO-protected organization says to authorize the token") + func ssoMessage() { + let error = GitHubError.http(status: 403, message: "Resource protected by organization SAML enforcement.") + #expect(error.localizedDescription.contains("single sign-on")) + } + + @Test("a rate limit says how long until Cleared tries again") + func rateLimitedMessage() { + let error = GitHubError.rateLimited(until: Date().addingTimeInterval(11 * 60 + 30)) + #expect(error.localizedDescription.contains("12m")) } @Test("a 403 about rate limits says so instead") diff --git a/Tests/ClearedTests/GitHubClientTests.swift b/Tests/ClearedTests/GitHubClientTests.swift new file mode 100644 index 0000000..000d8c4 --- /dev/null +++ b/Tests/ClearedTests/GitHubClientTests.swift @@ -0,0 +1,374 @@ +import Testing +import Foundation +@testable import Cleared + +private let repo = RepoRef(owner: "acme", name: "web") + +private func approval(run: Int = 42, environments: [Int] = [1, 2]) -> PendingApproval { + PendingApproval( + repo: repo, + runID: run, + runName: "Deploy", + branch: "main", + environments: environments.map { .init(id: $0, name: "env\($0)") }, + waitingSince: Date(), + htmlURL: URL(string: "https://github.com/acme/web/actions/runs/\(run)")! + ) +} + +private func pullRequest(headSHA: String = "abc123") -> PullRequestSummary { + PullRequestSummary( + repo: repo, + number: 7, + title: "feat: thing", + branch: "feat/thing", + isDraft: false, + checkState: .success, + mergeable: "mergeable", + htmlURL: URL(string: "https://github.com/acme/web/pull/7")!, + updatedAt: Date(), + headSHA: headSHA + ) +} + +@Suite("Conditional requests") +struct ConditionalRequestTests { + @Test("a repeat poll sends the ETag and reuses the cached body on 304") + func reusesCachedBody() async throws { + let github = StubGitHub() + github.respond { request in + request.headers["If-None-Match"] == "\"v1\"" + ? .json("", status: 304) + : .json(Fixture.runs([]), headers: ["ETag": "\"v1\""]) + } + let client = github.client() + + let first = try await client.pendingApprovals(in: repo) + let second = try await client.pendingApprovals(in: repo) + + #expect(first.isEmpty && second.isEmpty) + #expect(github.requests.count == 2) + #expect(github.requests[0].headers["If-None-Match"] == nil) + #expect(github.requests[1].headers["If-None-Match"] == "\"v1\"") + } + + @Test("a held run's pending deployments are revalidated, not re-downloaded") + func cachesPendingDeployments() async throws { + let github = StubGitHub() + github.respond { request in + if request.headers["If-None-Match"] != nil { return .json("", status: 304) } + return request.path.contains("pending_deployments") + ? .json(Fixture.pendingDeployments([(1, "production", true)]), headers: ["ETag": "\"d1\""]) + : .json(Fixture.runs([42]), headers: ["ETag": "\"r1\""]) + } + let client = github.client() + + _ = try await client.pendingApprovals(in: repo) + let again = try await client.pendingApprovals(in: repo) + + #expect(again.map(\.runID) == [42]) + #expect(github.requests.suffix(2).allSatisfy { $0.headers["If-None-Match"] != nil }) + } + + @Test("without a token nothing is sent") + func refusesWithoutToken() async { + let github = StubGitHub() + let client = github.client(authenticated: false) + + await #expect(throws: GitHubError.notAuthenticated) { + _ = try await client.viewer() + } + #expect(github.requests.isEmpty) + } +} + +@Suite("Rate limits") +struct RateLimitHandlingTests { + private func limitHeaders(resource: String, remaining: Int, reset: Double = 1_800_003_600) -> [String: String] { + [ + "X-RateLimit-Resource": resource, + "X-RateLimit-Remaining": "\(remaining)", + "X-RateLimit-Limit": "5000", + "X-RateLimit-Reset": "\(Int(reset))", + ] + } + + @Test("the footer shows the REST budget even after a GraphQL call") + func keepsBudgetsApart() async throws { + let github = StubGitHub() + github.respond { [self] request in + request.path == "/graphql" + ? .json(Fixture.search([]), headers: limitHeaders(resource: "graphql", remaining: 4_999)) + : .json(#"{"login": "octocat"}"#, headers: limitHeaders(resource: "core", remaining: 4_000)) + } + let client = github.client() + + _ = try await client.viewer() + _ = try await client.myOpenPullRequests() + + #expect(await client.currentRateLimit()?.remaining == 4_000) + } + + @Test("a secondary limit pauses requests for as long as GitHub asked") + func honoursRetryAfter() async throws { + let github = StubGitHub() + github.respond { _ in + .json(#"{"message": "You have exceeded a secondary rate limit."}"#, status: 403, headers: ["Retry-After": "120"]) + } + let client = github.client() + let release = github.now.addingTimeInterval(120) + + await #expect(throws: GitHubError.rateLimited(until: release)) { + _ = try await client.viewer() + } + // Still inside the window: refused locally, nothing reaches GitHub. + github.advance(by: 60) + await #expect(throws: GitHubError.rateLimited(until: release)) { + _ = try await client.viewer() + } + #expect(github.requests.count == 1) + + // Past it: requests flow again. + github.advance(by: 61) + github.respond { _ in .json(#"{"login": "octocat"}"#) } + #expect(try await client.viewer().login == "octocat") + #expect(github.requests.count == 2) + } + + @Test("an exhausted budget waits for the reset GitHub names") + func waitsForReset() async { + let github = StubGitHub() + let reset = github.now.addingTimeInterval(900) + github.respond { [self] _ in + .json( + #"{"message": "API rate limit exceeded for user."}"#, + status: 403, + headers: limitHeaders(resource: "core", remaining: 0, reset: reset.timeIntervalSince1970) + ) + } + let client = github.client() + + await #expect(throws: GitHubError.rateLimited(until: reset)) { + _ = try await client.viewer() + } + } + + @Test("running out of GraphQL budget does not stop REST polling") + func limitsAreIndependent() async throws { + let github = StubGitHub() + github.respond { request in + request.path == "/graphql" + ? .json(#"{"message": "rate limit"}"#, status: 429) + : .json(#"{"login": "octocat"}"#) + } + let client = github.client() + + await #expect(throws: GitHubError.self) { _ = try await client.myOpenPullRequests() } + #expect(try await client.viewer().login == "octocat") + } + + @Test("a 403 that is not about rate limits stays a permission error") + func permissionErrorIsNotARateLimit() async { + let github = StubGitHub() + github.respond { _ in .json(#"{"message": "Resource not accessible by personal access token"}"#, status: 403) } + let client = github.client() + + await #expect(throws: GitHubError.http(status: 403, message: "Resource not accessible by personal access token")) { + _ = try await client.viewer() + } + } +} + +@Suite("The four verbs") +struct VerbTests { + @Test("clearing approves every environment on the run") + func approveBody() async throws { + let github = StubGitHub() + let client = github.client() + + try await client.approve(approval()) + + let sent = try #require(github.requests.first) + #expect(sent.method == "POST") + #expect(sent.path == "/repos/acme/web/actions/runs/42/pending_deployments") + #expect(sent.json["environment_ids"] as? [Int] == [1, 2]) + #expect(sent.json["state"] as? String == "approved") + } + + @Test("rejecting sends the reason") + func rejectBody() async throws { + let github = StubGitHub() + let client = github.client() + + try await client.reject(approval(), comment: "wrong tag") + + let sent = try #require(github.requests.first) + #expect(sent.json["state"] as? String == "rejected") + #expect(sent.json["comment"] as? String == "wrong tag") + } + + @Test("a merge is pinned to the commit Cleared saw") + func mergeSendsHeadSHA() async throws { + let github = StubGitHub() + github.respond { _ in .json(#"{"merged": true, "message": "Pull Request successfully merged"}"#) } + let client = github.client() + + try await client.merge(pullRequest(), method: .rebase) + + let sent = try #require(github.requests.first) + #expect(sent.method == "PUT") + #expect(sent.path == "/repos/acme/web/pulls/7/merge") + #expect(sent.json["sha"] as? String == "abc123") + #expect(sent.json["merge_method"] as? String == "rebase") + } + + @Test("a push since the last poll surfaces GitHub's own explanation") + func staleHeadIsRefused() async { + let github = StubGitHub() + let message = "Head branch was modified. Review and try the merge again." + github.respond { _ in .json(#"{"message": "\#(message)"}"#, status: 409) } + let client = github.client() + + await #expect(throws: GitHubError.http(status: 409, message: message)) { + try await client.merge(pullRequest()) + } + #expect(GitHubError.http(status: 409, message: message).localizedDescription == message) + } + + @Test("a merge GitHub declines is an error, not a success") + func declinedMerge() async { + let github = StubGitHub() + github.respond { _ in .json(#"{"merged": false, "message": "Required status check is expected."}"#) } + let client = github.client() + + await #expect(throws: GitHubError.http(status: 405, message: "Required status check is expected.")) { + try await client.merge(pullRequest()) + } + } + + @Test("starting a workflow returns the run GitHub created") + func dispatchReturnsRun() async throws { + let github = StubGitHub() + github.respond { _ in + .json(#"{"workflow_run_id": 99, "run_url": "https://api.github.com/x", "html_url": "https://github.com/acme/web/actions/runs/99"}"#) + } + let client = github.client() + let workflow = DispatchableWorkflow(repo: repo, workflowID: 5, name: "Deploy", path: ".github/workflows/deploy.yml") + + let run = try await client.dispatch(workflow, ref: "main", inputs: ["environment": "staging"]) + + #expect(run?.absoluteString == "https://github.com/acme/web/actions/runs/99") + let sent = try #require(github.requests.first) + #expect(sent.path == "/repos/acme/web/actions/workflows/5/dispatches") + #expect(sent.json["ref"] as? String == "main") + #expect(sent.json["inputs"] as? [String: String] == ["environment": "staging"]) + } + + @Test("an empty 204 from an older API still counts as started") + func dispatchWithoutBody() async throws { + let github = StubGitHub() + github.respond { _ in .json("", status: 204) } + let client = github.client() + let workflow = DispatchableWorkflow(repo: repo, workflowID: 5, name: "Deploy", path: ".github/workflows/deploy.yml") + + #expect(try await client.dispatch(workflow, ref: "main", inputs: [:]) == nil) + } +} + +@Suite("Reading the queue") +struct QueueReadingTests { + @Test("only gates this user can clear are surfaced") + func filtersToApprovableGates() async throws { + let github = StubGitHub() + github.respond { request in + if request.path.contains("/runs/1/pending_deployments") { + return .json(Fixture.pendingDeployments([(10, "staging", false), (11, "production", true)])) + } + if request.path.contains("/runs/2/pending_deployments") { + return .json(Fixture.pendingDeployments([(10, "staging", false)])) + } + return .json(Fixture.runs([1, 2])) + } + let client = github.client() + + let approvals = try await client.pendingApprovals(in: repo) + + #expect(approvals.map(\.runID) == [1]) + #expect(approvals.first?.environmentIDs == [11]) + } + + @Test("pull requests carry GitHub's merge verdict and the repo's allowed methods") + func decodesPullRequests() async throws { + let github = StubGitHub() + github.respond { _ in + .json(Fixture.search([ + Fixture.pullRequest(number: 1), + Fixture.pullRequest(number: 2, mergeState: "BLOCKED", review: "REVIEW_REQUIRED"), + Fixture.pullRequest(number: 3, mergeCommit: false, rebase: false), + "{}", + "null", + ])) + } + let client = github.client() + + let prs = try await client.myOpenPullRequests() + + #expect(prs.map(\.number) == [1, 2, 3]) + #expect(prs[0].isReadyToMerge) + #expect(prs[0].headSHA == "abc1") + #expect(prs[1].blockReason == "needs review") + #expect(prs[2].allowedMethods == [.squash]) + #expect(prs[2].mergeMethod(preferring: .merge) == .squash) + } + + @Test("only enabled workflows with a manual trigger can be started") + func buildsWorkflowCatalog() async throws { + let github = StubGitHub() + let deploy = "name: Deploy\\non:\\n workflow_dispatch:\\n inputs:\\n target:\\n required: true\\n" + let test = "name: Test\\non: [push]\\n" + github.respond { request in + if request.path == "/graphql" { + return .json(""" + {"data": {"repository": {"defaultBranchRef": {"name": "trunk"}, "object": {"entries": [ + {"path": ".github/workflows/deploy.yml", "object": {"text": "\(deploy)"}}, + {"path": ".github/workflows/off.yml", "object": {"text": "\(deploy)"}}, + {"path": ".github/workflows/test.yml", "object": {"text": "\(test)"}}, + {"path": ".github/workflows/scripts", "object": {}} + ]}}}} + """) + } + return .json(""" + {"total_count": 4, "workflows": [ + {"id": 1, "name": "Deploy", "path": ".github/workflows/deploy.yml", "state": "active"}, + {"id": 2, "name": "Switched off", "path": ".github/workflows/off.yml", "state": "disabled_manually"}, + {"id": 3, "name": "Test", "path": ".github/workflows/test.yml", "state": "active"}, + {"id": 4, "name": "pages-build-deployment", "path": "dynamic/pages/pages-build-deployment", "state": "active"} + ]} + """) + } + let client = github.client() + + let catalog = try await client.workflowCatalog(in: repo) + + #expect(catalog.defaultBranch == "trunk") + #expect(catalog.workflows.map(\.name) == ["Deploy"]) + #expect(catalog.workflows.first?.inputs.map(\.name) == ["target"]) + } + + @Test("a classic token without repo is flagged; a fine-grained token is trusted") + func readsScopes() async throws { + let github = StubGitHub() + let client = github.client() + + github.respond { _ in .json(#"{"login": "octocat"}"#, headers: ["X-OAuth-Scopes": "gist, read:org"]) } + #expect(try await client.viewer().canWrite == false) + + github.respond { _ in .json(#"{"login": "octocat"}"#, headers: ["X-OAuth-Scopes": "gist, repo, workflow"]) } + #expect(try await client.viewer().canWrite) + + github.respond { _ in .json(#"{"login": "octocat"}"#) } + let fineGrained = try await client.viewer() + #expect(fineGrained.scopes == nil) + #expect(fineGrained.canWrite) + } +} diff --git a/Tests/ClearedTests/StubGitHub.swift b/Tests/ClearedTests/StubGitHub.swift new file mode 100644 index 0000000..ed93dc9 --- /dev/null +++ b/Tests/ClearedTests/StubGitHub.swift @@ -0,0 +1,197 @@ +import Foundation +@testable import Cleared + +/// A stand-in for api.github.com. +/// +/// Each instance hands out its own token, and replies are looked up by the +/// `Authorization` header that token produces. That keeps every test's stubs +/// private to it, so the suites can run in parallel without sharing state. +final class StubGitHub: @unchecked Sendable { + struct Reply { + var status = 200 + var headers: [String: String] = [:] + var body = "{}" + var failure: URLError.Code? + + static func json(_ body: String, status: Int = 200, headers: [String: String] = [:]) -> Reply { + Reply(status: status, headers: headers, body: body) + } + + static let offline = Reply(failure: .notConnectedToInternet) + } + + struct Request { + let method: String + /// Path and query, e.g. `/repos/o/r/actions/runs?status=waiting&per_page=20`. + let path: String + let headers: [String: String] + let body: Data + + var json: [String: Any] { + (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] ?? [:] + } + + /// The GraphQL query text, for telling one query from another. + var query: String { json["query"] as? String ?? "" } + } + + let token = "stub-\(UUID().uuidString)" + private let lock = NSLock() + private var handler: @Sendable (Request) -> Reply = { _ in Reply() } + private var seen: [Request] = [] + private var clock = Date(timeIntervalSince1970: 1_800_000_000) + + init() { + StubProtocol.register(self) + } + + deinit { + StubProtocol.unregister(token) + } + + /// A client wired to this stub, with a clock the test controls. + func client(authenticated: Bool = true) -> GitHubClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + let token = authenticated ? self.token : nil + return GitHubClient(session: URLSession(configuration: config), token: { token }, now: { [weak self] in self?.now ?? Date() }) + } + + func respond(_ handler: @escaping @Sendable (Request) -> Reply) { + lock.withLock { self.handler = handler } + } + + var requests: [Request] { lock.withLock { seen } } + + func requests(matching fragment: String) -> [Request] { + requests.filter { $0.path.contains(fragment) } + } + + var now: Date { lock.withLock { clock } } + + func advance(by seconds: TimeInterval) { + lock.withLock { clock = clock.addingTimeInterval(seconds) } + } + + fileprivate func reply(to request: Request) -> Reply { + let handler = lock.withLock { + seen.append(request) + return self.handler + } + return handler(request) + } +} + +final class StubProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var stubs: [String: StubGitHub] = [:] + + static func register(_ stub: StubGitHub) { + lock.withLock { stubs["Bearer \(stub.token)"] = stub } + } + + static func unregister(_ token: String) { + lock.withLock { stubs["Bearer \(token)"] = nil } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { return } + let authorization = request.value(forHTTPHeaderField: "Authorization") ?? "" + guard let stub = Self.lock.withLock({ Self.stubs[authorization] }) else { + client?.urlProtocol(self, didFailWithError: URLError(.userAuthenticationRequired)) + return + } + + let seen = StubGitHub.Request( + method: request.httpMethod ?? "GET", + path: url.path + (url.query.map { "?\($0)" } ?? ""), + headers: request.allHTTPHeaderFields ?? [:], + body: Self.body(of: request) + ) + let reply = stub.reply(to: seen) + + if let failure = reply.failure { + client?.urlProtocol(self, didFailWithError: URLError(failure)) + return + } + let response = HTTPURLResponse(url: url, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: reply.headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(reply.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + /// URLSession moves a request's body into a stream before a protocol sees it. + private static func body(of request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + data.append(buffer, count: count) + } + return data + } +} + +/// Canned GitHub responses, trimmed to the fields Cleared reads. +enum Fixture { + static func runs(_ ids: [Int], updatedAt: String = "2027-01-15T08:00:00Z") -> String { + let runs = ids.map { id in + """ + {"id": \(id), "name": "Deploy", "head_branch": "main", "status": "waiting", "conclusion": null, + "created_at": "\(updatedAt)", "updated_at": "\(updatedAt)", + "html_url": "https://github.com/acme/web/actions/runs/\(id)"} + """ + } + return #"{"total_count": \#(ids.count), "workflow_runs": [\#(runs.joined(separator: ","))]}"# + } + + static func pendingDeployments(_ environments: [(id: Int, name: String, mine: Bool)]) -> String { + let items = environments.map { + #"{"environment": {"id": \#($0.id), "name": "\#($0.name)"}, "current_user_can_approve": \#($0.mine), "wait_timer": 0}"# + } + return "[\(items.joined(separator: ","))]" + } + + static func repos(_ names: [String], pushedAt: String) -> String { + let items = names.map { + #"{"name": "\#($0)", "owner": {"login": "acme"}, "archived": false, "disabled": false, "pushed_at": "\#(pushedAt)"}"# + } + return "[\(items.joined(separator: ","))]" + } + + static func pullRequest( + number: Int, + mergeState: String = "CLEAN", + review: String? = nil, + rollup: String? = "SUCCESS", + squash: Bool = true, + mergeCommit: Bool = true, + rebase: Bool = true, + defaultMethod: String = "SQUASH" + ) -> String { + """ + {"number": \(number), "title": "feat: thing \(number)", "url": "https://github.com/acme/web/pull/\(number)", + "isDraft": false, "headRefName": "feat/thing", "headRefOid": "abc\(number)", "updatedAt": "2027-01-15T08:00:00Z", + "mergeable": "MERGEABLE", "mergeStateStatus": "\(mergeState)", + "reviewDecision": \(review.map { "\"\($0)\"" } ?? "null"), "isInMergeQueue": false, + "repository": {"name": "web", "owner": {"login": "acme"}, "squashMergeAllowed": \(squash), + "mergeCommitAllowed": \(mergeCommit), "rebaseMergeAllowed": \(rebase), + "viewerDefaultMergeMethod": "\(defaultMethod)"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": \(rollup.map { "{\"state\": \"\($0)\"}" } ?? "null")}}]}} + """ + } + + static func search(_ nodes: [String]) -> String { + #"{"data": {"search": {"nodes": [\#(nodes.joined(separator: ","))]}}}"# + } +} diff --git a/Tests/ClearedTests/WorkflowFileTests.swift b/Tests/ClearedTests/WorkflowFileTests.swift new file mode 100644 index 0000000..feeae19 --- /dev/null +++ b/Tests/ClearedTests/WorkflowFileTests.swift @@ -0,0 +1,124 @@ +import Testing +@testable import Cleared + +@Suite("Workflow files") +struct WorkflowFileTests { + @Test("the ways a workflow can declare a manual trigger", arguments: [ + "on: workflow_dispatch", + "on: [push, workflow_dispatch]", + "on:\n - push\n - workflow_dispatch", + "on:\n- push\n- workflow_dispatch", + "on:\n push:\n branches: [main]\n workflow_dispatch:", + "on:\n workflow_dispatch: {}", + "\"on\":\n workflow_dispatch:", + "'on':\n workflow_dispatch:", + "name: Deploy # by hand\non: # triggers\n workflow_dispatch: # manual\njobs:\n go:\n runs-on: ubuntu-latest", + "on: { workflow_dispatch: {}, push: { branches: [main] } }", + ]) + func recognisesDispatch(yaml: String) { + #expect(WorkflowFile.dispatchInputs(in: yaml) == []) + } + + @Test("workflows that cannot be started by hand", arguments: [ + "on: push", + "on: [push, pull_request]", + "on:\n push:\n branches: [main]\n schedule:\n - cron: '0 6 * * *'", + "name: No triggers at all", + // Mentioned, but not as a trigger. + "on: push\njobs:\n go:\n steps:\n - run: echo workflow_dispatch", + "on:\n push:\n paths: ['workflow_dispatch/**']", + "", + ]) + func rejectsOthers(yaml: String) { + #expect(WorkflowFile.dispatchInputs(in: yaml) == nil) + } + + @Test("reads each input's type, default and whether it is required") + func readsInputs() throws { + let yaml = """ + name: Deploy + on: + push: + branches: [main] + workflow_dispatch: + inputs: + environment: + description: 'Where to deploy: staging or production' + required: true + default: staging + type: choice + options: + - staging + - production + dry_run: + description: "Plan only" + type: boolean + default: true + version: + description: Tag to ship # e.g. v1.2.3 + replicas: + type: number + default: 3 + target: + type: environment + required: false + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo "inputs: ${{ inputs.environment }}" + """ + + let inputs = try #require(WorkflowFile.dispatchInputs(in: yaml)) + + #expect(inputs.map(\.name) == ["environment", "dry_run", "version", "replicas", "target"]) + #expect(inputs[0] == WorkflowInput( + name: "environment", + description: "Where to deploy: staging or production", + kind: .choice(["staging", "production"]), + isRequired: true, + defaultValue: "staging" + )) + #expect(inputs[1].kind == .boolean) + #expect(inputs[1].defaultValue == "true") + #expect(inputs[2] == WorkflowInput(name: "version", description: "Tag to ship", kind: .string, isRequired: false, defaultValue: "")) + #expect(inputs[3].kind == .number) + #expect(inputs[3].defaultValue == "3") + #expect(inputs[4].kind == .environment) + } + + @Test("copes with the looser layouts YAML allows") + func tolerantLayouts() throws { + let yaml = """ + on: + workflow_dispatch: + inputs: + level: + type: choice + options: + - info + - "debug" + regions: + type: choice + options: [us, eu] + notes: + description: > + Free text shown + on the run + default: "has: colon and # hash" + """ + + let inputs = try #require(WorkflowFile.dispatchInputs(in: yaml)) + + #expect(inputs[0].kind == .choice(["info", "debug"])) + #expect(inputs[1].kind == .choice(["us", "eu"])) + #expect(inputs[2].description == "Free text shown on the run") + #expect(inputs[2].defaultValue == "has: colon and # hash") + } + + @Test("a choice with no options falls back to free text rather than an empty picker") + func choiceWithoutOptions() throws { + let yaml = "on:\n workflow_dispatch:\n inputs:\n level:\n type: choice\n" + #expect(try #require(WorkflowFile.dispatchInputs(in: yaml)).first?.kind == .string) + } +} diff --git a/project.yml b/project.yml index cfa795e..5d35e78 100644 --- a/project.yml +++ b/project.yml @@ -6,16 +6,18 @@ options: createIntermediateGroups: true groupSortPosition: top +# Cleared.xcconfig optionally includes Local.xcconfig. Pointing at +# Local.xcconfig directly would fail on every clone that does not have one. configFiles: - Debug: Local.xcconfig - Release: Local.xcconfig + Debug: Cleared.xcconfig + Release: Cleared.xcconfig settings: base: SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete - MARKETING_VERSION: "0.3.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "0.4.0" + CURRENT_PROJECT_VERSION: "3" # Ad-hoc by default so anyone can clone and build with no Apple account. # Override both in Local.xcconfig (gitignored) for a stable signature that # keeps the Keychain entry valid across rebuilds — see Local.xcconfig.example. @@ -24,8 +26,14 @@ settings: # apply. The :default= operator supplies the ad-hoc fallback instead. CODE_SIGN_IDENTITY: "$(CLEARED_SIGN_IDENTITY:default=-)" CODE_SIGN_STYLE: "$(CLEARED_SIGN_STYLE:default=Automatic)" - ENABLE_HARDENED_RUNTIME: NO DEAD_CODE_STRIPPING: YES + configs: + # Required for notarization. Release only: the unit tests are injected + # into a Debug build, which library validation would refuse. + Debug: + ENABLE_HARDENED_RUNTIME: NO + Release: + ENABLE_HARDENED_RUNTIME: YES targets: Cleared: @@ -33,12 +41,13 @@ targets: platform: macOS sources: - path: Sources/Cleared + - path: Resources/Assets.xcassets settings: base: INFOPLIST_FILE: Resources/Info.plist PRODUCT_BUNDLE_IDENTIFIER: net.hlnd.Cleared PRODUCT_NAME: Cleared - ASSETCATALOG_COMPILER_APPICON_NAME: "" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon COMBINE_HIDPI_IMAGES: YES LD_RUNPATH_SEARCH_PATHS: ["$(inherited)", "@executable_path/../Frameworks"]