krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

b526cddd8ec421eba00feecbdffae3978cb61b34

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-12T23:53:13Z

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
 Hutch.xcodeproj/project.pbxproj        |  18 +--
 Hutch/App/AppState.swift               |   6 +
 Hutch/App/AppStorageKeys.swift         |   3 +
 Hutch/App/RootView.swift               |   3 +
 Hutch/Views/Builds/BuildRowView.swift  |  38 ++++++
 Hutch/Views/Home/HomeSectionView.swift |  63 ++++++++++
 Hutch/Views/Home/HomeView.swift        | 207 +++++++++++++++++++++++----------
 labels.sh                              |  96 +++++++++++++++
 8 files changed, 365 insertions(+), 69 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 0f11fbc..70c6c29 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -307,8 +307,8 @@
 			projectRoot = "";
 			targets = (
 				8B4B28D02F6704280045FA19 /* Hutch */,
-				8B8182B62F6B73F3000AE049 /* HutchTests */,
 				8BE081F72F80000100000001 /* HutchWidgetExtension */,
+				8B8182B62F6B73F3000AE049 /* HutchTests */,
 			);
 		};
 /* End PBXProject section */
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 35;
+				CURRENT_PROJECT_VERSION = 36;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.15.3;
+				MARKETING_VERSION = 2.16.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 35;
+				CURRENT_PROJECT_VERSION = 36;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.15.3;
+				MARKETING_VERSION = 2.16.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 35;
+				CURRENT_PROJECT_VERSION = 36;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.15.3;
+				MARKETING_VERSION = 2.16.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 35;
+				CURRENT_PROJECT_VERSION = 36;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.15.3;
+				MARKETING_VERSION = 2.16.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 35ac77f..0f0b036 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -20,6 +20,7 @@ final class AppState {
         case tracker(TrackerSummary)
         case mailingList(InboxMailingListReference)
         case systemStatus
+        case builds
     }
 
     enum AuthPhase {
@@ -309,6 +310,11 @@ final class AppState {
         selectedTab = .more
     }
 
+    func navigateToBuildsList() {
+        pendingTabNavigation = .builds
+        selectedTab = .builds
+    }
+
     func presentRepositoryDeepLinkError() {
         deepLinkError = "The repository could not be found or is inaccessible."
     }
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index c1b39c4..68a9588 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -8,4 +8,7 @@ enum AppStorageKeys {
     static let hutchStatsBaseURL = "hutchStatsBaseURL"
     static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
     static let systemStatusIncidentCache = "systemStatusIncidentCache"
+    static let homeProjectsExpanded = "homeProjectsExpanded"
+    static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
+    static let homeBuildsExpanded = "homeBuildsExpanded"
 }
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 80532a0..baca7a7 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -242,6 +242,9 @@ struct RootView: View {
                 await settleNavigationTransition()
                 morePath.append(MoreRoute.systemStatus)
             }
+        case .builds:
+            buildsPath = NavigationPath()
+            appState.selectedTab = .builds
         }
     }
 
diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift
index e2afd27..9602e5c 100644
--- a/Hutch/Views/Builds/BuildRowView.swift
+++ b/Hutch/Views/Builds/BuildRowView.swift
@@ -36,6 +36,44 @@ struct BuildRowView: View {
     }
 }
 
