import Foundation final class FeishuService { private let baseURL = "https://open.feishu.cn/open-apis" private let session: URLSession private let decoder: JSONDecoder init(session: URLSession = .shared) { self.session = session let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder = decoder } func fetchBugs( config: AppConfig, assigneeName: String, accessToken: String ) async throws -> [Bug] { var allRecords: [RecordItem] = [] var pageToken: String? var pages = 0 // 25,000 records at 500/page — guard against a runaway cursor that // would otherwise loop forever accumulating memory. let maxPages = 50 repeat { pages += 1 guard pages <= maxPages else { throw FeishuError.paginationLimitExceeded } let page = try await fetchPage( appToken: config.appToken, tableId: config.tableId, pageToken: pageToken, accessToken: accessToken ) allRecords.append(contentsOf: page.items) pageToken = page.hasMore ? page.pageToken : nil } while pageToken != nil return allRecords .map { BugMapper.map($0, config: config) } .filter { bug in bug.assignee.localizedCaseInsensitiveContains(assigneeName) } } func fetchRecordCount( appToken: String, tableId: String, accessToken: String ) async throws -> Int { let page = try await fetchPage( appToken: appToken, tableId: tableId, pageToken: nil, pageSize: 1, accessToken: accessToken ) return page.total ?? page.items.count } private func fetchPage( appToken: String, tableId: String, pageToken: String?, pageSize: Int = 500, accessToken: String ) async throws -> RecordListData { guard !appToken.isEmpty, !tableId.isEmpty else { throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") } guard var components = URLComponents( string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records" ) else { throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.") } var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))] if let pageToken { queryItems.append(URLQueryItem(name: "page_token", value: pageToken)) } components.queryItems = queryItems guard let url = components.url else { throw FeishuError.invalidConfiguration("Could not build records request URL.") } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("fetchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse: FeishuAPIResponse do { apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) } catch { BuggerLog.error("fetchPage: decode FAILED: \(error)") BuggerLog.error("fetchPage: raw body: \(String(rawBody.prefix(1000)))") throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200))) } guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return pageData } }