diff options
Diffstat (limited to 'Hutch/Networking')
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 469 |
1 files changed, 469 insertions, 0 deletions
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift index 91dc339..10af2fb 100644 --- a/Hutch/Networking/ProjectService.swift +++ b/Hutch/Networking/ProjectService.swift @@ -198,6 +198,170 @@ private struct ProjectTrackerPayload: Decodable, Sendable { } } +/// A public project surfaced by discovery, carrying its owner for display. +struct DiscoveredProject: Identifiable, Hashable, Sendable { + let project: Project + let ownerCanonicalName: String + + var id: String { project.id } +} + +struct DiscoveredProjectsPage: Sendable { + let projects: [DiscoveredProject] + let cursor: String? +} + +private struct PublicProjectsResponse: Decodable, Sendable { + let projects: PublicProjectPage +} + +private struct PublicProjectPage: Decodable, Sendable { + let results: [PublicProjectPayload] + let cursor: String? + + init(from decoder: any Decoder) throws { + enum CodingKeys: String, CodingKey { case results, cursor } + let container = try decoder.container(keyedBy: CodingKeys.self) + results = try container.decodeIfPresent([PublicProjectPayload].self, forKey: .results) ?? [] + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } +} + +private struct PublicProjectPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let updated: Date + let owner: Entity + + init(from decoder: any Decoder) throws { + enum CodingKeys: String, CodingKey { + case rid, name, description, website, visibility, tags, updated, owner + } + let container = try decoder.container(keyedBy: CodingKeys.self) + rid = try container.decode(String.self, forKey: .rid) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + description = try container.decodeIfPresent(String.self, forKey: .description) + website = try container.decodeIfPresent(String.self, forKey: .website) + visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast + owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown") + } +} + +/// A resource the current user can link to a project (#15 add flow). +struct LinkableResource: Identifiable, Hashable, Sendable { + enum Kind: Sendable { case source, tracker, mailingList } + + let rid: String + let name: String + let ownerCanonicalName: String + let kind: Kind + + var id: String { rid } + var displayName: String { "\(ownerCanonicalName)/\(name)" } +} + +private struct CandidatePayload: Decodable, Sendable { + let rid: String + let name: String + let owner: Entity? +} + +private struct CandidatePage: Decodable, Sendable { + let results: [CandidatePayload] + let cursor: String? + + init(from decoder: any Decoder) throws { + enum CodingKeys: String, CodingKey { case results, cursor } + let container = try decoder.container(keyedBy: CodingKeys.self) + results = try container.decodeIfPresent([CandidatePayload].self, forKey: .results) ?? [] + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } +} + +private struct RepoCandidatesResponse: Decodable, Sendable { + let repositories: CandidatePage +} + +private struct TrackerCandidatesResponse: Decodable, Sendable { + let trackers: CandidatePage +} + +private struct ListSubscriptionPayload: Decodable, Sendable { + let list: CandidatePayload? +} + +private struct ListSubscriptionPage: Decodable, Sendable { + let results: [ListSubscriptionPayload] + let cursor: String? + + init(from decoder: any Decoder) throws { + enum CodingKeys: String, CodingKey { case results, cursor } + let container = try decoder.container(keyedBy: CodingKeys.self) + results = try container.decodeIfPresent([ListSubscriptionPayload].self, forKey: .results) ?? [] + cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } +} + +private struct ListCandidatesResponse: Decodable, Sendable { + let subscriptions: ListSubscriptionPage +} + +/// Link/unlink mutations only need success; the returned resource is ignored. +private struct LinkMutationResponse: Decodable, Sendable {} + +private struct CreateProjectResponse: Decodable, Sendable { + let createProject: MutatedProjectPayload? +} + +private struct UpdateProjectResponse: Decodable, Sendable { + let updateProject: MutatedProjectPayload? +} + +private struct MutatedProjectPayload: Decodable, Sendable { + let rid: String + let name: String + let description: String? + let website: String? + let visibility: Visibility + let tags: [String] + let updated: Date + + init(from decoder: any Decoder) throws { + enum CodingKeys: String, CodingKey { + case rid, name, description, website, visibility, tags, updated + } + let container = try decoder.container(keyedBy: CodingKeys.self) + rid = try container.decode(String.self, forKey: .rid) + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + description = try container.decodeIfPresent(String.self, forKey: .description) + website = try container.decodeIfPresent(String.self, forKey: .website) + visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast + } + + var project: Project { + Project( + metadata: .init( + id: rid, + name: name, + description: description, + website: website, + visibility: visibility, + tags: tags, + updated: updated + ), + resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) + ) + } +} + struct ProjectService: Sendable { private let client: SRHTClient @@ -265,6 +429,91 @@ struct ProjectService: Sendable { } """ + private static let publicProjectsQuery = """ + query publicProjects($cursor: Cursor) { + projects(cursor: $cursor) { + results { + rid + name + description + website + visibility + tags + updated + owner { canonicalName } + } + cursor + } + } + """ + + private static let createProjectMutation = """ + mutation createProject($name: String!, $visibility: Visibility!, $description: String, $tags: [String!]) { + createProject(name: $name, visibility: $visibility, description: $description, tags: $tags) { + rid + name + description + website + visibility + tags + updated + } + } + """ + + private static let updateProjectMutation = """ + mutation updateProject($rid: ID!, $input: ProjectInput!) { + updateProject(rid: $rid, input: $input) { + rid + name + description + website + visibility + tags + updated + } + } + """ + + private static func linkMutation(field: String, resourceParam: String) -> String { + """ + mutation link($projectID: ID!, $resourceID: ID!) { + \(field)(projectID: $projectID, \(resourceParam): $resourceID) { rid } + } + """ + } + + private static let repositoriesCandidatesQuery = """ + query repositories($cursor: Cursor) { + repositories(cursor: $cursor) { + results { rid name owner { canonicalName } } + cursor + } + } + """ + + private static let trackersCandidatesQuery = """ + query trackers($cursor: Cursor) { + trackers(cursor: $cursor) { + results { rid name owner { canonicalName } } + cursor + } + } + """ + + private static let listCandidatesQuery = """ + query subscriptions($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { + list { rid name owner { canonicalName } } + } + } + cursor + } + } + """ + init(client: SRHTClient) { self.client = client } @@ -277,6 +526,226 @@ struct ProjectService: Sendable { try await fetchProjectDetailPayload(rid: rid) } + // MARK: - Discovery (#12) + + /// Lists public projects across all users. Not cached — discovery is + /// browsed live and paginated by the caller. + func fetchPublicProjects(cursor: String? = nil) async throws -> DiscoveredProjectsPage { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .hub, + query: Self.publicProjectsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: PublicProjectsResponse.self + ) + + let projects = response.projects.results.map { payload in + DiscoveredProject( + project: Project( + metadata: .init( + id: payload.rid, + name: payload.name, + description: payload.description, + website: payload.website, + visibility: payload.visibility, + tags: payload.tags, + updated: payload.updated + ), + resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) + ), + ownerCanonicalName: payload.owner.canonicalName + ) + } + return DiscoveredProjectsPage(projects: projects, cursor: response.projects.cursor) + } + + // MARK: - Mutations (#13, #14, #15) + + func createProject( + name: String, + visibility: Visibility, + description: String?, + tags: [String] + ) async throws -> Project { + var variables: [String: any Sendable] = ["name": name, "visibility": visibility.rawValue] + if let description, !description.isEmpty { + variables["description"] = description + } + if !tags.isEmpty { + variables["tags"] = tags + } + + let response = try await client.execute( + service: .hub, + query: Self.createProjectMutation, + variables: variables, + responseType: CreateProjectResponse.self + ) + await invalidateProjectCaches() + + guard let payload = response.createProject else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing createProject payload")) + ) + } + return payload.project + } + + /// Updates a project. Only non-nil fields are sent; `description`/`website` + /// pass an empty string to clear the field. + func updateProject( + rid: String, + name: String? = nil, + description: String? = nil, + website: String? = nil, + visibility: Visibility? = nil, + tags: [String]? = nil + ) async throws -> Project { + var input: [String: any Sendable] = [:] + if let name { + input["name"] = name + } + if let description { + input["description"] = description + } + if let website { + input["website"] = website + } + if let visibility { + input["visibility"] = visibility.rawValue + } + if let tags { + input["tags"] = tags + } + + let response = try await client.execute( + service: .hub, + query: Self.updateProjectMutation, + variables: ["rid": rid, "input": input], + responseType: UpdateProjectResponse.self + ) + await invalidateProjectCaches() + + guard let payload = response.updateProject else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing updateProject payload")) + ) + } + return payload.project + } + + func linkSource(projectID: String, sourceRepoID: String) async throws { + try await runLink(field: "linkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID) + } + + func unlinkSource(projectID: String, sourceRepoID: String) async throws { + try await runLink(field: "unlinkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID) + } + + func linkTracker(projectID: String, trackerID: String) async throws { + try await runLink(field: "linkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID) + } + + func unlinkTracker(projectID: String, trackerID: String) async throws { + try await runLink(field: "unlinkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID) + } + + func linkMailingList(projectID: String, listID: String) async throws { + try await runLink(field: "linkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID) + } + + func unlinkMailingList(projectID: String, listID: String) async throws { + try await runLink(field: "unlinkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID) + } + + private func runLink(field: String, resourceParam: String, projectID: String, resourceID: String) async throws { + _ = try await client.execute( + service: .hub, + query: Self.linkMutation(field: field, resourceParam: resourceParam), + variables: ["projectID": projectID, "resourceID": resourceID], + responseType: LinkMutationResponse.self + ) + await invalidateProjectCaches() + } + + private func invalidateProjectCaches() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.hub.rawValue)) + } + + // MARK: - Linkable resource candidates (#15 add flow) + + /// Repositories the user can link (git and hg), sorted by display name. + func fetchLinkableSources() async throws -> [LinkableResource] { + async let git = fetchRepoCandidates(service: .git) + async let hg = fetchRepoCandidates(service: .hg) + return dedupeSorted(try await git + (try await hg)) + } + + func fetchLinkableTrackers() async throws -> [LinkableResource] { + var results: [LinkableResource] = [] + var cursor: String? + repeat { + let response = try await client.execute( + service: .todo, + query: Self.trackersCandidatesQuery, + variables: cursor.map { ["cursor": $0] }, + responseType: TrackerCandidatesResponse.self + ) + results.append(contentsOf: response.trackers.results.map { + LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .tracker) + }) + cursor = response.trackers.cursor + } while cursor != nil + return dedupeSorted(results) + } + + func fetchLinkableMailingLists() async throws -> [LinkableResource] { + var results: [LinkableResource] = [] + var cursor: String? + repeat { + let response = try await client.execute( + service: .lists, + query: Self.listCandidatesQuery, + variables: cursor.map { ["cursor": $0] }, + responseType: ListCandidatesResponse.self + ) + results.append(contentsOf: response.subscriptions.results.compactMap(\.list).map { + LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .mailingList) + }) + cursor = response.subscriptions.cursor + } while cursor != nil + return dedupeSorted(results) + } + + private func fetchRepoCandidates(service: SRHTService) async throws -> [LinkableResource] { + var results: [LinkableResource] = [] + var cursor: String? + repeat { + let response = try await client.execute( + service: service, + query: Self.repositoriesCandidatesQuery, + variables: cursor.map { ["cursor": $0] }, + responseType: RepoCandidatesResponse.self + ) + results.append(contentsOf: response.repositories.results.map { + LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .source) + }) + cursor = response.repositories.cursor + } while cursor != nil + return results + } + + private func dedupeSorted(_ items: [LinkableResource]) -> [LinkableResource] { + var seen = Set<String>() + return items + .filter { seen.insert($0.rid).inserted } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] { var results: [ProjectSummaryPayload] = [] var cursor: String? |
