mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Summary Project issues, pull requests, reviews, and commit details no longer flatten rich content into inert or plain text. They now share the message markdown and media pipeline, preserving NIP-92 `imeta` metadata so links, images, and videos render consistently. Commit bodies are fetched only when a single-commit detail view is opened, keeping list queries lightweight while exposing full context beside the diff. ### Related issue None found. ### Testing - Pre-push suite: `desktop-check`, `desktop-test`, `desktop-tauri-test`, `rust-tests`, and `mobile-test` - Project issue and pull request regression tests cover preserving attachment metadata on roots, updates, and comments - The project commit detail smoke scenario verifies linked text, images, and video in commit bodies Signed-off-by: Thomas Petersen <thomasp@squareup.com>
620 lines
16 KiB
TypeScript
620 lines
16 KiB
TypeScript
import type {
|
|
ProjectLocalRepository,
|
|
ProjectLocalRepoSnapshot,
|
|
ProjectRepoBranchResult,
|
|
ProjectRepoCloneResult,
|
|
ProjectRepoDiff,
|
|
ProjectRepoMergeResult,
|
|
ProjectRepoPullResult,
|
|
ProjectRepoPushResult,
|
|
ProjectRepoSnapshot,
|
|
ProjectRepoSyncStatus,
|
|
} from "@/shared/api/types";
|
|
import { invokeTauri, TauriInvokeError } from "@/shared/api/tauri";
|
|
|
|
type RawProjectRepoCommit = {
|
|
hash: string;
|
|
short_hash: string;
|
|
author_name: string;
|
|
author_email: string;
|
|
timestamp: number;
|
|
subject: string;
|
|
};
|
|
|
|
function fromRawProjectRepoCommit(commit: RawProjectRepoCommit) {
|
|
return {
|
|
hash: commit.hash,
|
|
shortHash: commit.short_hash,
|
|
authorName: commit.author_name,
|
|
authorEmail: commit.author_email,
|
|
timestamp: commit.timestamp,
|
|
subject: commit.subject,
|
|
};
|
|
}
|
|
|
|
type RawProjectRepoFile = {
|
|
path: string;
|
|
kind: string;
|
|
size: number | null;
|
|
preview_content: string | null;
|
|
last_changed_at: number | null;
|
|
latest_commit: RawProjectRepoCommit | null;
|
|
};
|
|
|
|
type RawProjectRepoContributor = {
|
|
name: string;
|
|
email: string;
|
|
commit_count: number;
|
|
last_commit_at: number;
|
|
};
|
|
|
|
type RawProjectRepoSnapshot = {
|
|
latest_commit: RawProjectRepoCommit | null;
|
|
commits?: RawProjectRepoCommit[];
|
|
files: RawProjectRepoFile[];
|
|
contributors?: RawProjectRepoContributor[];
|
|
};
|
|
|
|
type RawProjectLocalRepoSnapshot = {
|
|
path: string;
|
|
snapshot: RawProjectRepoSnapshot;
|
|
};
|
|
|
|
type RawProjectLocalRepository = {
|
|
name: string;
|
|
path: string;
|
|
};
|
|
|
|
type RawProjectRepoSyncStatus = {
|
|
local_path: string | null;
|
|
local_branch: string | null;
|
|
local_branches: string[];
|
|
local_head: string | null;
|
|
local_short_head: string | null;
|
|
remote_branch: string | null;
|
|
remote_head: string | null;
|
|
remote_short_head: string | null;
|
|
merge_base: string | null;
|
|
ahead_count: number;
|
|
behind_count: number;
|
|
has_uncommitted_changes: boolean;
|
|
has_untracked_files: boolean;
|
|
can_push: boolean;
|
|
push_block_reason: string | null;
|
|
can_pull: boolean;
|
|
pull_block_reason: string | null;
|
|
};
|
|
|
|
type RawProjectRepoPushResult = {
|
|
pushed: boolean;
|
|
message: string;
|
|
branch: string;
|
|
commit: string;
|
|
merge_base: string | null;
|
|
};
|
|
|
|
type RawProjectRepoPullResult = {
|
|
pulled: boolean;
|
|
message: string;
|
|
};
|
|
|
|
type RawProjectRepoBranchResult = {
|
|
branch: string;
|
|
commit: string;
|
|
message: string;
|
|
};
|
|
|
|
type RawProjectRepoDiffFile = {
|
|
path: string;
|
|
additions: number;
|
|
deletions: number;
|
|
patch: string;
|
|
truncated: boolean;
|
|
};
|
|
|
|
type RawProjectRepoDiff = {
|
|
files: RawProjectRepoDiffFile[];
|
|
additions: number;
|
|
deletions: number;
|
|
commit_body: string | null;
|
|
};
|
|
|
|
function fromRawProjectRepoSnapshot(
|
|
snapshot: RawProjectRepoSnapshot,
|
|
): ProjectRepoSnapshot {
|
|
return {
|
|
latestCommit: snapshot.latest_commit
|
|
? fromRawProjectRepoCommit(snapshot.latest_commit)
|
|
: null,
|
|
commits: (snapshot.commits ?? []).map(fromRawProjectRepoCommit),
|
|
files: snapshot.files.map((file) => ({
|
|
path: file.path,
|
|
kind: file.kind,
|
|
size: file.size,
|
|
previewContent: file.preview_content,
|
|
lastChangedAt: file.last_changed_at,
|
|
latestCommit: file.latest_commit
|
|
? fromRawProjectRepoCommit(file.latest_commit)
|
|
: null,
|
|
})),
|
|
contributors: (snapshot.contributors ?? []).map((contributor) => ({
|
|
name: contributor.name,
|
|
email: contributor.email,
|
|
commitCount: contributor.commit_count,
|
|
lastCommitAt: contributor.last_commit_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export type GitIdentity = {
|
|
name: string | null;
|
|
email: string | null;
|
|
};
|
|
|
|
/** The viewer's configured git identity (`git config user.name/user.email`). */
|
|
export async function getGitIdentity(): Promise<GitIdentity> {
|
|
return invokeTauri<GitIdentity>("get_git_identity");
|
|
}
|
|
|
|
export async function getProjectRepoSnapshot(input: {
|
|
cloneUrl: string;
|
|
defaultBranch?: string | null;
|
|
baseBranch?: string | null;
|
|
targetRef?: string | null;
|
|
targetCommit?: string | null;
|
|
}): Promise<ProjectRepoSnapshot> {
|
|
const snapshot = await invokeTauri<RawProjectRepoSnapshot>(
|
|
"get_project_repo_snapshot",
|
|
{
|
|
cloneUrl: input.cloneUrl,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
targetRef: input.targetRef ?? null,
|
|
targetCommit: input.targetCommit ?? null,
|
|
},
|
|
);
|
|
return fromRawProjectRepoSnapshot(snapshot);
|
|
}
|
|
|
|
export async function getProjectRepoDiff(input: {
|
|
cloneUrl: string;
|
|
defaultBranch?: string | null;
|
|
baseBranch?: string | null;
|
|
targetRef?: string | null;
|
|
targetCommit?: string | null;
|
|
}): Promise<ProjectRepoDiff> {
|
|
const diff = await invokeTauri<RawProjectRepoDiff>("get_project_repo_diff", {
|
|
cloneUrl: input.cloneUrl,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
targetRef: input.targetRef ?? null,
|
|
targetCommit: input.targetCommit ?? null,
|
|
});
|
|
return {
|
|
additions: diff.additions,
|
|
deletions: diff.deletions,
|
|
commitBody: diff.commit_body,
|
|
files: diff.files.map((file) => ({
|
|
path: file.path,
|
|
additions: file.additions,
|
|
deletions: file.deletions,
|
|
patch: file.patch,
|
|
truncated: file.truncated,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getProjectLocalRepoDiff(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl?: string | null;
|
|
defaultBranch?: string | null;
|
|
baseBranch?: string | null;
|
|
baseCommit?: string | null;
|
|
targetCommit?: string | null;
|
|
}): Promise<ProjectRepoDiff | null> {
|
|
const diff = await invokeTauri<RawProjectRepoDiff | null>(
|
|
"get_project_local_repo_diff",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl ?? null,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
baseCommit: input.baseCommit ?? null,
|
|
targetCommit: input.targetCommit ?? null,
|
|
},
|
|
);
|
|
if (!diff) return null;
|
|
return {
|
|
additions: diff.additions,
|
|
deletions: diff.deletions,
|
|
commitBody: diff.commit_body,
|
|
files: diff.files.map((file) => ({
|
|
path: file.path,
|
|
additions: file.additions,
|
|
deletions: file.deletions,
|
|
patch: file.patch,
|
|
truncated: file.truncated,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getProjectLocalRepoSnapshot(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl?: string | null;
|
|
defaultBranch?: string | null;
|
|
baseBranch?: string | null;
|
|
}): Promise<ProjectLocalRepoSnapshot | null> {
|
|
const localSnapshot = await invokeTauri<RawProjectLocalRepoSnapshot | null>(
|
|
"get_project_local_repo_snapshot",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl ?? null,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
},
|
|
);
|
|
if (!localSnapshot) return null;
|
|
return {
|
|
path: localSnapshot.path,
|
|
snapshot: fromRawProjectRepoSnapshot(localSnapshot.snapshot),
|
|
};
|
|
}
|
|
|
|
export async function listProjectLocalRepositories(input: {
|
|
reposDir?: string | null;
|
|
}): Promise<ProjectLocalRepository[]> {
|
|
const repositories = await invokeTauri<RawProjectLocalRepository[]>(
|
|
"list_project_local_repositories",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
},
|
|
);
|
|
return repositories.map((repository) => ({
|
|
name: repository.name,
|
|
path: repository.path,
|
|
}));
|
|
}
|
|
|
|
function fromRawProjectRepoSyncStatus(
|
|
status: RawProjectRepoSyncStatus,
|
|
): ProjectRepoSyncStatus {
|
|
return {
|
|
localPath: status.local_path,
|
|
localBranch: status.local_branch,
|
|
localBranches: status.local_branches,
|
|
localHead: status.local_head,
|
|
localShortHead: status.local_short_head,
|
|
remoteBranch: status.remote_branch,
|
|
remoteHead: status.remote_head,
|
|
remoteShortHead: status.remote_short_head,
|
|
mergeBase: status.merge_base,
|
|
aheadCount: status.ahead_count,
|
|
behindCount: status.behind_count,
|
|
hasUncommittedChanges: status.has_uncommitted_changes,
|
|
hasUntrackedFiles: status.has_untracked_files,
|
|
canPush: status.can_push,
|
|
pushBlockReason: status.push_block_reason,
|
|
canPull: status.can_pull,
|
|
pullBlockReason: status.pull_block_reason,
|
|
};
|
|
}
|
|
|
|
export async function getProjectRepoSyncStatus(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl: string;
|
|
branchName?: string | null;
|
|
baseBranch?: string | null;
|
|
}): Promise<ProjectRepoSyncStatus> {
|
|
const status = await invokeTauri<RawProjectRepoSyncStatus>(
|
|
"get_project_repo_sync_status",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl,
|
|
branchName: input.branchName ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
},
|
|
);
|
|
return fromRawProjectRepoSyncStatus(status);
|
|
}
|
|
|
|
type RawProjectTerminalResult = {
|
|
path: string;
|
|
cloned: boolean;
|
|
};
|
|
|
|
export async function openProjectTerminal(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl?: string | null;
|
|
defaultBranch?: string | null;
|
|
}): Promise<{ path: string; cloned: boolean }> {
|
|
const result = await invokeTauri<RawProjectTerminalResult>(
|
|
"open_project_terminal",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl ?? null,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
},
|
|
);
|
|
return {
|
|
path: result.path,
|
|
cloned: result.cloned,
|
|
};
|
|
}
|
|
|
|
export async function openProjectMergeRecoveryTerminal(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
targetCloneUrl: string;
|
|
sourceCloneUrl: string;
|
|
targetBranch: string;
|
|
sourceBranch: string;
|
|
expectedCommit: string;
|
|
}): Promise<{
|
|
path: string;
|
|
cloned: boolean;
|
|
recoveryRef: string;
|
|
targetRef: string;
|
|
}> {
|
|
const result = await invokeTauri<{
|
|
path: string;
|
|
cloned: boolean;
|
|
recoveryRef: string;
|
|
targetRef: string;
|
|
}>("open_project_merge_recovery_terminal", {
|
|
input: {
|
|
...input,
|
|
reposDir: input.reposDir ?? null,
|
|
},
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function pushProjectLocalRepository(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl: string;
|
|
branchName?: string | null;
|
|
baseBranch?: string | null;
|
|
}): Promise<ProjectRepoPushResult> {
|
|
const result = await invokeTauri<RawProjectRepoPushResult>(
|
|
"push_project_local_repository",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl,
|
|
branchName: input.branchName ?? null,
|
|
baseBranch: input.baseBranch ?? null,
|
|
},
|
|
);
|
|
return {
|
|
pushed: result.pushed,
|
|
message: result.message,
|
|
branch: result.branch,
|
|
commit: result.commit,
|
|
mergeBase: result.merge_base,
|
|
};
|
|
}
|
|
|
|
export async function pullProjectLocalRepository(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl: string;
|
|
branchName?: string | null;
|
|
}): Promise<ProjectRepoPullResult> {
|
|
const result = await invokeTauri<RawProjectRepoPullResult>(
|
|
"pull_project_local_repository",
|
|
{
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl,
|
|
branchName: input.branchName ?? null,
|
|
},
|
|
);
|
|
return {
|
|
pulled: result.pulled,
|
|
message: result.message,
|
|
};
|
|
}
|
|
|
|
export async function cloneProjectRepository(input: {
|
|
reposDir?: string | null;
|
|
projectDtag: string;
|
|
cloneUrl: string;
|
|
defaultBranch?: string | null;
|
|
}): Promise<ProjectRepoCloneResult> {
|
|
return invokeTauri<ProjectRepoCloneResult>("clone_project_repository", {
|
|
reposDir: input.reposDir ?? null,
|
|
projectDtag: input.projectDtag,
|
|
cloneUrl: input.cloneUrl,
|
|
defaultBranch: input.defaultBranch ?? null,
|
|
});
|
|
}
|
|
|
|
export async function createProjectRemoteBranch(input: {
|
|
cloneUrl: string;
|
|
sourceBranch: string;
|
|
expectedCommit: string;
|
|
newBranch: string;
|
|
}): Promise<ProjectRepoBranchResult> {
|
|
return invokeTauri<RawProjectRepoBranchResult>(
|
|
"create_project_remote_branch",
|
|
{
|
|
cloneUrl: input.cloneUrl,
|
|
sourceBranch: input.sourceBranch,
|
|
expectedCommit: input.expectedCommit,
|
|
newBranch: input.newBranch,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function deleteProjectRemoteBranch(input: {
|
|
cloneUrl: string;
|
|
branch: string;
|
|
expectedCommit: string;
|
|
}): Promise<ProjectRepoBranchResult> {
|
|
return invokeTauri<RawProjectRepoBranchResult>(
|
|
"delete_project_remote_branch",
|
|
{
|
|
cloneUrl: input.cloneUrl,
|
|
branch: input.branch,
|
|
expectedCommit: input.expectedCommit,
|
|
},
|
|
);
|
|
}
|
|
|
|
type RawProjectRepoMergeResult = {
|
|
message: string;
|
|
merge_commit: string;
|
|
status_event: string;
|
|
status_publication_error: string | null;
|
|
};
|
|
|
|
export type ProjectPullRequestMergeRecovery = {
|
|
action: "open_terminal";
|
|
targetBranch: string;
|
|
sourceBranch: string;
|
|
};
|
|
|
|
/** Machine-readable pull-request merge failure returned by the desktop shell. */
|
|
export class ProjectPullRequestMergeError extends Error {
|
|
readonly code: string;
|
|
readonly recovery: ProjectPullRequestMergeRecovery | null;
|
|
|
|
constructor(
|
|
code: string,
|
|
message: string,
|
|
recovery: ProjectPullRequestMergeRecovery | null,
|
|
) {
|
|
super(message);
|
|
this.name = "ProjectPullRequestMergeError";
|
|
this.code = code;
|
|
this.recovery = recovery;
|
|
}
|
|
}
|
|
|
|
function mergeErrorPayload(error: unknown): unknown {
|
|
const payload = error instanceof TauriInvokeError ? error.payload : error;
|
|
if (typeof payload !== "string") return payload;
|
|
try {
|
|
return JSON.parse(payload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Parse a structured native merge error without classifying generic failures. */
|
|
export function parseProjectPullRequestMergeError(
|
|
error: unknown,
|
|
): ProjectPullRequestMergeError | null {
|
|
const payload = mergeErrorPayload(error);
|
|
if (!payload || typeof payload !== "object") return null;
|
|
const candidate = payload as {
|
|
code?: unknown;
|
|
message?: unknown;
|
|
recovery?: unknown;
|
|
};
|
|
if (
|
|
typeof candidate.code !== "string" ||
|
|
typeof candidate.message !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
let recovery: ProjectPullRequestMergeRecovery | null = null;
|
|
if (candidate.recovery !== null && candidate.recovery !== undefined) {
|
|
if (typeof candidate.recovery !== "object") return null;
|
|
const value = candidate.recovery as {
|
|
action?: unknown;
|
|
sourceBranch?: unknown;
|
|
targetBranch?: unknown;
|
|
};
|
|
if (
|
|
value.action !== "open_terminal" ||
|
|
typeof value.targetBranch !== "string" ||
|
|
typeof value.sourceBranch !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
recovery = {
|
|
action: value.action,
|
|
sourceBranch: value.sourceBranch,
|
|
targetBranch: value.targetBranch,
|
|
};
|
|
}
|
|
return new ProjectPullRequestMergeError(
|
|
candidate.code,
|
|
candidate.message,
|
|
recovery,
|
|
);
|
|
}
|
|
|
|
export async function mergeProjectPullRequest(input: {
|
|
targetCloneUrl: string;
|
|
sourceCloneUrl: string;
|
|
targetOwner: string;
|
|
repoAddress: string;
|
|
pullRequestId: string;
|
|
pullRequestAuthor: string;
|
|
statusCreatedAt: number;
|
|
targetBranch: string;
|
|
sourceBranch: string;
|
|
expectedCommit: string;
|
|
}): Promise<ProjectRepoMergeResult> {
|
|
let result: RawProjectRepoMergeResult;
|
|
try {
|
|
result = await invokeTauri<RawProjectRepoMergeResult>(
|
|
"merge_project_pull_request",
|
|
{
|
|
input,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
throw parseProjectPullRequestMergeError(error) ?? error;
|
|
}
|
|
return {
|
|
message: result.message,
|
|
mergeCommit: result.merge_commit,
|
|
statusEvent: result.status_event,
|
|
statusPublicationError: result.status_publication_error,
|
|
};
|
|
}
|
|
|
|
export async function signProjectPullRequestReviewRequest(input: {
|
|
targetOwner: string;
|
|
repoAddress: string;
|
|
pullRequestId: string;
|
|
reviewers: string[];
|
|
reviewerLabel: string;
|
|
}): Promise<void> {
|
|
await invokeTauri<void>("sign_project_pull_request_review_request", {
|
|
input,
|
|
});
|
|
}
|
|
|
|
export async function signProjectPullRequestStatus(input: {
|
|
targetOwner: string;
|
|
repoAddress: string;
|
|
pullRequestId: string;
|
|
pullRequestAuthor: string;
|
|
status: "open" | "draft" | "closed";
|
|
createdAt: number;
|
|
}): Promise<void> {
|
|
await invokeTauri<void>("sign_project_pull_request_status", { input });
|
|
}
|
|
|
|
export async function publishProjectPullRequestMergedStatus(input: {
|
|
targetOwner: string;
|
|
statusEvent: string;
|
|
}): Promise<void> {
|
|
await invokeTauri<void>("publish_project_pull_request_merged_status", {
|
|
input,
|
|
});
|
|
}
|