Commit fbba92ac45

fbba92ac452bf6d5b765ef3c7f79f53abd7ddb26

parent: 6d901d0d72

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 23:53 UTC

feat: improve Home structure and navigation

implements: https://todo.sr.ht/~ccleberg/hutch/14
implements: https://todo.sr.ht/~ccleberg/hutch/15
implements: https://todo.sr.ht/~ccleberg/hutch/16

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +9 −9
@@ -307,8 +307,8 @@
307307 projectRoot = "";
308308 targets = (
309309 8B4B28D02F6704280045FA19 /* Hutch */,
310 8B8182B62F6B73F3000AE049 /* HutchTests */,
311310 8BE081F72F80000100000001 /* HutchWidgetExtension */,
311 8B8182B62F6B73F3000AE049 /* HutchTests */,
312312 );
313313 };
314314/* End PBXProject section */
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 35;
518 CURRENT_PROJECT_VERSION = 36;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.15.3;
535 MARKETING_VERSION = 2.16.0;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 35;
555 CURRENT_PROJECT_VERSION = 36;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.15.3;
572 MARKETING_VERSION = 2.16.0;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 35;
635 CURRENT_PROJECT_VERSION = 36;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.15.3;
645 MARKETING_VERSION = 2.16.0;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 35;
664 CURRENT_PROJECT_VERSION = 36;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.15.3;
674 MARKETING_VERSION = 2.16.0;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +6
@@ -20,6 +20,7 @@ final class AppState {
2020 case tracker(TrackerSummary)
2121 case mailingList(InboxMailingListReference)
2222 case systemStatus
23 case builds
2324 }
2425
2526 enum AuthPhase {
@@ -309,6 +310,11 @@ final class AppState {
309310 selectedTab = .more
310311 }
311312
313 func navigateToBuildsList() {
314 pendingTabNavigation = .builds
315 selectedTab = .builds
316 }
317
312318 func presentRepositoryDeepLinkError() {
313319 deepLinkError = "The repository could not be found or is inaccessible."
314320 }
Hutch/App/AppStorageKeys.swift +3
@@ -8,4 +8,7 @@ enum AppStorageKeys {
88 static let hutchStatsBaseURL = "hutchStatsBaseURL"
99 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
1010 static let systemStatusIncidentCache = "systemStatusIncidentCache"
11 static let homeProjectsExpanded = "homeProjectsExpanded"
12 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
13 static let homeBuildsExpanded = "homeBuildsExpanded"
1114}
Hutch/App/RootView.swift +3
@@ -242,6 +242,9 @@ struct RootView: View {
242242 await settleNavigationTransition()
243243 morePath.append(MoreRoute.systemStatus)
244244 }
245 case .builds:
246 buildsPath = NavigationPath()
247 appState.selectedTab = .builds
245248 }
246249 }
247250
Hutch/Views/Builds/BuildRowView.swift +38
@@ -36,6 +36,44 @@ struct BuildRowView: View {
3636 }
3737}
3838
39struct JobStatusBadge: View {
40 let status: JobStatus
41
42 var body: some View {
43 HStack(spacing: 4) {
44 JobStatusIcon(status: status)
45 .frame(width: 12, height: 12)
46 Text(status.displayTitle)
47 .font(.caption2.weight(.medium))
48 }
49 .foregroundStyle(.secondary)
50 .padding(.horizontal, 8)
51 .padding(.vertical, 4)
52 .background(Color(.secondarySystemFill), in: Capsule())
53 }
54}
55
56extension JobStatus {
57 var displayTitle: String {
58 switch self {
59 case .pending:
60 "Pending"
61 case .queued:
62 "Queued"
63 case .running:
64 "Running"
65 case .success:
66 "Succeeded"
67 case .failed:
68 "Failed"
69 case .cancelled:
70 "Cancelled"
71 case .timeout:
72 "Timed Out"
73 }
74 }
75}
76
3977// MARK: - Job Status Icon
4078
4179struct JobStatusIcon: View {
Hutch/Views/Home/HomeSectionView.swift added +63
@@ -0,0 +1,63 @@
1import SwiftUI
2
3struct HomeSectionView<Accessory: View, Content: View>: View {
4 let title: String
5 @Binding var isExpanded: Bool
6 let accessory: Accessory
7 let content: Content
8
9 init(
10 _ title: String,
11 isExpanded: Binding<Bool>,
12 @ViewBuilder accessory: () -> Accessory,
13 @ViewBuilder content: () -> Content
14 ) {
15 self.title = title
16 self._isExpanded = isExpanded
17 self.accessory = accessory()
18 self.content = content()
19 }
20
21 var body: some View {
22 Section {
23 if isExpanded {
24 content
25 }
26 } header: {
27 HomeSectionHeader(
28 title: title,
29 isExpanded: $isExpanded,
30 accessory: accessory
31 )
32 }
33 }
34}
35
36private struct HomeSectionHeader<Accessory: View>: View {
37 let title: String
38 @Binding var isExpanded: Bool
39 let accessory: Accessory
40
41 var body: some View {
42 HStack(spacing: 12) {
43 Button {
44 isExpanded.toggle()
45 } label: {
46 HStack(spacing: 8) {
47 Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
48 .font(.caption.weight(.semibold))
49 .foregroundStyle(.secondary)
50 Text(title)
51 }
52 .contentShape(Rectangle())
53 }
54 .buttonStyle(.plain)
55 .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)")
56
57 Spacer(minLength: 8)
58
59 accessory
60 }
61 .textCase(nil)
62 }
63}
Hutch/Views/Home/HomeView.swift +147 −60
@@ -2,6 +2,9 @@ import SwiftUI
22
33struct HomeView: View {
44 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
5 @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
6 @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
7 @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
58 @Environment(AppState.self) private var appState
69 @Environment(\.scenePhase) private var scenePhase
710 @State private var viewModel: HomeViewModel?
@@ -116,7 +119,7 @@ struct HomeView: View {
116119 summary: viewModel.buildsSummaryText,
117120 countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
118121 action: {
119 appState.selectedTab = .builds
122 appState.navigateToBuildsList()
120123 }
121124 )
122125 }
@@ -176,7 +179,15 @@ struct HomeView: View {
176179 @ViewBuilder
177180 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
178181 if !viewModel.projects.isEmpty {
179 Section {
182 HomeSectionView("Projects", isExpanded: $projectsExpanded) {
183 NavigationLink {
184 HomeProjectsListView(viewModel: viewModel)
185 } label: {
186 Text("See All")
187 .font(.caption.weight(.medium))
188 }
189 .buttonStyle(.plain)
190 } content: {
180191 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
181192 NavigationLink {
182193 ProjectDetailView(project: project)
@@ -184,17 +195,21 @@ struct HomeView: View {
184195 HomeProjectRow(project: project)
185196 }
186197 }
187 } header: {
188 HomeSectionHeader("Projects") {
189 HomeProjectsListView(viewModel: viewModel)
190 }
191198 }
192199 }
193200 }
194201
195202 @ViewBuilder
196203 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
197 Section {
204 HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
205 NavigationLink {
206 HomeAssignedTicketsListView(viewModel: viewModel)
207 } label: {
208 Text("See All")
209 .font(.caption.weight(.medium))
210 }
211 .buttonStyle(.plain)
212 } content: {
198213 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
199214 HomeSectionLoadingRow(label: "Loading assigned tickets")
200215 } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
@@ -241,16 +256,18 @@ struct HomeView: View {
241256 }
242257 }
243258 }
244 } header: {
245 HomeSectionHeader("Assigned Tickets") {
246 HomeAssignedTicketsListView(viewModel: viewModel)
247 }
248259 }
249260 }
250261
251262 @ViewBuilder
252263 private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
253 Section {
264 HomeSectionView("Builds", isExpanded: $buildsExpanded) {
265 Button("See All") {
266 appState.navigateToBuildsList()
267 }
268 .font(.caption.weight(.medium))
269 .buttonStyle(.plain)
270 } content: {
254271 if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
255272 HomeSectionLoadingRow(label: "Loading recent builds")
256273 } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
@@ -266,32 +283,67 @@ struct HomeView: View {
266283 systemImage: "clock"
267284 )
268285 } else {
269 ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
270 NavigationLink {
271 BuildDetailView(jobId: build.job.id)
272 } label: {
273 HomeBuildRow(build: build)
286 ForEach(buildGroups(for: viewModel.recentBuilds)) { group in
287 if let repositoryDisplayName = group.repositoryDisplayName {
288 HomeBuildGroupHeader(
289 repositoryDisplayName: repositoryDisplayName,
290 buildCount: group.builds.count,
291 latestStatus: group.latestStatus
292 )
274293 }
275 .swipeActions(edge: .leading, allowsFullSwipe: true) {
276 if swipeActionsEnabled, build.job.status.isCancellable {
277 Button {
278 Task {
279 await viewModel.cancelBuild(build)
294
295 ForEach(group.builds) { build in
296 NavigationLink {
297 BuildDetailView(jobId: build.job.id)
298 } label: {
299 HomeBuildRow(
300 build: build,
301 showsRepositoryLink: group.repositoryDisplayName == nil
302 )
303 }
304 .swipeActions(edge: .leading, allowsFullSwipe: true) {
305 if swipeActionsEnabled, build.job.status.isCancellable {
306 Button {
307 Task {
308 await viewModel.cancelBuild(build)
309 }
280310 }
311 label: {
312 Label("Cancel", systemImage: "xmark.circle")
313 }
314 .tint(.red)
281315 }
282 label: {
283 Label("Cancel", systemImage: "xmark.circle")
284 }
285 .tint(.red)
286316 }
287317 }
288318 }
289319 }
290 } header: {
291 HomeSectionActionHeader("Recent Builds") {
292 appState.selectedTab = .builds
320 }
321 }
322
323 private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
324 let previewBuilds = Array(builds.prefix(previewLimit))
325 guard let firstBuild = previewBuilds.first else { return [] }
326
327 var groups: [HomeBuildGroup] = []
328 var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
329 var currentBuilds: [HomeBuildItem] = []
330
331 for build in previewBuilds {
332 let identity = HomeBuildGroup.Identity(build: build)
333 if identity == currentIdentity {
334 currentBuilds.append(build)
335 } else {
336 groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
337 currentIdentity = identity
338 currentBuilds = [build]
293339 }
294340 }
341
342 if !currentBuilds.isEmpty {
343 groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
344 }
345
346 return groups
295347 }
296348
297349 @ViewBuilder
@@ -387,6 +439,7 @@ private struct HomeProjectsListView: View {
387439private struct HomeBuildRow: View {
388440 @Environment(AppState.self) private var appState
389441 let build: HomeBuildItem
442 var showsRepositoryLink = true
390443
391444 var body: some View {
392445 VStack(alignment: .leading, spacing: 6) {
@@ -395,7 +448,7 @@ private struct HomeBuildRow: View {
395448 .frame(width: 20)
396449
397450 VStack(alignment: .leading, spacing: 4) {
398 Text(primaryTitle)
451 Text(build.job.displayLabel)
399452 .font(.subheadline.weight(.medium))
400453 .lineLimit(1)
401454
@@ -408,7 +461,7 @@ private struct HomeBuildRow: View {
408461 .font(.caption)
409462 .foregroundStyle(.tertiary)
410463
411 Text(build.job.status.rawValue.capitalized)
464 Text(build.job.status.displayTitle)
412465 .font(.caption)
413466 .foregroundStyle(.secondary)
414467
@@ -425,7 +478,7 @@ private struct HomeBuildRow: View {
425478 }
426479 }
427480
428 if let repositoryDisplayName = build.repositoryDisplayName {
481 if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
429482 Button {
430483 openRepository()
431484 } label: {
@@ -439,13 +492,6 @@ private struct HomeBuildRow: View {
439492 .padding(.vertical, 2)
440493 }
441494
442 private var primaryTitle: String {
443 if let repositoryDisplayName = build.repositoryDisplayName {
444 return repositoryDisplayName
445 }
446 return build.job.displayLabel
447 }
448
449495 private func openRepository() {
450496 guard let repositoryName = build.repositoryName,
451497 let repositoryOwner = build.repositoryOwner else { return }
@@ -463,6 +509,68 @@ private struct HomeBuildRow: View {
463509 }
464510}
465511
512private struct HomeBuildGroup: Identifiable {
513 enum Identity: Hashable {
514 case repository(owner: String?, name: String)
515 case standalone(Int)
516
517 init(build: HomeBuildItem) {
518 if let repositoryName = build.repositoryName {
519 self = .repository(owner: build.repositoryOwner, name: repositoryName)
520 } else {
521 self = .standalone(build.id)
522 }
523 }
524 }
525
526 let identity: Identity
527 let builds: [HomeBuildItem]
528
529 var id: String {
530 switch identity {
531 case .repository(let owner, let name):
532 return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
533 case .standalone(let jobId):
534 return "job-\(jobId)"
535 }
536 }
537
538 var repositoryDisplayName: String? {
539 builds.first?.repositoryDisplayName
540 }
541
542 var latestStatus: JobStatus {
543 builds.first?.job.status ?? .pending
544 }
545}
546
547private struct HomeBuildGroupHeader: View {
548 let repositoryDisplayName: String
549 let buildCount: Int
550 let latestStatus: JobStatus
551
552 var body: some View {
553 HStack(spacing: 12) {
554 Label(repositoryDisplayName, systemImage: "book.closed")
555 .font(.caption.weight(.medium))
556 .foregroundStyle(.secondary)
557 .lineLimit(1)
558
559 Spacer(minLength: 8)
560
561 Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
562 .font(.caption2.weight(.medium))
563 .foregroundStyle(.tertiary)
564
565 JobStatusBadge(status: latestStatus)
566 }
567 .padding(.top, 4)
568 .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
569 .listRowSeparator(.hidden)
570 .accessibilityElement(children: .combine)
571 }
572}
573
466574private struct HomeAssignedTicketRow: View {
467575 @Environment(AppState.self) private var appState
468576 let ticket: HomeAssignedTicket
@@ -717,27 +825,6 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
717825 }
718826}
719827
720private struct HomeSectionActionHeader: View {
721 let title: String
722 let action: () -> Void
723
724 init(_ title: String, action: @escaping () -> Void) {
725 self.title = title
726 self.action = action
727 }
728
729 var body: some View {
730 HStack {
731 Text(title)
732 Spacer()
733 Button("See All", action: action)
734 .font(.caption.weight(.medium))
735 .buttonStyle(.plain)
736 }
737 .textCase(nil)
738 }
739}
740
741828private struct HomeAssignedTicketsListView: View {
742829 let viewModel: HomeViewModel
743830 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
labels.sh added +96
@@ -0,0 +1,96 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4TRACKER="hutch"
5ERR_FILE="$(mktemp)"
6trap 'rm -f "$ERR_FILE"' EXIT
7
8label_ticket() {
9 local ticket_id="$1"
10 shift
11
12 for label in "$@"; do
13 echo "Applying label '$label' to ticket #$ticket_id"
14 if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then
15 if grep -q "already assigned to this ticket" "$ERR_FILE"; then
16 echo " already present, skipping"
17 else
18 cat "$ERR_FILE" >&2
19 exit 1
20 fi
21 fi
22 done
23}
24
25# v2.16.x
26label_ticket 14 enhancement ui ux # Home: Collapsible Sections
27label_ticket 15 enhancement ui # Home: See All Navigation
28label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping
29
30label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle
31label_ticket 18 enhancement builds # Builds: Per-Repo Filter
32label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish
33
34label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions
35label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility
36
37label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes
38label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish
39
40label_ticket 24 new-feature theming ui # Theming: AMOLED Mode
41label_ticket 25 new-feature theming ui # Theming: High-Density Mode
42
43# v2.17.x
44label_ticket 26 new-feature projects # Projects: Project List View
45label_ticket 27 new-feature projects # Projects: Project Detail View
46label_ticket 28 enhancement projects ui # Projects: Pin to Home
47
48label_ticket 29 enhancement builds # Builds: Log Search
49label_ticket 30 enhancement builds # Builds: Jump to Error
50label_ticket 31 enhancement builds # Builds: Artifact List
51
52label_ticket 32 enhancement tickets # Tickets: Saved Filters
53label_ticket 33 enhancement tickets # Tickets: Label Filtering
54
55label_ticket 34 enhancement inbox # Inbox: Basic Threading
56label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs
57
58# v2.18.x
59label_ticket 36 new-feature projects # Projects: Create Project
60label_ticket 37 enhancement projects # Projects: Edit Project
61label_ticket 38 enhancement projects # Projects: Manage Linked Resources
62
63label_ticket 39 new-feature acl repo # ACL: View Permissions
64label_ticket 40 enhancement acl repo # ACL: Add and Remove Users
65label_ticket 41 enhancement acl repo # ACL: Edit Permissions
66
67label_ticket 42 enhancement repo # Repo Settings: Edit Metadata
68label_ticket 43 enhancement repo # Repo Settings: Set Default Branch
69label_ticket 44 enhancement repo # Repo Settings: Visibility Changes
70
71label_ticket 45 new-feature tickets # Tickets: Label CRUD
72label_ticket 46 enhancement tickets # Tickets: Bulk Actions
73
74# v2.19.x
75label_ticket 47 new-feature search # Search: Per-Type Search
76label_ticket 48 enhancement search # Search: Recent Searches
77
78label_ticket 49 new-feature accounts # Accounts: Multi-Account Support
79label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI
80label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching
81
82label_ticket 52 enhancement power # Power: Open in Browser
83label_ticket 53 enhancement power # Power: Copy Actions
84label_ticket 54 enhancement power # Power: Debug View Toggle
85
86# v2.20.x
87label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation
88
89label_ticket 56 enhancement performance # Performance: Caching Improvements
90label_ticket 57 enhancement performance # Performance: List Virtualization
91
92label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity
93
94label_ticket 59 bug api # Errors: Normalize GraphQL Handling
95
96echo "Done."