Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
4a1eef3
fix(web): resize review file trees and show active mode
Bil0000 Sep 12, 2026
e2403c1
fix(web): use one control to fold review files and folders
Bil0000 Sep 12, 2026
0945970
fix(server): guard review saves against checkout changes
Bil0000 Sep 12, 2026
80ef58c
fix(server): serialize worktree removal with review saves
Bil0000 Sep 12, 2026
7bdd593
feat(web): edit review files with explicit save and unsaved warnings
Bil0000 Sep 12, 2026
b4d1fcf
feat(web): edit review diffs directly on click
Bil0000 Sep 12, 2026
ea96a33
fix(web): refresh checkout status before editing PR files
Bil0000 Sep 12, 2026
4c2c8e4
fix(web): discard prepared editors when review diffs change
Bil0000 Sep 12, 2026
04ea1af
fix(web): keep review drafts tied to their checkout
Bil0000 Sep 12, 2026
673b6bf
feat(server): publish PR review edits from isolated worktrees
Bil0000 Sep 12, 2026
888df6e
feat(web): save and publish inline PR edits
Bil0000 Sep 12, 2026
967f86c
fix(web): keep saved PR edits in their source workspace
Bil0000 Sep 12, 2026
7e65b4f
fix(server): serialize all workspace file saves
Bil0000 Sep 12, 2026
263e8cb
test(server): give Git route mocks a repository lock target
Bil0000 Sep 12, 2026
1be5799
fix(web): block PR actions while review files save
Bil0000 Sep 12, 2026
b4864f5
fix(web): preserve server error details in failed requests
Bil0000 Sep 12, 2026
e73c50f
fix(web): keep unsaved review edits open until navigation is confirmed
Bil0000 Sep 13, 2026
0ad945f
fix(web): preserve review editor focus during line edits
Bil0000 Sep 13, 2026
b8d5d9b
feat(web): distinguish changed files and show rename paths in review …
Bil0000 Sep 13, 2026
74acf6e
feat(web): search read-only review diffs with keyboard shortcuts
Bil0000 Sep 13, 2026
e729e09
feat(web): copy review comment links and send comments to the composer
Bil0000 Sep 13, 2026
3302cdf
feat(web): expose permission-gated GitHub merge bypass
Bil0000 Sep 13, 2026
64b9eb2
feat(web): add an optional guided pull request review mode
Bil0000 Sep 13, 2026
6bd88b7
feat(review): stage hunks and commit selected local changes
Bil0000 Sep 13, 2026
1af9cf1
Merge origin/main into combined review improvements
Bil0000 Sep 13, 2026
4d18c10
fix(source-control): guard Forgejo review publishing
Bil0000 Sep 13, 2026
5efac4c
fix(web): disable PR comments while publishing review edits
Bil0000 Sep 13, 2026
45ec54e
fix(mobile): accept staged review sources
Bil0000 Sep 13, 2026
5e77e25
test(web): release tree resize at the final pointer position
Bil0000 Sep 13, 2026
263e9db
fix(web): protect review edits when browser storage fails
Bil0000 Sep 13, 2026
5efff45
fix(web): defer panel activation until review exit is confirmed
Bil0000 Sep 13, 2026
0a3d6ed
fix(web): load guided review pages only on request
Bil0000 Sep 13, 2026
a641544
fix(review): return truncated staged diff previews
Bil0000 Sep 13, 2026
9e97884
fix(review): serialize hunk staging with branch changes
Bil0000 Sep 13, 2026
9240bac
fix(web): expand guided review files after scope changes
Bil0000 Sep 13, 2026
4821757
fix(web): disable PR handoff controls during pending actions
Bil0000 Sep 13, 2026
fd06796
fix(review): refresh staged status before releasing the repository lock
Bil0000 Sep 13, 2026
7f559a5
fix(review): integrate viewed files and current main
Bil0000 Sep 18, 2026
5e7e2d4
test(mobile): allow review source metadata in scope checks
Bil0000 Sep 18, 2026
2e7eeba
fix(review): serialize ordinary commits with staging
Bil0000 Sep 18, 2026
a5d0047
fix(review): derive save errors from structured failures
Bil0000 Sep 18, 2026
81dc516
fix(web): use shared review control styles
Bil0000 Sep 18, 2026
a8d9c5e
test(server): declare repository in commit error fixture
Bil0000 Sep 18, 2026
35930fe
fix(web): reuse multiline sizing for guide sections
Bil0000 Sep 18, 2026
a24ba64
feat(web): mark files and folders viewed from the PR tree
Bil0000 Sep 18, 2026
0ae7b68
fix(web): scope saving status to the active review drafts
Bil0000 Sep 18, 2026
4a9c7c6
Merge main to preserve file type-change diff support
Bil0000 Sep 18, 2026
9332d6e
fix(git): preserve racy edit detection in review previews
Bil0000 Sep 18, 2026
251693b
fix(web): align review checkboxes with tree file names
Bil0000 Sep 18, 2026
3a2e3c2
fix(deps): correct Pierre diff patch hunk positions
Bil0000 Sep 18, 2026
b7d4259
feat(pr): improve review threads and add native attachments
Bil0000 Sep 18, 2026
36aad90
fix(web): align diff and guide order with file trees
Bil0000 Sep 18, 2026
63b1257
Merge main into PR review improvements
Bil0000 Sep 19, 2026
8d2c5dc
refactor(server): use tagged handlers for attachment failures
Bil0000 Sep 19, 2026
db07429
docs: explain PR threads and attachment uploads
Bil0000 Sep 19, 2026
0137fc8
fix(web): keep staging patch identities distinct for type changes
Bil0000 Sep 19, 2026
aa31211
test(deps): accept omitted single-line patch counts
Bil0000 Sep 19, 2026
a840b5c
fix(forgejo): keep PR details readable when upload auth fails
Bil0000 Sep 19, 2026
7a74497
fix(gitlab): sort merged discussion comments by time
Bil0000 Sep 19, 2026
5c3c42c
fix(pr): enforce current host capabilities before writes
Bil0000 Sep 19, 2026
5da5048
test(azure): cover null right-side review anchors
Bil0000 Sep 19, 2026
31aaab8
fix(pr): preserve private media access and bound redirects
Bil0000 Sep 19, 2026
63a2c6e
style(server): use namespace import for PR media service
Bil0000 Sep 19, 2026
d6fc2ae
fix(pr): honor host rate limits when reading attachments
Bil0000 Sep 19, 2026
002c1ca
fix(pr): allow manual write preflights during host pauses
Bil0000 Sep 19, 2026
b021119
style(web): show renamed paths as a compact diff
Bil0000 Sep 19, 2026
192519a
feat(azure): support inline reviews and native votes
Bil0000 Sep 19, 2026
908732e
feat(gitlab): support native request-changes reviews
Bil0000 Sep 19, 2026
06423b0
fix(azure): preserve attachment failure causes
Bil0000 Sep 19, 2026
37d186d
fix(gitlab): model review failures with structured errors
Bil0000 Sep 19, 2026
da419ea
feat(web): show rename paths inline in diff headers
Bil0000 Sep 19, 2026
9b0276d
fix(server): block editing and publishing completed PRs
Bil0000 Sep 20, 2026
9e0e885
fix(web): keep completed PRs read-only and preserve review drafts
Bil0000 Sep 20, 2026
b0ea4a2
fix(server): check current PR state before review actions
Bil0000 Sep 20, 2026
be589dc
chore: integrate main into PR review improvements
Bil0000 Sep 20, 2026
9e1b421
fix(server): retain causes at attachment error boundaries
Bil0000 Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/mobile/src/features/review/reviewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,35 @@ describe("buildReviewSectionItems", () => {
]);
expect(getDefaultReviewSectionId(items)).toBe("git:working-tree");
});

it.each([
["staged", "Staged changes", "Changes staged for commit"],
["unstaged", "Unstaged changes", "Changes not staged for commit"],
] as const)("keeps the %s source distinct from branch changes", (kind, title, subtitle) => {
const diff = `diff --git a/${kind}.ts b/${kind}.ts`;
const items = buildReviewSectionItems({
checkpoints: [],
gitSections: [
{
id: kind,
kind,
title,
baseRef: kind === "staged" ? "HEAD" : null,
headRef: null,
diff,
diffHash: kind,
truncated: false,
},
],
turnDiffById: {},
loadingTurnIds: {},
loadingGitSections: false,
});
expect(items).toMatchObject([
{ id: `git:${kind}`, kind, title, subtitle, diff, isLoading: false },
]);
expect(getDefaultReviewSectionId(items)).toBe(`git:${kind}`);
});
});

describe("buildReviewParsedDiff", () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
import * as Order from "effect/Order";

export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
export type ReviewSectionKind = "turn" | ReviewDiffPreviewSource["kind"];
Comment thread
Bil0000 marked this conversation as resolved.

const DIRTY_WORKTREE_SECTION_ID = "git:working-tree";
const DIRTY_WORKTREE_TITLE = "Dirty worktree";
Expand Down Expand Up @@ -125,6 +125,12 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
if (section.kind === "working-tree") {
return DIRTY_WORKTREE_SUBTITLE;
}
if (section.kind === "staged") {
return "Changes staged for commit";
}
if (section.kind === "unstaged") {
return "Changes not staged for commit";
}
if (section.baseRef) {
return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
}
Expand Down
41 changes: 40 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
import * as NodeFSP from "node:fs/promises";
import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
import {
AssetAccessError,
AssetPreviewTypeValidationError,
ProjectId,
ThreadId,
} from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -1169,3 +1174,37 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);
});

