Skip to content

T-5: GitHubClient HTTP foundation — ETag, DTO, typed errors #219

@kirich1409

Description

@kirich1409

Description

Implement public struct GitHubClient: Sendable in GitHubIntegrationClient/GitHubClient.swift:

public init(token: String, session: URLSession = .shared)

Three MVP functions

1. Combined Status (trust-critical aggregate):

func getCombinedStatus(owner: String, repo: String, ref: String, etag: String?)
    async throws(GitHubAPIError) -> (GHCombinedStatus?, newETag: String?)

GET /repos/{o}/{r}/commits/{ref}/status

2. Workflow runs for branch:

func listWorkflowRuns(owner: String, repo: String, branch: String, etag: String?)
    async throws(GitHubAPIError) -> (GHPagedWorkflowRuns?, newETag: String?)

GET /repos/{o}/{r}/actions/runs?branch={branch}&per_page=10

3. Validate token:

func validateToken() async throws(GitHubAPIError) -> GHAuthInfo

GET /user + GET /rate_limit + parse X-OAuth-Scopes.

DTOs (Sendable + Codable + Equatable)

  • GHCombinedStatus { state: String, statuses: [GHStatusItem] } (state: success|pending|failure)
  • GHStatusItem { context, state, description, target_url }
  • GHPagedWorkflowRuns { total_count, workflow_runs: [GHWorkflowRun] }
  • GHWorkflowRun { id, name, status, conclusion, head_branch, html_url, workflow_id, created_at, pull_requests: [GHPullRequestStub] }
  • GHPullRequestStub { id, number, head_ref, html_url? }
  • GHAuthInfo { login, scopes: [String], rateLimit: GHRateLimit }
  • GHRateLimit { limit, remaining, resetAt: Date }

Typed errors

public enum GitHubAPIError: Error, Equatable, Sendable {
    case unauthorized
    case forbidden(reason: ForbidReason)
    case notFound
    case rateLimited(resetAt: Date)
    case networkError(URLError)
    case decodingFailed(description: String)
    case serverError(statusCode: Int)
}
public enum ForbidReason: Equatable, Sendable { case rateLimit, saml, scope, other }

Helper

func perform<T: Decodable>(path:, etag:, decodeAs: T.Type)
    async throws(GitHubAPIError) -> (T?, String?)

Headers (every request): Authorization: Bearer <token> (NEVER logged), Accept: application/vnd.github+json, X-GitHub-Api-Version: 2026-03-10, User-Agent: Relay/<bundleVersion>, If-None-Match: <etag> when present.

HTTP mapping: 200 → decode+ETag; 304 → (nil, requestETag); 401 → unauthorized; 403 inspect headers (ratelimit-remaining=0 → rateLimit; x-github-sso or SAML in body → saml; else scope/other); 404 → notFound; 429 → rateLimited with x-ratelimit-reset; 5xx → serverError; malformed JSON → decodingFailed.

Spec reference

See swarm-report/github-integration-decomposition.md#t-5 + research (A1 REST+ETag).

Relationships

Acceptance criteria

Unit tests via URLProtocol mock:

  • 200 with ETag → (decoded, newETag)
  • 304 → (nil, previousETag)
  • 401 → .unauthorized
  • 403 with x-ratelimit-remaining: 0.forbidden(.rateLimit)
  • 403 with x-github-sso.forbidden(.saml)
  • 403 generic → .forbidden(.scope) or .forbidden(.other)
  • 429 with x-ratelimit-reset: <epoch>.rateLimited(resetAt) with correct Date
  • 500/502 → .serverError(status)
  • URLError.notConnectedToInternet.networkError(...)
  • Malformed JSON (200 broken body) → .decodingFailed
  • Swift 6 strict concurrency — zero warnings
  • Authorization header NOT present in any logger output
  • GitHubAPIError: Equatable works (used by T-8a TestStore)

Complexity

L

Suggested agent

developer-workflow:swift-engineer

Module / Layer

GitHubIntegrationClient / Infrastructure (HTTP)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions