Commit 0084a879cd

0084a879cd1576f0d1c689c091da84c5cb850950

parent: 04c4dc7d48

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 20:29 UTC

Decode draft, review requests and the merge request stack

Layout: unified · split

gitbay/MRs/MRModels.swift +64 −2
@@ -1,5 +1,13 @@
1import Foundation 1import Foundation
2 2
3/// The MR below or above a stack member: `mr show` sends `{number, title}`.
4nonisolated struct StackRef: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let title: String
7
8 var id: Int64 { number }
9}
10
3/// One row of `mr list`, and the header half of `mr show`. 11/// One row of `mr list`, and the header half of `mr show`.
4nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable { 12nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64 13 let number: Int64
@@ -13,12 +21,31 @@ nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable {
13 let headSHA: String 21 let headSHA: String
14 let body: String? 22 let body: String?
15 let createdAt: Date 23 let createdAt: Date
24 /// Open but not asking: does not merge, does not appear in a review queue.
25 let draft: Bool
26 let stackedOn: StackRef?
16 27
17 enum CodingKeys: String, CodingKey { 28 enum CodingKeys: String, CodingKey {
18 case number, title, state, author, source, body 29 case number, title, state, author, source, body, draft
19 case targetRef = "target_ref" 30 case targetRef = "target_ref"
20 case headSHA = "head_sha" 31 case headSHA = "head_sha"
21 case createdAt = "created_at" 32 case createdAt = "created_at"
33 case stackedOn = "stacked_on"
34 }
35
36 init(from decoder: Decoder) throws {
37 let container = try decoder.container(keyedBy: CodingKeys.self)
38 number = try container.decode(Int64.self, forKey: .number)
39 title = try container.decode(String.self, forKey: .title)
40 state = try container.decode(String.self, forKey: .state)
41 author = try container.decode(String.self, forKey: .author)
42 source = try container.decode(String.self, forKey: .source)
43 targetRef = try container.decode(String.self, forKey: .targetRef)
44 headSHA = try container.decode(String.self, forKey: .headSHA)
45 body = try container.decodeIfPresent(String.self, forKey: .body)
46 createdAt = try container.decode(Date.self, forKey: .createdAt)
47 draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false
48 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn)
22 } 49 }
23 50
24 var id: Int64 { number } 51 var id: Int64 { number }
@@ -49,9 +76,14 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
49 let commits: [MRCommit]? 76 let commits: [MRCommit]?
50 let comments: [MRComment]? 77 let comments: [MRComment]?
51 let reviews: [Review]? 78 let reviews: [Review]?
79 /// Open but not asking: does not merge, does not appear in a review queue.
80 let draft: Bool
81 let reviewRequests: [String]
82 let stackedOn: StackRef?
83 let stacked: [StackRef]
52 84
53 enum CodingKeys: String, CodingKey { 85 enum CodingKeys: String, CodingKey {
54 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews 86 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked
55 case targetRef = "target_ref" 87 case targetRef = "target_ref"
56 case headSHA = "head_sha" 88 case headSHA = "head_sha"
57 case createdAt = "created_at" 89 case createdAt = "created_at"
@@ -61,6 +93,36 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
61 case closedBy = "closed_by" 93 case closedBy = "closed_by"
62 case checksCombined = "checks_combined" 94 case checksCombined = "checks_combined"
63 case unresolvedThreads = "unresolved_threads" 95 case unresolvedThreads = "unresolved_threads"
96 case reviewRequests = "review_requests"
97 case stackedOn = "stacked_on"
98 }
99
100 init(from decoder: Decoder) throws {
101 let container = try decoder.container(keyedBy: CodingKeys.self)
102 number = try container.decode(Int64.self, forKey: .number)
103 title = try container.decode(String.self, forKey: .title)
104 state = try container.decode(String.self, forKey: .state)
105 author = try container.decode(String.self, forKey: .author)
106 source = try container.decode(String.self, forKey: .source)
107 targetRef = try container.decode(String.self, forKey: .targetRef)
108 headSHA = try container.decode(String.self, forKey: .headSHA)
109 body = try container.decodeIfPresent(String.self, forKey: .body)
110 milestone = try container.decodeIfPresent(String.self, forKey: .milestone)
111 createdAt = try container.decode(Date.self, forKey: .createdAt)
112 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt)
113 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy)
114 closedAt = try container.decodeIfPresent(Date.self, forKey: .closedAt)
115 closedBy = try container.decodeIfPresent(String.self, forKey: .closedBy)
116 checks = try container.decodeIfPresent([Check].self, forKey: .checks)
117 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined)
118 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads)
119 commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits)
120 comments = try container.decodeIfPresent([MRComment].self, forKey: .comments)
121 reviews = try container.decodeIfPresent([Review].self, forKey: .reviews)
122 draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false
123 reviewRequests = try container.decodeIfPresent([String].self, forKey: .reviewRequests) ?? []
124 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn)
125 stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? []
64 } 126 }
65 127
66 var isOpen: Bool { state == "open" } 128 var isOpen: Bool { state == "open" }
gitbayTests/MRLifecycleTests.swift added +90
@@ -0,0 +1,90 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func decodeDetail(_ json: String) throws -> MRDetail {
6 let decoder = JSONDecoder()
7 decoder.dateDecodingStrategy = .iso8601
8 return try decoder.decode(MRDetail.self, from: Data(json.utf8))
9}
10
11private func decodeRow(_ json: String) throws -> MergeRequest {
12 let decoder = JSONDecoder()
13 decoder.dateDecodingStrategy = .iso8601
14 return try decoder.decode(MergeRequest.self, from: Data(json.utf8))
15}
16
17private let plainDetail = """
18 {"number":7,"title":"a change","state":"open","author":"cmc",\
19 "source":"feat","target_ref":"main","head_sha":"abc123",\
20 "created_at":"2026-09-01T00:00:00Z"}
21 """
22
23struct MRLifecycleDecodingTests {
24
25 /// Every one of these keys is omitempty. The ordinary merge request
26 /// carries none of them, so absence must decode, not throw.
27 @Test func anOrdinaryMergeRequestHasNoneOfTheNewKeys() throws {
28 let mr = try decodeDetail(plainDetail)
29 #expect(mr.draft == false)
30 #expect(mr.reviewRequests.isEmpty)
31 #expect(mr.stackedOn == nil)
32 #expect(mr.stacked.isEmpty)
33 }
34
35 @Test func aDraftDecodesAndStaysOpen() throws {
36 let mr = try decodeDetail("""
37 {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
38 "source":"feat","target_ref":"main","head_sha":"abc123",\
39 "created_at":"2026-09-01T00:00:00Z"}
40 """)
41 #expect(mr.draft)
42 // Draft is a flag, not a fifth state.
43 #expect(mr.state == "open")
44 #expect(mr.isOpen)
45 }
46
47 @Test func reviewRequestsDecode() throws {
48 let mr = try decodeDetail("""
49 {"number":7,"title":"a change","state":"open","author":"cmc",\
50 "review_requests":["rae","sam"],\
51 "source":"feat","target_ref":"main","head_sha":"abc123",\
52 "created_at":"2026-09-01T00:00:00Z"}
53 """)
54 #expect(mr.reviewRequests == ["rae", "sam"])
55 }
56
57 @Test func bothHalvesOfAStackDecode() throws {
58 let mr = try decodeDetail("""
59 {"number":7,"title":"a change","state":"open","author":"cmc",\
60 "stacked_on":{"number":6,"title":"the one below"},\
61 "stacked":[{"number":8,"title":"one above"},{"number":9,"title":"another"}],\
62 "source":"feat","target_ref":"main","head_sha":"abc123",\
63 "created_at":"2026-09-01T00:00:00Z"}
64 """)
65 #expect(mr.stackedOn?.number == 6)
66 #expect(mr.stackedOn?.title == "the one below")
67 #expect(mr.stacked.map(\.number) == [8, 9])
68 }
69
70 @Test func aListRowCarriesDraftAndStackedOn() throws {
71 let row = try decodeRow("""
72 {"number":7,"title":"a change","state":"open","draft":true,"author":"cmc",\
73 "stacked_on":{"number":6,"title":"below"},\
74 "source":"feat","target_ref":"main","head_sha":"abc123",\
75 "created_at":"2026-09-01T00:00:00Z"}
76 """)
77 #expect(row.draft)
78 #expect(row.stackedOn?.number == 6)
79 }
80
81 @Test func aPlainListRowHasNeither() throws {
82 let row = try decodeRow("""
83 {"number":7,"title":"a change","state":"open","author":"cmc",\
84 "source":"feat","target_ref":"main","head_sha":"abc123",\
85 "created_at":"2026-09-01T00:00:00Z"}
86 """)
87 #expect(row.draft == false)
88 #expect(row.stackedOn == nil)
89 }
90}