Skip to content

Commit ce6bff6

Browse files
committed
Initial support for reading GitLab feeds (#27).
Please note: I'm experimenting with AI-assisted software delivery, and there is AI-generated but not yet reviewed code in this commit.
1 parent 49efd16 commit ce6bff6

File tree

9 files changed

+418
-6
lines changed

9 files changed

+418
-6
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ xcuserdata/
1212
*.dSYM.zip
1313
*.dSYM
1414

15-
## Playgrounds
15+
## Playgrounds and other IDE files
1616
timeline.xctimeline
1717
playground.xcworkspace
18+
PROMPT.md
19+
.windsurfrc
1820

1921
## Command line builds
2022
build/

CCMenu.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
0322053F2B9E601D00205DC6 /* PipelineWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0322053E2B9E601D00205DC6 /* PipelineWindowTests.swift */; };
2626
032205412B9E606600205DC6 /* CCTrayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032205402B9E606600205DC6 /* CCTrayTests.swift */; };
2727
032205432B9E615800205DC6 /* GitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032205422B9E615800205DC6 /* GitHubTests.swift */; };
28+
03278EA32E1FEE2300995F34 /* GitLabFeedReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03278EA12E1FEE2300995F34 /* GitLabFeedReader.swift */; };
29+
03278EA42E1FEE2300995F34 /* GitLabResponseParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03278EA22E1FEE2300995F34 /* GitLabResponseParser.swift */; };
30+
03278EA52E1FEE2300995F34 /* GitLabAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03278EA02E1FEE2300995F34 /* GitLabAPI.swift */; };
2831
0329F89E25B0F0F10043FAB1 /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329F89D25B0F0F10043FAB1 /* AppearanceSettings.swift */; };
2932
0329F8E125B0F6880043FAB1 /* CCMenuApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0329F8E025B0F6880043FAB1 /* CCMenuApp.swift */; };
3033
0331F39F29A8155000245956 /* MenuExtraViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0331F39E29A8155000245956 /* MenuExtraViewModel.swift */; };
@@ -151,6 +154,9 @@
151154
0322053E2B9E601D00205DC6 /* PipelineWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipelineWindowTests.swift; sourceTree = "<group>"; };
152155
032205402B9E606600205DC6 /* CCTrayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCTrayTests.swift; sourceTree = "<group>"; };
153156
032205422B9E615800205DC6 /* GitHubTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubTests.swift; sourceTree = "<group>"; };
157+
03278EA02E1FEE2300995F34 /* GitLabAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabAPI.swift; sourceTree = "<group>"; };
158+
03278EA12E1FEE2300995F34 /* GitLabFeedReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabFeedReader.swift; sourceTree = "<group>"; };
159+
03278EA22E1FEE2300995F34 /* GitLabResponseParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabResponseParser.swift; sourceTree = "<group>"; };
154160
0329F89D25B0F0F10043FAB1 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = "<group>"; };
155161
0329F8E025B0F6880043FAB1 /* CCMenuApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCMenuApp.swift; sourceTree = "<group>"; };
156162
0331F39E29A8155000245956 /* MenuExtraViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuExtraViewModel.swift; sourceTree = "<group>"; };
@@ -545,6 +551,9 @@
545551
03D4BB02265EE5840023F4CB /* GitHubFeedReader.swift */,
546552
03D4BB04265EE6B50023F4CB /* GitHubResponseParser.swift */,
547553
03E6A9F62B2651C200284138 /* GitHubAPI.swift */,
554+
03278EA12E1FEE2300995F34 /* GitLabFeedReader.swift */,
555+
03278EA22E1FEE2300995F34 /* GitLabResponseParser.swift */,
556+
03278EA02E1FEE2300995F34 /* GitLabAPI.swift */,
548557
);
549558
path = "Server Monitor";
550559
sourceTree = "<group>";
@@ -791,6 +800,9 @@
791800
03F9B88E297C5D5B00FA866E /* CompactRelativeFormatStyle.swift in Sources */,
792801
03F9B896297D486C00FA866E /* Pipeline.Feed.swift in Sources */,
793802
03CC11F2265467C200130833 /* CCTrayResponseParser.swift in Sources */,
803+
03278EA32E1FEE2300995F34 /* GitLabFeedReader.swift in Sources */,
804+
03278EA42E1FEE2300995F34 /* GitLabResponseParser.swift in Sources */,
805+
03278EA52E1FEE2300995F34 /* GitLabAPI.swift in Sources */,
794806
0362EBF52B548C1D0079DEFE /* NotificationReceiver.swift in Sources */,
795807
03F6830B25C743F2005D56D9 /* PipelineListToolbar.swift in Sources */,
796808
03B263F82B51FF2100CA989A /* NotificationSender.swift in Sources */,

