Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,11 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss
}
>
<View className="flex-row items-center gap-2">
<PullRequestAvatar actor={comment.author} size={28} />
<PullRequestAvatar
actor={comment.author}
environmentId={target.environmentId}
size={28}
/>
<Text className="min-w-0 flex-1 text-sm font-t3-medium">
{comment.author?.login ?? "Unknown author"}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ export const PullRequestListRow = memo(
)}
>
<View className="flex-row items-center gap-1.5">
<PullRequestAvatar actor={item.author} size={16} />
<PullRequestAvatar
actor={item.author}
environmentId={item.environmentId}
size={16}
/>
<Text className={cn("min-w-0 flex-1 text-xs", mutedForeground)} numberOfLines={1}>
{item.repository} <Text className={tertiaryForeground}>#{item.number}</Text>
</Text>
Expand Down
68 changes: 61 additions & 7 deletions apps/mobile/src/features/pull-requests/PullRequestPresentation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
import type { PullRequestActor } from "@t3tools/contracts";
import {
sourceControlClients,
type ChangeRequestTerminology,
} from "@t3tools/client-runtime/source-control-clients";
import {
SOURCE_CONTROL_MEDIA_ASSET,
type EnvironmentId,
type PullRequestActor,
} from "@t3tools/contracts";
import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";
import { Image } from "expo-image";
import { View } from "react-native";
Expand All @@ -9,6 +16,7 @@ import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "../../components/ui/AppText";
import { cn } from "../../lib/cn";
import { EmptyState } from "../../components/ui/EmptyState";
import { useAssetUrlState } from "../../state/assets";

