Skip to content
Merged
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
11 changes: 11 additions & 0 deletions apps/server/src/issue/IssueProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,17 @@ export interface IssueAdapter {
project: OrchestrationProjectShell,
) => Effect.Effect<IssueAdapterSource | null>;

/**
* A source for a repository a caller names outright, for trackers whose items do not belong to
* the project's checkout or binding: a Linear issue can be linked from any project. Null when
* this adapter cannot read that repository with any connected account. May spend an API call,
* so it is only asked once a reference matched none of the project's own sources.
*/
readonly resolveReference?: (
project: OrchestrationProjectShell,
repository: string,
) => Effect.Effect<IssueAdapterSource | null>;

/** The signed-in account, which is what involvement filtering compares against. */
readonly getViewer: (
input: {
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/issue/IssueProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,17 @@ export class IssueProviderRegistry extends Context.Service<
projects: ReadonlyArray<OrchestrationProjectShell>,
filter: IssueProjectFilter,
) => Effect.Effect<IssueWorkspaceProjects>;
/** Providers whose repositories can be named outright, independent of any project. */
readonly referenceProviders: ReadonlySet<IssueProviderKind>;
/** The first adapter that can read a repository the caller named outright, if any. */
readonly resolveReference: (
project: OrchestrationProjectShell,
reference: {
readonly repository: string;
readonly provider?: IssueProviderKind | undefined;
readonly host?: string | undefined;
},
) => Effect.Effect<IssueProjectSource | null>;
}
>()("t3/issue/IssueProviderRegistry") {}

Expand All @@ -293,6 +304,36 @@ export const fromProviders = Effect.fn("IssueProviderRegistry.fromProviders")(fu
: Effect.succeed(tracker);
},
resolveProjects: yield* projectResolver(byKind),
referenceProviders: new Set(
providers.flatMap((provider) =>
provider.resolveReference === undefined ? [] : [provider.kind],
),
),
resolveReference: Effect.fn("IssueProviderRegistry.resolveReference")(function* (
project: OrchestrationProjectShell,
reference: {
readonly repository: string;
readonly provider?: IssueProviderKind | undefined;
readonly host?: string | undefined;
},
) {
for (const adapter of byKind.values()) {
if (adapter.resolveReference === undefined) continue;
if (reference.provider !== undefined && adapter.kind !== reference.provider) continue;
const source = yield* adapter.resolveReference(project, reference.repository.trim());
if (source === null) continue;
const host = source.host.trim().toLowerCase();
if (reference.host !== undefined && host !== reference.host.toLowerCase()) continue;
return {
project,
adapter,
repository: source.repository,
host,
...(source.credentialId === undefined ? {} : { credentialId: source.credentialId }),
} satisfies IssueProjectSource;
}
return null;
}),
};
});

Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/issue/IssueService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,53 @@ it.effect("routes each repository on one project through its matching adapter",
}),
);

it.effect("reads a Linear issue from a project with no git host or Linear binding", () =>
Effect.gen(function* () {
const asked: Array<string | undefined> = [];
const linear = fakeProvider("linear", {
resolveReference: (_project, repository) =>
Effect.succeed(
repository === "CO"
? { host: "linear.app", repository: "CO", credentialId: "user-1" }
: null,
),
getIssue: ({ repository, number, credentialId }) => {
asked.push(`${repository}-${number}:${credentialId}`);
return Effect.succeed(issueDetail(number, { title: "Linear issue" }));
},
});
const hostless = [project({ id: "p1", title: "TRUSK", workspaceRoot: "/trusk" })];
const service = yield* makeService({
projects: hostless,
providers: [fakeProvider("github"), linear],
});

const detail = yield* service.detail({
projectId: "p1" as ProjectId,
repository: "CO",
number: 130,
provider: "linear",
});
assert.strictEqual(detail.title, "Linear issue");
assert.strictEqual(detail.provider, "linear");
assert.deepStrictEqual(asked, ["CO-130:user-1"]);

const unknownTeam = yield* service
.detail({ projectId: "p1" as ProjectId, repository: "OPS", number: 1, provider: "linear" })
.pipe(Effect.flip);
assert.strictEqual(unknownTeam._tag, "IssueOperationError");
assert.include(unknownTeam.message, "No connected linear account can read OPS");
const github = yield* service.detail({ ...REFERENCE, provider: "github" }).pipe(Effect.flip);
assert.strictEqual(github._tag, "IssueUnavailableError");

// A project on a Git host reads an unbound Linear team the same way, and its own repository
// still goes to its own host.
const onGitHub = yield* makeService({ projects: ONE_PROJECT, providers: [linear] });
yield* onGitHub.detail({ projectId: "p1" as ProjectId, repository: "CO", number: 7 });
assert.deepStrictEqual(asked, ["CO-130:user-1", "CO-7:user-1"]);
}),
);

