Skip to content

Commit 95d383e

Browse files
committed
Added additional request to fetch build details when needed (#27).
This desperately needs some refactoring.
1 parent e5cbf27 commit 95d383e

5 files changed

Lines changed: 149 additions & 23 deletions

File tree

CCMenu/Source/Model/Build.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum BuildResult: String, Codable {
1616

1717
struct Build: Codable {
1818
var result: BuildResult
19+
var id: String?
1920
var label: String?
2021
var timestamp: Date?
2122
var duration: TimeInterval?

CCMenu/Source/Server Monitor/GitLabAPI.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ class GitLabAPI {
116116
return makeRequest(url: components.url!.absoluteURL, token: token)
117117
}
118118

119+
static func requestForDetail(feed: PipelineFeed, pipelineId: String, token: String?) -> URLRequest? {
120+
// TODO: double check that this works with query params (for branches)
121+
let url = feed.url.appendingPathComponent(pipelineId)
122+
return makeRequest(url: url, token: token)
123+
}
124+
119125

120126
// MARK: - send requests
121127

CCMenu/Source/Server Monitor/GitLabFeedReader.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,74 @@ class GitLabFeedReader {
7979
return parser.pipelineStatus(name: pipeline.name)
8080
}
8181

82+
83+
func enrichPipelineCurrentBuild() async {
84+
do {
85+
let token = try Keychain.standard.getToken(forService: "GitLab")
86+
87+
if let pid = pipeline.status.currentBuild?.id {
88+
guard let request = GitLabAPI.requestForDetail(feed: pipeline.feed, pipelineId: pid, token: token) else {
89+
throw GitLabFeedReaderError.invalidURLError
90+
}
91+
guard let currentBuild = try await fetchBuild(request: request) else {
92+
throw GitLabFeedReaderError.noStatusError
93+
}
94+
pipeline.status.currentBuild = currentBuild
95+
}
96+
pipeline.connectionError = nil
97+
} catch {
98+
if let error = error as? GitLabFeedReaderError, case .rateLimitError(let pauseUntil) = error {
99+
pipeline.feed.setPauseUntil(pauseUntil, reason: error.localizedDescription)
100+
} else {
101+
pipeline.status = PipelineStatus(activity: .other)
102+
pipeline.connectionError = error.localizedDescription
103+
}
104+
}
105+
}
106+
107+
func enrichPipelineLastBuild() async {
108+
do {
109+
let token = try Keychain.standard.getToken(forService: "GitLab")
110+
111+
if let pid = pipeline.status.lastBuild?.id {
112+
guard let request = GitLabAPI.requestForDetail(feed: pipeline.feed, pipelineId: pid, token: token) else {
113+
throw GitLabFeedReaderError.invalidURLError
114+
}
115+
guard let lastBuild = try await fetchBuild(request: request) else {
116+
throw GitLabFeedReaderError.noStatusError
117+
}
118+
pipeline.status.lastBuild = lastBuild
119+
}
120+
pipeline.connectionError = nil
121+
} catch {
122+
if let error = error as? GitLabFeedReaderError, case .rateLimitError(let pauseUntil) = error {
123+
pipeline.feed.setPauseUntil(pauseUntil, reason: error.localizedDescription)
124+
} else {
125+
pipeline.status = PipelineStatus(activity: .other)
126+
pipeline.connectionError = error.localizedDescription
127+
}
128+
}
129+
}
130+
131+
132+
private func fetchBuild(request: URLRequest) async throws -> Build? {
133+
let (data, response) = try await URLSession.shared.data(for: request)
134+
guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) }
135+
if response.statusCode == 403 || response.statusCode == 429 {
136+
guard let v = response.value(forHTTPHeaderField: "RateLimit-Remaining"), Int(v) == 0 else {
137+
throw GitLabFeedReaderError.httpError(response.statusCode)
138+
}
139+
guard let v = response.value(forHTTPHeaderField: "RateLimit-Reset"), let pauseUntil = Int(v) else {
140+
throw GitLabFeedReaderError.httpError(response.statusCode)
141+
}
142+
throw GitLabFeedReaderError.rateLimitError(pauseUntil)
143+
}
144+
if response.statusCode != 200 {
145+
throw GitLabFeedReaderError.httpError(response.statusCode)
146+
}
147+
let parser = GitLabDetailResponseParser()
148+
try parser.parseResponse(data)
149+
return parser.build()
150+
}
151+
82152
}

