~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app shows a run's steps and logs, and runs a workflow when asked

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/checks
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 {