export function PullRequestReadStatus(props: {
readonly pending: boolean;
Expand Down Expand Up @@ -41,14 +49,60 @@ export function PullRequestConflictBadge(props: { readonly surfaceClassName: str

export function PullRequestAvatar(props: {
readonly actor: PullRequestActor | null;
/** Needed for a host, such as Azure DevOps, whose avatars only the environment's credential reads. */
readonly environmentId?: EnvironmentId | null | undefined;
readonly size?: number;
}) {
const size = props.size ?? 24;
return props.actor?.avatarUrl ? (
const login = props.actor?.login ?? null;
const avatarUrl = props.actor?.avatarUrl ?? null;
const hostMedia = avatarUrl === null ? null : sourceControlClients.mediaUrl(avatarUrl);
if (hostMedia !== null) {
return (
<HostAvatar
login={login}
fetchUrl={hostMedia.fetchUrl}
environmentId={props.environmentId ?? null}
size={size}
/>
);
}
return <AvatarImage login={login} uri={avatarUrl} size={size} />;
}

function HostAvatar(props: {
readonly login: string | null;
readonly fetchUrl: string;
readonly environmentId: EnvironmentId | null;
readonly size: number;
}) {
const asset = useAssetUrlState(props.environmentId, {
_tag: SOURCE_CONTROL_MEDIA_ASSET,
url: props.fetchUrl,
});
return (
<AvatarImage
login={props.login}
uri={asset._tag === "Success" ? asset.url : null}
// The signed URL changes on every mint, so cache and recycle by the host's URL instead.
cacheKey={props.fetchUrl}
size={props.size}
/>
);
}

function AvatarImage(props: {
readonly login: string | null;
readonly uri: string | null;
readonly cacheKey?: string;
readonly size: number;
}) {
const { size } = props;
return props.uri ? (
<Image
source={{ uri: props.actor.avatarUrl }}
recyclingKey={props.actor.avatarUrl}
accessibilityLabel={props.actor.login}
source={props.cacheKey ? { uri: props.uri, cacheKey: props.cacheKey } : { uri: props.uri }}
recyclingKey={props.cacheKey ?? props.uri}
accessibilityLabel={props.login ?? undefined}
style={{ width: size, height: size, borderRadius: size / 2 }}
cachePolicy="memory-disk"
/>
Expand All @@ -58,7 +112,7 @@ export function PullRequestAvatar(props: {
style={{ width: size, height: size }}
>
<Text className="font-t3-medium text-foreground-muted" style={{ fontSize: size * 0.45 }}>
{props.actor?.login.slice(0, 1).toUpperCase() ?? "?"}
{props.login?.slice(0, 1).toUpperCase() ?? "?"}
</Text>
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
onPress={() => void update(item)}
className="min-h-16 flex-row items-center gap-3 border-b border-border-subtle px-4 py-3 active:opacity-70"
>
<PullRequestAvatar actor={item} size={32} />
<PullRequestAvatar actor={item} environmentId={target.environmentId} size={32} />
<View className="min-w-0 flex-1 gap-0.5">
<Text className="text-sm font-t3-medium" numberOfLines={1}>
{item.name || item.login}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react";
import { pullRequestReviewKey } from "./pullRequestReviewDrafts";
import {
EnvironmentId,
pullRequestConversationThreads,
type PullRequestDetail,
type PullRequestListEntry,
Expand Down Expand Up @@ -227,7 +228,11 @@ function PullRequestIdentity(props: {
{pr.title}
</Text>
<View className="flex-row items-center gap-2">
<PullRequestAvatar actor={pr.author ?? null} size={20} />
<PullRequestAvatar
actor={pr.author ?? null}
environmentId={EnvironmentId.make(props.params.environmentId)}
size={20}
/>
<Text className="min-w-0 flex-1 text-xs font-t3-medium" numberOfLines={1}>
{pr.author?.login ?? "Unknown author"}
</Text>
Expand Down
14 changes: 11 additions & 3 deletions apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor";
import { usePullRequestConnected } from "./usePullRequestConnected";
import { useAtomValue } from "@effect/atom-react";
import { useNavigation } from "@react-navigation/native";
import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";
import { EnvironmentId, type PullRequestAction, type PullRequestDetail } from "@t3tools/contracts";
import {
capitalizeTerm,
sourceControlClients,
Expand Down Expand Up @@ -302,7 +302,11 @@ export function PullRequestReviewersRow(props: RowProps) {
: "accent-icon-muted"
}
/>
<PullRequestAvatar actor={review.author} size={32} />
<PullRequestAvatar
actor={review.author}
environmentId={EnvironmentId.make(props.params.environmentId)}
size={32}
/>
<Text className="min-w-0 flex-1 text-sm font-t3-medium" numberOfLines={1}>
{review.author?.login ?? "Unknown reviewer"}
</Text>
Expand All @@ -320,7 +324,11 @@ export function PullRequestReviewersRow(props: RowProps) {
className="min-h-14 flex-row items-center gap-3 px-4 py-2"
>
<SymbolView name="clock" size={18} tintColorClassName="accent-icon-muted" />
<PullRequestAvatar actor={reviewer} size={32} />
<PullRequestAvatar
actor={reviewer}
environmentId={EnvironmentId.make(props.params.environmentId)}
size={32}
/>
<Text className="min-w-0 flex-1 text-sm font-t3-medium" numberOfLines={1}>
{reviewer.login}
</Text>
Expand Down
20 changes: 17 additions & 3 deletions apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import IconGitCommit from "@tabler/icons-react-native/IconGitCommit";
import {
EnvironmentId,
pullRequestConversationThreads,
type PullRequestActivity,
type PullRequestComment,
Expand Down Expand Up @@ -102,7 +103,11 @@ function TimelineComment({
tone={approved ? "success" : changes ? "danger" : undefined}
>
<View className="flex-row items-center gap-2">
<PullRequestAvatar actor={comment.author} size={22} />
<PullRequestAvatar
actor={comment.author}
environmentId={EnvironmentId.make(params.environmentId)}
size={22}
/>
<View className="min-w-0 flex-1">
<Text className="text-sm">
<Text className="font-t3-medium">{comment.author?.login ?? "Unknown author"}</Text>
Expand Down Expand Up @@ -219,6 +224,7 @@ export function pullRequestTimelineRows(
navigation: Pick<NavigationProp<ReactNavigation.RootParamList>, "navigate" | "dispatch">,
actions: CommentActions,
) {
const environmentId = EnvironmentId.make(params.environmentId);
const linkTarget = parsePullRequestAnchor(params.navigationUrl);
const threads = pullRequestConversationThreads(activity);
const discussionIds = new Set(
Expand Down Expand Up @@ -250,7 +256,11 @@ export function pullRequestTimelineRows(
content: (
<TimelineEvent icon="text.bubble" tone={thread.isResolved ? "success" : undefined}>
<View className="flex-row items-center gap-2">
<PullRequestAvatar actor={thread.comments[0]?.author ?? null} size={22} />
<PullRequestAvatar
actor={thread.comments[0]?.author ?? null}
environmentId={environmentId}
size={22}
/>
<Text className="flex-1 text-sm">
<Text className="font-t3-medium">
{thread.comments[0]?.author?.login ?? "Unknown author"}
Expand Down Expand Up @@ -309,7 +319,11 @@ export function pullRequestTimelineRows(
}
className="flex-row items-center gap-2 active:opacity-70"
>
<PullRequestAvatar actor={commit.authors?.[0] ?? null} size={22} />
<PullRequestAvatar
actor={commit.authors?.[0] ?? null}
environmentId={environmentId}
size={22}
/>
<Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
{commit.messageHeadline}
</Text>
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/LinkedPullRequestList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import {
capitalizeTerm,
sourceControlClients,
Expand Down Expand Up @@ -58,6 +59,7 @@ export function linkedPullRequestKey(line: PullRequestListLine) {

export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: {
readonly line: PullRequestListLine;
readonly environmentId: EnvironmentId | null;
readonly separator: boolean;
readonly surfaceClassName: string;
readonly openPullRequest: (url: string) => Promise<boolean>;
Expand Down Expand Up @@ -114,7 +116,13 @@ export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: {
)}
>
<View className="flex-row items-center gap-1.5">
{snapshot?.author ? <PullRequestAvatar actor={snapshot.author} size={16} /> : null}
{snapshot?.author ? (
<PullRequestAvatar
actor={snapshot.author}
environmentId={props.environmentId}
size={16}
/>
) : null}
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{link.repository}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function ThreadPullRequestsSheet(
renderItem={({ item, index }) => (
<LinkedPullRequestRow
line={item}
environmentId={selectedThread?.environmentId ?? null}
separator={index < lines.length - 1}
surfaceClassName={Platform.OS === "ios" ? "text-screen" : "text-sheet-solid"}
openPullRequest={openPullRequest}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
<LinkedPullRequestRow
key={linkedPullRequestKey(line)}
line={line}
environmentId={environmentId}
separator={index < linkedPrLines.length - 1}
surfaceClassName="text-grouped-card"
openPullRequest={openPullRequest}
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ const layerTest = Layer.mergeAll(
),
).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));

const decodeCreateUrlInput = Schema.decodeEffect(AssetCreateUrlInput);

describe("AssetAccess", () => {
describe("readMcpAppHtml", () => {
it.effect("accepts snapshots at the stored HTML byte limit", () =>
Expand Down Expand Up @@ -1572,6 +1574,65 @@ describe("AssetAccess", () => {
);
});

it.effect("lets the browser retry host media the host refused", () => {
const url =
"https://dev.azure.com/acme/_api/_common/identityImage?id=15f0488e-b980-68ca-9d71-8a387c166d09";
const media = sourceControlDefinitions.mediaUrl(url)!;
const layerDrivers = Layer.succeed(BuiltInDrivers.SourceControlDrivers, [
{
driver: { kind: media.definition.kind, definition: media.definition },
instance: fakeSourceControlDriverInstance(media.definition.kind, {
pullRequests: null,
fetchMedia: (request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
HttpClientRequest.get(request.url),
new Response(null, { status: 404 }),
),
),
}),
},
]);
return Effect.gen(function* () {
// A host that refused before sign-in serves the media right after it.
const response = yield* sourceControlMediaResponse(
{ url: media.fetchUrl, expiresAt: Date.UTC(2100, 0, 1) },
{},
);
expect(response.status).toBe(404);
expect(response.headers["cache-control"]).toBe("private, no-store");
}).pipe(
Effect.provide(
Layer.mergeAll(
layerTest,
layerDrivers,
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}),
),
),
Effect.scoped,
);
});

it.effect("signs host media a client asks for without a repository", () =>
Effect.gen(function* () {
// A pull request list draws avatars with no repository at hand.
const { resource } = yield* decodeCreateUrlInput({
resource: {
_tag: "github-media",
url: "https://dev.azure.com/acme/_api/_common/identityImage?id=15F0488E-B980-68CA-9D71-8A387C166D09",
},
});
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))).toEqual({
kind: "source-control-media",
url: "https://dev.azure.com/acme/_api/_common/identityImage?id=15f0488e-b980-68ca-9d71-8a387c166d09",
expiresAt: issued.expiresAt,
});
}).pipe(Effect.provide(layerTest)),
);

it.effect("refuses media no host keeps behind a credential, without echoing the URL", () =>
Effect.gen(function* () {
const encodeAssetAccessError = Schema.encodeEffect(Schema.fromJsonString(AssetAccessError));
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const AssetClaimsSchema = Schema.Union([
kind: Schema.Literal(SOURCE_CONTROL_MEDIA_ASSET),
/** A host's `fetchUrl`, narrowed at mint time; the signature is what keeps it there. */
url: Schema.String,
cwd: Schema.String,
cwd: Schema.optionalKey(Schema.String),
repository: Schema.optionalKey(Schema.String),
expiresAt: Schema.Number,
}),
Expand Down Expand Up @@ -197,7 +197,7 @@ export type ResolvedAsset =
| {
readonly kind: "source-control-media";
readonly url: string;
readonly cwd: string;
readonly cwd?: string;
readonly repository?: string;
readonly expiresAt: number;
};
Expand Down Expand Up @@ -832,11 +832,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
if (media === null) {
return yield* new AssetSourceControlMediaUrlValidationError({});
}
const { _tag, ...resource } = input.resource;
const { _tag, cwd, ...resource } = input.resource;
claims = {
version: 1,
kind: SOURCE_CONTROL_MEDIA_ASSET,
...resource,
...(cwd === undefined ? {} : { cwd }),
url: media.fetchUrl,
expiresAt,
};
Expand Down
Loading
Loading