chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An iOS app reads the inbox, pulls, diffs, repositories and profiles
.knotless/workflows/ios.ymlnew+13−0
| @@ -0,0 +1,13 @@ | ||
# 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 | ||
# touch what it reads, and no check deploy.yml waits for: nothing the app does | ||
# holds up a deploy. | ||
image: swift:6.2 | ||
on: | ||
pull: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [ios/**, lexicons/**, harness/shots/fixtures.json, .knotless/workflows/ios.yml] | ||
steps: | ||
- name: models | ||
run: sh ios/check-models.sh | ||
ios/Knotless/API/Client.swiftnew+58−0
| @@ -0,0 +1,58 @@ | ||
import Foundation | ||
| ||
struct APIError: LocalizedError { | ||
let status: Int | ||
let name: String? | ||
let message: String? | ||
var errorDescription: String? { message ?? "The server answered \(status)." } | ||
var needsSignIn: Bool { status == 401 } | ||
} | ||
| ||
private struct Failure: Decodable { var error: String?; var message: String? } | ||
| ||
/// Reads knotless's XRPC queries. Asks as an account only for a method it | ||
/// holds a service token for; everything else is public and asked as no one. | ||
actor Client { | ||
static let shared = Client() | ||
| ||
private let base = URL(string: "https://knotless.dev/xrpc/")! | ||
private let decoder = JSONDecoder() | ||
| ||
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T { | ||
let nsid = "dev.knotless." + method | ||
var parts = URLComponents(url: base.appendingPathComponent(nsid), resolvingAgainstBaseURL: false)! | ||
parts.queryItems = params.compactMap { k, v in v.map { URLQueryItem(name: k, value: $0) } }.sorted { $0.name < $1.name } | ||
var request = URLRequest(url: parts.url!) | ||
request.timeoutInterval = 40 | ||
if let token = DevAccount.token(for: nsid) { | ||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | ||
} | ||
let (data, response) = try await URLSession.shared.data(for: request) | ||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | ||
guard (200..<300).contains(status) else { | ||
let failure = try? decoder.decode(Failure.self, from: data) | ||
throw APIError(status: status, name: failure?.error, message: failure?.message) | ||
} | ||
return try decoder.decode(T.self, from: data) | ||
} | ||
} | ||
| ||
/// An account for trying the app before it has a sign-in: a handle to read | ||
/// as, and service tokens handed over at launch, never kept or built in. | ||
/// `ios/run.sh` mints them with goat. | ||
enum DevAccount { | ||
private static let env = ProcessInfo.processInfo.environment | ||
| ||
static var actor: String? { env["KNOTLESS_ACTOR"] } | ||
static var route: String? { env["KNOTLESS_ROUTE"] } | ||
| ||
private static let tokens: [String: String] = { | ||
guard let path = env["KNOTLESS_TOKENS"], | ||
let data = FileManager.default.contents(atPath: path), | ||
let map = try? JSONDecoder().decode([String: String].self, from: data) | ||
else { return [:] } | ||
return map | ||
}() | ||
| ||
static func token(for nsid: String) -> String? { tokens[nsid] } | ||
} | ||
ios/Knotless/API/Models.swiftnew+295−0
| @@ -0,0 +1,295 @@ | ||
import Foundation | ||
| ||
struct Person: Decodable, Hashable, Identifiable { | ||
let did: String | ||
let handle: String | ||
var avatar: String? | ||
var id: String { did } | ||
} | ||
| ||
/// A repository as the feed and notifications name it. | ||
struct RepoRef: Decodable, Hashable { | ||
let did: String | ||
let handle: String | ||
let name: String | ||
var uri: String { "at://\(did)/dev.knotless.repo/\(name)" } | ||
var label: String { "\(handle)/\(name)" } | ||
} | ||
| ||
struct Repo: Decodable, Identifiable, Hashable { | ||
let uri: String | ||
let owner: Person | ||
let name: String | ||
var description: String? | ||
var starCount: Int? | ||
var forkCount: Int? | ||
var `private`: Bool? | ||
var id: String { uri } | ||
var ref: RepoRef { RepoRef(did: owner.did, handle: owner.handle, name: name) } | ||
} | ||
| ||
struct RepoList: Decodable { | ||
var repos: [Repo] = [] | ||
} | ||
| ||
struct RepoDetail: Decodable { | ||
let uri: String | ||
let owner: Person | ||
let name: String | ||
var description: String? | ||
let defaultBranch: String | ||
let openPulls: Int | ||
let openIssues: Int | ||
var starCount: Int? | ||
var `private`: Bool? | ||
} | ||
| ||
struct ItemLabel: Decodable, Hashable { | ||
let name: String | ||
var color: String? | ||
} | ||
| ||
struct Item: Decodable, Identifiable, Hashable { | ||
let uri: String | ||
let kind: String | ||
let title: String | ||
let state: String | ||
let author: Person | ||
let createdAt: String | ||
let comments: Int | ||
var draft: Bool? | ||
var labels: [ItemLabel]? | ||
var id: String { uri } | ||
} | ||
| ||
struct ItemList: Decodable { | ||
var items: [Item] = [] | ||
var openCount: Int? | ||
var closedCount: Int? | ||
var cursor: String? | ||
} | ||
| ||
struct DiffRow: Decodable, Hashable { | ||
let kind: String | ||
let line: Bool | ||
var old: Int? | ||
var new: Int? | ||
let text: String | ||
} | ||
| ||
struct DiffFile: Decodable, Identifiable, Hashable { | ||
let old: String | ||
let new: String | ||
let status: String | ||
let added: Int | ||
let removed: Int | ||
var note: String? | ||
var ranges: String? | ||
var rows: [DiffRow] = [] | ||
var rowCount: Int? | ||
var id: String { old + "\u{0}" + new } | ||
var path: String { new.isEmpty ? old : new } | ||
| ||
enum CodingKeys: String, CodingKey { case old, new, status, added, removed, note, ranges, rows, rowCount } | ||
init(from decoder: Decoder) throws { | ||
let c = try decoder.container(keyedBy: CodingKeys.self) | ||
old = try c.decode(String.self, forKey: .old) | ||
new = try c.decode(String.self, forKey: .new) | ||
status = try c.decode(String.self, forKey: .status) | ||
added = try c.decode(Int.self, forKey: .added) | ||
removed = try c.decode(Int.self, forKey: .removed) | ||
note = try c.decodeIfPresent(String.self, forKey: .note) | ||
ranges = try c.decodeIfPresent(String.self, forKey: .ranges) | ||
rows = try c.decodeIfPresent([DiffRow].self, forKey: .rows) ?? [] | ||
rowCount = try c.decodeIfPresent(Int.self, forKey: .rowCount) | ||
} | ||
} | ||
| ||
struct CommitAuthor: Decodable, Hashable { | ||
var name: String? | ||
var actor: Person? | ||
} | ||
| ||
struct Commit: Decodable, Identifiable, Hashable { | ||
let sha: String | ||
let message: String | ||
var author: CommitAuthor? | ||
var date: String? | ||
var id: String { sha } | ||
var subject: String { message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? message } | ||
} | ||
| ||
struct Changes: Decodable { | ||
var commits: [Commit]? | ||
var files: [DiffFile]? | ||
} | ||
| ||
struct Comment: Decodable, Identifiable, Hashable { | ||
var uri: String? | ||
let author: Person | ||
let createdAt: String | ||
var body: String? | ||
var verdict: String? | ||
var id: String { uri ?? (author.did + createdAt) } | ||
} | ||
| ||
struct Check: Decodable, Identifiable, Hashable { | ||
let uri: String | ||
let workflow: String | ||
let status: String | ||
/// Why it was skipped, when it was. | ||
var skipped: String? | ||
var id: String { uri } | ||
} | ||
| ||
struct Review: Decodable, Hashable { | ||
let reviewer: Person | ||
var verdict: String? | ||
var outdated: Bool? | ||
} | ||
| ||
struct PullHead: Decodable, Hashable { | ||
var branch: String? | ||
} | ||
| ||
struct PullThread: Decodable { | ||
let item: Item | ||
var body: String? | ||
var base: String? | ||
var head: PullHead? | ||
var merged: String? | ||
var comments: [Comment]? | ||
var reviews: [Review]? | ||
var checks: [Check]? | ||
var changes: Changes? | ||
} | ||
| ||
struct DiffAnswer: Decodable { | ||
var files: [DiffFile] = [] | ||
} | ||
| ||
struct TreeEntry: Decodable, Identifiable, Hashable { | ||
let name: String | ||
let kind: String | ||
var size: Int? | ||
var id: String { name } | ||
} | ||
| ||
struct Tree: Decodable { | ||
var ref: String? | ||
var path: String? | ||
var lastCommit: Commit? | ||
var entries: [TreeEntry]? | ||
var readme: Readme? | ||
var checks: [Check]? | ||
struct Readme: Decodable { var name: String? } | ||
} | ||
| ||
struct FileAnswer: Decodable { | ||
var ref: String? | ||
var path: String? | ||
var kind: String? | ||
var size: Int? | ||
var lines: Int? | ||
var text: String? | ||
var ranges: String? | ||
} | ||
| ||
struct Org: Decodable, Hashable { let actor: Person } | ||
| ||
struct Profile: Decodable { | ||
let did: String | ||
let handle: String | ||
var displayName: String? | ||
var description: String? | ||
var pronouns: String? | ||
var avatar: String? | ||
var followersCount: Int? | ||
var followsCount: Int? | ||
var orgs: [Org]? | ||
} | ||
| ||
struct InboxItem: Decodable, Identifiable, Hashable { | ||
let uri: String | ||
let author: Person | ||
let reason: String | ||
var reasonSubject: String? | ||
var repo: RepoRef? | ||
var title: String? | ||
var verdict: String? | ||
var text: String? | ||
var subjectState: String? | ||
let isRead: Bool | ||
let createdAt: String | ||
var id: String { uri + reason } | ||
} | ||
| ||
struct NotificationList: Decodable { | ||
var notifications: [InboxItem] = [] | ||
var cursor: String? | ||
} | ||
| ||
struct FeedEvent: Decodable, Identifiable, Hashable { | ||
let createdAt: String | ||
let actor: Person | ||
let repo: RepoRef | ||
let action: Action | ||
var id: String { createdAt + actor.did + repo.label + action.summary } | ||
| ||
/// An open union: a kind this version doesn't know still shows as an event. | ||
struct Action: Decodable, Hashable { | ||
let type: String | ||
var subject: String? | ||
var title: String? | ||
var state: String? | ||
var text: String? | ||
var verdict: String? | ||
var ref: String? | ||
var count: Int? | ||
var forkOf: RepoRef? | ||
| ||
enum CodingKeys: String, CodingKey { | ||
case type = "$type", subject, title, state, text, verdict, ref, count, forkOf | ||
} | ||
| ||
var kind: String { type.split(separator: "#").last.map(String.init) ?? type } | ||
var branch: String? { ref?.replacingOccurrences(of: "refs/heads/", with: "") } | ||
| ||
var summary: String { | ||
switch kind { | ||
case "created": forkOf.map { "forked \($0.label)" } ?? "created this repository" | ||
case "pushed": | ||
if let tag = ref?.replacing("refs/tags/", with: ""), ref?.hasPrefix("refs/tags/") == true { | ||
"pushed tag \(tag)" | ||
} else if (count ?? 0) == 0 { | ||
"pushed to \(branch ?? "a branch")" | ||
} else { | ||
count == 1 ? "pushed 1 commit to \(branch ?? "a branch")" : "pushed \(count ?? 0) commits to \(branch ?? "a branch")" | ||
} | ||
case "deleted": "deleted \(branch ?? "a branch")" | ||
case "opened": "opened" | ||
case "commented": | ||
switch verdict { | ||
case "approve": "approved" | ||
case "changes": "asked for changes on" | ||
default: "commented on" | ||
} | ||
case "changed": | ||
switch state { | ||
case "merged": "merged" | ||
case "closed": "closed" | ||
case "open": "reopened" | ||
default: "changed" | ||
} | ||
case "starred": "starred this repository" | ||
default: "did something new" | ||
} | ||
} | ||
} | ||
} | ||
| ||
struct Feed: Decodable { | ||
var events: [FeedEvent] = [] | ||
var cursor: String? | ||
} | ||
| ||
ios/Knotless/Assets.xcassets/oct-file-diff.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"file-diff.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
ios/Knotless/Assets.xcassets/oct-file-diff.imageset/file-diff.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-git-commit.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"git-commit.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
ios/Knotless/Assets.xcassets/oct-git-commit.imageset/git-commit.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-git-merge.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"git-merge.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
ios/Knotless/Assets.xcassets/oct-git-merge.imageset/git-merge.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Assets.xcassets/oct-git-pull-request-closed.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"git-pull-request-closed.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||