Skip to content
Draft
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
23 changes: 23 additions & 0 deletions apps/server/src/issue/IssueService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ function makeService(input: {
}

describe("IssueService", () => {
it.effect("places a project where its host keeps its issues", () =>
Effect.gen(function* () {
const azure = IssueTrackerKind.make("azure-devops");
const service = yield* makeService({
projects: [
project({
id: "p",
provider: "azure-devops",
host: "dev.azure.com",
repository: "acme/Web App/_git/web",
}),
],
trackers: [fakeTracker({ kind: azure })],
});
expect(yield* service.projectScope("p" as ProjectId)).toEqual({
projectId: "p",
tracker: "azure-devops",
host: "dev.azure.com",
scope: "acme/Web App",
});
}),
);

it.effect("reads another repository on the same host but refuses to write to it", () =>
Effect.gen(function* () {
const writes: string[] = [];
Expand Down
65 changes: 65 additions & 0 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type IssueTrackerSummary,
type IssueUpdateInput,
type OrchestrationProjectShell,
type ProjectId,
} from "@t3tools/contracts";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import {
Expand Down Expand Up @@ -140,6 +141,22 @@ export class IssueService extends Context.Service<
readonly update: (input: IssueUpdateInput) => Effect.Effect<void, IssueError>;
readonly setState: (input: IssueSetStateInput) => Effect.Effect<void, IssueError>;
readonly react: (input: IssueReactionInput) => Effect.Effect<void, IssueError>;
/**
* The reference behind an issue URL: served by the project whose binding is that host and
* scope, else read-only through any project on the same tracker and host. `preferredProjectId`
* wins among equals, so a thread's own project serves its own issues.
*/
/** The tracker and container a project's own issues live in. */
readonly projectScope: (
projectId: ProjectId,
) => Effect.Effect<IssueScopeRef & { readonly host: string }, IssueError>;
readonly resolveUrl: (
url: string,
preferredProjectId?: ProjectId,
) => Effect.Effect<
IssueRef & { readonly host: string; readonly writable: boolean },
IssueError
>;
}
>()("t3/issue/IssueService") {}

Expand Down Expand Up @@ -552,6 +569,52 @@ const make = Effect.gen(function* () {
),
);

const projectScope: IssueService["Service"]["projectScope"] = Effect.fn(
"IssueService.projectScope",
)(function* (projectId) {
const { supported } = yield* listProjects({ projectId });
const [own] = supported;
if (own === undefined) {
return yield* new IssueUnavailableError({ reason: "tracker-unsupported" });
}
return { projectId, tracker: own.tracker, host: own.host, scope: own.scope };
});

const resolveUrl: IssueService["Service"]["resolveUrl"] = Effect.fn("IssueService.resolveUrl")(
function* (url, preferredProjectId) {
const parsed = sourceControlDefinitions.parseIssueUrl(url);
if (parsed === null) {
return yield* new IssueOperationError({
operation: "resolveUrl",
reason: "not-found",
detail: "This is not a recognised issue URL.",
});
}
const { definition, link } = parsed;
const { supported } = yield* listProjects({ host: link.host });
const onTracker = supported.filter(
(project) => project.tracker === IssueTrackerKind.make(definition.kind),
);
const preferFirst = (projects: ReadonlyArray<SupportedProject>) =>
projects.find((project) => project.project.id === preferredProjectId) ?? projects[0];
const own = preferFirst(
onTracker.filter((project) => project.scope.toLowerCase() === link.scope),
);
const lender = own ?? preferFirst(onTracker);
if (lender === undefined) {
return yield* new IssueUnavailableError({ reason: "tracker-unsupported" });
}
return {
projectId: lender.project.id,
tracker: lender.tracker,
host: lender.host,
scope: own === undefined ? link.scope : lender.scope,
key: link.key,
writable: own !== undefined,
};
},
);

return IssueService.of({
list,
detail: traced("detail", detail),
Expand All @@ -563,6 +626,8 @@ const make = Effect.gen(function* () {
update: interactive(traced("update", update)),
setState: interactive(traced("setState", setState)),
react: interactive(traced("react", react)),
projectScope,
resolveUrl,
});
});

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import * as ToolSearchHandlers from "./toolkits/toolSearch/handlers.ts";
import { ToolSearchToolkit } from "./toolkits/toolSearch/tools.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import * as IssuesHandlers from "./toolkits/issues/handlers.ts";
import { IssuesToolkit } from "./toolkits/issues/tools.ts";
import * as DeviceHandlers from "./toolkits/device/handlers.ts";
import {
DeviceScreenshotTool,
Expand Down Expand Up @@ -1289,6 +1291,8 @@ export const layerPullRequestsToolkit = toolkitRegistration(
PullRequestsHandlers.layer,
).pipe(Layer.provide(PullRequestWatchService.layer));

const layerIssuesToolkit = toolkitRegistration(IssuesToolkit, IssuesHandlers.layer);

const layerDeviceStandardToolkitRegistration = toolkitRegistration(
DeviceStandardToolkit,
DeviceHandlers.layerStandard,
Expand Down Expand Up @@ -1346,6 +1350,7 @@ export const layerTools = Layer.mergeAll(
layerPreviewControlsRegistration,
layerWorktreeToolkitRegistration,
layerPullRequestsToolkit,
layerIssuesToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
layerMessageToolkit,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ALL_MCP_CAPABILITIES = [
"worktree",
"device",
"pull-requests",
"issues",
] as const;
export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];

Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/mcp/McpSessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
const resolved = yield* registry.resolve(token);
expect(resolved?.thread.threadId).toBe(threadId);
expect(resolved?.capabilities).toEqual(
new Set(["preview", "orchestration", "worktree", "pull-requests"]),
new Set(["preview", "orchestration", "worktree", "pull-requests", "issues"]),
);

yield* registry.revokeThread(threadId);
Expand Down Expand Up @@ -87,18 +87,21 @@ it.effect("always grants pull-requests and gates browser and device access indep
.pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort()));

expect(yield* capabilitiesOf(withPreview)).toEqual([
"issues",
"orchestration",
"preview",
"pull-requests",
"worktree",
]);
expect(yield* capabilitiesOf(withoutPreview)).toEqual([
"issues",
"orchestration",
"pull-requests",
"worktree",
]);
expect(yield* capabilitiesOf(withDevice)).toEqual([
"device",
"issues",
"orchestration",
"pull-requests",
"worktree",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
"orchestration",
"worktree",
"pull-requests",
"issues",
...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])),
]),
issuedAt: issuedAlive.wallMs,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ const clientScope = (
thread: undefined,
client: { sessionId: "session-1", label: "Claude Code", access },
issuedAt: 0,
capabilities: new Set(["orchestration", "worktree", "pull-requests"]),
capabilities: new Set(["orchestration", "worktree", "pull-requests", "issues"]),
});

it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () =>
Expand Down
Loading
Loading