CCMenu/Source/Server Monitor/GitLabResponseParser.swift

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,35 +23,39 @@ class GitLabResponseParser {
2323
var status = PipelineStatus(activity: .other)
2424
status.webUrl = latest["web_url"] as? String
2525
if let pipelineStatus = latest["status"] as? String {
26-
status.activity = activityForString(pipelineStatus)
26+
status.activity = GitLabResponseParser.activityForString(pipelineStatus)
2727
}
2828

2929
if status.activity == .building {
30-
status.currentBuild = build(pipeline: latest)
30+
status.currentBuild = GitLabResponseParser.build(pipeline: latest)
3131
if let completed = pipelineList.first(where: isCompletedSuccessful(pipeline:)) ??
32-
pipelineList.first(where: isCompleted(pipeline:)) {
33-
status.lastBuild = build(pipeline: completed)
32+
pipelineList.first(where: GitLabResponseParser.isCompleted(pipeline:)) {
33+
status.lastBuild = GitLabResponseParser.build(pipeline: completed)
3434
}
3535
} else {
36-
status.lastBuild = build(pipeline: latest)
36+
status.lastBuild = GitLabResponseParser.build(pipeline: latest)
3737
}
3838

3939
return status
4040
}
4141

42-
private func build(pipeline: Dictionary<String, Any>) -> Build {
42+
fileprivate static func build(pipeline: Dictionary<String, Any>) -> Build {
4343
let status = pipeline["status"] as? String
4444
var build = Build(result: resultForString(status))
4545

46-
if let pipelineId = pipeline["iid"] as? Int {
47-
build.label = String(pipelineId)
46+
if let pipelineId = pipeline["id"] as? Int {
47+
build.id = String(pipelineId)
48+
}
49+
50+
if let pipelineIid = pipeline["iid"] as? Int {
51+
build.label = String(pipelineIid)
4852
}
4953

50-
// TODO: AI generated code - rework to get actual run start if possible
5154
if let createdAt = pipeline["created_at"] as? String, let createdAtDate = dateForString(createdAt) {
5255
build.timestamp = createdAtDate
53-
if let updatedAt = pipeline["updated_at"] as? String, let updatedAtDate = dateForString(updatedAt) {
54-
build.duration = updatedAtDate.timeIntervalSince(createdAtDate)
56+
// This is only present when called with a response with the pipeline detail
57+
if let duration = pipeline["duration"] as? Int {
58+
build.duration = Double(duration)
5559
}
5660
}
5761

@@ -68,30 +72,34 @@ class GitLabResponseParser {
6872
build.message = messageParts.joined(separator: " \u{22EE} ")
6973
}
7074

71-
// GitLab doesn't include user info directly in pipeline API response
72-
// We would need to make an additional API call to get user details
73-
// For now, we'll leave user and avatar empty
75+
// This is only present when called with a response with the pipeline detail
76+
if let user = pipeline["user"] as? Dictionary<String, Any> {
77+
build.user = user["name"] as? String
78+
if let avatar = user["avatar_url"] as? String {
79+
build.avatar = URL(string: avatar)
80+
}
81+
}
7482

7583
return build
7684
}
7785

78-
private func isCompleted(pipeline: Dictionary<String, Any>) -> Bool {
79-
return activityForString(pipeline["status"] as? String) == .sleeping
86+
private static func isCompleted(pipeline: Dictionary<String, Any>) -> Bool {
87+
return GitLabResponseParser.activityForString(pipeline["status"] as? String) == .sleeping
8088
}
8189

8290
private func isCompletedSuccessful(pipeline: Dictionary<String, Any>) -> Bool {
83-
return resultForString(pipeline["status"] as? String) == .success
91+
return GitLabResponseParser.resultForString(pipeline["status"] as? String) == .success
8492
}
8593

86-
func activityForString(_ string: String?) -> PipelineStatus.Activity {
94+
static func activityForString(_ string: String?) -> PipelineStatus.Activity {
8795
switch string {
8896
case "running", "pending": return .building
8997
case "success", "failed", "canceled", "skipped", "manual", "scheduled": return .sleeping
9098
default: return .other
9199
}
92100
}
93101

94-
func resultForString(_ string: String?) -> BuildResult {
102+
static func resultForString(_ string: String?) -> BuildResult {
95103
switch string {
96104
case "success": return .success
97105
case "failed": return .failure
@@ -100,9 +108,28 @@ class GitLabResponseParser {
100108
}
101109
}
102110

103-
func dateForString(_ string: String) -> Date? {
111+
static func dateForString(_ string: String) -> Date? {
104112
let formatter = ISO8601DateFormatter()
105113
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
106114
return formatter.date(from: string)
107115
}
108116
}
117+
118+
119+
class GitLabDetailResponseParser {
120+
121+
var pipelineDetail: Dictionary<String, Any> = [:]
122+
123+
func parseResponse(_ data: Data) throws {
124+
if let response = try JSONSerialization.jsonObject(with: data, options: []) as? Dictionary<String, Any> {
125+
pipelineDetail = response
126+
} else {
127+
pipelineDetail = [:]
128+
}
129+
}
130+
131+
func build() -> Build {
132+
GitLabResponseParser.build(pipeline: pipelineDetail)
133+
}
134+
135+
}

CCMenu/Source/Server Monitor/ServerMonitor.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class ServerMonitor {
6868
}
6969
}
7070

71+
7172
// TODO: Consider moving the following methods to the reader, with a protocol and base class
7273
// TODO: Consider adding a limit to the number of parallel requests (see https://stackoverflow.com/questions/70976323/)
7374

@@ -134,10 +135,31 @@ class ServerMonitor {
134135
}
135136
let reader = GitLabFeedReader(for: pipeline)
136137
await reader.updatePipelineStatus()
137-
model.update(pipeline: reader.pipeline)
138+
var newPipeline = reader.pipeline
139+
140+
if let idOld = pipeline.status.lastBuild?.id, let idNew = newPipeline.status.lastBuild?.id, idNew == idOld {
141+
print("last build: matching build found, will copy")
142+
newPipeline.status.lastBuild = pipeline.status.lastBuild
143+
} else {
144+
print("last build: seems new, will fetch details")
145+
await reader.enrichPipelineLastBuild()
146+
newPipeline = reader.pipeline
147+
}
148+
149+
if let idOld = pipeline.status.currentBuild?.id, let idNew = newPipeline.status.currentBuild?.id, idNew == idOld {
150+
print("current build: matching build found, will copy")
151+
newPipeline.status.currentBuild = pipeline.status.currentBuild
152+
} else if pipeline.status.currentBuild != nil {
153+
print("current build: seems new, will fetch details")
154+
await reader.enrichPipelineCurrentBuild()
155+
newPipeline = reader.pipeline
156+
} else {
157+
print("current build: no build")
158+
}
159+
160+
model.update(pipeline: newPipeline)
138161
}
139-
140-
162+
141163
private func pipelineIsRemote(_ p: Pipeline) -> Bool {
142164
if p.feed.url.host() != "localhost" {
143165
return true

0 commit comments

Comments
 (0)