Commit 43f4f01b01
Verified · cmc
gitbay/Builds/BuildListViewModel.swift added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `build list <repo>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final class BuildListViewModel { | |
| 8 | ||
| 9 | private(set) var state: LoadState<[Build]> = .loading | |
| 10 | ||
| 11 | private let client: GitbayClient | |
| 12 | let repoPath: String | |
| 13 | ||
| 14 | init(client: GitbayClient, repoPath: String) { | |
| 15 | self.client = client | |
| 16 | self.repoPath = repoPath | |
| 17 | } | |
| 18 | ||
| 19 | func load() async { | |
| 20 | do { | |
| 21 | let builds = try await client.readList(["build", "list", repoPath], of: Build.self) | |
| 22 | state = builds.isEmpty | |
| 23 | ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.") | |
| 24 | : .loaded(builds.sorted { $0.number > $1.number }) | |
| 25 | } catch { | |
| 26 | state = .from(error) | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
gitbay/Builds/BuildModels.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `build list`. | |
| 4 | nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { | |
| 5 | let number: Int64 | |
| 6 | let job: String | |
| 7 | let status: String | |
| 8 | let sha: String | |
| 9 | let ref: String | |
| 10 | let createdAt: Date | |
| 11 | let finishedAt: Date? | |
| 12 | ||
| 13 | enum CodingKeys: String, CodingKey { | |
| 14 | case number, job, status, sha, ref | |
| 15 | case createdAt = "created_at" | |
| 16 | case finishedAt = "finished_at" | |
| 17 | } | |
| 18 | ||
| 19 | var id: Int64 { number } | |
| 20 | var shortSHA: String { String(sha.prefix(10)) } | |
| 21 | } | |
gitbay/ContentView.swift +26
| @@ -15,6 +15,12 @@ struct ContentView: View { | ||
| 15 | 15 | .navigationDestination(for: MRRoute.self) { route in |
| 16 | 16 | destination(route, client: client) |
| 17 | 17 | } |
| 18 | .navigationDestination(for: IssueRoute.self) { route in | |
| 19 | destination(route, client: client) | |
| 20 | } | |
| 21 | .navigationDestination(for: BuildRoute.self) { route in | |
| 22 | destination(route, client: client) | |
| 23 | } | |
| 18 | 24 | } else { |
| 19 | 25 | SignInView() |
| 20 | 26 | } |
| @@ -48,6 +54,26 @@ struct ContentView: View { | ||
| 48 | 54 | DiffView(client: client, repo: repo, number: number) |
| 49 | 55 | } |
| 50 | 56 | } |
| 57 | ||
| 58 | @ViewBuilder | |
| 59 | private func destination(_ route: IssueRoute, client: GitbayClient) -> some View { | |
| 60 | switch route { | |
| 61 | case .list(let repo): | |
| 62 | IssueListView(client: client, repo: repo) | |
| 63 | case .issue(let repo, let number): | |
| 64 | IssueView(client: client, repo: repo, number: number) | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | @ViewBuilder | |
| 69 | private func destination(_ route: BuildRoute, client: GitbayClient) -> some View { | |
| 70 | switch route { | |
| 71 | case .list(let repo): | |
| 72 | BuildListView(client: client, repo: repo) | |
| 73 | case .log(let repo, let number): | |
| 74 | BuildLogView(client: client, repo: repo, number: number) | |
| 75 | } | |
| 76 | } | |
| 51 | 77 | } |
| 52 | 78 | |
| 53 | 79 | #Preview { |
gitbay/Issues/IssueDetailViewModel.swift added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One issue and everything triage needs: comment, close, reopen, labels, | |
| 5 | /// assignees. Writes reload; refusals surface verbatim. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class IssueDetailViewModel { | |
| 9 | ||
| 10 | private(set) var state: LoadState<IssueDetail> = .loading | |
| 11 | private(set) var actionError: String? | |
| 12 | private(set) var working = false | |
| 13 | ||
| 14 | private let client: GitbayClient | |
| 15 | let repoPath: String | |
| 16 | let number: Int64 | |
| 17 | ||
| 18 | init(client: GitbayClient, repoPath: String, number: Int64) { | |
| 19 | self.client = client | |
| 20 | self.repoPath = repoPath | |
| 21 | self.number = number | |
| 22 | } | |
| 23 | ||
| 24 | private var ref: [String] { [repoPath, String(number)] } | |
| 25 | ||
| 26 | func load() async { | |
| 27 | do { | |
| 28 | state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self)) | |
| 29 | } catch { | |
| 30 | state = .from(error) | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | func comment(_ text: String) async { | |
| 35 | await perform(["issue", "comment"] + ref, stdin: text) | |
| 36 | } | |
| 37 | ||
| 38 | func close() async { | |
| 39 | await perform(["issue", "close"] + ref) | |
| 40 | } | |
| 41 | ||
| 42 | func reopen() async { | |
| 43 | await perform(["issue", "reopen"] + ref) | |
| 44 | } | |
| 45 | ||
| 46 | func addLabel(_ label: String) async { | |
| 47 | await perform(["issue", "label"] + ref + ["--add", label]) | |
| 48 | } | |
| 49 | ||
| 50 | func removeLabel(_ label: String) async { | |
| 51 | await perform(["issue", "label"] + ref + ["--remove", label]) | |
| 52 | } | |
| 53 | ||
| 54 | func addAssignee(_ user: String) async { | |
| 55 | await perform(["issue", "assign"] + ref + ["--add", user]) | |
| 56 | } | |
| 57 | ||
| 58 | func removeAssignee(_ user: String) async { | |
| 59 | await perform(["issue", "assign"] + ref + ["--remove", user]) | |
| 60 | } | |
| 61 | ||
| 62 | private func perform(_ argv: [String], stdin: String? = nil) async { | |
| 63 | working = true | |
| 64 | actionError = nil | |
| 65 | defer { working = false } | |
| 66 | do { | |
| 67 | try await client.run(argv, stdin: stdin) | |
| 68 | await load() | |
| 69 | } catch let error as GitbayError { | |
| 70 | actionError = error.userFacingMessage | |
| 71 | } catch { | |
| 72 | actionError = GitbayError.transport(error).userFacingMessage | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
gitbay/Issues/IssueListViewModel.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `issue list <repo> --state <s>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final class IssueListViewModel { | |
| 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | |
| 10 | case open, closed, all | |
| 11 | var id: String { rawValue } | |
| 12 | } | |
| 13 | ||
| 14 | private(set) var state: LoadState<[Issue]> = .loading | |
| 15 | var filter: StateFilter = .open { | |
| 16 | didSet { if filter != oldValue { Task { await load() } } } | |
| 17 | } | |
| 18 | ||
| 19 | private let client: GitbayClient | |
| 20 | let repoPath: String | |
| 21 | ||
| 22 | init(client: GitbayClient, repoPath: String) { | |
| 23 | self.client = client | |
| 24 | self.repoPath = repoPath | |
| 25 | } | |
| 26 | ||
| 27 | func load() async { | |
| 28 | state = .loading | |
| 29 | do { | |
| 30 | let issues = try await client.readList( | |
| 31 | ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self | |
| 32 | ) | |
| 33 | state = issues.isEmpty | |
| 34 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.") | |
| 35 | : .loaded(issues.sorted { $0.number > $1.number }) | |
| 36 | } catch { | |
| 37 | state = .from(error) | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
gitbay/Issues/IssueModels.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One issue; `issue list` rows and the header of `issue show`. | |
| 4 | nonisolated struct Issue: Decodable, Sendable, Hashable, Identifiable { | |
| 5 | let number: Int64 | |
| 6 | let title: String | |
| 7 | let state: String | |
| 8 | let author: String | |
| 9 | let milestone: String? | |
| 10 | let labels: [String]? | |
| 11 | let assignees: [String]? | |
| 12 | let body: String? | |
| 13 | let createdAt: Date | |
| 14 | ||
| 15 | enum CodingKeys: String, CodingKey { | |
| 16 | case number, title, state, author, milestone, labels, assignees, body | |
| 17 | case createdAt = "created_at" | |
| 18 | } | |
| 19 | ||
| 20 | var id: Int64 { number } | |
| 21 | var isOpen: Bool { state == "open" } | |
| 22 | } | |
| 23 | ||
| 24 | /// `issue show <owner/name> <n>` — the issue plus its comments. | |
| 25 | nonisolated struct IssueDetail: Decodable, Sendable, Hashable { | |
| 26 | let number: Int64 | |
| 27 | let title: String | |
| 28 | let state: String | |
| 29 | let author: String | |
| 30 | let milestone: String? | |
| 31 | let labels: [String]? | |
| 32 | let assignees: [String]? | |
| 33 | let body: String? | |
| 34 | let createdAt: Date | |
| 35 | let comments: [Comment]? | |
| 36 | ||
| 37 | enum CodingKeys: String, CodingKey { | |
| 38 | case number, title, state, author, milestone, labels, assignees, body, comments | |
| 39 | case createdAt = "created_at" | |
| 40 | } | |
| 41 | ||
| 42 | var isOpen: Bool { state == "open" } | |
| 43 | ||
| 44 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { | |
| 45 | let author: String | |
| 46 | let body: String | |
| 47 | let createdAt: Date | |
| 48 | ||
| 49 | enum CodingKeys: String, CodingKey { | |
| 50 | case author, body | |
| 51 | case createdAt = "created_at" | |
| 52 | } | |
| 53 | ||
| 54 | var id: String { author + createdAt.timeIntervalSince1970.description + body } | |
| 55 | } | |
| 56 | } | |
gitbay/Views/Builds/BuildListView.swift added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct BuildListView: View { | |
| 4 | ||
| 5 | @State private var model: BuildListViewModel | |
| 6 | ||
| 7 | init(client: GitbayClient, repo: String) { | |
| 8 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) | |
| 9 | } | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | List { | |
| 13 | ForEach(model.state.value ?? []) { build in | |
| 14 | NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) { | |
| 15 | BuildRow(build: build) | |
| 16 | } | |
| 17 | } | |
| 18 | } | |
| 19 | .overlay { LoadStateOverlay(state: model.state) } | |
| 20 | .navigationTitle("Builds") | |
| 21 | .navigationBarTitleDisplayMode(.inline) | |
| 22 | .task { await model.load() } | |
| 23 | .refreshable { await model.load() } | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | private struct BuildRow: View { | |
| 28 | let build: Build | |
| 29 | ||
| 30 | var body: some View { | |
| 31 | HStack(spacing: 10) { | |
| 32 | Image(systemName: icon) | |
| 33 | .foregroundStyle(color) | |
| 34 | VStack(alignment: .leading, spacing: 2) { | |
| 35 | Text("#\(build.number) \(build.job)") | |
| 36 | .font(.subheadline.weight(.medium)) | |
| 37 | HStack(spacing: 6) { | |
| 38 | Text(build.ref) | |
| 39 | Text(build.shortSHA) | |
| 40 | .font(.caption.monospaced()) | |
| 41 | } | |
| 42 | .font(.caption) | |
| 43 | .foregroundStyle(.secondary) | |
| 44 | } | |
| 45 | Spacer() | |
| 46 | VStack(alignment: .trailing, spacing: 2) { | |
| 47 | Text(build.status) | |
| 48 | .font(.caption.weight(.medium)) | |
| 49 | .foregroundStyle(color) | |
| 50 | Text(build.createdAt, format: .relative(presentation: .named)) | |
| 51 | .font(.caption) | |
| 52 | .foregroundStyle(.tertiary) | |
| 53 | } | |
| 54 | } | |
| 55 | .padding(.vertical, 2) | |
| 56 | } | |
| 57 | ||
| 58 | private var icon: String { | |
| 59 | switch build.status { | |
| 60 | case "success": "checkmark.circle.fill" | |
| 61 | case "failure", "error": "xmark.circle.fill" | |
| 62 | case "running": "circle.dotted" | |
| 63 | case "queued", "pending": "clock" | |
| 64 | default: "questionmark.circle" | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | private var color: Color { | |
| 69 | switch build.status { | |
| 70 | case "success": .green | |
| 71 | case "failure", "error": .red | |
| 72 | case "running", "queued", "pending": .orange | |
| 73 | default: .secondary | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | nonisolated enum BuildRoute: Hashable { | |
| 79 | case list(repo: String) | |
| 80 | case log(repo: String, number: Int64) | |
| 81 | } | |
gitbay/Views/Builds/BuildLogView.swift added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// `build log` — plain text, can be large. Monospaced, both-axis scroll. | |
| 4 | struct BuildLogView: View { | |
| 5 | ||
| 6 | private let client: GitbayClient | |
| 7 | private let repo: String | |
| 8 | private let number: Int64 | |
| 9 | @State private var state: LoadState<String> = .loading | |
| 10 | ||
| 11 | init(client: GitbayClient, repo: String, number: Int64) { | |
| 12 | self.client = client | |
| 13 | self.repo = repo | |
| 14 | self.number = number | |
| 15 | } | |
| 16 | ||
| 17 | var body: some View { | |
| 18 | ZStack { | |
| 19 | Color.clear | |
| 20 | if let log = state.value { | |
| 21 | if log.isEmpty { | |
| 22 | ContentUnavailableView { | |
| 23 | Label("No log yet", systemImage: "doc.text") | |
| 24 | } | |
| 25 | } else { | |
| 26 | ScrollView([.horizontal, .vertical]) { | |
| 27 | Text(log) | |
| 28 | .font(.caption2.monospaced()) | |
| 29 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 30 | .padding(12) | |
| 31 | .textSelection(.enabled) | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | .overlay { LoadStateOverlay(state: state) } | |
| 37 | .navigationTitle("Build #\(number)") | |
| 38 | .navigationBarTitleDisplayMode(.inline) | |
| 39 | .task { | |
| 40 | do { | |
| 41 | state = .loaded(try await client.readText(["build", "log", repo, String(number)])) | |
| 42 | } catch { | |
| 43 | state = .from(error) | |
| 44 | } | |
| 45 | } | |
| 46 | .refreshable { | |
| 47 | state = .loading | |
| 48 | do { | |
| 49 | state = .loaded(try await client.readText(["build", "log", repo, String(number)])) | |
| 50 | } catch { | |
| 51 | state = .from(error) | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
gitbay/Views/Issues/IssueListView.swift added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct IssueListView: View { | |
| 4 | ||
| 5 | @State private var model: IssueListViewModel | |
| 6 | ||
| 7 | init(client: GitbayClient, repo: String) { | |
| 8 | _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) | |
| 9 | } | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | List { | |
| 13 | Picker("State", selection: Bindable(model).filter) { | |
| 14 | ForEach(IssueListViewModel.StateFilter.allCases) { filter in | |
| 15 | Text(filter.rawValue.capitalized).tag(filter) | |
| 16 | } | |
| 17 | } | |
| 18 | .pickerStyle(.segmented) | |
| 19 | .listRowBackground(Color.clear) | |
| 20 | .listRowInsets(EdgeInsets()) | |
| 21 | ||
| 22 | ForEach(model.state.value ?? []) { issue in | |
| 23 | NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) { | |
| 24 | IssueRow(issue: issue) | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | .overlay { LoadStateOverlay(state: model.state) } | |
| 29 | .navigationTitle("Issues") | |
| 30 | .navigationBarTitleDisplayMode(.inline) | |
| 31 | .task { await model.load() } | |
| 32 | .refreshable { await model.load() } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | private struct IssueRow: View { | |
| 37 | let issue: Issue | |
| 38 | ||
| 39 | var body: some View { | |
| 40 | VStack(alignment: .leading, spacing: 4) { | |
| 41 | HStack(alignment: .firstTextBaseline, spacing: 6) { | |
| 42 | Text("#\(issue.number)") | |
| 43 | .font(.caption.monospaced()) | |
| 44 | .foregroundStyle(.secondary) | |
| 45 | Text(issue.title) | |
| 46 | .font(.subheadline.weight(.medium)) | |
| 47 | .lineLimit(2) | |
| 48 | } | |
| 49 | HStack(spacing: 6) { | |
| 50 | Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill") | |
| 51 | .font(.caption2) | |
| 52 | .foregroundStyle(issue.isOpen ? .green : .purple) | |
| 53 | ForEach(issue.labels ?? [], id: \.self) { label in | |
| 54 | Text(label) | |
| 55 | .font(.caption2) | |
| 56 | .padding(.horizontal, 5) | |
| 57 | .padding(.vertical, 1) | |
| 58 | .background(.quaternary, in: Capsule()) | |
| 59 | } | |
| 60 | Spacer() | |
| 61 | if let assignees = issue.assignees, !assignees.isEmpty { | |
| 62 | Text(assignees.joined(separator: ", ")) | |
| 63 | .font(.caption) | |
| 64 | .foregroundStyle(.secondary) | |
| 65 | .lineLimit(1) | |
| 66 | } | |
| 67 | Text(issue.createdAt, format: .relative(presentation: .named)) | |
| 68 | .font(.caption) | |
| 69 | .foregroundStyle(.tertiary) | |
| 70 | } | |
| 71 | } | |
| 72 | .padding(.vertical, 2) | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | nonisolated enum IssueRoute: Hashable { | |
| 77 | case list(repo: String) | |
| 78 | case issue(repo: String, number: Int64) | |
| 79 | } | |
gitbay/Views/Issues/IssueView.swift added +182
| @@ -0,0 +1,182 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct IssueView: View { | |
| 4 | ||
| 5 | @State private var model: IssueDetailViewModel | |
| 6 | @State private var commentText = "" | |
| 7 | @State private var editingLabel = "" | |
| 8 | @State private var editingAssignee = "" | |
| 9 | ||
| 10 | init(client: GitbayClient, repo: String, number: Int64) { | |
| 11 | _model = State(initialValue: IssueDetailViewModel( | |
| 12 | client: client, repoPath: repo, number: number | |
| 13 | )) | |
| 14 | } | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | List { | |
| 18 | if let issue = model.state.value { | |
| 19 | header(issue) | |
| 20 | ||
| 21 | if let error = model.actionError { | |
| 22 | Section { | |
| 23 | Label(error, systemImage: "hand.raised") | |
| 24 | .foregroundStyle(.orange) | |
| 25 | .font(.subheadline) | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | if let body = issue.body, !body.isEmpty { | |
| 30 | Section { | |
| 31 | MarkdownView(markdown: body) | |
| 32 | .padding(.vertical, 4) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | triageSection(issue) | |
| 37 | commentsSection(issue.comments ?? []) | |
| 38 | } | |
| 39 | } | |
| 40 | .overlay { LoadStateOverlay(state: model.state) } | |
| 41 | .navigationTitle("#\(model.number)") | |
| 42 | .navigationBarTitleDisplayMode(.inline) | |
| 43 | .toolbar { toolbar } | |
| 44 | .task { await model.load() } | |
| 45 | .refreshable { await model.load() } | |
| 46 | } | |
| 47 | ||
| 48 | @ViewBuilder | |
| 49 | private func header(_ issue: IssueDetail) -> some View { | |
| 50 | Section { | |
| 51 | VStack(alignment: .leading, spacing: 6) { | |
| 52 | Text(issue.title) | |
| 53 | .font(.headline) | |
| 54 | HStack(spacing: 6) { | |
| 55 | Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill") | |
| 56 | .font(.caption.weight(.medium)) | |
| 57 | .foregroundStyle(issue.isOpen ? .green : .purple) | |
| 58 | Text("by \(issue.author)") | |
| 59 | Text(issue.createdAt, format: .relative(presentation: .named)) | |
| 60 | .foregroundStyle(.tertiary) | |
| 61 | if let milestone = issue.milestone { | |
| 62 | Label(milestone, systemImage: "flag") | |
| 63 | } | |
| 64 | } | |
| 65 | .font(.caption) | |
| 66 | .foregroundStyle(.secondary) | |
| 67 | } | |
| 68 | .padding(.vertical, 2) | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | @ViewBuilder | |
| 73 | private func triageSection(_ issue: IssueDetail) -> some View { | |
| 74 | Section("Labels") { | |
| 75 | labelFlow(issue.labels ?? [], remove: { label in | |
| 76 | Task { await model.removeLabel(label) } | |
| 77 | }) | |
| 78 | HStack { | |
| 79 | TextField("Add label", text: $editingLabel) | |
| 80 | .autocorrectionDisabled() | |
| 81 | .textInputAutocapitalization(.never) | |
| 82 | Button { | |
| 83 | let label = editingLabel.trimmingCharacters(in: .whitespaces) | |
| 84 | editingLabel = "" | |
| 85 | Task { await model.addLabel(label) } | |
| 86 | } label: { | |
| 87 | Image(systemName: "plus.circle.fill") | |
| 88 | } | |
| 89 | .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 90 | } | |
| 91 | } | |
| 92 | Section("Assignees") { | |
| 93 | labelFlow(issue.assignees ?? [], remove: { user in | |
| 94 | Task { await model.removeAssignee(user) } | |
| 95 | }) | |
| 96 | HStack { | |
| 97 | TextField("Assign user", text: $editingAssignee) | |
| 98 | .autocorrectionDisabled() | |
| 99 | .textInputAutocapitalization(.never) | |
| 100 | Button { | |
| 101 | let user = editingAssignee.trimmingCharacters(in: .whitespaces) | |
| 102 | editingAssignee = "" | |
| 103 | Task { await model.addAssignee(user) } | |
| 104 | } label: { | |
| 105 | Image(systemName: "plus.circle.fill") | |
| 106 | } | |
| 107 | .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 108 | } | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | @ViewBuilder | |
| 113 | private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View { | |
| 114 | if !items.isEmpty { | |
| 115 | ScrollView(.horizontal, showsIndicators: false) { | |
| 116 | HStack(spacing: 6) { | |
| 117 | ForEach(items, id: \.self) { item in | |
| 118 | HStack(spacing: 3) { | |
| 119 | Text(item) | |
| 120 | Button { | |
| 121 | remove(item) | |
| 122 | } label: { | |
| 123 | Image(systemName: "xmark.circle.fill") | |
| 124 | .foregroundStyle(.tertiary) | |
| 125 | } | |
| 126 | .disabled(model.working) | |
| 127 | } | |
| 128 | .font(.caption) | |
| 129 | .padding(.horizontal, 8) | |
| 130 | .padding(.vertical, 3) | |
| 131 | .background(.quaternary, in: Capsule()) | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View { | |
| 139 | Section("Comments") { | |
| 140 | ForEach(comments) { comment in | |
| 141 | VStack(alignment: .leading, spacing: 4) { | |
| 142 | HStack { | |
| 143 | Text(comment.author) | |
| 144 | .font(.caption.weight(.semibold)) | |
| 145 | Text(comment.createdAt, format: .relative(presentation: .named)) | |
| 146 | .font(.caption) | |
| 147 | .foregroundStyle(.tertiary) | |
| 148 | } | |
| 149 | MarkdownView(markdown: comment.body) | |
| 150 | .font(.subheadline) | |
| 151 | } | |
| 152 | .padding(.vertical, 2) | |
| 153 | } | |
| 154 | ||
| 155 | HStack { | |
| 156 | TextField("Comment", text: $commentText, axis: .vertical) | |
| 157 | .lineLimit(1...5) | |
| 158 | Button { | |
| 159 | let text = commentText | |
| 160 | commentText = "" | |
| 161 | Task { await model.comment(text) } | |
| 162 | } label: { | |
| 163 | Image(systemName: "arrow.up.circle.fill") | |
| 164 | } | |
| 165 | .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 166 | || model.working) | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | @ToolbarContentBuilder | |
| 172 | private var toolbar: some ToolbarContent { | |
| 173 | ToolbarItem(placement: .topBarTrailing) { | |
| 174 | if let issue = model.state.value { | |
| 175 | Button(issue.isOpen ? "Close" : "Reopen") { | |
| 176 | Task { issue.isOpen ? await model.close() : await model.reopen() } | |
| 177 | } | |
| 178 | .disabled(model.working) | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
gitbay/Views/Repos/RepoView.swift +6
| @@ -25,6 +25,12 @@ struct RepoView: View { | ||
| 25 | 25 | NavigationLink(value: MRRoute.list(repo: path)) { |
| 26 | 26 | Label("Merge Requests", systemImage: "arrow.triangle.merge") |
| 27 | 27 | } |
| 28 | NavigationLink(value: IssueRoute.list(repo: path)) { | |
| 29 | Label("Issues", systemImage: "smallcircle.filled.circle") | |
| 30 | } | |
| 31 | NavigationLink(value: BuildRoute.list(repo: path)) { | |
| 32 | Label("Builds", systemImage: "hammer") | |
| 33 | } | |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | 36 | if let readme = model.readme { |
gitbayTests/IssueBuildViewModelTests.swift added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private let issueListJSON = """ | |
| 16 | {"protocol_version":1,"data":[\ | |
| 17 | {"number":11,"title":"iOS app","state":"open","author":"krz",\ | |
| 18 | "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ | |
| 19 | {"number":40,"title":"pagination","state":"open","author":"krz",\ | |
| 20 | "created_at":"2026-08-21T10:00:00.000Z"}\ | |
| 21 | ],"exit_code":0} | |
| 22 | """ | |
| 23 | ||
| 24 | private let issueShowJSON = """ | |
| 25 | {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ | |
| 26 | "author":"krz","labels":["app"],"assignees":["cmc"],"body":"Build it.",\ | |
| 27 | "created_at":"2026-08-20T10:00:00.000Z",\ | |
| 28 | "comments":[{"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z"}]},\ | |
| 29 | "exit_code":0} | |
| 30 | """ | |
| 31 | ||
| 32 | private let buildListJSON = """ | |
| 33 | {"protocol_version":1,"data":[\ | |
| 34 | {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ | |
| 35 | "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ | |
| 36 | "finished_at":"2026-08-20T10:05:00.000Z"},\ | |
| 37 | {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ | |
| 38 | "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\ | |
| 39 | ],"exit_code":0} | |
| 40 | """ | |
| 41 | ||
| 42 | @MainActor | |
| 43 | struct IssueListViewModelTests { | |
| 44 | ||
| 45 | @Test func listsNewestFirstWithLabelsAndAssignees() async throws { | |
| 46 | let (client, stub) = try makeClient() | |
| 47 | stub.enqueue(.init(status: 200, json: issueListJSON)) | |
| 48 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | |
| 49 | ||
| 50 | await model.load() | |
| 51 | ||
| 52 | let issues = try #require(model.state.value) | |
| 53 | #expect(issues.map(\.number) == [40, 11]) | |
| 54 | #expect(issues[1].labels == ["app"]) | |
| 55 | #expect(issues[1].assignees == ["cmc"]) | |
| 56 | #expect(stub.seen.first?.url.query() == | |
| 57 | "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open") | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | @MainActor | |
| 62 | struct IssueDetailViewModelTests { | |
| 63 | ||
| 64 | private func loadedModel() async throws -> (IssueDetailViewModel, StubProtocol.Box) { | |
| 65 | let (client, stub) = try makeClient() | |
| 66 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | |
| 67 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | |
| 68 | await model.load() | |
| 69 | return (model, stub) | |
| 70 | } | |
| 71 | ||
| 72 | @Test func loadsIssueWithComments() async throws { | |
| 73 | let (model, _) = try await loadedModel() | |
| 74 | ||
| 75 | let issue = try #require(model.state.value) | |
| 76 | #expect(issue.title == "iOS app") | |
| 77 | #expect(issue.comments?.count == 1) | |
| 78 | } | |
| 79 | ||
| 80 | @Test func commentTravelsInStdin() async throws { | |
| 81 | let (model, stub) = try await loadedModel() | |
| 82 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 83 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | |
| 84 | ||
| 85 | await model.comment("triaged from the phone") | |
| 86 | ||
| 87 | let write = stub.seen[1] | |
| 88 | #expect(write.method == "POST") | |
| 89 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 90 | #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11"]) | |
| 91 | #expect(body["stdin"] as? String == "triaged from the phone") | |
| 92 | } | |
| 93 | ||
| 94 | @Test func labelAndAssigneeEditsUseAddRemoveFlags() async throws { | |
| 95 | let (model, stub) = try await loadedModel() | |
| 96 | for _ in 0..<4 { | |
| 97 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 98 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | |
| 99 | } | |
| 100 | ||
| 101 | await model.addLabel("bug") | |
| 102 | await model.removeLabel("app") | |
| 103 | await model.addAssignee("krz") | |
| 104 | await model.removeAssignee("cmc") | |
| 105 | ||
| 106 | let writes = stub.seen.filter { $0.method == "POST" } | |
| 107 | let argvs = try writes.map { | |
| 108 | try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String] | |
| 109 | } | |
| 110 | #expect(argvs[0] == ["issue", "label", "krz/gitbay", "11", "--add", "bug"]) | |
| 111 | #expect(argvs[1] == ["issue", "label", "krz/gitbay", "11", "--remove", "app"]) | |
| 112 | #expect(argvs[2] == ["issue", "assign", "krz/gitbay", "11", "--add", "krz"]) | |
| 113 | #expect(argvs[3] == ["issue", "assign", "krz/gitbay", "11", "--remove", "cmc"]) | |
| 114 | } | |
| 115 | ||
| 116 | @Test func closeAndReopenAreSeparateCommands() async throws { | |
| 117 | let (model, stub) = try await loadedModel() | |
| 118 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 119 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | |
| 120 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#)) | |
| 121 | stub.enqueue(.init(status: 200, json: issueShowJSON)) | |
| 122 | ||
| 123 | await model.close() | |
| 124 | await model.reopen() | |
| 125 | ||
| 126 | let writes = stub.seen.filter { $0.method == "POST" } | |
| 127 | let argvs = try writes.map { | |
| 128 | try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String] | |
| 129 | } | |
| 130 | #expect(argvs[0] == ["issue", "close", "krz/gitbay", "11"]) | |
| 131 | #expect(argvs[1] == ["issue", "reopen", "krz/gitbay", "11"]) | |
| 132 | } | |
| 133 | ||
| 134 | @Test func aDeniedTriageActionSurfacesTheRule() async throws { | |
| 135 | let (model, stub) = try await loadedModel() | |
| 136 | stub.enqueue(.init(status: 403, json: | |
| 137 | #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#)) | |
| 138 | ||
| 139 | await model.close() | |
| 140 | ||
| 141 | #expect(model.actionError == "krz/gitbay is archived and read-only") | |
| 142 | #expect(model.state.value != nil) | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | @MainActor | |
| 147 | struct BuildListViewModelTests { | |
| 148 | ||
| 149 | @Test func listsNewestFirst() async throws { | |
| 150 | let (client, stub) = try makeClient() | |
| 151 | stub.enqueue(.init(status: 200, json: buildListJSON)) | |
| 152 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | |
| 153 | ||
| 154 | await model.load() | |
| 155 | ||
| 156 | let builds = try #require(model.state.value) | |
| 157 | #expect(builds.map(\.number) == [4, 3]) | |
| 158 | #expect(builds[0].status == "running") | |
| 159 | #expect(builds[0].finishedAt == nil) | |
| 160 | #expect(builds[1].finishedAt != nil) | |
| 161 | } | |
| 162 | } | |