+struct JobStatusBadge: View {
+    let status: JobStatus
+
+    var body: some View {
+        HStack(spacing: 4) {
+            JobStatusIcon(status: status)
+                .frame(width: 12, height: 12)
+            Text(status.displayTitle)
+                .font(.caption2.weight(.medium))
+        }
+        .foregroundStyle(.secondary)
+        .padding(.horizontal, 8)
+        .padding(.vertical, 4)
+        .background(Color(.secondarySystemFill), in: Capsule())
+    }
+}
+
+extension JobStatus {
+    var displayTitle: String {
+        switch self {
+        case .pending:
+            "Pending"
+        case .queued:
+            "Queued"
+        case .running:
+            "Running"
+        case .success:
+            "Succeeded"
+        case .failed:
+            "Failed"
+        case .cancelled:
+            "Cancelled"
+        case .timeout:
+            "Timed Out"
+        }
+    }
+}
+
 // MARK: - Job Status Icon
 
 struct JobStatusIcon: View {
diff --git a/Hutch/Views/Home/HomeSectionView.swift b/Hutch/Views/Home/HomeSectionView.swift
new file mode 100644
index 0000000..5f04d58
--- /dev/null
+++ b/Hutch/Views/Home/HomeSectionView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct HomeSectionView<Accessory: View, Content: View>: View {
+    let title: String
+    @Binding var isExpanded: Bool
+    let accessory: Accessory
+    let content: Content
+
+    init(
+        _ title: String,
+        isExpanded: Binding<Bool>,
+        @ViewBuilder accessory: () -> Accessory,
+        @ViewBuilder content: () -> Content
+    ) {
+        self.title = title
+        self._isExpanded = isExpanded
+        self.accessory = accessory()
+        self.content = content()
+    }
+
+    var body: some View {
+        Section {
+            if isExpanded {
+                content
+            }
+        } header: {
+            HomeSectionHeader(
+                title: title,
+                isExpanded: $isExpanded,
+                accessory: accessory
+            )
+        }
+    }
+}
+
+private struct HomeSectionHeader<Accessory: View>: View {
+    let title: String
+    @Binding var isExpanded: Bool
+    let accessory: Accessory
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Button {
+                isExpanded.toggle()
+            } label: {
+                HStack(spacing: 8) {
+                    Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+                        .font(.caption.weight(.semibold))
+                        .foregroundStyle(.secondary)
+                    Text(title)
+                }
+                .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)")
+
+            Spacer(minLength: 8)
+
+            accessory
+        }
+        .textCase(nil)
+    }
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 3efef7a..180f0f3 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -2,6 +2,9 @@ import SwiftUI
 
 struct HomeView: View {
     @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+    @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
+    @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
+    @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
     @Environment(AppState.self) private var appState
     @Environment(\.scenePhase) private var scenePhase
     @State private var viewModel: HomeViewModel?
@@ -116,7 +119,7 @@ struct HomeView: View {
                 summary: viewModel.buildsSummaryText,
                 countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
                 action: {
-                    appState.selectedTab = .builds
+                    appState.navigateToBuildsList()
                 }
             )
         }
@@ -176,7 +179,15 @@ struct HomeView: View {
     @ViewBuilder
     private func projectsSection(_ viewModel: HomeViewModel) -> some View {
         if !viewModel.projects.isEmpty {
-            Section {
+            HomeSectionView("Projects", isExpanded: $projectsExpanded) {
+                NavigationLink {
+                    HomeProjectsListView(viewModel: viewModel)
+                } label: {
+                    Text("See All")
+                        .font(.caption.weight(.medium))
+                }
+                .buttonStyle(.plain)
+            } content: {
                 ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
                     NavigationLink {
                         ProjectDetailView(project: project)
@@ -184,17 +195,21 @@ struct HomeView: View {
                         HomeProjectRow(project: project)
                     }
                 }
-            } header: {
-                HomeSectionHeader("Projects") {
-                    HomeProjectsListView(viewModel: viewModel)
-                }
             }
         }
     }
 
     @ViewBuilder
     private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
-        Section {
+        HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
+            NavigationLink {
+                HomeAssignedTicketsListView(viewModel: viewModel)
+            } label: {
+                Text("See All")
+                    .font(.caption.weight(.medium))
+            }
+            .buttonStyle(.plain)
+        } content: {
             if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
                 HomeSectionLoadingRow(label: "Loading assigned tickets")
             } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
@@ -241,16 +256,18 @@ struct HomeView: View {
                     }
                 }
             }
