@@ -142,13 +142,7 @@ struct RemotePipeline: Identifiable, Decodable {
142142 let updatedAt : Date ?
143143 let startedAt : Date ?
144144
145- var state : PipelineState {
146- switch status . lowercased ( ) {
147- case " success " , " passed " , " completed " : return . success
148- case " running " , " in_progress " , " in progress " , " pending " , " queued " : return . running
149- default : return . failed
150- }
151- }
145+ var state : PipelineState { PipelineState ( remoteStatus : status ) }
152146}
153147
154148struct RemoteJob : Identifiable , Decodable {
@@ -159,13 +153,8 @@ struct RemoteJob: Identifiable, Decodable {
159153 let duration : String
160154 let log : String
161155
162- var state : PipelineState {
163- switch status . lowercased ( ) {
164- case " success " , " passed " , " completed " : return . success
165- case " running " , " in_progress " , " pending " , " queued " : return . running
166- default : return . failed
167- }
168- }
156+ var stageOrder : Int ? = nil
157+ var state : PipelineState { PipelineState ( remoteStatus : status ) }
169158}
170159
171160// MARK: - GitHub REST
@@ -482,12 +471,65 @@ final class GitLabAPIClient {
482471 return response . map { makeRemotePipeline ( $0 , prefix : prefix ) }
483472 }
484473
485- func jobs ( projectID : String , pipelineID : String ) async throws -> [ RemoteJob ] {
474+ func jobs ( projectID : String , pipelineID : String , repository : String = " " ) async throws -> [ RemoteJob ] {
486475 let encoded = apiProjectID ( projectID )
487476 let url = try makeURL ( path : " /api/v4/projects/ \( encoded ) /pipelines/ \( pipelineID ) /jobs " , query : [ URLQueryItem ( name : " per_page " , value : " 100 " ) ] )
488477 let response : [ GitLabJob ] = try await send ( url )
489- return response . map {
490- RemoteJob ( id : " gitlab-job- \( $0 . id ) " , name : $0 . name , stage : $0 . stage , status : $0 . status , duration : CIExecutionTimeFormatter . duration ( seconds : $0 . duration ) , log : " " )
478+ // REST jobs are newest first, not in pipeline stage order. Obtain the
479+ // declared stages once per pipeline; job retries can have newer IDs.
480+ let order = Set ( response . map ( \. stage ) ) . count > 1 && ! repository . isEmpty
481+ ? await pipelineStageOrder ( repository : repository , pipelineID : pipelineID ) : [ ]
482+ try Task . checkCancellation ( )
483+ let firstIDs = Dictionary ( grouping : response , by : \. stage ) . mapValues { $0 . map ( \. id ) . min ( ) ?? Int . max }
484+ return response . sorted { left , right in
485+ if left . stage != right . stage {
486+ let leftOrder = order . firstIndex ( of : left . stage ) ?? Int . max
487+ let rightOrder = order . firstIndex ( of : right . stage ) ?? Int . max
488+ if leftOrder != rightOrder { return leftOrder < rightOrder }
489+ return ( firstIDs [ left . stage ] ?? 0 ) < ( firstIDs [ right . stage ] ?? 0 )
490+ }
491+ let nameOrder = left . name . localizedStandardCompare ( right . name )
492+ return nameOrder == . orderedSame ? left . id < right . id : nameOrder == . orderedAscending
493+ } . map {
494+ RemoteJob ( id : " gitlab-job- \( $0 . id ) " , name : $0 . name , stage : $0 . stage , status : $0 . status ,
495+ duration : CIExecutionTimeFormatter . duration ( seconds : $0 . duration ) , log : " " , stageOrder : order . firstIndex ( of : $0 . stage ) )
496+ }
497+ }
498+
499+ private static let stageOrders : NSCache < NSString , GitLabStageOrder > = {
500+ let cache = NSCache < NSString , GitLabStageOrder > ( )
501+ cache . countLimit = 256
502+ return cache
503+ } ( )
504+
505+ private func pipelineStageOrder ( repository : String , pipelineID : String ) async -> [ String ] {
506+ let key = " \( baseURL . absoluteString ) | \( repository ) | \( pipelineID ) " as NSString
507+ if let cached = Self . stageOrders . object ( forKey : key ) , cached . expiresAt > Date ( ) { return cached . names }
508+ do {
509+ let variables = try JSONSerialization . data ( withJSONObject : [
510+ " project " : repository , " pipeline " : " gid://gitlab/Ci::Pipeline/ \( pipelineID ) "
511+ ] )
512+ let query = """
513+ query PipelineStages($project: ID!, $pipeline: CiPipelineID!) {
514+ project(fullPath: $project) { pipeline(id: $pipeline) { stages(first: 100) { nodes { name } } } }
515+ }
516+ """
517+ let url = try makeURL ( path : " /api/graphql " , query : [
518+ URLQueryItem ( name : " query " , value : query ) ,
519+ URLQueryItem ( name : " variables " , value : String ( decoding : variables , as : UTF8 . self ) )
520+ ] )
521+ let response : GitLabStageOrderResponse = try await send ( url )
522+ let names = response . data ? . project ? . pipeline ? . stages . nodes . compactMap ( \. name ) ?? [ ]
523+ if ! Task . isCancelled {
524+ Self . stageOrders . setObject ( GitLabStageOrder ( names : names ) , forKey : key )
525+ }
526+ return names
527+ } catch {
528+ // Ordering metadata is optional on older/self-hosted instances.
529+ // Preserve job results and back off rather than failing refresh or
530+ // issuing this extra request every round when GraphQL is disabled.
531+ if ! Task . isCancelled { Self . stageOrders . setObject ( GitLabStageOrder ( names : [ ] ) , forKey : key ) }
532+ return [ ]
491533 }
492534 }
493535
@@ -607,3 +649,29 @@ private struct GitLabJob: Decodable {
607649 let status : String
608650 let duration : Double ?
609651}
652+
653+ private final class GitLabStageOrder {
654+ let names : [ String ]
655+ let expiresAt : Date
656+ init ( names : [ String ] ) {
657+ self . names = names
658+ expiresAt = Date ( ) . addingTimeInterval ( names . isEmpty ? 300 : 86_400 )
659+ }
660+ }
661+
662+ private struct GitLabStageOrderResponse : Decodable {
663+ struct Result : Decodable {
664+ struct Project : Decodable {
665+ struct Pipeline : Decodable {
666+ struct Stages : Decodable {
667+ struct Stage : Decodable { let name : String ? }
668+ let nodes : [ Stage ]
669+ }
670+ let stages : Stages
671+ }
672+ let pipeline : Pipeline ?
673+ }
674+ let project : Project ?
675+ }
676+ let data : Result ?
677+ }
0 commit comments