it.effect("signs private media with its PR scope and rejects other hosts", () =>
Effect.gen(function* () {
const reference = {
projectId: "p1" as ProjectId,
host: "gitlab.example",
repository: "owner/repo",
number: 7,
expectedAccountId: "selected-account",
};
const resource = {
_tag: "pull-request-media" as const,
reference,
provider: "gitlab" as const,
url: `/uploads/${"a".repeat(32)}/shot.png`,
};
const issued = yield* issueAssetUrl({ resource });
const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separator = suffix.indexOf("/");
expect(
yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)),
).toMatchObject({
kind: "pull-request-media",
reference,
provider: "gitlab",
url: `https://gitlab.example/owner/repo${resource.url}`,
expiresAt: issued.expiresAt,
});
const invalid = yield* issueAssetUrl({
resource: { ...resource, url: `https://other.example${resource.url}` },
}).pipe(Effect.flip);
expect(invalid._tag).toBe("AssetPullRequestMediaUrlValidationError");
}).pipe(Effect.provide(testLayer)),
);
35 changes: 35 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { AssetResource } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetGitHubMediaUrlValidationError,
AssetPullRequestMediaUrlValidationError,
PullRequestRef,
SourceControlProviderKind,
AssetPreviewTypeValidationError,
AssetProjectFaviconInspectionError,
AssetProjectFaviconNotFoundError,
Expand All @@ -28,6 +31,7 @@ import {
readImageDimensions,
type ImageDimensions,
} from "@t3tools/shared/imageDimensions";
import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia";
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import * as Clock from "effect/Clock";
Expand Down Expand Up @@ -83,6 +87,14 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([
]);

const AssetClaimsSchema = Schema.Union([
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("pull-request-media"),
reference: PullRequestRef,
provider: SourceControlProviderKind,
url: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("workspace-file"),
Expand Down Expand Up @@ -153,6 +165,7 @@ const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);

export type ResolvedAsset =
| Extract<AssetClaims, { readonly kind: "pull-request-media" }>
| {
readonly kind: "file";
readonly path: string;
Expand Down Expand Up @@ -676,6 +689,26 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
fileName = "native-app-icon.png";
break;
}
case "pull-request-media": {
const resource = input.resource;
const url = pullRequestMediaUrl({
...resource.reference,
provider: resource.provider,
host: resource.reference.host,
url: resource.url,
});
if (url === null) return yield* new AssetPullRequestMediaUrlValidationError({});
claims = {
version: 1,
kind: "pull-request-media",
reference: resource.reference,
provider: resource.provider,
url,
expiresAt,
};
fileName = githubMediaFileName(url);
break;
}
case "github-media": {
const fetchUrl = githubMediaFetchUrl(input.resource.url);
if (fetchUrl === null) {
Expand Down Expand Up @@ -791,6 +824,8 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
: null;
}

if (claims.kind === "pull-request-media") return claims;

if (claims.kind === "github-media") {
return {
kind: "github-media",
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/assets/GitHubMediaFetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Mime from "effect/unstable/http/Mime";
import { githubMediaFileName } from "@t3tools/shared/githubMedia";
import { isPullRequestMediaRedirectAllowed } from "@t3tools/shared/pullRequestMedia";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -131,8 +132,9 @@ const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedire
if (response.status < 300 || response.status >= 400) return response;
// A chain this long is not GitHub answering with bytes, and its body is not the media.
if (!location || hop >= MAX_REDIRECTS) return null;
const next = new URL(location, target);
if (next.protocol !== "https:") return null;
const next = URL.parse(location, target);
if (next === null || !isPullRequestMediaRedirectAllowed("github", new URL(url).origin, next))
return null;
target = next.toString();
}
});
Expand All @@ -153,6 +155,13 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon
if (value !== undefined) forwarded[name] = value;
}
const response = yield* fetchFollowingRedirects(asset.url, forwarded, token);
return yield* mediaResponse(asset, response);
});

export const mediaResponse = Effect.fn("GitHubMediaFetch.mediaResponse")(function* (
asset: { readonly url: string; readonly expiresAt: number },
response: HttpClientResponse.HttpClientResponse | null,
) {
// An upload GitHub hosts never changes under its URL, so the only thing a cached copy must
// not outlive is the signed URL that granted it — which is the same bound the URL itself has.
const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000);
Expand Down
Loading
Loading