Commit 381d5e7372

381d5e7372ca3471ae4b392fdfe4d8eb84da7d25

parent: da1c8a11b6

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:19 UTC

Issue templates in the compose sheet (!70)

issue templates loads with the issue list; when the repository has any, the New Issue sheet offers a template menu that fills the body. A failed read leaves the sheet without the menu. No live-suite step: the fixture repository has no templates.

Layout: unified · split

gitbay/Issues/IssueCreateViewModel.swift +16 −1
@@ -1,13 +1,21 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `issue create <repo> --title <t> --file -`. 4/// One of `issue templates <repo>` — a body the compose sheet can start from.
5nonisolated struct IssueTemplate: Decodable, Sendable, Hashable, Identifiable {
6 let name: String
7 let body: String
8 var id: String { name }
9}
10
11/// `issue create <repo> --title <t> --file -`, and the repo's templates.
5@Observable 12@Observable
6@MainActor 13@MainActor
7final class IssueCreateViewModel { 14final class IssueCreateViewModel {
8 15
9 private(set) var working = false 16 private(set) var working = false
10 private(set) var errorMessage: String? 17 private(set) var errorMessage: String?
18 private(set) var templates: [IssueTemplate] = []
11 var format: String? 19 var format: String?
12 20
13 private let client: GitbayClient 21 private let client: GitbayClient
@@ -22,6 +30,13 @@ final class IssueCreateViewModel {
22 let number: Int64 30 let number: Int64
23 } 31 }
24 32
33 /// `issue templates <repo>`. Templates are a convenience, so a failed
34 /// read leaves the sheet without them rather than surfacing an error.
35 func loadTemplates() async {
36 templates = (try? await client.readList(
37 ["issue", "templates", repoPath], of: IssueTemplate.self)) ?? []
38 }
39
25 /// Returns the new issue's number, or nil with `errorMessage` set. 40 /// Returns the new issue's number, or nil with `errorMessage` set.
26 func create(title: String, body: String) async -> Int64? { 41 func create(title: String, body: String) async -> Int64? {
27 working = true 42 working = true
gitbay/Views/Issues/IssueListView.swift +3 −1
@@ -85,7 +85,8 @@ struct IssueListView: View {
85 errorMessage: createModel.errorMessage, 85 errorMessage: createModel.errorMessage,
86 title: $draftTitle, 86 title: $draftTitle,
87 bodyText: $draftBody, 87 bodyText: $draftBody,
88 format: $draftFormat 88 format: $draftFormat,
89 templates: createModel.templates
89 ) { 90 ) {
90 createModel.format = draftFormat 91 createModel.format = draftFormat
91 Task { 92 Task {
@@ -100,6 +101,7 @@ struct IssueListView: View {
100 } 101 }
101 } 102 }
102 .task { await model.load() } 103 .task { await model.load() }
104 .task { await createModel.loadTemplates() }
103 .refreshable { await model.load() } 105 .refreshable { await model.load() }
104 } 106 }
105} 107}
gitbay/Views/Shared/ComposeSheet.swift +15 −1
@@ -14,13 +14,16 @@ struct ComposeSheet: View {
14 /// when the value changed and the picker kept whatever it first saw. 14 /// when the value changed and the picker kept whatever it first saw.
15 @Binding var format: String 15 @Binding var format: String
16 let showsFormat: Bool 16 let showsFormat: Bool
17 /// Issue templates to start the body from; empty hides the menu.
18 let templates: [IssueTemplate]
17 let onSubmit: () -> Void 19 let onSubmit: () -> Void
18 20
19 /// A nil `format` hides the picker entirely — the release editor has 21 /// A nil `format` hides the picker entirely — the release editor has
20 /// no parity row and passes nothing, leaving that call site untouched. 22 /// no parity row and passes nothing, leaving that call site untouched.
21 init(heading: String, submitLabel: String, working: Bool, errorMessage: String?, 23 init(heading: String, submitLabel: String, working: Bool, errorMessage: String?,
22 title: Binding<String>, bodyText: Binding<String>, 24 title: Binding<String>, bodyText: Binding<String>,
23 format: Binding<String>? = nil, onSubmit: @escaping () -> Void) { 25 format: Binding<String>? = nil, templates: [IssueTemplate] = [],
26 onSubmit: @escaping () -> Void) {
24 self.heading = heading 27 self.heading = heading
25 self.submitLabel = submitLabel 28 self.submitLabel = submitLabel
26 self.working = working 29 self.working = working
@@ -29,6 +32,7 @@ struct ComposeSheet: View {
29 _bodyText = bodyText 32 _bodyText = bodyText
30 _format = format ?? .constant("md") 33 _format = format ?? .constant("md")
31 showsFormat = format != nil 34 showsFormat = format != nil
35 self.templates = templates
32 self.onSubmit = onSubmit 36 self.onSubmit = onSubmit
33 } 37 }
34 38
@@ -46,6 +50,16 @@ struct ComposeSheet: View {
46 .accessibilityIdentifier("compose-title") 50 .accessibilityIdentifier("compose-title")
47 } 51 }
48 Section("Body") { 52 Section("Body") {
53 if !templates.isEmpty {
54 Menu {
55 ForEach(templates) { template in
56 Button(template.name) { bodyText = template.body }
57 }
58 } label: {
59 Label("Template", systemImage: "doc.text")
60 }
61 .accessibilityIdentifier("compose-template-menu")
62 }
49 TextEditor(text: $bodyText) 63 TextEditor(text: $bodyText)
50 .frame(minHeight: 160) 64 .frame(minHeight: 160)
51 .font(.gbSans(.body)) 65 .font(.gbSans(.body))
gitbayTests/ComposeViewModelTests.swift +31
@@ -61,6 +61,37 @@ struct IssueCreateViewModelTests {
61 } 61 }
62} 62}
63 63
64@MainActor
65struct IssueTemplateTests {
66
67 @Test func loadTemplatesReadsTheRepoTemplates() async throws {
68 let (client, stub) = try makeClient()
69 stub.enqueue(.init(status: 200, json: """
70 {"protocol_version":1,"data":[\
71 {"name":"bug","body":"## Steps\\n\\n1. "},\
72 {"name":"feature","body":"## Why\\n"}],"exit_code":0}
73 """))
74 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
75
76 await model.loadTemplates()
77
78 #expect(model.templates.map(\.name) == ["bug", "feature"])
79 #expect(model.templates.first?.body == "## Steps\n\n1. ")
80 #expect(stub.seen.first?.url.query() == "argv=issue&argv=templates&argv=krz/gitbay")
81 }
82
83 @Test func aTemplateReadFailureLeavesNoTemplatesAndNoError() async throws {
84 let (client, stub) = try makeClient()
85 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
86 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
87
88 await model.loadTemplates()
89
90 #expect(model.templates.isEmpty)
91 #expect(model.errorMessage == nil)
92 }
93}
94
64@MainActor 95@MainActor
65struct MRCreateViewModelTests { 96struct MRCreateViewModelTests {
66 97