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 @@
11import Foundation
22import Observation
33
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.
512@Observable
613@MainActor
714final class IssueCreateViewModel {
815
916 private(set) var working = false
1017 private(set) var errorMessage: String?
18 private(set) var templates: [IssueTemplate] = []
1119 var format: String?
1220
1321 private let client: GitbayClient
@@ -22,6 +30,13 @@ final class IssueCreateViewModel {
2230 let number: Int64
2331 }
2432
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
2540 /// Returns the new issue's number, or nil with `errorMessage` set.
2641 func create(title: String, body: String) async -> Int64? {
2742 working = true
gitbay/Views/Issues/IssueListView.swift +3 −1
@@ -85,7 +85,8 @@ struct IssueListView: View {
8585 errorMessage: createModel.errorMessage,
8686 title: $draftTitle,
8787 bodyText: $draftBody,
88 format: $draftFormat
88 format: $draftFormat,
89 templates: createModel.templates
8990 ) {
9091 createModel.format = draftFormat
9192 Task {
@@ -100,6 +101,7 @@ struct IssueListView: View {
100101 }
101102 }
102103 .task { await model.load() }
104 .task { await createModel.loadTemplates() }
103105 .refreshable { await model.load() }
104106 }
105107}
gitbay/Views/Shared/ComposeSheet.swift +15 −1
@@ -14,13 +14,16 @@ struct ComposeSheet: View {
1414 /// when the value changed and the picker kept whatever it first saw.
1515 @Binding var format: String
1616 let showsFormat: Bool
17 /// Issue templates to start the body from; empty hides the menu.
18 let templates: [IssueTemplate]
1719 let onSubmit: () -> Void
1820
1921 /// A nil `format` hides the picker entirely — the release editor has
2022 /// no parity row and passes nothing, leaving that call site untouched.
2123 init(heading: String, submitLabel: String, working: Bool, errorMessage: String?,
2224 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) {
2427 self.heading = heading
2528 self.submitLabel = submitLabel
2629 self.working = working
@@ -29,6 +32,7 @@ struct ComposeSheet: View {
2932 _bodyText = bodyText
3033 _format = format ?? .constant("md")
3134 showsFormat = format != nil
35 self.templates = templates
3236 self.onSubmit = onSubmit
3337 }
3438
@@ -46,6 +50,16 @@ struct ComposeSheet: View {
4650 .accessibilityIdentifier("compose-title")
4751 }
4852 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 }
4963 TextEditor(text: $bodyText)
5064 .frame(minHeight: 160)
5165 .font(.gbSans(.body))
gitbayTests/ComposeViewModelTests.swift +31
@@ -61,6 +61,37 @@ struct IssueCreateViewModelTests {
6161 }
6262}
6363
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
6495@MainActor
6596struct MRCreateViewModelTests {
6697