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
30 changes: 26 additions & 4 deletions apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
} from "./PullRequestMedia";
import { videoMimeType } from "@t3tools/shared/video";
import { createContext, useContext, useMemo, useState } from "react";
import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown";
import {
changeRequestLinkResolver,
changeRequestReferenceAutolinker,
} from "@t3tools/client-runtime/pull-request-markdown";
import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients";
import { linkReferenceAutolinks } from "./pullRequestAutolinks";

Expand All @@ -31,6 +34,7 @@ import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"
const AutolinksContext = createContext<((text: string) => ReadonlyArray<ReferenceAutolink>) | null>(
null,
);
const RelativeLinksContext = createContext<((markdown: string) => string) | null>(null);

export function PullRequestMarkdownRepository(props: {
/** The change request the markdown belongs to, whose host decides what its text links. */
Expand All @@ -44,6 +48,10 @@ export function PullRequestMarkdownRepository(props: {
provider === undefined || url === undefined
? null
: changeRequestReferenceAutolinker({ provider, url });
const resolveLinks =
provider === undefined || url === undefined
? null
: changeRequestLinkResolver({ provider, url });
const target = pullRequestTarget(props.params);
const project = useProject({
environmentId: target.environmentId,
Expand All @@ -53,7 +61,9 @@ export function PullRequestMarkdownRepository(props: {
const media = cwd ? { environmentId: target.environmentId, cwd } : null;
return (
<AutolinksContext value={autolinks}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
<RelativeLinksContext value={resolveLinks}>
<PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
</RelativeLinksContext>
</AutolinksContext>
);
}
Expand Down Expand Up @@ -89,15 +99,27 @@ export function PullRequestMarkdown(props: {
readonly markdown: string;
readonly imageGroupMarkdown?: string;
}) {
const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
const resolveLinks = useContext(RelativeLinksContext);
const markdown = useMemo(
() => resolveLinks?.(props.markdown) ?? props.markdown,
[resolveLinks, props.markdown],
);
const imageGroupMarkdown = useMemo(
() =>
props.imageGroupMarkdown === undefined
? markdown
: (resolveLinks?.(props.imageGroupMarkdown) ?? props.imageGroupMarkdown),
[resolveLinks, props.imageGroupMarkdown, markdown],
);
const segments = useMemo(() => splitPullRequestBody(markdown), [markdown]);
return (
<View style={{ gap: 12 }}>
{segments.map((segment) =>
segment.kind === "markdown" ? (
<PullRequestMarkdownContent
key={segment.id}
markdown={segment.text}
imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown}
imageGroupMarkdown={imageGroupMarkdown}
/>
) : (
<PullRequestMedia key={segment.id} url={segment.url} kind={segment.media} />
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { projectChangeRequestUrl } from "@t3tools/client-runtime/change-request-links";
import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests";
import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown";
import {
changeRequestLinkResolver,
changeRequestReferenceAutolinker,
} from "@t3tools/client-runtime/pull-request-markdown";
import { useAtomValue } from "@effect/atom-react";
import { usePullRequestStack } from "~/state/usePullRequestStack";
import {
Expand Down Expand Up @@ -239,7 +242,11 @@ function pullRequestMarkdownContext(
detail: Pick<PullRequestDetail, "provider" | "url"> | null,
threadRef: ScopedThreadRef | null,
) {
return { autolinks: changeRequestReferenceAutolinker(detail), threadRef };
return {
autolinks: changeRequestReferenceAutolinker(detail),
resolveLinks: changeRequestLinkResolver(detail),
threadRef,
};
}

function pullRequestStackReference(
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestM

export const PullRequestMarkdownContext = createContext<{
autolinks: ((text: string) => ReadonlyArray<ReferenceAutolink>) | null;
resolveLinks: ((markdown: string) => string) | null;
threadRef: ScopedThreadRef | null;
} | null>(null);

Expand Down Expand Up @@ -67,8 +68,8 @@ export function PullRequestMarkdown({
threadRef?: ScopedThreadRef | null;
className?: string;
}) {
const segments = splitPullRequestBody(text);
const context = useContext(PullRequestMarkdownContext);
const segments = splitPullRequestBody(context?.resolveLinks?.(text) ?? text);
const autolinks = context?.autolinks;
const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined;
const extraRemarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = autolinks
Expand Down
3 changes: 3 additions & 0 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in:
glab auth login
```

Images and videos uploaded to a private project's merge requests load through this login, which
needs GitLab 17.4 or later.

### Bitbucket

Open **Settings → Source control**, expand **Bitbucket**, and enter one of:
Expand Down
26 changes: 25 additions & 1 deletion packages/client-runtime/src/pullRequestMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";

import { changeRequestReferenceAutolinker, splitPullRequestBody } from "./pullRequestMarkdown.ts";
import {
changeRequestLinkResolver,
changeRequestReferenceAutolinker,
splitPullRequestBody,
} from "./pullRequestMarkdown.ts";

describe("pull request body segmentation", () => {
// GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on
Expand Down Expand Up @@ -140,3 +144,23 @@ describe("change request reference autolinks", () => {
expect(urls("forgejo", "https://codeberg.org/acme/web/pulls/7", "#5")).toBeNull();
});
});

describe("change request relative links", () => {
const upload = "/uploads/66dbcd21ec5d24ed6ea225176098d52b/shot.png";

it("resolves a GitLab upload against the merge request's project", () => {
const resolve = changeRequestLinkResolver({
provider: "gitlab",
url: "https://gitlab.example.com/group/Sub/app/-/merge_requests/7",
});
expect(resolve?.(`![shot](${upload})`)).toBe(
`![shot](https://gitlab.example.com/group/Sub/app${upload})`,
);
});

it("leaves bodies alone on a host that writes no relative links", () => {
expect(
changeRequestLinkResolver({ provider: "github", url: "https://github.com/acme/web/pull/7" }),
).toBeNull();
});
});
16 changes: 16 additions & 0 deletions packages/client-runtime/src/pullRequestMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,19 @@ export function changeRequestReferenceAutolinker(
? null
: (text) => find(text, repositoryUrl);
}

/**
* Makes the links the change request's host writes relative to its repository absolute, so they
* load from the host. Null when the host writes none.
*/
export function changeRequestLinkResolver(
changeRequest: { readonly provider: string; readonly url: string } | null | undefined,
): ((markdown: string) => string) | null {
if (!changeRequest) return null;
const resolve = sourceControlClients.get(changeRequest.provider).resolveRelativeLinks;
const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link
.repositoryUrl;
return resolve === undefined || repositoryUrl === undefined
? null
: (markdown) => resolve(markdown, repositoryUrl);
}
5 changes: 5 additions & 0 deletions packages/source-control-core/src/shared/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,11 @@ export interface SourceControlDefinition {
text: string,
repositoryUrl: string,
) => ReadonlyArray<ReferenceAutolink>;
/**
* `markdown` with the links this host writes relative to the repository made absolute against
* `repositoryUrl`. Absent for a host whose bodies carry none.
*/
readonly resolveRelativeLinks?: (markdown: string, repositoryUrl: string) => string;
/** Where the host lists a change request's checks. Absent means the change request's own page. */
readonly changeRequestChecksUrl?: (changeRequestUrl: string) => string;
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Redacted from "effect/Redacted";

import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
import * as GitLabCredentials from "./GitLabCredentials.ts";

/** A scripted glab: `config get <key> --host <host>` reads `config`, `api` refreshes. */
function harness(config: Record<string, Record<string, string>>, refreshed?: string) {
const runs: Array<SourceControlHost.SourceControlProcessInput> = [];
const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) =>
Effect.sync(() => {
runs.push(input);
const host = input.args[input.args.indexOf("--host") + 1] ?? "";
if (input.args[0] === "api") {
if (refreshed !== undefined) config[input.args[2]!]!.token = refreshed;
return TestSourceControlHost.processOutput("{}");
}
return TestSourceControlHost.processOutput(`${config[host]?.[input.args[2]!] ?? ""}\n`);
});
const layer = GitLabCredentials.layer.pipe(
Layer.provide(TestSourceControlHost.layer({ process: { run } })),
);
return { layer, runs };
}

const token = (host: string) =>
GitLabCredentials.GitLabCredentials.pipe(
Effect.flatMap((credentials) => credentials.get(host)),
Effect.map(Option.map(Redacted.value)),
);

describe("GitLabCredentials", () => {
it.effect("reads only the token glab stores for the host", () => {
const { layer, runs } = harness({ "gitlab.example.com": { token: "stored" } });
return Effect.gen(function* () {
expect(yield* token("GitLab.Example.com")).toEqual(Option.some("stored"));
expect(runs.map((input) => input.args)).toEqual([
["config", "get", "token", "--host", "gitlab.example.com"],
["config", "get", "is_oauth2", "--host", "gitlab.example.com"],
["config", "get", "token", "--host", "gitlab.example.com"],
]);
// glab would otherwise answer every host with an ambient token.
for (const input of runs) {
expect(input.env).toMatchObject({ GITLAB_TOKEN: "", OAUTH_TOKEN: "", CI_JOB_TOKEN: "" });
}
// Cached: a second read asks glab nothing.
yield* token("gitlab.example.com");
expect(runs).toHaveLength(3);
}).pipe(Effect.provide(layer));
});

it.effect("has nothing for a host glab holds no login for", () => {
const { layer, runs } = harness({});
return Effect.gen(function* () {
expect(yield* token("attacker.example")).toEqual(Option.none());
expect(runs).toHaveLength(1);
}).pipe(Effect.provide(layer));
});

it.effect("refreshes an OAuth login through an API call before reading it", () => {
const { layer, runs } = harness(
{ "gitlab.com": { token: "expired", is_oauth2: "true" } },
"refreshed",
);
return Effect.gen(function* () {
expect(yield* token("gitlab.com")).toEqual(Option.some("refreshed"));
expect(runs.map((input) => input.args)).toContainEqual([
"api",
"--hostname",
"gitlab.com",
"user",
]);
}).pipe(Effect.provide(layer));
});

it.effect("asks glab again after a refused token is invalidated", () => {
const { layer, runs } = harness({ "gitlab.com": { token: "stored" } });
return Effect.gen(function* () {
const credentials = yield* GitLabCredentials.GitLabCredentials;
yield* credentials.get("gitlab.com");
yield* credentials.invalidate("gitlab.com");
yield* credentials.get("gitlab.com");
expect(runs).toHaveLength(6);
}).pipe(Effect.provide(layer));
});
});
88 changes: 88 additions & 0 deletions packages/source-control-gitlab/src/server/GitLabCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Redacted from "effect/Redacted";

import * as HostProcess from "@t3tools/shared/HostProcess";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";

/** How long a token is reused before `glab` is asked again. */
const TOKEN_TTL = Duration.minutes(5);
/** A missing login is asked again soon, so a fresh `glab auth login` applies. */
const MISSING_TTL = Duration.seconds(10);

/**
* glab hands an environment token to whichever host it is asked about. The host here comes from a
* URL someone wrote into a merge request, so only the token stored for that host may answer.
*/
const STORED_ONLY_ENV = {
GITLAB_TOKEN: "",
GITLAB_ACCESS_TOKEN: "",
OAUTH_TOKEN: "",
CI_JOB_TOKEN: "",
GLAB_ENABLE_CI_AUTOLOGIN: "false",
};

/** The token `glab` stores for a GitLab host, used to read what that host serves to the user. */
export class GitLabCredentials extends Context.Service<
GitLabCredentials,
{
/** None when `glab` holds no login for the host. */
readonly get: (host: string) => Effect.Effect<Option.Option<Redacted.Redacted<string>>>;
/** Drops the held token after GitLab refused it, so the next read asks `glab` again. */
readonly invalidate: (host: string) => Effect.Effect<void>;
}
>()("@t3tools/source-control-gitlab/server/GitLabCredentials") {}

/** @public */
export const make = Effect.gen(function* () {
const { process } = yield* SourceControlHost.SourceControlHost;
const workingDirectory = yield* HostProcess.WorkingDirectory;

const glab = (args: ReadonlyArray<string>) =>
process
.run({
operation: "GitLabCredentials.get",
command: "glab",
args,
cwd: workingDirectory,
env: STORED_ONLY_ENV,
timeoutMs: 10_000,
maxOutputBytes: 64 * 1024,
})
.pipe(Effect.map((output) => output.stdout.trim()));

const storedToken = (host: string) => glab(["config", "get", "token", "--host", host]);

const lookup = Effect.fn("GitLabCredentials.lookup")(function* (host: string) {
if ((yield* storedToken(host)) === "") return Option.none();
// An OAuth login expires within hours and glab refreshes it only on an API call.
if ((yield* glab(["config", "get", "is_oauth2", "--host", host])) === "true") {
yield* glab(["api", "--hostname", host, "user"]).pipe(Effect.ignore);
}
const token = yield* storedToken(host);
return token === "" ? Option.none() : Option.some(Redacted.make(token));
});

const cache = yield* Cache.makeWith(
(host: string) => lookup(host).pipe(Effect.orElseSucceed(() => Option.none())),
{
capacity: 32,
timeToLive: (exit) =>
Exit.isSuccess(exit) && Option.isSome(exit.value) ? TOKEN_TTL : MISSING_TTL,
},
);

return GitLabCredentials.of({
get: Effect.fn("GitLabCredentials.get")((host) => Cache.get(cache, host.trim().toLowerCase())),
invalidate: Effect.fn("GitLabCredentials.invalidate")((host) =>
Cache.invalidate(cache, host.trim().toLowerCase()),
),
});
});

export const layer = Layer.effect(GitLabCredentials, make);
8 changes: 7 additions & 1 deletion packages/source-control-gitlab/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@ import * as Layer from "effect/Layer";
import { definition } from "../shared/definition.ts";

import * as GitLabCli from "./GitLabCli.ts";
import * as GitLabCredentials from "./GitLabCredentials.ts";
import * as GitLabMediaFetch from "./gitLabMediaFetch.ts";
import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";

const services = GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer));
const services = Layer.merge(
GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)),
GitLabCredentials.layer,
);

const makeInstance = Effect.all({
sourceControl: GitLabSourceControlProvider.make,
discovery: Effect.succeed(GitLabSourceControlProvider.discovery),
pullRequests: GitLabPullRequestProvider.make,
fetchMedia: GitLabMediaFetch.make,
});

export const driver = defineSourceControlDriver({
Expand Down
Loading
Loading