CCMenu/Source/Model/Pipeline.Feed.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ struct PipelineFeed: Codable, Equatable {
1111
enum FeedType: String, Codable {
1212
case
1313
cctray,
14-
github
14+
github,
15+
gitlab
1516
}
1617

1718
var type: FeedType

CCMenu/Source/Model/PipelineModel.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ final class PipelineModel: ObservableObject {
3737
timer = nil
3838
}
3939
}
40-
40+
4141
@discardableResult
4242
func add(pipeline newPipeline: Pipeline) -> Bool {
4343
if pipelines.contains(where: { $0.id == newPipeline.id }) {
@@ -66,9 +66,15 @@ final class PipelineModel: ObservableObject {
6666
pipelines = references.compactMap({ Pipeline(legacyReference: $0) })
6767
}
6868
else {
69+
// TODO: Change to use API class
6970
let url = URL(string: "https://api.github.com/repos/ccmenu/ccmenu2/actions/workflows/build-and-test.yaml/runs?branch=main")!
7071
pipelines = [ Pipeline(name: "ccmenu2 | build-and-test", feed: PipelineFeed(type: .github, url: url)) ]
7172
}
73+
#if DEBUG
74+
// TODO: Remove when GitLab UI exists
75+
let url = GitLabAPI.feedUrl(projectId: "66079563", branch: nil)
76+
#endif
77+
self.add(pipeline: Pipeline(name: "quvyn | build-and-test", feed: PipelineFeed(type: .gitlab, url: url)))
7278
// TODO: Remove before App Store release
7379
UserDefaults.active.removeObject(forKey: "GitHubToken")
7480
}
@@ -88,7 +94,7 @@ final class PipelineModel: ObservableObject {
8894
fatalError("Couldn't load pipelines from \(filename): \(error)")
8995
}
9096
}
91-
97+
9298
func importPipelinesFromFile(url: URL) -> Error? {
9399
do {
94100
let document = try PipelineDocument(url: url)
@@ -99,7 +105,7 @@ final class PipelineModel: ObservableObject {
99105
return error
100106
}
101107
}
102-
108+
103109
func exportPipelinesToDocument(selection: Set<String>) -> PipelineDocument {
104110
let pipelines = selection.isEmpty ? pipelines : pipelines.filter({ selection.contains($0.id )})
105111
return PipelineDocument(pipelines: pipelines)

CCMenu/Source/Server Monitor/GitHubAPI.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class GitHubAPI {
100100

101101
// MARK: - feed
102102

103+
// TODO: fix path overwriting bug (see GitLab implementation)
103104
static func feedUrl(owner: String, repository: String, workflow: String, branch: String?) -> URL {
104105
// see https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow
105106
var components = URLComponents(string: baseURL(forAPI: true))!
@@ -154,7 +155,7 @@ class GitHubAPI {
154155

155156
private static func makeRequest(method: String = "GET", baseUrl: String, path: String, params: Dictionary<String, String> = [:], token: String? = nil) -> URLRequest {
156157
var components = URLComponents(string: baseUrl)!
157-
components.path = path
158+
components.path = path // TODO: check for path overwriting issues
158159
components.queryItems = params.map({ URLQueryItem(name: $0.key, value: $0.value) })
159160
// TODO: Consider filtering token when the URL is overwritten via defaults
160161
return makeRequest(method: method, url: components.url!, token: token)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import Foundation
8+
import Combine
9+
import os
10+
11+
12+
class GitLabAPI {
13+
14+
// TODO: AI generated code - review
15+
static var clientId: String {
16+
if let defaultsId = UserDefaults.active.string(forKey: "GitLabClientID") {
17+
return defaultsId
18+
}
19+
return "" // Default client ID should be configured
20+
}
21+
22+
// MARK: - user, projects, pipelines, and branches
23+
24+
// TODO: AI generated code - review
25+
static func requestForUser(token: String) -> URLRequest {
26+
let path = "/user"
27+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, token: token)
28+
}
29+
30+
// TODO: AI generated code - review
31+
static func requestForAllProjects(token: String) -> URLRequest {
32+
let path = "/projects"
33+
let queryParams = [
34+
"membership": "true",
35+
"order_by": "last_activity_at",
36+
"per_page": "100",
37+
];
38+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token)
39+
}
40+
41+
// TODO: AI generated code - review
42+
static func requestForGroupProjects(group: String, token: String) -> URLRequest {
43+
let path = String(format: "/groups/%@/projects", group)
44+
let queryParams = [
45+
"order_by": "last_activity_at",
46+
"per_page": "100",
47+
];
48+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token)
49+
}
50+
51+
// TODO: AI generated code - review
52+
static func requestForUserProjects(user: String, token: String) -> URLRequest {
53+
let path = String(format: "/users/%@/projects", user)
54+
let queryParams = [
55+
"order_by": "last_activity_at",
56+
"per_page": "100",
57+
];
58+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token)
59+
}
60+
61+
// TODO: AI generated code - review
62+
static func requestForPipelines(projectId: String, token: String) -> URLRequest {
63+
let path = String(format: "/projects/%@/pipelines", projectId)
64+
let queryParams = [
65+
"per_page": "100",
66+
];
67+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token)
68+
}
69+
70+
// TODO: AI generated code - review
71+
static func requestForBranches(projectId: String, token: String) -> URLRequest {
72+
let path = String(format: "/projects/%@/repository/branches", projectId)
73+
let queryParams = [
74+
"per_page": "100",
75+
];
76+
return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token)
77+
}
78+
79+
80+
// MARK: - device flow and applications
81+
82+
// TODO: AI generated code - review
83+
static func requestForAccessToken(code: String, redirectUri: String) -> URLRequest {
84+
let path = "/oauth/token"
85+
let queryParams = [
86+
"client_id": clientId,
87+
"client_secret": "", // Client secret should be configured
88+
"code": code,
89+
"grant_type": "authorization_code",
90+
"redirect_uri": redirectUri
91+
];
92+
return makeRequest(method: "POST", baseUrl: baseURL(forAPI: false), path: path, params: queryParams)
93+
}
94+
95+
// TODO: AI generated code - review
96+
static func applicationsUrl() -> URL {
97+
URL(string: "\(baseURL(forAPI: false))/profile/applications")!
98+
}
99+
100+
101+
// MARK: - feed
102+
103+
static func feedUrl(projectId: String, branch: String?) -> URL {
104+
var url = URL(string: baseURL(forAPI: true))!
105+
url.append(path:"/projects/\(projectId)/pipelines")
106+
107+
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
108+
if let branch {
109+
components.appendQueryItem(URLQueryItem(name: "ref", value: branch))
110+
}
111+
return components.url!.absoluteURL
112+
}
113+
114+
static func requestForFeed(feed: PipelineFeed, token: String?) -> URLRequest? {
115+
guard var components = URLComponents(url: feed.url, resolvingAgainstBaseURL: true) else { return nil }
116+
components.appendQueryItem(URLQueryItem(name: "per_page", value: "3"))
117+
return makeRequest(url: components.url!.absoluteURL, token: token)
118+
}
119+
120+
121+
// MARK: - send requests
122+
123+
static func sendRequest<T>(request: URLRequest) async -> (T?, String) where T: Decodable {
124+
do {
125+
let (data, response) = try await URLSession.feedSession.data(for: request)
126+
guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) }
127+
if response.statusCode == 403 || response.statusCode == 429 {
128+
if let v = response.value(forHTTPHeaderField: "RateLimit-Remaining"), Int(v) == 0 {
129+
return (nil, "too many requests")
130+
} else {
131+
return (nil, HTTPURLResponse.localizedString(forStatusCode: response.statusCode))
132+
}
133+
}
134+
if response.statusCode != 200 {
135+
return (nil, HTTPURLResponse.localizedString(forStatusCode: response.statusCode))
136+
}
137+
return (try JSONDecoder().decode(T.self, from: data), "OK")
138+
} catch {
139+
return (nil, error.localizedDescription)
140+
}
141+
}
142+
143+
144+
145+
// MARK: - helper functions
146+
147+
static func baseURL(forAPI: Bool) -> String {
148+
let defaultsKey = forAPI ? "GitLabAPIBaseURL" : "GitLabBaseURL"
149+
if let defaultsBaseURL = UserDefaults.active.string(forKey: defaultsKey) {
150+
return defaultsBaseURL
151+
}
152+
return forAPI ? "https://gitlab.com/api/v4" : "https://github.com"
153+
}
154+
155+
private static func makeRequest(method: String = "GET", baseUrl: String, path: String, params: Dictionary<String, String> = [:], token: String? = nil) -> URLRequest {
156+
var components = URLComponents(string: baseUrl)!
157+
components.path = path // TODO: check for path overwriting issues
158+
components.queryItems = params.map({ URLQueryItem(name: $0.key, value: $0.value) })
159+
return makeRequest(method: method, url: components.url!, token: token)
160+
}
161+
162+
private static func makeRequest(method: String = "GET", url: URL, token: String?) -> URLRequest {
163+
var request = URLRequest(url: url)
164+
request.httpMethod = method
165+
// TODO: AI generated code - review
166+
if let token, !token.isEmpty {
167+
request.setValue(URLRequest.bearerAuthValue(token: token), forHTTPHeaderField: "Authorization")
168+
}
169+
170+
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "application")
171+
logger.trace("Request: \(method, privacy: .public) \(url.absoluteString, privacy: .public)")
172+
173+
return request
174+
}
175+
176+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import Foundation
8+
9+
enum GitLabFeedReaderError: LocalizedError {
10+
case invalidURLError
11+
case httpError(Int)
12+
case rateLimitError(Int)
13+
case noStatusError
14+
15+
var errorDescription: String? {
16+
switch self {
17+
case .invalidURLError:
18+
return NSLocalizedString("invalid URL", comment: "")
19+
case .httpError(let statusCode):
20+
return HTTPURLResponse.localizedString(forStatusCode: statusCode)
21+
case .rateLimitError(let timestamp):
22+
let date = Date(timeIntervalSince1970: Double(timestamp)).formatted(date: .omitted, time: .shortened)
23+
return String(format: NSLocalizedString("Rate limit exceeded, next update at %@.", comment: ""), date)
24+
case .noStatusError:
25+
return "No status available for this pipeline."
26+
}
27+
}
28+
}
29+
30+
31+
class GitLabFeedReader {
32+
33+
private(set) var pipeline: Pipeline
34+
35+
init(for pipeline: Pipeline) {
36+
self.pipeline = pipeline
37+
}
38+
39+
// TODO: AI generated code - review
40+
func updatePipelineStatus() async {
41+
do {
42+
let token = try Keychain.standard.getToken(forService: "GitLab")
43+
guard let request = GitLabAPI.requestForFeed(feed: pipeline.feed, token: token) else {
44+
throw GitLabFeedReaderError.invalidURLError
45+
}
46+
guard let newStatus = try await fetchStatus(request: request) else {
47+
throw GitLabFeedReaderError.noStatusError
48+
}
49+
pipeline.status = newStatus
50+
pipeline.connectionError = nil
51+
} catch {
52+
if let error = error as? GitLabFeedReaderError, case .rateLimitError(let pauseUntil) = error {
53+
pipeline.feed.setPauseUntil(pauseUntil, reason: error.localizedDescription)
54+
} else {
55+
pipeline.status = PipelineStatus(activity: .other)
56+
pipeline.connectionError = error.localizedDescription
57+
}
58+
}
59+
}
60+
61+
62+
private func fetchStatus(request: URLRequest) async throws -> PipelineStatus? {
63+
let (data, response) = try await URLSession.shared.data(for: request)
64+
guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) }
65+
if response.statusCode == 403 || response.statusCode == 429 {
66+
guard let v = response.value(forHTTPHeaderField: "RateLimit-Remaining"), Int(v) == 0 else {
67+
throw GitLabFeedReaderError.httpError(response.statusCode)
68+
}
69+
guard let v = response.value(forHTTPHeaderField: "RateLimit-Reset"), let pauseUntil = Int(v) else {
70+
throw GitLabFeedReaderError.httpError(response.statusCode)
71+
}
72+
throw GitLabFeedReaderError.rateLimitError(pauseUntil)
73+
}
74+
if response.statusCode != 200 {
75+
throw GitLabFeedReaderError.httpError(response.statusCode)
76+
}
77+
let parser = GitLabResponseParser()
78+
try parser.parseResponse(data)
79+
return parser.pipelineStatus(name: pipeline.name)
80+
}
81+
82+
}

0 commit comments

Comments
 (0)