chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app shows a run's steps and logs, and runs a workflow when asked
ios/Knotless/API/Models.swift+56−0
| @@ -147,6 +147,57 @@ struct Run: Decodable, Identifiable, Hashable { | ||
struct RunList: Decodable { | ||
var runs: [Run] = [] | ||
var cursor: String? | ||
var workflows: [Workflow] = [] | ||
} | ||
| ||
/// A workflow on the default branch. | ||
struct Workflow: Decodable, Hashable { | ||
let name: String | ||
/// It runs only when asked for by name. | ||
var asked: Bool? | ||
/// The first sentence of the comment its file opens with. | ||
var about: String? | ||
} | ||
| ||
/// One run, as its runner filed it: each step, and the end of what they printed. | ||
struct RunDetail: Decodable, Hashable { | ||
let check: Check | ||
let ref: String | ||
var pull: String? | ||
let runner: Person | ||
var steps: [Step] = [] | ||
/// Each step's after a line `==> <name>`. | ||
var log: String? | ||
} | ||
| ||
struct Step: Decodable, Hashable { | ||
let name: String | ||
/// `success`, `failure`, `skipped` or `cancelled`; `running` only while it runs. | ||
let status: String | ||
/// Absent when it was killed. | ||
var exitCode: Int? | ||
var durationMs: Int? | ||
} | ||
| ||
/// The workflows at a ref, and what runs each there. | ||
struct WorkflowList: Decodable, Hashable { | ||
/// In full. | ||
let ref: String | ||
var workflows: [Entry] = [] | ||
| ||
struct Entry: Decodable, Hashable { | ||
let name: String | ||
let push: Bool | ||
let pull: Bool | ||
/// Its owner may ask for it at the ref by name. | ||
let asked: Bool | ||
} | ||
} | ||
| ||
struct CheckImage: Decodable, Hashable { | ||
let alt: String | ||
/// On this server, from its root. | ||
let url: String | ||
} | ||
| ||
struct ItemLabel: Decodable, Hashable { | ||
| @@ -403,6 +454,11 @@ struct Check: Decodable, Identifiable, Hashable { | ||
/// Why it was skipped, when it was. | ||
var skipped: String? | ||
var startedAt: String? | ||
var finishedAt: String? | ||
var commit: String? | ||
/// Why it couldn't run. | ||
var error: String? | ||
var images: [CheckImage]? | ||
var id: String { uri } | ||
} | ||
| ||
ios/Knotless/API/Writes.swift+8−0
| @@ -98,6 +98,14 @@ enum Writes { | ||
Write(method: "editItem", input: ["repo": repo.uri, "subject": subject, "body": body]) | ||
} | ||
| ||
/// The runners run a pull's head, or a ref, again: all its workflows, or `workflow`. | ||
static func rerun(repo: RepoRef, pull: String?, ref: String?, workflow: String?) -> Write { | ||
var input = ["repo": repo.uri] | ||
if let pull { input["pull"] = pull } else if let ref { input["ref"] = ref } | ||
if let workflow { input["workflow"] = workflow } | ||
return Write(method: "requestRerun", input: input) | ||
} | ||
| ||
static func seen(at: String) -> Write { | ||
Write(method: "updateSeen", input: ["seenAt": at]) | ||
} | ||
ios/Knotless/Support/Theme.swift+6−1
| @@ -138,8 +138,13 @@ enum When { | ||
}() | ||
nonisolated(unsafe) private static let plain = ISO8601DateFormatter() | ||
| ||
static func date(_ iso: String?) -> Date? { | ||
guard let iso else { return nil } | ||
return withFraction.date(from: iso) ?? plain.date(from: iso) | ||
} | ||
| ||
static func short(_ iso: String?) -> String { | ||
guard let iso, let date = withFraction.date(from: iso) ?? plain.date(from: iso) else { return "" } | ||
guard let date = date(iso) else { return "" } | ||
let s = Int((DevAccount.now ?? Date()).timeIntervalSince(date)) | ||
switch s { | ||
case ..<60: return "now" | ||
ios/Knotless/Views/RepoView.swift+5−40
| @@ -521,6 +521,8 @@ struct PagedList<Item: Identifiable & Equatable & Sendable, Row: View>: View { | ||
| ||
let empty: String | ||
let fetch: @Sendable (String?) async throws -> Page | ||
/// Shown over the list, edge to edge. | ||
var header: AnyView? | ||
@ViewBuilder let row: (Item) -> Row | ||
| ||
@State private var items: [Item] = [] | ||
| @@ -530,6 +532,9 @@ struct PagedList<Item: Identifiable & Equatable & Sendable, Row: View>: View { | ||
| ||
var body: some View { | ||
List { | ||
if let header { | ||
header.listRowInsets(EdgeInsets()).listRowSeparator(.hidden) | ||
} | ||
if let failure { | ||
Text(failure).foregroundStyle(.secondary) | ||
} else if loading { | ||
| @@ -651,46 +656,6 @@ struct ReleaseView: View { | ||
} | ||
} | ||
| ||
struct RunsView: View { | ||
let repo: RepoRef | ||
| ||
var body: some View { | ||
PagedList<Run, RunRow>(empty: "Nothing has run yet.", fetch: { [repo] cursor in | ||
let list: RunList = try await Client.shared.query("listRuns", ["repo": repo.uri, "cursor": cursor]) | ||
return .init(items: list.runs, cursor: list.cursor) | ||
}) { RunRow(run: $0) } | ||
.navigationTitle("Checks") | ||
.navigationBarTitleDisplayMode(.inline) | ||
} | ||
} | ||
| ||
private struct RunRow: View { | ||
let run: Run | ||
| ||
private var what: String? { | ||
if let title = run.pull?.title { return title } | ||
if let batch = run.batch { return batch == 1 ? "A batch of 1 pull" : "A batch of \(batch) pulls" } | ||
return run.subject | ||
} | ||
| ||
var body: some View { | ||
let summary = CheckSummary([run.check]) | ||
HStack(alignment: .top, spacing: Space.s3) { | ||
Oct(name: summary.symbol).foregroundStyle(summary.color).accessibilityLabel(run.check.status).padding(.top, Space.s0_5) | ||
VStack(alignment: .leading, spacing: Space.s0_5) { | ||
Text(run.check.workflow).font(.body.weight(.medium)) | ||
if let what { Text(what).font(.subheadline).lineLimit(2) } | ||
HStack(spacing: Space.s1_5) { | ||
Text(run.ref.replacingOccurrences(of: "refs/heads/", with: "")).font(.footnote.monospaced()).lineLimit(1) | ||
if let when = run.check.startedAt { Text(When.short(when)) } | ||
} | ||
.font(.footnote) | ||
.foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
} | ||
| ||
struct ItemsView: View { | ||
let repo: RepoRef | ||
let kind: String | ||
ios/Knotless/Views/RunViews.swiftnew+954−0
954 lines changed: not loaded until asked for.
ios/Knotless/Views/ThreadView.swift+12−7
| @@ -296,14 +296,19 @@ struct ThreadView: View { | ||
Spacer() | ||
}) { | ||
ForEach(checks) { check in | ||
let one = CheckSummary([check]) | ||
ThreadRow { | ||
Oct(name: one.symbol, size: Icon.xl).foregroundStyle(one.color) | ||
} content: { | ||
Text(check.workflow) | ||
Spacer() | ||
if check.skipped != nil { Text("Skipped").foregroundStyle(.secondary) } | ||
NavigationLink(value: Route.run(repo, uri: check.uri)) { | ||
ThreadRow { | ||
StatusMark(check: check) | ||
} content: { | ||
Text(check.workflow).lineLimit(1) | ||
Spacer() | ||
Text(RunWords.took(check).map { check.skipped == nil ? $0 : "Skipped" } ?? RunWords.status(check)) | ||
.font(.subheadline) | ||
.foregroundStyle(.secondary) | ||
RowChevron(name: "chevron-right") | ||
} | ||
} | ||
.buttonStyle(RowPress()) | ||
} | ||
} | ||
} | ||
ios/Knotless/App.swift+12−1
| @@ -44,6 +44,11 @@ enum Route: Hashable { | ||
case releases(RepoRef) | ||
case release(Release) | ||
case runs(RepoRef) | ||
/// One workflow's runs, or all of them. | ||
case workflowRuns(RepoRef, workflow: String?) | ||
case run(RepoRef, uri: String) | ||
/// A run's step's log, or all of its log past its steps. | ||
case step(RepoRef, uri: String, index: Int) | ||
case repositories(String) | ||
case mine(kind: String) | ||
case starred(String) | ||
| @@ -75,7 +80,10 @@ extension View { | ||
case .commit(let repo, let sha): CommitView(repo: repo, sha: sha) | ||
case .releases(let repo): ReleasesView(repo: repo) | ||
case .release(let release): ReleaseView(release: release) | ||
case .runs(let repo): RunsView(repo: repo) | ||
case .runs(let repo): ChecksView(repo: repo) | ||
case .workflowRuns(let repo, let workflow): WorkflowRunsView(repo: repo, workflow: workflow) | ||
case .run(let repo, let uri): RunView(repo: repo, uri: uri) | ||
case .step(let repo, let uri, let index): StepLogView(repo: repo, uri: uri, index: index) | ||
case .repositories(let actor): RepositoriesView(actor: actor) | ||
case .mine(let kind): MyItemsView(kind: kind) | ||
case .starred(let actor): StarredView(actor: actor) | ||
| @@ -182,6 +190,9 @@ enum DevRoute { | ||
case "commit" where p.count == 5: return (.home, [.commit(repo, sha: p[4])]) | ||
case "releases": return (.home, [.releases(repo)]) | ||
case "runs": return (.home, [.runs(repo)]) | ||
case "workflow-runs" where p.count == 5: return (.home, [.runs(repo), .workflowRuns(repo, workflow: p[4])]) | ||
case "run" where p.count == 5: return (.home, [.runs(repo), .run(repo, uri: p[4])]) | ||
case "step" where p.count == 6: return (.home, [.runs(repo), .run(repo, uri: p[4]), .step(repo, uri: p[4], index: Int(p[5]) ?? 0)]) | ||
case "tree" where p.count == 5: return (.home, [.tree(repo, ref: nil, path: p[4])]) | ||
case "thread" where p.count == 5: return (.home, [.thread(repo, uri: p[4])]) | ||
case "files" where p.count == 5: return (.home, [.files(repo, uri: p[4])]) | ||
ios/ModelCheck/main.swift+4−0
| @@ -22,6 +22,7 @@ let readers: [String: (Data) throws -> Void] = [ | ||
"getCommit": { try check(CommitAnswer.self, $0) }, | ||
"listReleases": { try check(ReleaseList.self, $0) }, | ||
"listRuns": { try check(RunList.self, $0) }, | ||
"getCheck": { try check(RunDetail.self, $0) }, | ||
] | ||
| ||
let path = CommandLine.arguments[1] | ||
| @@ -149,6 +150,9 @@ let writes: [Write] = [ | ||
Writes.hear(repo: repo, level: "participating"), | ||
Writes.follow(did: repo.did, undo: false), | ||
Writes.follow(did: repo.did, undo: true), | ||
Writes.rerun(repo: repo, pull: pull, ref: nil, workflow: nil), | ||
Writes.rerun(repo: repo, pull: pull, ref: nil, workflow: "test"), | ||
Writes.rerun(repo: repo, pull: nil, ref: "refs/heads/main", workflow: "deploy"), | ||
Writes.seen(at: "2026-11-05T12:00:00.000Z"), | ||
Writes.edit(repo: repo, subject: pull, body: "- [x] Done"), | ||
Writes.registerPush(token: "0a1b", environment: "sandbox"), | ||
ios/look-answers.json+2−1
| @@ -58,5 +58,6 @@ | ||
{ "collection": "dev.knotless.issue", "count": 7 }, | ||
{ "collection": "dev.knotless.pull", "count": 12 }, | ||
{ "collection": "dev.knotless.repo", "count": 3 } | ||
] } | ||
] }, | ||
"listWorkflows": {"ref": "refs/heads/main", "workflows": [{"name": "build", "push": true, "pull": true, "asked": true}, {"name": "test", "push": true, "pull": true, "asked": true}, {"name": "release", "push": false, "pull": false, "asked": true}]} | ||
} | ||
ios/look.mjs+6−1
| @@ -123,6 +123,8 @@ const thread = (kind, test) => { | ||
return `thread|${owner}|${owner}|${name}|${t.item.uri}` | ||
} | ||
const tally = 'alice.test|alice.test|tally' | ||
// The run the recording has whole, its steps and log. | ||
const ranCheck = 'at://did:plc:55j6e4wxzlzndxm4nov2lz7x/dev.knotless.check/qdjbmvovu2tnodagwluxk6jxmq.3myvwh72yvtgz.uuiududme6qwohxvshbn32i4nw.bea82d97f7448b5d6d93b6a8a108ccb9d86277ad.test' | ||
const pullOpen = thread('pull', (t) => t.item.state === 'open' && !t.queue) | ||
const screens = { | ||
home: 'home', | ||
| @@ -145,6 +147,9 @@ const screens = { | ||
commit: `commit|${tally}|1be297a440088f5808a7cdcf5254ac71c024458f`, | ||
releases: `releases|${tally}`, | ||
actions: `runs|${tally}`, | ||
'workflow-runs': `workflow-runs|${tally}|test`, | ||
run: `run|${tally}|${ranCheck}`, | ||
'run-step': `step|${tally}|${ranCheck}|1`, | ||
person: 'user|bob.test', | ||
settings: 'settings', | ||
'notification-settings': 'notification-settings', | ||
| @@ -194,7 +199,7 @@ if (Lexicons) { | ||
const dir = join(root, 'lexicons/dev/knotless') | ||
const docs = readdirSync(dir, { recursive: true }).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8'))) | ||
const lexicons = new Lexicons(docs) | ||
const held = new Set(['getViewer', 'getActorStars', 'getFollowers', 'getFollows', 'getMergeStatus', 'listNotifications', 'getPullDiff', 'getPreferences', ...sets.map((s) => s.method)]) | ||
const held = new Set(['getViewer', 'getActorStars', 'getFollowers', 'getFollows', 'getMergeStatus', 'listNotifications', 'getPullDiff', 'getPreferences', 'listWorkflows', ...sets.map((s) => s.method)]) | ||
for (const answer of answers) { | ||
if (!held.has(answer.method) || answer.status !== 200) continue | ||
try { | ||