Commit 0e13bacb04
Unsigned
Layout: unified · split
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 | .navigationDestination(for: MRRoute.self) { route in | 15 | .navigationDestination(for: MRRoute.self) { route in |
| 16 | destination(route, client: client) | 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 | } else { | 24 | } else { |
| 19 | SignInView() | 25 | SignInView() |
| 20 | } | 26 | } |
| @@ -48,6 +54,26 @@ struct ContentView: View { | |||
| 48 | DiffView(client: client, repo: repo, number: number) | 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 | #Preview { | 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 | NavigationLink(value: MRRoute.list(repo: path)) { | 25 | NavigationLink(value: MRRoute.list(repo: path)) { |
| 26 | Label("Merge Requests", systemImage: "arrow.triangle.merge") | 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 | if let readme = model.readme { | 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 | } | ||