mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
refactor: extract PullRequestDetailViewModel from the PR detail view
Second slice of the incremental MVVM migration proposed in #41, and the first applied to one of the project's two largest views. PullRequestDetailView drops from 612 lines to a 174-line binding layer plus a 244-line sections extension. All state, derived properties, and async work move into a @MainActor @Observable view model behind PullRequestServiceProtocol and RepositoryServiceProtocol seams, so the view becomes a thin binding layer. Behavior is unchanged, and the view model is covered by unit tests against a fake service. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3063eabfde
commit
c5f4d6d698
5 changed files with 868 additions and 515 deletions
47
Forji/Forji/Services/PullRequestDetailServiceProtocols.swift
Normal file
47
Forji/Forji/Services/PullRequestDetailServiceProtocols.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import ForgejoKit
|
||||
import Foundation
|
||||
|
||||
/// The pull-request operations the pull-request detail feature depends on, as a
|
||||
/// seam so the view model can be exercised against a fake service in unit tests.
|
||||
/// Part of the incremental MVVM migration (#41).
|
||||
protocol PullRequestServiceProtocol: Sendable {
|
||||
func fetchPullRequest(owner: String, repo: String, index: Int) async throws -> PullRequest
|
||||
func fetchComments(owner: String, repo: String, index: Int) async throws -> [IssueComment]
|
||||
func fetchReviews(owner: String, repo: String, index: Int) async throws -> [PullRequestReview]
|
||||
func fetchReviewComments(
|
||||
owner: String, repo: String, index: Int, reviewId: Int,
|
||||
) async throws -> [ReviewComment]
|
||||
func fetchDiff(owner: String, repo: String, index: Int) async throws -> String
|
||||
func setState(owner: String, repo: String, index: Int, state: String) async throws -> PullRequest
|
||||
func createComment(owner: String, repo: String, index: Int, body: String) async throws -> IssueComment
|
||||
func editComment(owner: String, repo: String, commentId: Int, body: String) async throws -> IssueComment
|
||||
}
|
||||
|
||||
/// The repository reads the pull-request detail feature depends on, exposed as a
|
||||
/// seam alongside `PullRequestServiceProtocol` for the same testing reason.
|
||||
protocol RepositoryServiceProtocol: Sendable {
|
||||
func fetchRepository(owner: String, repo: String) async throws -> Repository
|
||||
func fetchAssignees(owner: String, repo: String) async throws -> [User]
|
||||
}
|
||||
|
||||
/// The attachment upload the pull-request detail feature reuses from the issue
|
||||
/// service, exposed as a seam alongside the others for the same testing reason.
|
||||
protocol IssueServiceProtocol: Sendable {
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func uploadIssueAttachment(
|
||||
owner: String, repo: String, index: Int, fileData: Data, fileName: String, mimeType: String,
|
||||
) async throws -> Attachment
|
||||
}
|
||||
|
||||
extension PullRequestService: PullRequestServiceProtocol {
|
||||
/// Focused seam over `editPullRequest` for the only edit the detail view makes:
|
||||
/// opening or closing the pull request.
|
||||
func setState(owner: String, repo: String, index: Int, state: String) async throws -> PullRequest {
|
||||
try await editPullRequest(
|
||||
owner: owner, repo: repo, index: index, title: nil, body: nil, state: state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension RepositoryService: RepositoryServiceProtocol {}
|
||||
extension IssueService: IssueServiceProtocol {}
|
||||
283
Forji/Forji/ViewModels/PullRequestDetailViewModel.swift
Normal file
283
Forji/Forji/ViewModels/PullRequestDetailViewModel.swift
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
/// Owns the pull-request detail state and async work so the view is a thin
|
||||
/// binding layer. Part of the incremental MVVM migration (#41).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PullRequestDetailViewModel {
|
||||
let prNumber: Int
|
||||
|
||||
var repository: Repository
|
||||
var pullRequest: PullRequest?
|
||||
var comments: [IssueComment] = []
|
||||
var reviews: [PullRequestReview] = []
|
||||
var reviewComments: [Int: [ReviewComment]] = [:]
|
||||
var assignees: [User] = []
|
||||
var parsedDiff: ParsedDiff?
|
||||
var inlineCommentContext: InlineCommentContext?
|
||||
|
||||
var isLoading = true
|
||||
var isLoadingDiff = false
|
||||
var isDiffExpanded = false
|
||||
var isTogglingState = false
|
||||
var errorMessage: String?
|
||||
var showError = false
|
||||
|
||||
private var diffText: String?
|
||||
private let currentUserLogin: String?
|
||||
private let prService: (any PullRequestServiceProtocol)?
|
||||
private let repositoryService: (any RepositoryServiceProtocol)?
|
||||
private let issueService: (any IssueServiceProtocol)?
|
||||
|
||||
init(
|
||||
repository: Repository,
|
||||
prNumber: Int,
|
||||
prService: (any PullRequestServiceProtocol)?,
|
||||
repositoryService: (any RepositoryServiceProtocol)?,
|
||||
issueService: (any IssueServiceProtocol)?,
|
||||
currentUserLogin: String?,
|
||||
) {
|
||||
self.repository = repository
|
||||
self.prNumber = prNumber
|
||||
self.prService = prService
|
||||
self.repositoryService = repositoryService
|
||||
self.issueService = issueService
|
||||
self.currentUserLogin = currentUserLogin
|
||||
}
|
||||
|
||||
// MARK: - Derived State
|
||||
|
||||
var owner: String {
|
||||
repository.owner
|
||||
}
|
||||
|
||||
var repo: String {
|
||||
repository.repoName
|
||||
}
|
||||
|
||||
var isMerged: Bool {
|
||||
pullRequest?.merged == true
|
||||
}
|
||||
|
||||
var hasPushPermission: Bool {
|
||||
repository.permissions?.push == true || repository.permissions?.admin == true
|
||||
}
|
||||
|
||||
var isOwnPR: Bool {
|
||||
guard let currentUserLogin, let prAuthor = pullRequest?.user.login else { return false }
|
||||
return currentUserLogin == prAuthor
|
||||
}
|
||||
|
||||
var canEditOrClose: Bool {
|
||||
hasPushPermission || isOwnPR
|
||||
}
|
||||
|
||||
var canMerge: Bool {
|
||||
hasPushPermission
|
||||
}
|
||||
|
||||
var status: PRStatusStyle {
|
||||
PRStatusStyle(
|
||||
state: pullRequest?.stateValue ?? .closed,
|
||||
merged: pullRequest?.merged,
|
||||
draft: pullRequest?.draft,
|
||||
)
|
||||
}
|
||||
|
||||
var allReviewComments: [ReviewComment] {
|
||||
reviewComments.values.flatMap(\.self)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadData() async {
|
||||
guard let prService, let repositoryService else { return }
|
||||
isLoading = true
|
||||
do {
|
||||
async let fetchedPR = prService.fetchPullRequest(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedComments = prService.fetchComments(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedReviews = prService.fetchReviews(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedAssignees = repositoryService.fetchAssignees(owner: owner, repo: repo)
|
||||
async let fetchedRepo = repositoryService.fetchRepository(owner: owner, repo: repo)
|
||||
|
||||
let loadedPR = try await fetchedPR
|
||||
let loadedComments = try await fetchedComments
|
||||
let loadedReviews = try await fetchedReviews
|
||||
let loadedAssignees = await (try? fetchedAssignees) ?? []
|
||||
|
||||
try Task.checkCancellation()
|
||||
pullRequest = loadedPR
|
||||
comments = loadedComments
|
||||
reviews = loadedReviews
|
||||
assignees = loadedAssignees
|
||||
if let fullRepo = try? await fetchedRepo {
|
||||
repository = fullRepo
|
||||
}
|
||||
|
||||
reviewComments = await fetchAllReviewComments(for: reviews)
|
||||
|
||||
if !allReviewComments.isEmpty {
|
||||
await loadDiff()
|
||||
isDiffExpanded = true
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadReviews() async {
|
||||
guard let prService else { return }
|
||||
do {
|
||||
reviews = try await prService.fetchReviews(owner: owner, repo: repo, index: prNumber)
|
||||
reviewComments = await fetchAllReviewComments(for: reviews)
|
||||
|
||||
if !allReviewComments.isEmpty {
|
||||
if diffText == nil {
|
||||
await loadDiff()
|
||||
}
|
||||
isDiffExpanded = true
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAllReviewComments(for reviews: [PullRequestReview]) async -> [Int: [ReviewComment]] {
|
||||
guard let prService else { return [:] }
|
||||
let owner = owner
|
||||
let repo = repo
|
||||
let prNumber = prNumber
|
||||
return await withTaskGroup(of: (Int, [ReviewComment])?.self) { group in
|
||||
for review in reviews {
|
||||
group.addTask {
|
||||
guard let comments = try? await prService.fetchReviewComments(
|
||||
owner: owner, repo: repo, index: prNumber, reviewId: review.id,
|
||||
), !comments.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (review.id, comments)
|
||||
}
|
||||
}
|
||||
var commentsMap: [Int: [ReviewComment]] = [:]
|
||||
for await result in group {
|
||||
if let (reviewId, comments) = result {
|
||||
commentsMap[reviewId] = comments
|
||||
}
|
||||
}
|
||||
return commentsMap
|
||||
}
|
||||
}
|
||||
|
||||
func loadDiff() async {
|
||||
guard let prService else { return }
|
||||
isLoadingDiff = true
|
||||
do {
|
||||
let raw = try await prService.fetchDiff(owner: owner, repo: repo, index: prNumber)
|
||||
try Task.checkCancellation()
|
||||
diffText = raw
|
||||
parsedDiff = DiffParser.parse(raw)
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
isLoadingDiff = false
|
||||
}
|
||||
|
||||
func togglePRState() async {
|
||||
guard let prService, let currentPR = pullRequest else { return }
|
||||
isTogglingState = true
|
||||
|
||||
do {
|
||||
let newState = currentPR.stateValue == .open
|
||||
? PullRequestState.closed.rawValue : PullRequestState.open.rawValue
|
||||
let updated = try await prService.setState(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: prNumber,
|
||||
state: newState,
|
||||
)
|
||||
pullRequest = updated
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
||||
isTogglingState = false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func editComment(commentId: Int, body: String) async -> Bool {
|
||||
guard let prService else { return false }
|
||||
do {
|
||||
let updated = try await prService.editComment(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
commentId: commentId,
|
||||
body: body,
|
||||
)
|
||||
if let index = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func addComment(body: String) async throws {
|
||||
guard let prService else { throw URLError(.userAuthenticationRequired) }
|
||||
let comment = try await prService.createComment(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: prNumber,
|
||||
body: body,
|
||||
)
|
||||
comments.append(comment)
|
||||
}
|
||||
|
||||
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> String {
|
||||
guard let issueService else { throw URLError(.userAuthenticationRequired) }
|
||||
let attachment = try await issueService.uploadIssueAttachment(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: prNumber,
|
||||
fileData: data,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
)
|
||||
return attachmentMarkdown(for: attachment)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
preview _: Void,
|
||||
repository: Repository,
|
||||
prNumber: Int,
|
||||
pullRequest: PullRequest,
|
||||
comments: [IssueComment] = [],
|
||||
reviews: [PullRequestReview] = [],
|
||||
) {
|
||||
self.repository = repository
|
||||
self.prNumber = prNumber
|
||||
prService = nil
|
||||
repositoryService = nil
|
||||
issueService = nil
|
||||
currentUserLogin = nil
|
||||
self.pullRequest = pullRequest
|
||||
self.comments = comments
|
||||
self.reviews = reviews
|
||||
isLoading = false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
244
Forji/Forji/Views/PullRequestDetailView+Sections.swift
Normal file
244
Forji/Forji/Views/PullRequestDetailView+Sections.swift
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
extension PullRequestDetailView {
|
||||
// MARK: - View Sections
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func headerSection(_ pullReq: PullRequest) -> some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(pullReq.title)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.accessibilityIdentifier("pr-detail-title")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.status.icon)
|
||||
Text(viewModel.status.text)
|
||||
}
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.foregroundStyle(.white)
|
||||
.glassEffect(.regular.tint(viewModel.status.color))
|
||||
|
||||
Text("#\(pullReq.number)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("\(pullReq.user.login) opened \(formatRelativeDate(pullReq.createdAt))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Label(pullReq.head.ref, systemImage: "arrow.branch")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(pullReq.base.ref)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !viewModel.isMerged {
|
||||
mergeableIndicator(pullReq)
|
||||
}
|
||||
}
|
||||
|
||||
if !pullReq.labels.isEmpty {
|
||||
FlowLayout(spacing: 4) {
|
||||
ForEach(pullReq.labels) { label in
|
||||
IssueLabelView(label: label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowBackground(viewModel.status.color.opacity(0.08))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func mergeableIndicator(_ pullReq: PullRequest) -> some View {
|
||||
if pullReq.mergeable == true {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
} else if pullReq.mergeable == false {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func requestedReviewersSection(_ pullReq: PullRequest) -> some View {
|
||||
if let reviewers = pullReq.requestedReviewers, !reviewers.isEmpty {
|
||||
Section("Requested Reviewers") {
|
||||
ForEach(reviewers) { user in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eye")
|
||||
.foregroundStyle(.orange)
|
||||
Text(user.fullName ?? user.login)
|
||||
.font(.subheadline)
|
||||
if user.fullName != nil {
|
||||
Text("@\(user.login)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var reviewsSection: some View {
|
||||
if !viewModel.reviews.isEmpty {
|
||||
Section("Reviews (\(viewModel.reviews.count))") {
|
||||
ForEach(viewModel.reviews) { review in
|
||||
ReviewSummaryView(
|
||||
review: review,
|
||||
comments: viewModel.reviewComments[review.id] ?? [],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var changesSection: some View {
|
||||
@Bindable var viewModel = viewModel
|
||||
return Section {
|
||||
DisclosureGroup("Changes", isExpanded: $viewModel.isDiffExpanded) {
|
||||
if viewModel.isLoadingDiff {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if let parsed = viewModel.parsedDiff {
|
||||
DiffView(
|
||||
diff: parsed,
|
||||
reviewComments: viewModel.allReviewComments,
|
||||
) { line, path in
|
||||
viewModel.inlineCommentContext = InlineCommentContext(line: line, path: path)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.isDiffExpanded) {
|
||||
if viewModel.isDiffExpanded, viewModel.parsedDiff == nil, !viewModel.isLoadingDiff {
|
||||
Task { await viewModel.loadDiff() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func conflictSection(_ pullReq: PullRequest) -> some View {
|
||||
if pullReq.stateValue == .open, pullReq.mergeable == false, !viewModel.isMerged {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("This pull request has conflicts that must be resolved via git or the web interface.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var commentsSection: some View {
|
||||
if !viewModel.comments.isEmpty {
|
||||
Section("Comments (\(viewModel.comments.count))") {
|
||||
ForEach(viewModel.comments) { comment in
|
||||
CommentView(
|
||||
comment: comment,
|
||||
currentUsername: authService.currentUser?.login,
|
||||
) { newBody in
|
||||
await viewModel.editComment(commentId: comment.id, body: newBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func actionMenu(_ pullReq: PullRequest) -> some View {
|
||||
ExpandableActionMenu(isExpanded: $showActionsExpanded) {
|
||||
Button { showCommentSheet = true } label: {
|
||||
Label("Comment", systemImage: "text.bubble")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-comment-button")
|
||||
|
||||
if viewModel.canEditOrClose {
|
||||
Button { showEditSheet = true } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-edit-button")
|
||||
}
|
||||
|
||||
if pullReq.stateValue == .open {
|
||||
Button { showSubmitReviewSheet = true } label: {
|
||||
Label("Review", systemImage: "checkmark.message.fill")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.blue)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-submit-review")
|
||||
}
|
||||
|
||||
if viewModel.canMerge, pullReq.stateValue == .open, pullReq.draft != true, pullReq.mergeable != false {
|
||||
Button { showMergeSheet = true } label: {
|
||||
Label("Merge", systemImage: "arrow.triangle.merge")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.purple)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-merge-button")
|
||||
}
|
||||
|
||||
if !viewModel.isMerged, viewModel.canEditOrClose {
|
||||
Button { Task { await viewModel.togglePRState() } } label: {
|
||||
if viewModel.isTogglingState {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Label(
|
||||
pullReq.stateValue == .open ? "Close" : "Reopen",
|
||||
systemImage: pullReq.stateValue == .open ? "xmark.circle" : "arrow.uturn.left.circle",
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(pullReq.stateValue == .open ? .red : .green)
|
||||
.disabled(viewModel.isTogglingState)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-toggle-state")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +1,43 @@
|
|||
import ForgejoKit
|
||||
|
||||
// swiftlint:disable file_length
|
||||
import SwiftUI
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
struct PullRequestDetailView: View {
|
||||
@State private var repository: Repository
|
||||
let prNumber: Int
|
||||
@State private var authService: AuthenticationService
|
||||
@State private var pullRequest: PullRequest?
|
||||
@State private var comments: [IssueComment] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var showEditSheet = false
|
||||
@State private var showMergeSheet = false
|
||||
@State private var isTogglingState = false
|
||||
@State private var showCommentSheet = false
|
||||
@State private var diffText: String?
|
||||
@State private var isDiffExpanded = false
|
||||
@State private var isLoadingDiff = false
|
||||
@State var authService: AuthenticationService
|
||||
@State var viewModel: PullRequestDetailViewModel
|
||||
|
||||
// Reviews
|
||||
@State private var reviews: [PullRequestReview] = []
|
||||
@State private var reviewComments: [Int: [ReviewComment]] = [:]
|
||||
@State private var parsedDiff: ParsedDiff?
|
||||
@State private var assignees: [User] = []
|
||||
@State private var showSubmitReviewSheet = false
|
||||
@State private var inlineCommentContext: InlineCommentContext?
|
||||
@State private var showActionsExpanded = false
|
||||
@State var showEditSheet = false
|
||||
@State var showMergeSheet = false
|
||||
@State var showCommentSheet = false
|
||||
@State var showSubmitReviewSheet = false
|
||||
@State var showActionsExpanded = false
|
||||
|
||||
private let prService: PullRequestService?
|
||||
private let issueService: IssueService?
|
||||
private let repositoryService: RepositoryService?
|
||||
let prService: PullRequestService?
|
||||
|
||||
init(repository: Repository, prNumber: Int, authService: AuthenticationService) {
|
||||
self.repository = repository
|
||||
self.prNumber = prNumber
|
||||
self.authService = authService
|
||||
prService = authService.client.map { PullRequestService(client: $0) }
|
||||
issueService = authService.client.map { IssueService(client: $0) }
|
||||
repositoryService = authService.client.map { RepositoryService(client: $0) }
|
||||
}
|
||||
|
||||
private var owner: String {
|
||||
repository.owner
|
||||
}
|
||||
|
||||
private var repo: String {
|
||||
repository.repoName
|
||||
}
|
||||
|
||||
private var isMerged: Bool {
|
||||
pullRequest?.merged == true
|
||||
}
|
||||
|
||||
private var hasPushPermission: Bool {
|
||||
repository.permissions?.push == true || repository.permissions?.admin == true
|
||||
}
|
||||
|
||||
private var isOwnPR: Bool {
|
||||
guard let currentUser = authService.currentUser?.login,
|
||||
let prAuthor = pullRequest?.user.login
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return currentUser == prAuthor
|
||||
}
|
||||
|
||||
private var canEditOrClose: Bool {
|
||||
hasPushPermission || isOwnPR
|
||||
}
|
||||
|
||||
private var canMerge: Bool {
|
||||
hasPushPermission
|
||||
}
|
||||
|
||||
private var status: PRStatusStyle {
|
||||
PRStatusStyle(
|
||||
state: pullRequest?.stateValue ?? .closed,
|
||||
merged: pullRequest?.merged,
|
||||
draft: pullRequest?.draft,
|
||||
)
|
||||
let prService = authService.client.map { PullRequestService(client: $0) }
|
||||
let repositoryService = authService.client.map { RepositoryService(client: $0) }
|
||||
let issueService = authService.client.map { IssueService(client: $0) }
|
||||
self.prService = prService
|
||||
_viewModel = State(initialValue: PullRequestDetailViewModel(
|
||||
repository: repository,
|
||||
prNumber: prNumber,
|
||||
prService: prService,
|
||||
repositoryService: repositoryService,
|
||||
issueService: issueService,
|
||||
currentUserLogin: authService.currentUser?.login,
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var viewModel = viewModel
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading, pullRequest == nil {
|
||||
if viewModel.isLoading, viewModel.pullRequest == nil {
|
||||
ProgressView()
|
||||
} else if let activePR = pullRequest {
|
||||
} else if let activePR = viewModel.pullRequest {
|
||||
List {
|
||||
headerSection(activePR)
|
||||
|
||||
|
|
@ -130,492 +78,96 @@ struct PullRequestDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
if let activePR = pullRequest {
|
||||
if let activePR = viewModel.pullRequest {
|
||||
actionMenu(activePR)
|
||||
}
|
||||
}
|
||||
.navigationTitle("#\(prNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showEditSheet) {
|
||||
if let editingPR = pullRequest {
|
||||
if let editingPR = viewModel.pullRequest {
|
||||
PullRequestEditView(
|
||||
repository: repository,
|
||||
repository: viewModel.repository,
|
||||
pullRequest: editingPR,
|
||||
authService: authService,
|
||||
onUploadImage: uploadPRImage,
|
||||
onUploadImage: viewModel.uploadImage,
|
||||
) { _ in
|
||||
Task { await loadData() }
|
||||
Task { await viewModel.loadData() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMergeSheet) {
|
||||
if let mergingPR = pullRequest {
|
||||
if let mergingPR = viewModel.pullRequest {
|
||||
PullRequestMergeView(
|
||||
repository: repository,
|
||||
repository: viewModel.repository,
|
||||
pullRequest: mergingPR,
|
||||
authService: authService,
|
||||
) {
|
||||
Task { await loadData() }
|
||||
Task { await viewModel.loadData() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSubmitReviewSheet) {
|
||||
PullRequestReviewSheet(
|
||||
repository: repository,
|
||||
repository: viewModel.repository,
|
||||
prNumber: prNumber,
|
||||
authService: authService,
|
||||
isOwnPR: isOwnPR,
|
||||
isOwnPR: viewModel.isOwnPR,
|
||||
) {
|
||||
Task { await loadData() }
|
||||
Task { await viewModel.loadData() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $inlineCommentContext) { context in
|
||||
.sheet(item: $viewModel.inlineCommentContext) { context in
|
||||
if let prService {
|
||||
InlineCommentSheet(
|
||||
context: context,
|
||||
prService: prService,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
owner: viewModel.owner,
|
||||
repo: viewModel.repo,
|
||||
prNumber: prNumber,
|
||||
isOwnPR: isOwnPR,
|
||||
isOwnPR: viewModel.isOwnPR,
|
||||
) {
|
||||
Task { await loadReviews() }
|
||||
Task { await viewModel.loadReviews() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCommentSheet) {
|
||||
CommentSheet(users: assignees, onUploadImage: uploadPRImage) { body in
|
||||
guard let prService else { throw URLError(.userAuthenticationRequired) }
|
||||
let comment = try await prService.createComment(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: prNumber,
|
||||
body: body,
|
||||
)
|
||||
comments.append(comment)
|
||||
CommentSheet(users: viewModel.assignees, onUploadImage: viewModel.uploadImage) { body in
|
||||
try await viewModel.addComment(body: body)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadData()
|
||||
await viewModel.loadData()
|
||||
}
|
||||
.errorAlert(message: $errorMessage, isPresented: $showError)
|
||||
.errorAlert(message: $viewModel.errorMessage, isPresented: $viewModel.showError)
|
||||
}
|
||||
|
||||
// MARK: - View Sections
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
private func headerSection(_ pullReq: PullRequest) -> some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(pullReq.title)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.accessibilityIdentifier("pr-detail-title")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: status.icon)
|
||||
Text(status.text)
|
||||
}
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.foregroundStyle(.white)
|
||||
.glassEffect(.regular.tint(status.color))
|
||||
|
||||
Text("#\(pullReq.number)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("\(pullReq.user.login) opened \(formatRelativeDate(pullReq.createdAt))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Label(pullReq.head.ref, systemImage: "arrow.branch")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(pullReq.base.ref)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.glassEffect(.regular)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !isMerged {
|
||||
mergeableIndicator(pullReq)
|
||||
}
|
||||
}
|
||||
|
||||
if !pullReq.labels.isEmpty {
|
||||
FlowLayout(spacing: 4) {
|
||||
ForEach(pullReq.labels) { label in
|
||||
IssueLabelView(label: label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowBackground(status.color.opacity(0.08))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mergeableIndicator(_ pullReq: PullRequest) -> some View {
|
||||
if pullReq.mergeable == true {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.caption)
|
||||
} else if pullReq.mergeable == false {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func requestedReviewersSection(_ pullReq: PullRequest) -> some View {
|
||||
if let reviewers = pullReq.requestedReviewers, !reviewers.isEmpty {
|
||||
Section("Requested Reviewers") {
|
||||
ForEach(reviewers) { user in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eye")
|
||||
.foregroundStyle(.orange)
|
||||
Text(user.fullName ?? user.login)
|
||||
.font(.subheadline)
|
||||
if user.fullName != nil {
|
||||
Text("@\(user.login)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var reviewsSection: some View {
|
||||
if !reviews.isEmpty {
|
||||
Section("Reviews (\(reviews.count))") {
|
||||
ForEach(reviews) { review in
|
||||
ReviewSummaryView(
|
||||
review: review,
|
||||
comments: reviewComments[review.id] ?? [],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var changesSection: some View {
|
||||
Section {
|
||||
DisclosureGroup("Changes", isExpanded: $isDiffExpanded) {
|
||||
if isLoadingDiff {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if let parsed = parsedDiff {
|
||||
DiffView(
|
||||
diff: parsed,
|
||||
reviewComments: allReviewComments,
|
||||
) { line, path in
|
||||
inlineCommentContext = InlineCommentContext(line: line, path: path)
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.onChange(of: isDiffExpanded) {
|
||||
if isDiffExpanded, diffText == nil, !isLoadingDiff {
|
||||
Task { await loadDiff() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func conflictSection(_ pullReq: PullRequest) -> some View {
|
||||
if pullReq.stateValue == .open, pullReq.mergeable == false, !isMerged {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("This pull request has conflicts that must be resolved via git or the web interface.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var commentsSection: some View {
|
||||
if !comments.isEmpty {
|
||||
Section("Comments (\(comments.count))") {
|
||||
ForEach(comments) { comment in
|
||||
CommentView(
|
||||
comment: comment,
|
||||
currentUsername: authService.currentUser?.login,
|
||||
) { newBody in
|
||||
await editComment(commentId: comment.id, body: newBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
private func actionMenu(_ pullReq: PullRequest) -> some View {
|
||||
ExpandableActionMenu(isExpanded: $showActionsExpanded) {
|
||||
Button { showCommentSheet = true } label: {
|
||||
Label("Comment", systemImage: "text.bubble")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-comment-button")
|
||||
|
||||
if canEditOrClose {
|
||||
Button { showEditSheet = true } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-edit-button")
|
||||
}
|
||||
|
||||
if pullReq.stateValue == .open {
|
||||
Button { showSubmitReviewSheet = true } label: {
|
||||
Label("Review", systemImage: "checkmark.message.fill")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.blue)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-submit-review")
|
||||
}
|
||||
|
||||
if canMerge, pullReq.stateValue == .open, pullReq.draft != true, pullReq.mergeable != false {
|
||||
Button { showMergeSheet = true } label: {
|
||||
Label("Merge", systemImage: "arrow.triangle.merge")
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.purple)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-merge-button")
|
||||
}
|
||||
|
||||
if !isMerged, canEditOrClose {
|
||||
Button { Task { await togglePRState() } } label: {
|
||||
if isTogglingState {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Label(
|
||||
pullReq.stateValue == .open ? "Close" : "Reopen",
|
||||
systemImage: pullReq.stateValue == .open ? "xmark.circle" : "arrow.uturn.left.circle",
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(pullReq.stateValue == .open ? .red : .green)
|
||||
.disabled(isTogglingState)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityIdentifier("pr-toggle-state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
private var allReviewComments: [ReviewComment] {
|
||||
reviewComments.values.flatMap(\.self)
|
||||
}
|
||||
|
||||
private func uploadPRImage(data: Data, fileName: String, mimeType: String) async throws -> String {
|
||||
guard let issueService else { throw URLError(.userAuthenticationRequired) }
|
||||
let attachment = try await issueService.uploadIssueAttachment(
|
||||
owner: owner, repo: repo, index: prNumber,
|
||||
fileData: data, fileName: fileName, mimeType: mimeType,
|
||||
)
|
||||
return attachmentMarkdown(for: attachment)
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
guard let prService, let repositoryService else { return }
|
||||
isLoading = true
|
||||
do {
|
||||
async let fetchedPR = prService.fetchPullRequest(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedComments = prService.fetchComments(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedReviews = prService.fetchReviews(owner: owner, repo: repo, index: prNumber)
|
||||
async let fetchedAssignees = repositoryService.fetchAssignees(owner: owner, repo: repo)
|
||||
async let fetchedRepo = repositoryService.fetchRepository(owner: owner, repo: repo)
|
||||
|
||||
let loadedPR = try await fetchedPR
|
||||
let loadedComments = try await fetchedComments
|
||||
let loadedReviews = try await fetchedReviews
|
||||
let loadedAssignees = await (try? fetchedAssignees) ?? []
|
||||
|
||||
try Task.checkCancellation()
|
||||
pullRequest = loadedPR
|
||||
comments = loadedComments
|
||||
reviews = loadedReviews
|
||||
assignees = loadedAssignees
|
||||
if let fullRepo = try? await fetchedRepo {
|
||||
repository = fullRepo
|
||||
}
|
||||
|
||||
reviewComments = await fetchAllReviewComments(for: reviews)
|
||||
|
||||
if !allReviewComments.isEmpty {
|
||||
await loadDiff()
|
||||
isDiffExpanded = true
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadReviews() async {
|
||||
guard let prService else { return }
|
||||
do {
|
||||
reviews = try await prService.fetchReviews(owner: owner, repo: repo, index: prNumber)
|
||||
reviewComments = await fetchAllReviewComments(for: reviews)
|
||||
|
||||
if !allReviewComments.isEmpty {
|
||||
if diffText == nil {
|
||||
await loadDiff()
|
||||
}
|
||||
isDiffExpanded = true
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAllReviewComments(for reviews: [PullRequestReview]) async -> [Int: [ReviewComment]] {
|
||||
guard let prService else { return [:] }
|
||||
return await withTaskGroup(of: (Int, [ReviewComment])?.self) { group in
|
||||
for review in reviews {
|
||||
group.addTask {
|
||||
guard let comments = try? await prService.fetchReviewComments(
|
||||
owner: owner, repo: repo, index: prNumber, reviewId: review.id,
|
||||
), !comments.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (review.id, comments)
|
||||
}
|
||||
}
|
||||
var commentsMap: [Int: [ReviewComment]] = [:]
|
||||
for await result in group {
|
||||
if let (reviewId, comments) = result {
|
||||
commentsMap[reviewId] = comments
|
||||
}
|
||||
}
|
||||
return commentsMap
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDiff() async {
|
||||
guard let prService else { return }
|
||||
isLoadingDiff = true
|
||||
do {
|
||||
let raw = try await prService.fetchDiff(owner: owner, repo: repo, index: prNumber)
|
||||
try Task.checkCancellation()
|
||||
diffText = raw
|
||||
parsedDiff = DiffParser.parse(raw)
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
isLoadingDiff = false
|
||||
}
|
||||
|
||||
private func togglePRState() async {
|
||||
guard let prService, let currentPR = pullRequest else { return }
|
||||
isTogglingState = true
|
||||
|
||||
do {
|
||||
let newState = currentPR.stateValue == .open
|
||||
? PullRequestState.closed.rawValue : PullRequestState.open.rawValue
|
||||
let updated = try await prService.editPullRequest(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: prNumber,
|
||||
title: nil,
|
||||
body: nil,
|
||||
state: newState,
|
||||
)
|
||||
pullRequest = updated
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
||||
isTogglingState = false
|
||||
}
|
||||
|
||||
private func editComment(commentId: Int, body: String) async -> Bool {
|
||||
guard let prService else { return false }
|
||||
do {
|
||||
let updated = try await prService.editComment(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
commentId: commentId,
|
||||
body: body,
|
||||
)
|
||||
if let index = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(preview _: Void, repository: Repository, prNumber: Int, authService: AuthenticationService,
|
||||
pullRequest: PullRequest, comments: [IssueComment] = [], reviews: [PullRequestReview] = [])
|
||||
{
|
||||
self.repository = repository
|
||||
self.prNumber = prNumber
|
||||
self.authService = authService
|
||||
prService = nil
|
||||
issueService = nil
|
||||
repositoryService = nil
|
||||
_pullRequest = State(initialValue: pullRequest)
|
||||
_comments = State(initialValue: comments)
|
||||
_reviews = State(initialValue: reviews)
|
||||
_isLoading = State(initialValue: false)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PullRequestDetailView {
|
||||
init(
|
||||
preview _: Void,
|
||||
repository: Repository,
|
||||
prNumber: Int,
|
||||
authService: AuthenticationService,
|
||||
pullRequest: PullRequest,
|
||||
comments: [IssueComment] = [],
|
||||
reviews: [PullRequestReview] = [],
|
||||
) {
|
||||
self.prNumber = prNumber
|
||||
self.authService = authService
|
||||
prService = nil
|
||||
_viewModel = State(initialValue: PullRequestDetailViewModel(
|
||||
preview: (),
|
||||
repository: repository,
|
||||
prNumber: prNumber,
|
||||
pullRequest: pullRequest,
|
||||
comments: comments,
|
||||
reviews: reviews,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
PullRequestDetailView(
|
||||
|
|
|
|||
227
Forji/ForjiTests/PullRequestDetailViewModelTests.swift
Normal file
227
Forji/ForjiTests/PullRequestDetailViewModelTests.swift
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import ForgejoKit
|
||||
@testable import Forji
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
private final class FakePullRequestDetailService:
|
||||
PullRequestServiceProtocol, RepositoryServiceProtocol, IssueServiceProtocol
|
||||
{
|
||||
var pullRequestToReturn: PullRequest = .preview
|
||||
var commentsToReturn: [IssueComment] = []
|
||||
var reviewsToReturn: [PullRequestReview] = []
|
||||
var reviewCommentsToReturn: [ReviewComment] = []
|
||||
var assigneesToReturn: [User] = []
|
||||
var repositoryToReturn: Repository = .preview
|
||||
var diffToReturn = ""
|
||||
var editedPullRequestToReturn: PullRequest = .preview
|
||||
var editedCommentToReturn: IssueComment = .preview
|
||||
var createdCommentToReturn: IssueComment = .preview
|
||||
var attachmentToReturn: ForgejoKit.Attachment = .previewImage
|
||||
|
||||
var fetchError: Error?
|
||||
var editError: Error?
|
||||
var editCommentError: Error?
|
||||
|
||||
private(set) var editedStates: [String] = []
|
||||
|
||||
func fetchPullRequest(owner _: String, repo _: String, index _: Int) async throws -> PullRequest {
|
||||
if let fetchError { throw fetchError }
|
||||
return pullRequestToReturn
|
||||
}
|
||||
|
||||
func fetchComments(owner _: String, repo _: String, index _: Int) async throws -> [IssueComment] {
|
||||
commentsToReturn
|
||||
}
|
||||
|
||||
func fetchReviews(owner _: String, repo _: String, index _: Int) async throws -> [PullRequestReview] {
|
||||
reviewsToReturn
|
||||
}
|
||||
|
||||
func fetchReviewComments(
|
||||
owner _: String, repo _: String, index _: Int, reviewId _: Int,
|
||||
) async throws -> [ReviewComment] {
|
||||
reviewCommentsToReturn
|
||||
}
|
||||
|
||||
func fetchDiff(owner _: String, repo _: String, index _: Int) async throws -> String {
|
||||
diffToReturn
|
||||
}
|
||||
|
||||
func setState(owner _: String, repo _: String, index _: Int, state: String) async throws -> PullRequest {
|
||||
editedStates.append(state)
|
||||
if let editError { throw editError }
|
||||
return editedPullRequestToReturn
|
||||
}
|
||||
|
||||
func createComment(owner _: String, repo _: String, index _: Int, body _: String) async throws -> IssueComment {
|
||||
createdCommentToReturn
|
||||
}
|
||||
|
||||
func editComment(
|
||||
owner _: String, repo _: String, commentId _: Int, body _: String,
|
||||
) async throws -> IssueComment {
|
||||
if let editCommentError { throw editCommentError }
|
||||
return editedCommentToReturn
|
||||
}
|
||||
|
||||
func fetchRepository(owner _: String, repo _: String) async throws -> Repository {
|
||||
repositoryToReturn
|
||||
}
|
||||
|
||||
func fetchAssignees(owner _: String, repo _: String) async throws -> [User] {
|
||||
assigneesToReturn
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func uploadIssueAttachment(
|
||||
owner _: String, repo _: String, index _: Int, fileData _: Data, fileName _: String, mimeType _: String,
|
||||
) async throws -> ForgejoKit.Attachment {
|
||||
attachmentToReturn
|
||||
}
|
||||
}
|
||||
|
||||
private struct FetchFailed: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
"fetch failed"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PullRequestDetailViewModelTests {
|
||||
private func makeViewModel(
|
||||
service: FakePullRequestDetailService,
|
||||
currentUserLogin: String? = "testuser",
|
||||
repository: Repository = .preview,
|
||||
) -> PullRequestDetailViewModel {
|
||||
PullRequestDetailViewModel(
|
||||
repository: repository,
|
||||
prNumber: 4,
|
||||
prService: service,
|
||||
repositoryService: service,
|
||||
issueService: service,
|
||||
currentUserLogin: currentUserLogin,
|
||||
)
|
||||
}
|
||||
|
||||
@Test("load populates the detail from the services") func loadPopulatesDetail() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.commentsToReturn = [.preview]
|
||||
service.assigneesToReturn = [.preview]
|
||||
service.reviewsToReturn = [.preview]
|
||||
let viewModel = makeViewModel(service: service)
|
||||
|
||||
await viewModel.loadData()
|
||||
|
||||
#expect(viewModel.pullRequest?.number == 4)
|
||||
#expect(viewModel.comments.count == 1)
|
||||
#expect(viewModel.assignees.count == 1)
|
||||
#expect(viewModel.reviews.count == 1)
|
||||
#expect(!viewModel.isLoading)
|
||||
}
|
||||
|
||||
@Test("load failure surfaces the error and leaves no pull request") func loadFailureSurfacesError() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.fetchError = FetchFailed()
|
||||
let viewModel = makeViewModel(service: service)
|
||||
|
||||
await viewModel.loadData()
|
||||
|
||||
#expect(viewModel.pullRequest == nil)
|
||||
#expect(viewModel.showError)
|
||||
#expect(viewModel.errorMessage == "fetch failed")
|
||||
}
|
||||
|
||||
@Test("toggling an open pull request requests the closed state") func togglesToClosed() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
let viewModel = makeViewModel(service: service)
|
||||
await viewModel.loadData()
|
||||
|
||||
await viewModel.togglePRState()
|
||||
|
||||
#expect(service.editedStates == [PullRequestState.closed.rawValue])
|
||||
#expect(!viewModel.isTogglingState)
|
||||
}
|
||||
|
||||
@Test("toggle failure surfaces the error") func toggleFailureSurfacesError() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
let viewModel = makeViewModel(service: service)
|
||||
await viewModel.loadData()
|
||||
service.editError = FetchFailed()
|
||||
|
||||
await viewModel.togglePRState()
|
||||
|
||||
#expect(viewModel.showError)
|
||||
#expect(viewModel.errorMessage == "fetch failed")
|
||||
}
|
||||
|
||||
@Test("editing a comment replaces it in place") func editCommentUpdatesInPlace() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.editedCommentToReturn = IssueComment(
|
||||
id: 1, body: "edited body", user: .preview, createdAt: Date(), updatedAt: Date(),
|
||||
)
|
||||
let viewModel = makeViewModel(service: service)
|
||||
viewModel.comments = [.preview]
|
||||
|
||||
let succeeded = await viewModel.editComment(commentId: 1, body: "edited body")
|
||||
|
||||
#expect(succeeded)
|
||||
#expect(viewModel.comments.first?.body == "edited body")
|
||||
}
|
||||
|
||||
@Test("edit failure returns false and surfaces the error") func editCommentFailure() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.editCommentError = FetchFailed()
|
||||
let viewModel = makeViewModel(service: service)
|
||||
viewModel.comments = [.preview]
|
||||
|
||||
let succeeded = await viewModel.editComment(commentId: 1, body: "x")
|
||||
|
||||
#expect(!succeeded)
|
||||
#expect(viewModel.showError)
|
||||
}
|
||||
|
||||
@Test("adding a comment appends the created comment") func addCommentAppends() async throws {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.createdCommentToReturn = IssueComment(
|
||||
id: 2, body: "a new comment", user: .preview, createdAt: Date(), updatedAt: Date(),
|
||||
)
|
||||
let viewModel = makeViewModel(service: service)
|
||||
|
||||
try await viewModel.addComment(body: "a new comment")
|
||||
|
||||
#expect(viewModel.comments.map(\.id).contains(2))
|
||||
}
|
||||
|
||||
@Test("isOwnPR matches the current user against the pull request author") func isOwnPRMatchesAuthor() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
let ownViewModel = makeViewModel(service: service, currentUserLogin: "testuser")
|
||||
await ownViewModel.loadData()
|
||||
#expect(ownViewModel.isOwnPR)
|
||||
#expect(ownViewModel.canEditOrClose)
|
||||
|
||||
let otherViewModel = makeViewModel(service: service, currentUserLogin: "someone-else")
|
||||
await otherViewModel.loadData()
|
||||
#expect(!otherViewModel.isOwnPR)
|
||||
}
|
||||
|
||||
@Test("loading the diff parses it") func loadDiffParses() async {
|
||||
let service = FakePullRequestDetailService()
|
||||
service.diffToReturn = "diff --git a/README.md b/README.md\n@@ -1 +1,2 @@\n # Repo\n+New line"
|
||||
let viewModel = makeViewModel(service: service)
|
||||
|
||||
await viewModel.loadDiff()
|
||||
|
||||
#expect(viewModel.parsedDiff != nil)
|
||||
#expect(!viewModel.isLoadingDiff)
|
||||
}
|
||||
|
||||
@Test("uploading an image returns attachment markdown") func uploadImageReturnsMarkdown() async throws {
|
||||
let service = FakePullRequestDetailService()
|
||||
let viewModel = makeViewModel(service: service)
|
||||
|
||||
let markdown = try await viewModel.uploadImage(data: Data(), fileName: "image.png", mimeType: "image/png")
|
||||
|
||||
#expect(markdown == attachmentMarkdown(for: .previewImage))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue