Skip to content

Commit ab90a99

Browse files
committed
fix: display accurate CI job states and individual job indicators
1 parent ef2bb99 commit ab90a99

9 files changed

Lines changed: 376 additions & 78 deletions

File tree

‎docs/releases/v1.0.14.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 更新内容
2+
3+
- 修复 GitLab 等待、手动、跳过和取消等状态被误显示为失败的问题,并持续刷新尚未结束的流水线。
4+
- 按流水线实际阶段顺序显示作业,避免作业重试或接口返回顺序导致阶段错位。
5+
- 每个子任务独立显示状态圆点,鼠标悬浮可查看对应的作业名称和状态。
6+
- 流水线卡片显示实际运行的分支或标签,并自动刷新旧缓存中误判的作业状态。
7+
8+
## What's new
9+
10+
- Correct GitLab status mapping so waiting, manual, skipped, and canceled jobs are no longer shown as failures. Keep polling pipelines that can still change status.
11+
- Display jobs in the pipeline's declared stage order, including when jobs have been retried.
12+
- Show a separate status dot for every job, with its name and status available on hover.
13+
- Show the actual pipeline branch or tag on activity cards and refresh incorrectly classified job states in older caches.
14+
15+
---
16+
17+
macOS 14+ · Apple Silicon

‎swift-prototype/Sources/CIActivityOrdering.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ enum CIActivityOrdering {
8080
) -> [CIAccessibleProject] {
8181
let projects = uniqueProjects(retained)
8282
let runningIDs = Set(projects.compactMap { project in
83-
pipelineCache[project.id]?.contains { $0.state == .running } == true ? project.id : nil
83+
pipelineCache[project.id]?.contains { $0.state.needsStatusRefresh } == true ? project.id : nil
8484
})
8585
return projects.filter { runningIDs.contains($0.id) } + projects.filter { !runningIDs.contains($0.id) }
8686
}
@@ -101,7 +101,7 @@ enum CIActivityOrdering {
101101
let recentIDs = Set(projects.prefix(limit).map(\.id))
102102
let trackedIDs = Set(projects.filter { project in
103103
followedIDs.contains(project.id) ||
104-
pipelineCache[project.id]?.contains { $0.state == .running } == true
104+
pipelineCache[project.id]?.contains { $0.state.needsStatusRefresh } == true
105105
}.map(\.id))
106106
let changedIDs = Set(projects.filter { project in
107107
project.updatedAt.map { $0 > (successfulPolls[project.id] ?? .distantPast) } == true

‎swift-prototype/Sources/CIIntegration.swift‎

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

154148
struct 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+
}

‎swift-prototype/Sources/CIPipelineCache.swift‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ enum CIPipelineCache {
66
static func stageRefreshCandidates(in cache: [String: [Pipeline]]) -> [(String, Pipeline)] {
77
cache.compactMap { projectID, runs in
88
guard let latest = runs.sorted(by: CIActivityOrdering.newestFirst).first,
9-
!latest.hasLoadedStages || latest.state == .running ||
9+
!latest.hasLoadedStages || latest.state.needsStatusRefresh ||
1010
latest.stageSnapshotState.map({ $0 != latest.state }) == true ||
11-
latest.stages.contains(where: { $0.state == .running }) else { return nil }
11+
latest.stages.contains(where: { $0.state.isInProgress || $0.rawStatus == nil }) else { return nil }
1212
return (projectID, latest)
1313
}
1414
}
@@ -49,7 +49,9 @@ enum CIPipelineCache {
4949
duration: stage.duration,
5050
state: stage.state,
5151
log: old.log,
52-
group: stage.group
52+
group: stage.group,
53+
rawStatus: stage.rawStatus,
54+
groupOrder: stage.groupOrder
5355
)
5456
}
5557
return copy(refreshed, stages: stages, hasLoadedStages: true,
@@ -65,7 +67,9 @@ enum CIPipelineCache {
6567
duration: job.duration,
6668
state: job.state,
6769
log: "",
68-
group: job.stage.isEmpty ? nil : job.stage
70+
group: job.stage.isEmpty ? nil : job.stage,
71+
rawStatus: job.status,
72+
groupOrder: job.stageOrder
6973
)
7074
}
7175
return copy(pipeline, stages: stages, hasLoadedStages: true, stageSnapshotState: pipeline.state)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import SwiftUI
2+
3+
/// Keep provider states distinct: a created/skipped/manual job is not a failure.
4+
/// Existing success/failed/running raw values remain compatible with saved data.
5+
enum PipelineState: String, Codable {
6+
case success, failed, running, pending, manual, scheduled, skipped, canceled, unknown
7+
8+
init(remoteStatus: String) {
9+
switch remoteStatus.lowercased() {
10+
case "success", "passed", "completed", "neutral": self = .success
11+
case "failed", "failure", "timed_out", "startup_failure": self = .failed
12+
case "running", "in_progress", "in progress", "preparing", "canceling": self = .running
13+
case "created", "pending", "queued", "waiting", "waiting_for_resource", "waiting_for_callback", "requested": self = .pending
14+
case "manual", "blocked", "action_required": self = .manual
15+
case "scheduled": self = .scheduled
16+
case "skipped": self = .skipped
17+
case "canceled", "cancelled", "stale": self = .canceled
18+
default: self = .unknown
19+
}
20+
}
21+
22+
var isInProgress: Bool { self == .running || self == .pending }
23+
var needsStatusRefresh: Bool { isInProgress || self == .manual || self == .scheduled || self == .unknown }
24+
25+
var color: Color {
26+
switch self {
27+
case .success: return .green
28+
case .failed: return .red
29+
case .running: return .blue
30+
case .manual, .scheduled: return .orange
31+
case .pending, .skipped, .canceled, .unknown: return .gray
32+
}
33+
}
34+
35+
var label: String {
36+
switch self {
37+
case .success: return L("成功")
38+
case .failed: return L("失败")
39+
case .running: return L("运行中")
40+
case .pending: return L("等待中")
41+
case .manual: return L("等待手动操作")
42+
case .scheduled: return L("等待定时运行")
43+
case .skipped: return L("已跳过")
44+
case .canceled: return L("已取消")
45+
case .unknown: return L("未知状态")
46+
}
47+
}
48+
49+
static func aggregate(_ states: [PipelineState]) -> PipelineState {
50+
guard !states.isEmpty else { return .unknown }
51+
for state in [failed, running, pending, manual, scheduled, canceled, unknown] where states.contains(state) {
52+
return state
53+
}
54+
return states.contains(.success) ? .success : .skipped
55+
}
56+
}

‎swift-prototype/Sources/Localization/en.lproj/Localizable.strings‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@
213213
"阶段:%@" = "Stage: %@";
214214
"%ld 个作业" = "%ld jobs";
215215
"阶段 %@,%ld 个作业" = "Stage %@, %ld jobs";
216-
"阶段 %@,%@,%ld 个作业,点击查看步骤" = "Stage %@, %@, %ld jobs. Click to view steps.";
216+
"作业 %@,%@,点击查看步骤" = "Job %@, %@. Click to view steps.";
217217
"作业 %@,%@,点击查看日志" = "Job %@, %@. Click to view log.";
218218
"最新流水线" = "Latest pipelines";
219219
"点击查看历史" = "View history";
@@ -315,3 +315,9 @@
315315
"无法识别 %@ 版本:%@" = "Cannot determine the %@ version: %@";
316316
"运行环境检测超时,请检查 Shell 启动配置和运行时路径" = "Runtime detection timed out. Check shell startup files and runtime paths.";
317317
"运行环境检测失败,请检查 Shell 启动配置和运行时路径" = "Runtime detection failed. Check shell startup files and runtime paths.";
318+
"等待中" = "Waiting";
319+
"等待手动操作" = "Manual action required";
320+
"等待定时运行" = "Scheduled";
321+
"已跳过" = "Skipped";
322+
"已取消" = "Canceled";
323+
"未知状态" = "Unknown status";

‎swift-prototype/Sources/Localization/zh-Hans.lproj/Localizable.strings‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"阶段:%@" = "阶段:%@";
1414
"%ld 个作业" = "%ld 个作业";
1515
"阶段 %@,%ld 个作业" = "阶段 %@,%ld 个作业";
16-
"阶段 %@,%@,%ld 个作业,点击查看步骤" = "阶段 %@,%@,%ld 个作业,点击查看步骤";
16+
"作业 %@,%@,点击查看步骤" = "作业 %@,%@,点击查看步骤";
1717
"作业 %@,%@,点击查看日志" = "作业 %@,%@,点击查看日志";
1818
"管理 CI 实例" = "管理 CI 实例";
1919
"实例管理" = "实例管理";
@@ -82,3 +82,9 @@
8282
"无法识别 %@ 版本:%@" = "无法识别 %@ 版本:%@";
8383
"运行环境检测超时,请检查 Shell 启动配置和运行时路径" = "运行环境检测超时,请检查 Shell 启动配置和运行时路径";
8484
"运行环境检测失败,请检查 Shell 启动配置和运行时路径" = "运行环境检测失败,请检查 Shell 启动配置和运行时路径";
85+
"等待中" = "等待中";
86+
"等待手动操作" = "等待手动操作";
87+
"等待定时运行" = "等待定时运行";
88+
"已跳过" = "已跳过";
89+
"已取消" = "已取消";
90+
"未知状态" = "未知状态";

0 commit comments

Comments
 (0)