-        } header: {
-            HomeSectionHeader("Assigned Tickets") {
-                HomeAssignedTicketsListView(viewModel: viewModel)
-            }
         }
     }
 
     @ViewBuilder
     private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
-        Section {
+        HomeSectionView("Builds", isExpanded: $buildsExpanded) {
+            Button("See All") {
+                appState.navigateToBuildsList()
+            }
+            .font(.caption.weight(.medium))
+            .buttonStyle(.plain)
+        } content: {
             if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
                 HomeSectionLoadingRow(label: "Loading recent builds")
             } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
@@ -266,32 +283,67 @@ struct HomeView: View {
                     systemImage: "clock"
                 )
             } else {
-                ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
-                    NavigationLink {
-                        BuildDetailView(jobId: build.job.id)
-                    } label: {
-                        HomeBuildRow(build: build)
+                ForEach(buildGroups(for: viewModel.recentBuilds)) { group in
+                    if let repositoryDisplayName = group.repositoryDisplayName {
+                        HomeBuildGroupHeader(
+                            repositoryDisplayName: repositoryDisplayName,
+                            buildCount: group.builds.count,
+                            latestStatus: group.latestStatus
+                        )
                     }
-                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
-                        if swipeActionsEnabled, build.job.status.isCancellable {
-                            Button {
-                                Task {
-                                    await viewModel.cancelBuild(build)
+
+                    ForEach(group.builds) { build in
+                        NavigationLink {
+                            BuildDetailView(jobId: build.job.id)
+                        } label: {
+                            HomeBuildRow(
+                                build: build,
+                                showsRepositoryLink: group.repositoryDisplayName == nil
+                            )
+                        }
+                        .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                            if swipeActionsEnabled, build.job.status.isCancellable {
+                                Button {
+                                    Task {
+                                        await viewModel.cancelBuild(build)
+                                    }
                                 }
+                                label: {
+                                    Label("Cancel", systemImage: "xmark.circle")
+                                }
+                                .tint(.red)
                             }
-                            label: {
-                                Label("Cancel", systemImage: "xmark.circle")
-                            }
-                            .tint(.red)
                         }
                     }
                 }
             }
-        } header: {
-            HomeSectionActionHeader("Recent Builds") {
-                appState.selectedTab = .builds
+        }
+    }
+
+    private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
+        let previewBuilds = Array(builds.prefix(previewLimit))
+        guard let firstBuild = previewBuilds.first else { return [] }
+
+        var groups: [HomeBuildGroup] = []
+        var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
+        var currentBuilds: [HomeBuildItem] = []
+
+        for build in previewBuilds {
+            let identity = HomeBuildGroup.Identity(build: build)
+            if identity == currentIdentity {
+                currentBuilds.append(build)
+            } else {
+                groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
+                currentIdentity = identity
+                currentBuilds = [build]
             }
         }
+
+        if !currentBuilds.isEmpty {
+            groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
+        }
+
+        return groups
     }
 
     @ViewBuilder
@@ -387,6 +439,7 @@ private struct HomeProjectsListView: View {
 private struct HomeBuildRow: View {
     @Environment(AppState.self) private var appState
     let build: HomeBuildItem
+    var showsRepositoryLink = true
 
     var body: some View {
         VStack(alignment: .leading, spacing: 6) {
@@ -395,7 +448,7 @@ private struct HomeBuildRow: View {
                     .frame(width: 20)
 
                 VStack(alignment: .leading, spacing: 4) {
-                    Text(primaryTitle)
+                    Text(build.job.displayLabel)
                         .font(.subheadline.weight(.medium))
                         .lineLimit(1)
 
@@ -408,7 +461,7 @@ private struct HomeBuildRow: View {
                             .font(.caption)
                             .foregroundStyle(.tertiary)
 
-                        Text(build.job.status.rawValue.capitalized)
+                        Text(build.job.status.displayTitle)
                             .font(.caption)
                             .foregroundStyle(.secondary)
 
@@ -425,7 +478,7 @@ private struct HomeBuildRow: View {
                 }
             }
 
-            if let repositoryDisplayName = build.repositoryDisplayName {
+            if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
                 Button {
                     openRepository()
                 } label: {
@@ -439,13 +492,6 @@ private struct HomeBuildRow: View {
         .padding(.vertical, 2)
     }
 
-    private var primaryTitle: String {
-        if let repositoryDisplayName = build.repositoryDisplayName {
-            return repositoryDisplayName
-        }
-        return build.job.displayLabel
-    }
-
     private func openRepository() {
         guard let repositoryName = build.repositoryName,
               let repositoryOwner = build.repositoryOwner else { return }
@@ -463,6 +509,68 @@ private struct HomeBuildRow: View {
     }
 }
 
+private struct HomeBuildGroup: Identifiable {
+    enum Identity: Hashable {
+        case repository(owner: String?, name: String)
+        case standalone(Int)
+
+        init(build: HomeBuildItem) {
+            if let repositoryName = build.repositoryName {
+                self = .repository(owner: build.repositoryOwner, name: repositoryName)
+            } else {
+                self = .standalone(build.id)
+            }
+        }
+    }
+
+    let identity: Identity
+    let builds: [HomeBuildItem]
+
+    var id: String {
+        switch identity {
+        case .repository(let owner, let name):
+            return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
+        case .standalone(let jobId):
+            return "job-\(jobId)"
+        }
+    }
+
+    var repositoryDisplayName: String? {
+        builds.first?.repositoryDisplayName
+    }
+
+    var latestStatus: JobStatus {
+        builds.first?.job.status ?? .pending
+    }
+}
+
+private struct HomeBuildGroupHeader: View {
+    let repositoryDisplayName: String
+    let buildCount: Int
+    let latestStatus: JobStatus
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Label(repositoryDisplayName, systemImage: "book.closed")
+                .font(.caption.weight(.medium))
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
+
+            Spacer(minLength: 8)
+
+            Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
+                .font(.caption2.weight(.medium))
+                .foregroundStyle(.tertiary)
+
+            JobStatusBadge(status: latestStatus)
+        }
+        .padding(.top, 4)
+        .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
+        .listRowSeparator(.hidden)
+        .accessibilityElement(children: .combine)
+    }
+}
+
 private struct HomeAssignedTicketRow: View {
     @Environment(AppState.self) private var appState
     let ticket: HomeAssignedTicket
@@ -717,27 +825,6 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
     }
 }
 
-private struct HomeSectionActionHeader: View {
-    let title: String
-    let action: () -> Void
-
-    init(_ title: String, action: @escaping () -> Void) {
-        self.title = title
-        self.action = action
-    }
-
-    var body: some View {
-        HStack {
-            Text(title)
-            Spacer()
-            Button("See All", action: action)
-                .font(.caption.weight(.medium))
-                .buttonStyle(.plain)
-        }
-        .textCase(nil)
-    }
-}
-
 private struct HomeAssignedTicketsListView: View {
     let viewModel: HomeViewModel
     @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
diff --git a/labels.sh b/labels.sh
new file mode 100755
index 0000000..746e0c8
--- /dev/null
+++ b/labels.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+TRACKER="hutch"
+ERR_FILE="$(mktemp)"
+trap 'rm -f "$ERR_FILE"' EXIT
+
+label_ticket() {
+  local ticket_id="$1"
+  shift
+
+  for label in "$@"; do
+    echo "Applying label '$label' to ticket #$ticket_id"
+    if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then
+      if grep -q "already assigned to this ticket" "$ERR_FILE"; then
+        echo "  already present, skipping"
+      else
+        cat "$ERR_FILE" >&2
+        exit 1
+      fi
+    fi
+  done
+}
+
+# v2.16.x
+label_ticket 14 enhancement ui ux                         # Home: Collapsible Sections
+label_ticket 15 enhancement ui                            # Home: See All Navigation
+label_ticket 16 enhancement ui ux                         # Home: Repo-Based Grouping
+
+label_ticket 17 enhancement builds                        # Builds: Auto-Refresh Toggle
+label_ticket 18 enhancement builds                        # Builds: Per-Repo Filter
+label_ticket 19 enhancement builds ux                     # Builds: Retry/Cancel Polish
+
+label_ticket 20 enhancement tickets ui                    # Tickets: Swipe Actions
+label_ticket 21 enhancement tickets ui                    # Tickets: Status and Label Visibility
+
+label_ticket 22 bug inbox ui                              # Inbox: Patch Rendering Fixes
+label_ticket 23 enhancement inbox ux                      # Inbox: Reply Flow Polish
+
+label_ticket 24 new-feature theming ui                    # Theming: AMOLED Mode
+label_ticket 25 new-feature theming ui                    # Theming: High-Density Mode
+
+# v2.17.x
+label_ticket 26 new-feature projects                      # Projects: Project List View
+label_ticket 27 new-feature projects                      # Projects: Project Detail View
+label_ticket 28 enhancement projects ui                   # Projects: Pin to Home
+
+label_ticket 29 enhancement builds                        # Builds: Log Search
+label_ticket 30 enhancement builds                        # Builds: Jump to Error
+label_ticket 31 enhancement builds                        # Builds: Artifact List
+
+label_ticket 32 enhancement tickets                       # Tickets: Saved Filters
+label_ticket 33 enhancement tickets                       # Tickets: Label Filtering
+
+label_ticket 34 enhancement inbox                         # Inbox: Basic Threading
+label_ticket 35 enhancement inbox                         # Inbox: Collapse Long Diffs
+
+# v2.18.x
+label_ticket 36 new-feature projects                      # Projects: Create Project
+label_ticket 37 enhancement projects                      # Projects: Edit Project
+label_ticket 38 enhancement projects                      # Projects: Manage Linked Resources
+
+label_ticket 39 new-feature acl repo                      # ACL: View Permissions
+label_ticket 40 enhancement acl repo                      # ACL: Add and Remove Users
+label_ticket 41 enhancement acl repo                      # ACL: Edit Permissions
+
+label_ticket 42 enhancement repo                          # Repo Settings: Edit Metadata
+label_ticket 43 enhancement repo                          # Repo Settings: Set Default Branch
+label_ticket 44 enhancement repo                          # Repo Settings: Visibility Changes
+
+label_ticket 45 new-feature tickets                       # Tickets: Label CRUD
+label_ticket 46 enhancement tickets                       # Tickets: Bulk Actions
+
+# v2.19.x
+label_ticket 47 new-feature search                        # Search: Per-Type Search
+label_ticket 48 enhancement search                        # Search: Recent Searches
+
+label_ticket 49 new-feature accounts                      # Accounts: Multi-Account Support
+label_ticket 50 enhancement accounts ui                   # Accounts: Account Switcher UI
+label_ticket 51 enhancement accounts performance          # Accounts: Isolated Caching
+
+label_ticket 52 enhancement power                         # Power: Open in Browser
+label_ticket 53 enhancement power                         # Power: Copy Actions
+label_ticket 54 enhancement power                         # Power: Debug View Toggle
+
+# v2.20.x
+label_ticket 55 enhancement navigation ux                 # Navigation: Merge Inbox and Tickets Evaluation
+
+label_ticket 56 enhancement performance                   # Performance: Caching Improvements
+label_ticket 57 enhancement performance                   # Performance: List Virtualization
+
+label_ticket 58 enhancement repo                          # Consistency: Git and Mercurial Parity
+
+label_ticket 59 bug api                                   # Errors: Normalize GraphQL Handling
+
+echo "Done."