chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS check holds writes to lexicons, and words to each pull state
Only what this pull adds. just ios-look draws the iOS app's screens in any state landed as 1c3ae05, so its changes do not show here again.
.knotless/workflows/ios.yml+16−3
| @@ -1,6 +1,7 @@ | ||
# The iOS app's models against the XRPC answers the screenshots recorded, so a | ||
# model that misreads an answer fails here rather than on a screen. Xcode needs | ||
# macOS, so the app itself is built by hand (ios/README.md). Only on pulls that | ||
# model that misreads an answer fails here rather than on a screen; what its | ||
# merge section says of each state; and every write it makes against its lexicon. | ||
# Xcode needs macOS, so the app itself is built by hand (ios/README.md). Only on pulls that | ||
# touch what it reads, and no check deploy.yml waits for: nothing the app does | ||
# holds up a deploy. | ||
image: swift:6.2 | ||
| @@ -9,5 +10,17 @@ on: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [ios/**, lexicons/**, harness/shots/fixtures.json, .knotless/workflows/ios.yml] | ||
steps: | ||
- name: tools | ||
run: | | ||
# What the step below needs to fetch and unpack Node, as this image hasn't them. | ||
apt-get update -qq | ||
apt-get install -y -qq --no-install-recommends curl ca-certificates xz-utils | ||
- uses: chadtmiller.com/steps/node@1 | ||
with: {version: 24.22.1} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: harness | ||
with: {dir: harness, omit: dev} | ||
- name: models | ||
run: sh ios/check-models.sh | ||
run: | | ||
export PATH=/opt/node/bin:$PATH | ||
sh ios/check-models.sh | ||
crates/knotless-serve/tests/pages.rs+24−3
| @@ -348,11 +348,32 @@ fn the_app_logs_in_with_a_code_only_it_can_trade() { | ||
let named = format!("{}/.well-known/apple-app-site-association", server.base); | ||
let (status, _, said) = browse("GET", &named, None, None); | ||
assert_eq!(status, 200); | ||
// What it answers is what the app is built to ask: without both, a browser | ||
// session won't open for it, or won't come back. The file on Apple's CDN | ||
// follows this one. | ||
let project = include_str!("../../../ios/project.yml"); | ||
let entitlements = include_str!("../../../ios/Knotless/Knotless.entitlements"); | ||
let setting = |key: &str| { | ||
project | ||
.lines() | ||
.find_map(|line| line.trim().strip_prefix(key)) | ||
.unwrap() | ||
}; | ||
let app = format!( | ||
"{}.{}", | ||
setting("DEVELOPMENT_TEAM: "), | ||
setting("PRODUCT_BUNDLE_IDENTIFIER: ") | ||
); | ||
let details = &said["applinks"]["details"][0]; | ||
assert_eq!(details["appIDs"][0], json!("YN68LN9T7Z.dev.knotless.app")); | ||
assert_eq!(details["appIDs"][0], json!(app)); | ||
assert_eq!(details["components"][0]["/"], json!(where_to)); | ||
let apps = &said["webcredentials"]["apps"]; | ||
assert_eq!(apps[0], json!("YN68LN9T7Z.dev.knotless.app")); | ||
assert_eq!(said["webcredentials"]["apps"][0], json!(app)); | ||
for service in ["applinks:knotless.dev", "webcredentials:knotless.dev"] { | ||
assert!( | ||
project.contains(service) && entitlements.contains(service), | ||
"{service}" | ||
); | ||
} | ||
| ||
// A wrong verifier spends the code, for whoever stole it. | ||
let code = code_from(&app_log_in(&server, &who.account, &challenge, "")); | ||
ios/Knotless/API/Client.swift+5−1
| @@ -44,7 +44,11 @@ actor Client { | ||
| ||
/// A write, as the account signed in. What it answers is left, as a screen | ||
/// that writes reads what it shows again. | ||
func call(_ method: String, _ input: [String: String]) async throws { | ||
func send(_ write: Write) async throws { | ||
try await call(write.method, write.input) | ||
} | ||
| ||
private func call(_ method: String, _ input: [String: String]) async throws { | ||
#if DEBUG | ||
if DevAccount.recording != nil { return } | ||
#endif | ||
ios/Knotless/API/Standing.swiftnew+73−0
| @@ -0,0 +1,73 @@ | ||
import Foundation | ||
| ||
/// Where a pull stands on landing, in the app's own words. An answer's `reason` | ||
/// is written for the command line, with commands to run, so it isn't shown. | ||
struct Standing: Equatable { | ||
enum Tone { case good, waiting, bad, neutral } | ||
| ||
let title: String | ||
let icon: String | ||
let tone: Tone | ||
var lines: [String] = [] | ||
| ||
static func of(_ s: MergeStatus, base: String) -> Standing { | ||
let queues = s.may?.queue == true && s.queueBlocked == nil | ||
var after: [String] = [] | ||
if s.queueBlocked != nil { after.append("The merge queue is off until the owner delegates writes to this server.") } | ||
if s.may?.merge != true { after.append("Only the owner and collaborators merge it.") } | ||
| ||
if s.state == "draft" { | ||
return Standing(title: "Still a draft", icon: "pencil.circle", tone: .neutral, lines: ["It can't be merged or queued until it's marked ready for review."]) | ||
} | ||
if s.state == "conflicting" { | ||
return Standing(title: "Conflicts with \(base)", icon: "exclamationmark.triangle.fill", tone: .bad, lines: ["Merge \(base) into its branch, resolve the files that conflict, then push."]) | ||
} | ||
if s.reviews == "blocked" { | ||
let missing = (s.reviewsMissing ?? []).map { $0.text.prefix(1).uppercased() + $0.text.dropFirst() } | ||
return Standing(title: "Review required", icon: "xmark.circle.fill", tone: .bad, lines: missing + after) | ||
} | ||
switch s.checks { | ||
case "failed": | ||
return Standing(title: "Checks failed", icon: "xmark.circle.fill", tone: .bad, lines: ["It can land once they pass. Fix what failed and push."] + after) | ||
case "running": | ||
let line = queues ? "It can land once they pass. Queued now, it lands then." : "It can land once they pass." | ||
return Standing(title: "Checks are running", icon: "clock", tone: .waiting, lines: [line] + after) | ||
case "pending": | ||
let line = queues | ||
? "The merge queue checks it against \(base) as it is now, then lands it." | ||
: "Its checks must pass against \(base) as it is now first." | ||
return Standing(title: "Not checked yet", icon: "clock", tone: .waiting, lines: [line] + after) | ||
default: | ||
break | ||
} | ||
if s.queueOnly == true { | ||
return Standing(title: "Ready for the merge queue", icon: "checkmark.circle.fill", tone: .good, lines: ["\(base) takes pulls only through the merge queue."] + after) | ||
} | ||
if s.state == "clean" { | ||
return Standing(title: "Ready to merge", icon: "checkmark.circle.fill", tone: .good, lines: after) | ||
} | ||
return Standing(title: "Not ready to merge", icon: "clock", tone: .waiting, lines: after) | ||
} | ||
| ||
/// One the queue turned away, which can be queued again once fixed. The | ||
/// commits it names mean nothing on a phone, and an older runner's reason | ||
/// ends with a command to run. | ||
static func ejected(reason: String?) -> Standing { | ||
var why = reason ?? "" | ||
for pattern in [#"[.;] [Tt]heir logs: `knotless run list [^`]*`.*$"#, #",? on \S+ at [0-9a-f]{7,40}"#, #" at [0-9a-f]{7,40}"#] { | ||
why = why.replacingOccurrences(of: pattern, with: "", options: .regularExpression) | ||
} | ||
let line = why.prefix(1).uppercased() + why.dropFirst() | ||
return Standing(title: "Taken out of the merge queue", icon: "xmark.circle.fill", tone: .bad, lines: line.isEmpty ? [] : [line]) | ||
} | ||
| ||
/// Whether the queue would take it, as the server would answer if asked. | ||
static func canQueue(_ s: MergeStatus) -> Bool { | ||
s.may?.queue == true && s.queueBlocked == nil && s.state != "conflicting" && s.state != "draft" | ||
&& s.checks != "failed" && s.reviews != "blocked" | ||
} | ||
| ||
static func canMerge(_ s: MergeStatus) -> Bool { | ||
s.may?.merge == true && s.queueOnly != true && s.state == "clean" | ||
} | ||
} | ||
ios/Knotless/API/Writes.swiftnew+46−0
| @@ -0,0 +1,46 @@ | ||
import Foundation | ||
| ||
/// A write the app makes: a procedure, and what it's given. They're all here so | ||
/// that `ios/check-models.sh` can hold each to its lexicon, with no server. | ||
struct Write: Sendable { | ||
let method: String | ||
let input: [String: String] | ||
} | ||
| ||
enum Writes { | ||
/// `verdict` makes it a review: `approve` or `changes`. | ||
static func comment(repo: RepoRef, subject: String, body: String, verdict: String?) -> Write { | ||
var input = ["repo": repo.uri, "subject": subject, "body": body] | ||
if let verdict { input["verdict"] = verdict } | ||
return Write(method: "createComment", input: input) | ||
} | ||
| ||
static func react(repo: RepoRef, subject: String, content: String, undo: Bool) -> Write { | ||
Write(method: undo ? "removeReaction" : "addReaction", input: ["repo": repo.uri, "subject": subject, "content": content]) | ||
} | ||
| ||
/// `open` or `closed`. | ||
static func setState(repo: RepoRef, subject: String, state: String) -> Write { | ||
Write(method: "setItemState", input: ["repo": repo.uri, "subject": subject, "state": state]) | ||
} | ||
| ||
static func merge(repo: RepoRef, pull: String) -> Write { | ||
Write(method: "mergePull", input: ["repo": repo.uri, "pull": pull]) | ||
} | ||
| ||
static func queue(repo: RepoRef, pull: String) -> Write { | ||
Write(method: "queuePull", input: ["repo": repo.uri, "pull": pull]) | ||
} | ||
| ||
static func leaveQueue(repo: RepoRef, pull: String) -> Write { | ||
Write(method: "leaveQueue", input: ["repo": repo.uri, "pull": pull]) | ||
} | ||
| ||
static func star(repo: RepoRef, undo: Bool) -> Write { | ||
Write(method: undo ? "unstarRepo" : "starRepo", input: ["repo": repo.uri]) | ||
} | ||
| ||
static func seen(at: String) -> Write { | ||
Write(method: "updateSeen", input: ["seenAt": at]) | ||
} | ||
} | ||
ios/Knotless/Views/InboxView.swift+1−1
| @@ -96,7 +96,7 @@ struct InboxView: View { | ||
// Seen, once all of them were on offer. The dots stay until the next | ||
// look, so someone can see what was new this time. | ||
if filter == nil, SessionStore.token != nil, let newest = items.first?.createdAt { | ||
try? await Client.shared.call("updateSeen", ["seenAt": newest]) | ||
try? await Client.shared.send(Writes.seen(at: newest)) | ||
} | ||
} catch let error as APIError where error.needsSignIn { | ||
phase = .signedOut | ||
ios/Knotless/Views/RepoView.swift+1−1
| @@ -219,7 +219,7 @@ struct StarButton: View { | ||
busy = true | ||
defer { busy = false } | ||
do { | ||
try await Client.shared.call(starred ? "unstarRepo" : "starRepo", ["repo": repo.uri]) | ||
try await Client.shared.send(Writes.star(repo: repo, undo: starred)) | ||
count += starred ? -1 : 1 | ||
starred.toggle() | ||
} catch { | ||
ios/Knotless/Views/ThreadView.swift+1−1
| @@ -86,7 +86,7 @@ struct ThreadView: View { | ||
private func setState(_ next: String) { | ||
Task { | ||
do { | ||
try await Client.shared.call("setItemState", ["repo": repo.uri, "subject": uri, "state": next]) | ||
try await Client.shared.send(Writes.setState(repo: repo, subject: uri, state: next)) | ||
edits += 1 | ||
} catch { | ||
failure = error.localizedDescription | ||
ios/Knotless/Views/Writing.swift+28−76
| @@ -70,14 +70,13 @@ struct Composer: View { | ||
sending = true | ||
failure = nil | ||
defer { sending = false } | ||
var input = ["repo": repo.uri, "subject": uri, "body": text] | ||
switch kind { | ||
case .comment: break | ||
case .approve: input["verdict"] = "approve" | ||
case .changes: input["verdict"] = "changes" | ||
let verdict: String? = switch kind { | ||
case .comment: nil | ||
case .approve: "approve" | ||
case .changes: "changes" | ||
} | ||
do { | ||
try await Client.shared.call("createComment", input) | ||
try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: verdict)) | ||
sent() | ||
dismiss() | ||
} catch { | ||
| @@ -158,8 +157,7 @@ struct ReactionBar: View { | ||
touched = next | ||
Task { | ||
do { | ||
try await Client.shared.call(mine ? "removeReaction" : "addReaction", | ||
["repo": repo.uri, "subject": subject, "content": content]) | ||
try await Client.shared.send(Writes.react(repo: repo, subject: subject, content: content, undo: mine)) | ||
changed() | ||
} catch { | ||
touched = nil | ||
| @@ -193,23 +191,23 @@ struct MergeSection: View { | ||
var body: some View { | ||
VStack(alignment: .leading, spacing: 12) { | ||
if let queue, queued { | ||
said(title: queue.said, icon: "clock", color: .attention, lines: []) | ||
BigButton(title: doing == "leaveQueue" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaveQueue") } | ||
said(Standing(title: queue.said, icon: "clock", tone: .waiting)) | ||
BigButton(title: doing == "leaving" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaving", Writes.leaveQueue(repo: repo, pull: pull)) } | ||
} else if let status { | ||
// One the queue turned away can be queued again, once fixed. | ||
if let queue { | ||
said(title: queue.reason ?? queue.said, icon: "xmark.circle.fill", color: .danger, lines: []) | ||
said(.ejected(reason: queue.reason)) | ||
} | ||
summary(status) | ||
let merges = canMerge(status) | ||
said(.of(status, base: base)) | ||
let merges = Standing.canMerge(status) | ||
if merges { | ||
BigButton(title: doing == "mergePull" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true } | ||
BigButton(title: doing == "merging" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true } | ||
.confirmationDialog("Merge this pull?", isPresented: $confirming, titleVisibility: .visible) { | ||
Button("Merge pull") { act("mergePull") } | ||
Button("Merge pull") { act("merging", Writes.merge(repo: repo, pull: pull)) } | ||
} | ||
} | ||
if canQueue(status) { | ||
BigButton(title: doing == "queuePull" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("queuePull") } | ||
if Standing.canQueue(status) { | ||
BigButton(title: doing == "adding" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("adding", Writes.queue(repo: repo, pull: pull)) } | ||
} | ||
} else if loadFailed { | ||
Text("Can't say whether this can merge.").foregroundStyle(.secondary) | ||
| @@ -234,79 +232,33 @@ struct MergeSection: View { | ||
} | ||
} | ||
| ||
private func canQueue(_ s: MergeStatus) -> Bool { | ||
s.may?.queue == true && s.queueBlocked == nil && s.state != "conflicting" && s.state != "draft" | ||
&& s.checks != "failed" && s.reviews != "blocked" | ||
} | ||
| ||
private func canMerge(_ s: MergeStatus) -> Bool { | ||
s.may?.merge == true && s.queueOnly != true && s.state == "clean" | ||
} | ||
| ||
/// Where it stands in the app's own words. The answer's `reason` is written | ||
/// for the command line, with commands to run, so it isn't shown. | ||
private func summary(_ s: MergeStatus) -> some View { | ||
let it = standing(s) | ||
return said(title: it.title, icon: it.icon, color: it.color, lines: it.lines) | ||
} | ||
| ||
private func said(title: String, icon: String, color: Color, lines: [String]) -> some View { | ||
private func said(_ it: Standing) -> some View { | ||
HStack(alignment: .top, spacing: 12) { | ||
Image(systemName: icon).foregroundStyle(color).frame(width: 24) | ||
Image(systemName: it.icon).foregroundStyle(color(it.tone)).frame(width: 24) | ||
VStack(alignment: .leading, spacing: 4) { | ||
Text(title) | ||
ForEach(lines, id: \.self) { line in | ||
Text(it.title) | ||
ForEach(it.lines, id: \.self) { line in | ||
Text(line).font(.subheadline).foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
} | ||
| ||
private func standing(_ s: MergeStatus) -> (title: String, icon: String, color: Color, lines: [String]) { | ||
let queues = s.may?.queue == true && s.queueBlocked == nil | ||
var after: [String] = [] | ||
if s.queueBlocked != nil { after.append("The merge queue is off until the owner delegates writes to this server.") } | ||
if s.may?.merge != true { after.append("Only the owner and collaborators merge it.") } | ||
| ||
if s.state == "draft" { | ||
return ("Still a draft", "pencil.circle", .neutral, ["It can't be merged or queued until it's marked ready for review."]) | ||
} | ||
if s.state == "conflicting" { | ||
return ("Conflicts with \(base)", "exclamationmark.triangle.fill", .danger, ["Merge \(base) into its branch, resolve the files that conflict, then push."]) | ||
} | ||
if s.reviews == "blocked" { | ||
let missing = (s.reviewsMissing ?? []).map { $0.text.prefix(1).uppercased() + $0.text.dropFirst() } | ||
return ("Review required", "xmark.circle.fill", .danger, missing + after) | ||
} | ||
switch s.checks { | ||
case "failed": | ||
return ("Checks failed", "xmark.circle.fill", .danger, ["It can land once they pass. Fix what failed and push."] + after) | ||
case "running": | ||
let line = queues ? "It can land once they pass. Queued now, it lands then." : "It can land once they pass." | ||
return ("Checks are running", "clock", .attention, [line] + after) | ||
case "pending": | ||
let line = queues | ||
? "The merge queue checks it against \(base) as it is now, then lands it." | ||
: "Its checks must pass against \(base) as it is now first." | ||
return ("Not checked yet", "clock", .attention, [line] + after) | ||
default: | ||
break | ||
} | ||
if s.queueOnly == true { | ||
return ("Ready for the merge queue", "checkmark.circle.fill", .success, ["\(base) takes pulls only through the merge queue."] + after) | ||
} | ||
if s.state == "clean" { | ||
return ("Ready to merge", "checkmark.circle.fill", .success, after) | ||
private func color(_ tone: Standing.Tone) -> Color { | ||
switch tone { | ||
case .good: .success | ||
case .waiting: .attention | ||
case .bad: .danger | ||
case .neutral: .neutral | ||
} | ||
return ("Not ready to merge", "clock", .attention, after) | ||
} | ||
| ||
private func act(_ method: String) { | ||
doing = method | ||
private func act(_ what: String, _ write: Write) { | ||
doing = what | ||
failure = nil | ||
Task { | ||
do { | ||
try await Client.shared.call(method, ["repo": repo.uri, "pull": pull]) | ||
try await Client.shared.send(write) | ||
changed() | ||
// The change usually shows sooner, and ends this: see onChange. | ||
try? await Task.sleep(for: .seconds(3)) | ||
ios/ModelCheck/main.swift+57−0
| @@ -40,3 +40,60 @@ for (url, answer) in answers.sorted(by: { $0.key < $1.key }) { | ||
} | ||
print("\(read) answers read, \(failed) not") | ||
if failed > 0 || read == 0 { exit(1) } | ||
| ||
// What the merge section says of each state: nothing from the command line, and | ||
// the queue offered only to a pull it would take. | ||
func status(_ state: String, checks: String = "none", reviews: String? = nil, queueOnly: Bool = false) -> MergeStatus { | ||
MergeStatus(state: state, checks: checks, queueOnly: queueOnly, reviews: reviews, may: .init(merge: true, queue: true)) | ||
} | ||
let saying: [(MergeStatus, String, Bool)] = [ | ||
(status("clean", checks: "passed"), "Ready to merge", true), | ||
(status("clean", checks: "passed", queueOnly: true), "Ready for the merge queue", true), | ||
(status("conflicting"), "Conflicts with main", false), | ||
(status("draft"), "Still a draft", false), | ||
(status("blocked", checks: "failed"), "Checks failed", false), | ||
(status("blocked", checks: "running"), "Checks are running", true), | ||
(status("blocked", checks: "pending"), "Not checked yet", true), | ||
(status("blocked", checks: "passed", reviews: "blocked"), "Review required", false), | ||
] | ||
var wrong = 0 | ||
func expect(_ held: Bool, _ what: String) { | ||
if !held { | ||
print(what) | ||
wrong += 1 | ||
} | ||
} | ||
for (s, title, queues) in saying { | ||
let said = Standing.of(s, base: "main") | ||
expect(said.title == title, "\(s.state)/\(s.checks ?? ""): says \(said.title), not \(title)") | ||
expect(Standing.canQueue(s) == queues, "\(title): the queue \(queues ? "isn't" : "is") offered") | ||
} | ||
let ejected = Standing.ejected(reason: "its batch failed its checks at 6f89484c63a3, on main at 226488be4afa: test (failure). Their logs: `knotless run list knotless://a/b --pull at://c --log`") | ||
expect(ejected.lines == ["Its batch failed its checks: test (failure)"], "ejected says \(ejected.lines)") | ||
for said in saying.map({ Standing.of($0.0, base: "main") }) + [ejected] { | ||
for line in said.lines where line.contains("knotless ") || line.contains("at://") || line.contains("--") { | ||
expect(false, "reads like the command line: \(line)") | ||
} | ||
} | ||
if wrong > 0 { exit(1) } | ||
| ||
// Every write the app makes, for check-writes.mjs to hold to its lexicon. | ||
let repo = RepoRef(did: "did:plc:bcgltzqazw5tb6k2g3ttenbj", handle: "alice.test", name: "tally") | ||
let pull = "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.pull/3mzlqso3vpyqg" | ||
let writes: [Write] = [ | ||
Writes.comment(repo: repo, subject: pull, body: "Looks right", verdict: nil), | ||
Writes.comment(repo: repo, subject: pull, body: "", verdict: "approve"), | ||
Writes.comment(repo: repo, subject: pull, body: "Not yet", verdict: "changes"), | ||
Writes.react(repo: repo, subject: pull, content: "+1", undo: false), | ||
Writes.react(repo: repo, subject: pull, content: "+1", undo: true), | ||
Writes.setState(repo: repo, subject: pull, state: "closed"), | ||
Writes.setState(repo: repo, subject: pull, state: "open"), | ||
Writes.merge(repo: repo, pull: pull), | ||
Writes.queue(repo: repo, pull: pull), | ||
Writes.leaveQueue(repo: repo, pull: pull), | ||
Writes.star(repo: repo, undo: false), | ||
Writes.star(repo: repo, undo: true), | ||
Writes.seen(at: "2026-11-05T12:00:00.000Z"), | ||
] | ||
let made = writes.map { ["method": $0.method, "input": $0.input] as [String: Any] } | ||
try JSONSerialization.data(withJSONObject: made).write(to: URL(fileURLWithPath: CommandLine.arguments[2])) | ||