it.effect("keeps a project available when one of its issue sources is unreadable", () =>
Effect.gen(function* () {
const service = yield* makeService({
Expand Down
58 changes: 35 additions & 23 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,38 +421,50 @@ export const make = Effect.gen(function* () {
/**
* The project a request names, with the repository it claims checked against the project's own
* remote: that field travels through the client, so it is never handed to a provider verbatim.
* A reference none of the project's sources match may still name a tracker that is not tied to
* the checkout, such as a Linear team, which its adapter accepts only for a connected account.
*/
const requireProject = (
ref: Pick<IssueRef, "projectId" | "provider" | "repository" | "host">,
): Effect.Effect<IssueProviderRegistry.IssueProjectSource, IssueError> =>
Effect.gen(function* () {
const resolved = yield* ResolvedSource;
if (resolved !== null) return resolved;
return yield* listWorkspaceProjects({ projectId: ref.projectId }).pipe(
Effect.flatMap(
({ supported }): Effect.Effect<IssueProviderRegistry.IssueProjectSource, IssueError> => {
if (supported.length === 0) {
return Effect.fail(new IssueUnavailableError({ reason: "provider-unsupported" }));
}
const repository = ref.repository.trim().toLowerCase();
const match = supported.find(
(project) =>
project.repository.toLowerCase() === repository &&
(ref.provider === undefined || project.adapter.kind === ref.provider) &&
(ref.host === undefined || project.host.toLowerCase() === ref.host.toLowerCase()),
);
if (match === undefined) {
return Effect.fail(
new IssueOperationError({
operation: "resolveRepository",
detail: "The issue does not belong to the selected project.",
}),
);
}
return Effect.succeed(match);
},
const { supported } = yield* listWorkspaceProjects({ projectId: ref.projectId });
const repository = ref.repository.trim().toLowerCase();
const match = supported.find(
(project) =>
project.repository.toLowerCase() === repository &&
(ref.provider === undefined || project.adapter.kind === ref.provider) &&
(ref.host === undefined || project.host.toLowerCase() === ref.host.toLowerCase()),
);
if (match !== undefined) return match;
const shells = yield* projects.listShells({ projectIds: [ref.projectId] }).pipe(
Effect.mapError(
(error) =>
new IssueOperationError({
operation: "resolveRepository",
detail: "The project could not be read.",
cause: error,
}),
),
);
const shell = shells.find((project) => project.id === ref.projectId);
const referenced = shell === undefined ? null : yield* registry.resolveReference(shell, ref);
if (referenced !== null) return referenced;
if (ref.provider !== undefined && registry.referenceProviders.has(ref.provider)) {
return yield* new IssueOperationError({
operation: "resolveRepository",
detail: `No connected ${ref.provider} account can read ${ref.repository}.`,
});
}
if (supported.length === 0) {
return yield* new IssueUnavailableError({ reason: "provider-unsupported" });
}
return yield* new IssueOperationError({
operation: "resolveRepository",
detail: "The issue does not belong to the selected project.",
});
});

/**
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/issue/LinearIssueProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,47 @@ it.effect("carries a Linear issue's ancestors and nested sub-issues on its detai
),
),
);

it.effect("resolves a named team to the connected account that can read it", () =>
Effect.gen(function* () {
let connectionReads = 0;
const account = (credentialId: string, keys: ReadonlyArray<string>) => ({
credentialId,
status: "authenticated" as const,
accountName: credentialId,
accountEmail: null,
projects: keys.map((key) => ({ id: key, key, name: key })),
});
const adapter = yield* make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(LinearApi.LinearApi)({
connection: Effect.sync(() => {
connectionReads += 1;
return {
status: "authenticated" as const,
hasStoredToken: true,
accountName: "first",
accountEmail: null,
projects: [],
accounts: [account("first", ["ENG"]), account("second", ["CO"])],
};
}),
}),
ServerSettings.layerTest({ issueTracking: { connections: { linear: {} } } } as never),
),
),
);

assert.deepStrictEqual(yield* adapter.resolveReference!(PROJECT, "co"), {
host: "linear.app",
repository: "CO",
credentialId: "second",
});
yield* adapter.resolveReference!(PROJECT, "CO");
assert.strictEqual(connectionReads, 1);
assert.isNull(yield* adapter.resolveReference!(PROJECT, "OPS"));
assert.isNull(yield* adapter.resolveReference!(PROJECT, "acme/web"));
assert.strictEqual(connectionReads, 2);
}),
);
51 changes: 50 additions & 1 deletion apps/server/src/issue/LinearIssueProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import type {
IssueCapabilities,
Expand All @@ -13,7 +15,17 @@ import type {
import * as ServerSettings from "../serverSettings.ts";
import * as LinearConnection from "./LinearConnection.ts";
import * as LinearApi from "./LinearApi.ts";
import { IssueProviderError, type IssueAdapter, type ProviderIssue } from "./IssueProvider.ts";
import {
IssueProviderError,
type IssueAdapter,
type IssueAdapterSource,
type ProviderIssue,
} from "./IssueProvider.ts";

/** A Linear team key, the letters before the dash in an identifier such as `ENG-123`. */
const TEAM_KEY = /^[A-Za-z][A-Za-z0-9_]*$/;
/** How long the account that can read a team is believed without asking Linear again. */
const TEAM_ACCOUNT_CACHE_TTL = Duration.minutes(10);

const CAPABILITIES: IssueCapabilities = {
sorts: ["updated"],
Expand Down Expand Up @@ -195,6 +207,42 @@ export const make = Effect.gen(function* () {
);
const identifier = (repository: string, number: number) => `${repository}-${number}`;

const teamAccounts = new Map<
string,
{ readonly at: number; readonly source: IssueAdapterSource }
>();
/**
* The connected account that can read a team, preferring the environment account like an
* unbound request does. Only answers are held: a team that is missing now may be granted later.
*/
const resolveTeam = (repository: string): Effect.Effect<IssueAdapterSource | null> =>
Effect.gen(function* () {
if (!TEAM_KEY.test(repository)) return null;
const key = repository.toUpperCase();
const now = yield* Clock.currentTimeMillis;
const held = teamAccounts.get(key);
if (held !== undefined && now - held.at <= Duration.toMillis(TEAM_ACCOUNT_CACHE_TTL)) {
return held.source;
}
const connection = yield* api.connection;
const readsTeam = (projects: ReadonlyArray<{ readonly key: string }>) =>
projects.find((team) => team.key.toUpperCase() === key)?.key;
const environment = connection.environmentAccount;
const environmentTeam =
environment?.status === "authenticated" ? readsTeam(environment.projects) : undefined;
let source: IssueAdapterSource | null =
environmentTeam === undefined ? null : { host: "linear.app", repository: environmentTeam };
for (const account of connection.accounts) {
if (source !== null) break;
const team = account.status === "authenticated" ? readsTeam(account.projects) : undefined;
if (team !== undefined) {
source = { host: "linear.app", repository: team, credentialId: account.credentialId };
}
}
if (source !== null) teamAccounts.set(key, { at: now, source });
return source;
}).pipe(Effect.orElseSucceed(() => null));

return {
kind: "linear",
tracker: yield* LinearConnection.make,
Expand All @@ -215,6 +263,7 @@ export const make = Effect.gen(function* () {
}),
Effect.orElseSucceed(() => null),
),
resolveReference: (_project, repository) => resolveTeam(repository),
getViewer: ({ credentialId }) =>
api.getViewer(credentialId === undefined ? {} : { credentialId }).pipe(
Effect.map((viewer) => viewer.id),
Expand Down
20 changes: 15 additions & 5 deletions apps/server/src/mcp/toolkits/issues/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

export const IssueTargetInput = Schema.Struct({
repository: TrimmedNonEmptyString,
number: PositiveInt,
provider: Schema.optional(IssueProviderKind),
repository: TrimmedNonEmptyString.annotate({
description:
"Where the issue lives: owner/name (or the host's full project path) for a Git host, or the team key for Linear, the letters before the dash in an identifier such as CO in CO-130.",
}),
number: PositiveInt.annotate({
description: "Issue number; for Linear, the digits after the dash, such as 130 in CO-130.",
}),
provider: Schema.optional(
IssueProviderKind.annotate({
description:
"Issue tracker. Defaults to the project's own. Pass linear for a Linear issue: it can be read and linked from any project while a connected Linear account can see its team.",
}),
),
});

export class IssueThreadNotFoundError extends Schema.TaggedError<IssueThreadNotFoundError>()(
Expand Down Expand Up @@ -61,7 +71,7 @@ const dependencies = [

const ReadIssueTool = Tool.make("read_issue", {
description:
"Read an issue body and a bounded page of comments as Markdown. Linked issues use their saved project and host; unlinked references use this thread's project. Pass repository, number, and provider when needed. Pass url to choose between linked issues with the same repository and number on different hosts. If nextCommentsCursor is present, pass it as commentsCursor to read the next page without reloading the issue body. commentsTruncated=true with a null cursor means this host cannot return the rest through T3.",
"Read an issue body and a bounded page of comments as Markdown. Linked issues use their saved project and host; unlinked references use this thread's project. Pass repository, number, and provider when needed; a Linear issue CO-130 is repository CO, number 130, provider linear. Pass url to choose between linked issues with the same repository and number on different hosts; it never selects an unlinked issue. If nextCommentsCursor is present, pass it as commentsCursor to read the next page without reloading the issue body. commentsTruncated=true with a null cursor means this host cannot return the rest through T3.",
parameters: Schema.Struct({
...IssueTargetInput.fields,
url: Schema.optional(TrimmedNonEmptyString),
Expand All @@ -83,7 +93,7 @@ const ReadIssueTool = Tool.make("read_issue", {

const LinkIssueTool = Tool.make("link_issue", {
description:
"Link an issue in this thread's project to this thread. Use after taking work on an issue. The link appears with the thread and its pull requests. Linking the same issue again succeeds with alreadyLinked=true.",
"Link an issue to this thread. Git-host issues must belong to this thread's project; a Linear issue only needs a connected Linear account that can see its team, so pass its team key as repository (CO for CO-130), its number, and provider linear. Use after taking work on an issue. The link appears with the thread and its pull requests. Linking the same issue again succeeds with alreadyLinked=true.",
parameters: IssueTargetInput,
success: Schema.Struct({ issue: ThreadIssueLink, alreadyLinked: Schema.Boolean }),
failure: IssueToolError,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/issue/IssueTree.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { IssueLinkedPullRequest, IssueRelative, IssueRelativeNode } from "@t3tools/contracts";

import { Fragment, type ReactNode } from "react";

import { cn } from "~/lib/utils";
import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation";
import { IssueStateGlyph } from "./issuePresentation";
Expand Down Expand Up @@ -171,12 +173,17 @@ export function IssueTreePullRequestRows({
links,
depth,
onOpen,
renderPullRequest,
}: {
links: ReadonlyArray<IssueLinkedPullRequest> | undefined;
depth: number;
onOpen: (link: IssueLinkedPullRequest) => void;
/** A fuller row for a pull request the caller already knows, such as one linked to the thread. */
renderPullRequest?: ((link: IssueLinkedPullRequest, depth: number) => ReactNode) | undefined;
}) {
return (links ?? []).map((link) => {
const custom = renderPullRequest?.(link, depth + 1);
if (custom != null) return <Fragment key={link.url}>{custom}</Fragment>;
const presentation = resolvePullRequestState({ state: link.state, isDraft: link.isDraft });
return (
<button
Expand Down
Loading
Loading