diff --git a/CCMenu.xcodeproj/project.pbxproj b/CCMenu.xcodeproj/project.pbxproj index a3eeeca..a55d81f 100644 --- a/CCMenu.xcodeproj/project.pbxproj +++ b/CCMenu.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ 038FF7162BB62E6D0017CD4C /* GitHubReposByUserCCM2OnlyResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 038FF7152BB62E6D0017CD4C /* GitHubReposByUserCCM2OnlyResponse.json */; }; 038FF7182BB631CB0017CD4C /* URLComponentsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038FF7172BB631CB0017CD4C /* URLComponentsExtension.swift */; }; 039B524629676D0700994910 /* Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039B524529676D0700994910 /* Build.swift */; }; + 03A3EF932D3459D400407A6F /* GitHubUserResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 03A3EF922D3459D400407A6F /* GitHubUserResponse.json */; }; + 03A3EF942D3459D400407A6F /* GitHubUserOrgResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 03A3EF912D3459D400407A6F /* GitHubUserOrgResponse.json */; }; 03B021252A5D9F9D00B889BE /* MenuExtraModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B021242A5D9F9D00B889BE /* MenuExtraModelTests.swift */; }; 03B1D75F2A6079CD007BCB8A /* MenuItemModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B1D75E2A6079CD007BCB8A /* MenuItemModelTests.swift */; }; 03B1D7612A607DD0007BCB8A /* PipelineRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B1D7602A607DD0007BCB8A /* PipelineRowViewModel.swift */; }; @@ -178,6 +180,8 @@ 038FF7152BB62E6D0017CD4C /* GitHubReposByUserCCM2OnlyResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = GitHubReposByUserCCM2OnlyResponse.json; sourceTree = ""; }; 038FF7172BB631CB0017CD4C /* URLComponentsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtension.swift; sourceTree = ""; }; 039B524529676D0700994910 /* Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Build.swift; sourceTree = ""; }; + 03A3EF912D3459D400407A6F /* GitHubUserOrgResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = GitHubUserOrgResponse.json; sourceTree = ""; }; + 03A3EF922D3459D400407A6F /* GitHubUserResponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = GitHubUserResponse.json; sourceTree = ""; }; 03B021242A5D9F9D00B889BE /* MenuExtraModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuExtraModelTests.swift; sourceTree = ""; }; 03B1D75E2A6079CD007BCB8A /* MenuItemModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemModelTests.swift; sourceTree = ""; }; 03B1D7602A607DD0007BCB8A /* PipelineRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipelineRowViewModel.swift; sourceTree = ""; }; @@ -294,6 +298,8 @@ 0322051F2B913BC900205DC6 /* Responses */ = { isa = PBXGroup; children = ( + 03A3EF912D3459D400407A6F /* GitHubUserOrgResponse.json */, + 03A3EF922D3459D400407A6F /* GitHubUserResponse.json */, 032205222B913CC000205DC6 /* GitHubWorkflowRunsResponse.json */, 032205242B913D4000205DC6 /* GitHubReposByUserResponse.json */, 038FF7152BB62E6D0017CD4C /* GitHubReposByUserCCM2OnlyResponse.json */, @@ -711,6 +717,8 @@ 032205212B913C4E00205DC6 /* GitHubWorkflowsResponse.json in Resources */, 032205292B96677200205DC6 /* GitHubUserReposResponse.json in Resources */, 0322052B2B96708200205DC6 /* GitHubPipelineLocalhost.json in Resources */, + 03A3EF932D3459D400407A6F /* GitHubUserResponse.json in Resources */, + 03A3EF942D3459D400407A6F /* GitHubUserOrgResponse.json in Resources */, 032205252B913D4000205DC6 /* GitHubReposByUserResponse.json in Resources */, 038FF7162BB62E6D0017CD4C /* GitHubReposByUserCCM2OnlyResponse.json in Resources */, 03825EA5259FFD1500DEB003 /* DefaultPipelines.json in Resources */, diff --git a/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubRepositoryList.swift b/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubRepositoryList.swift index 0b6b4cb..d501ee5 100644 --- a/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubRepositoryList.swift +++ b/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubRepositoryList.swift @@ -17,14 +17,25 @@ class GitHubRepositoryList: ObservableObject { } private func fetchRepositories(owner: String, token: String?) async -> [GitHubRepository] { - let ownerRepoRequest = GitHubAPI.requestForRepositories(owner: owner, token: token) + let userRequest = GitHubAPI.requestForUser(user: owner, token: token) + let (user, error) = await fetchUser(request: userRequest) + guard let user else { + return [GitHubRepository(message: error)] + } + + let ownerRepoRequest: URLRequest + if user.isOrganization { + ownerRepoRequest = GitHubAPI.requestForAllRepositories(org: owner, token: token) + } else { + ownerRepoRequest = GitHubAPI.requestForAllPublicRepositories(user: owner, token: token) + } var allRepos = await fetchRepositories(request: ownerRepoRequest) if allRepos.count > 0 && !allRepos[0].isValid { return allRepos } - if let token, !token.isEmpty { - let privateRepoRequest = GitHubAPI.requestForPrivateRepositories(token: token) + if !user.isOrganization, let token, !token.isEmpty { + let privateRepoRequest = GitHubAPI.requestForAllPrivateRepositories(token: token) let privateRepos = await fetchRepositories(request: privateRepoRequest) if privateRepos.count > 0 && !privateRepos[0].isValid { return privateRepos @@ -41,6 +52,28 @@ class GitHubRepositoryList: ObservableObject { return allRepos } + private func fetchUser(request: URLRequest) async -> (GitHubUser?, String) { + do { + let (data, response) = try await URLSession.feedSession.data(for: request) + guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) } + // TODO: Somehow refactor this to use the same code as fetchRepositories + if response.statusCode == 403 || response.statusCode == 429 { + if let v = response.value(forHTTPHeaderField: "x-ratelimit-remaining"), Int(v) == 0 { + // HTTPURLResponse doesn't have a specific message for code 429 + return (nil, "too many requests") + } else { + return (nil, HTTPURLResponse.localizedString(forStatusCode: response.statusCode)) + } + } + if response.statusCode != 200 { + return (nil, HTTPURLResponse.localizedString(forStatusCode: response.statusCode)) + } + return (try JSONDecoder().decode(GitHubUser.self, from: data), "OK") + } catch { + return (nil, error.localizedDescription) + } + } + private func fetchRepositories(request: URLRequest) async -> [GitHubRepository] { do { let (data, response) = try await URLSession.feedSession.data(for: request) diff --git a/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubSheetModel.swift b/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubSheetModel.swift index 7441061..6f069f3 100644 --- a/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubSheetModel.swift +++ b/CCMenu/Source/Pipeline Window/GitHub Sheets/GitHubSheetModel.swift @@ -7,6 +7,16 @@ import Foundation +struct GitHubUser: Identifiable, Decodable { + var id: Int + var type: String + + var isOrganization: Bool { + return type == "Organization" + } +} + + struct GitHubRepository: Identifiable, Hashable, Decodable { var id: Int diff --git a/CCMenu/Source/Server Monitor/GitHubAPI.swift b/CCMenu/Source/Server Monitor/GitHubAPI.swift index b461145..c8b39e2 100644 --- a/CCMenu/Source/Server Monitor/GitHubAPI.swift +++ b/CCMenu/Source/Server Monitor/GitHubAPI.swift @@ -18,10 +18,25 @@ class GitHubAPI { return "4eafcf49451c588fbeac" } - // MARK: - repositories, workflows, and branches + // MARK: - user, repositories, workflows, and branches - static func requestForRepositories(owner: String, token: String?) -> URLRequest { - let path = String(format: "/users/%@/repos", owner) + static func requestForUser(user: String, token: String?) -> URLRequest { + let path = String(format: "/users/%@", user) + return makeRequest(baseUrl: baseURL(forAPI: true), path: path, token: token) + } + + static func requestForAllPublicRepositories(user: String, token: String?) -> URLRequest { + let path = String(format: "/users/%@/repos", user) + let queryParams = [ + "type": "all", + "sort": "pushed", + "per_page": "100", + ]; + return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token) + } + + static func requestForAllRepositories(org: String, token: String?) -> URLRequest { + let path = String(format: "/orgs/%@/repos", org) let queryParams = [ "type": "all", "sort": "pushed", @@ -30,7 +45,7 @@ class GitHubAPI { return makeRequest(baseUrl: baseURL(forAPI: true), path: path, params: queryParams, token: token) } - static func requestForPrivateRepositories(token: String) -> URLRequest { + static func requestForAllPrivateRepositories(token: String) -> URLRequest { let path = String(format: "/user/repos") let queryParams = [ "type": "private", @@ -112,7 +127,7 @@ class GitHubAPI { return forAPI ? "https://api.github.com" : "https://github.com" } - private static func makeRequest(method: String = "GET", baseUrl: String, path: String, params: Dictionary, token: String? = nil) -> URLRequest { + private static func makeRequest(method: String = "GET", baseUrl: String, path: String, params: Dictionary = [:], token: String? = nil) -> URLRequest { var components = URLComponents(string: baseUrl)! components.path = path components.queryItems = params.map({ URLQueryItem(name: $0.key, value: $0.value) }) diff --git a/CCMenuUITests/GitHubTests.swift b/CCMenuUITests/GitHubTests.swift index 9adbd0a..ad58d3a 100644 --- a/CCMenuUITests/GitHubTests.swift +++ b/CCMenuUITests/GitHubTests.swift @@ -81,6 +81,9 @@ class GitHubTests: XCTestCase { } func testAddsGitHubPipeline() throws { + webapp.router.get("/users/erikdoe") { _ in + try TestHelper.contentsOfFile("GitHubUserResponse.json") + } webapp.router.get("/users/erikdoe/repos") { _ in try TestHelper.contentsOfFile("GitHubReposByUserCCM2OnlyResponse.json") } @@ -130,6 +133,9 @@ class GitHubTests: XCTestCase { } func testAddsGitHubPipelineByIdIfNeccessary() throws { + webapp.router.get("/users/erikdoe") { _ in + try TestHelper.contentsOfFile("GitHubUserResponse.json") + } webapp.router.get("/users/erikdoe/repos") { _ in try TestHelper.contentsOfFile("GitHubReposByUserCCM2OnlyResponse.json") } @@ -177,6 +183,9 @@ class GitHubTests: XCTestCase { func testAddsGitHubPipelineWithBranch() throws { var branchParam: String? + webapp.router.get("/users/erikdoe") { _ in + try TestHelper.contentsOfFile("GitHubUserResponse.json") + } webapp.router.get("/users/erikdoe/repos") { _ in try TestHelper.contentsOfFile("GitHubReposByUserCCM2OnlyResponse.json") } @@ -223,6 +232,9 @@ class GitHubTests: XCTestCase { } func testAddGitHubPipelinePrivateRepos() throws { + webapp.router.get("/users/erikdoe") { _ in + try TestHelper.contentsOfFile("GitHubUserResponse.json") + } webapp.router.get("/users/erikdoe/repos") { _ in try TestHelper.contentsOfFile("GitHubReposByUserResponse.json") } @@ -263,7 +275,7 @@ class GitHubTests: XCTestCase { } func testShowsRateLimitExceededForRepositories() throws { - webapp.router.get("/users/erikdoe/repos", options: .editResponse) { r -> String in + webapp.router.get("/users/erikdoe", options: .editResponse) { r -> String in r.response.status = .forbidden r.response.headers.replaceOrAdd(name: "x-ratelimit-remaining", value: "0") return "{ \"message\": \"API rate limit exceeded for ...\" } " @@ -289,6 +301,9 @@ class GitHubTests: XCTestCase { func testDoesntDoubleFetchRepositories() throws { var fetchCount = 0 + webapp.router.get("/users/erikdoe") { _ in + try TestHelper.contentsOfFile("GitHubUserResponse.json") + } webapp.router.get("/users/erikdoe/repos") { _ in fetchCount += 1 return try TestHelper.contentsOfFile("GitHubReposByUserResponse.json") @@ -318,7 +333,6 @@ class GitHubTests: XCTestCase { // Assert that no further fetch occured XCTAssertEqual(1, fetchCount) - } } diff --git a/CCMenuUITests/Responses/GitHubUserOrgResponse.json b/CCMenuUITests/Responses/GitHubUserOrgResponse.json new file mode 100644 index 0000000..6428e2f --- /dev/null +++ b/CCMenuUITests/Responses/GitHubUserOrgResponse.json @@ -0,0 +1,35 @@ +{ + "login": "thoughtworks", + "id": 18878, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjE4ODc4", + "avatar_url": "https://avatars.githubusercontent.com/u/18878?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/thoughtworks", + "html_url": "https://github.com/thoughtworks", + "followers_url": "https://api.github.com/users/thoughtworks/followers", + "following_url": "https://api.github.com/users/thoughtworks/following{/other_user}", + "gists_url": "https://api.github.com/users/thoughtworks/gists{/gist_id}", + "starred_url": "https://api.github.com/users/thoughtworks/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/thoughtworks/subscriptions", + "organizations_url": "https://api.github.com/users/thoughtworks/orgs", + "repos_url": "https://api.github.com/users/thoughtworks/repos", + "events_url": "https://api.github.com/users/thoughtworks/events{/privacy}", + "received_events_url": "https://api.github.com/users/thoughtworks/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false, + "name": "Thoughtworks", + "company": null, + "blog": "https://thoughtworks.com", + "location": "Global", + "email": null, + "hireable": null, + "bio": "We're a leading global technology consultancy that integrates strategy, design and software engineering to enable our clients to thrive. ", + "twitter_username": "thoughtworks", + "public_repos": 67, + "public_gists": 0, + "followers": 581, + "following": 0, + "created_at": "2008-07-29T21:54:09Z", + "updated_at": "2024-11-25T18:40:42Z" +} diff --git a/CCMenuUITests/Responses/GitHubUserResponse.json b/CCMenuUITests/Responses/GitHubUserResponse.json new file mode 100644 index 0000000..4ea5d7c --- /dev/null +++ b/CCMenuUITests/Responses/GitHubUserResponse.json @@ -0,0 +1,35 @@ +{ + "login": "erikdoe", + "id": 954026, + "node_id": "MDQ6VXNlcjk1NDAyNg==", + "avatar_url": "https://avatars.githubusercontent.com/u/954026?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/erikdoe", + "html_url": "https://github.com/erikdoe", + "followers_url": "https://api.github.com/users/erikdoe/followers", + "following_url": "https://api.github.com/users/erikdoe/following{/other_user}", + "gists_url": "https://api.github.com/users/erikdoe/gists{/gist_id}", + "starred_url": "https://api.github.com/users/erikdoe/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/erikdoe/subscriptions", + "organizations_url": "https://api.github.com/users/erikdoe/orgs", + "repos_url": "https://api.github.com/users/erikdoe/repos", + "events_url": "https://api.github.com/users/erikdoe/events{/privacy}", + "received_events_url": "https://api.github.com/users/erikdoe/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false, + "name": "Erik Doernenburg", + "company": "Thoughtworks", + "blog": "http://erik.doernenburg.com", + "location": "Hamburg, Germany", + "email": null, + "hireable": null, + "bio": null, + "twitter_username": null, + "public_repos": 21, + "public_gists": 6, + "followers": 266, + "following": 0, + "created_at": "2011-08-02T12:34:47Z", + "updated_at": "2025-01-10T21:12:36Z" +}