Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7014eda
feat(issues): add issue tracking core, contracts, and GitHub tracker
juliusmarminge Oct 14, 2026
d344e4b
chore(issues): refresh the lockfile for Effect 4.0.4
juliusmarminge Oct 14, 2026
cf13507
fix(server): give issue RPCs their own handler layer
juliusmarminge Oct 14, 2026
3e938b5
chore(issues): drop unused exports and register new packages in CI lists
juliusmarminge Oct 14, 2026
c306735
fix(issues): match GitHub search words apart, not as one phrase
juliusmarminge Oct 14, 2026
0e1f41c
style(issues): format the issue service
juliusmarminge Oct 14, 2026
b72ded8
fix(issues): select GitHub issue comments once in the detail query
juliusmarminge Oct 14, 2026
1a4e4c1
test(issues): focus coverage on observable behavior
juliusmarminge Oct 19, 2026
b846a88
fix(issues): integrate tracker with current GitHub clients
juliusmarminge Oct 19, 2026
f0b32e9
fix(issues): support YAML forms and interactive writes
juliusmarminge Oct 19, 2026
2702072
fix(issues): keep valid templates when a form is malformed
juliusmarminge Oct 19, 2026
53554e6
fix(issues): preserve continuation cursors for retry
juliusmarminge Oct 19, 2026
d95de65
fix(issues): verify GitHub issue targets before writing
juliusmarminge Oct 19, 2026
b8fb68b
fix(server): deduplicate issue lists by canonical repository
juliusmarminge Oct 19, 2026
b4f989d
fix(issues): preserve duplicate reasons and skip blank form names
juliusmarminge Oct 19, 2026
d45d8dc
Merge remote-tracking branch 'github/main' into issues/1-core
juliusmarminge Oct 22, 2026
feea186
refactor(issues): name the issue list and GitHub reads as Effect func…
juliusmarminge Oct 22, 2026
8e601da
fix(issues): typecheck issue-tracker-core with tsc like main's packages
juliusmarminge Oct 22, 2026
35f2f6f
Merge remote-tracking branch 'github/main' into issues/1-core
juliusmarminge Oct 22, 2026
361e85a
fix(issues): give blank tracker failures a detail and trace issue ope…
juliusmarminge Oct 22, 2026
786a24e
fix(issues): give an unusable tracker's summary a detail too
juliusmarminge Oct 23, 2026
25980d0
refactor(issues): share the tracker failure detail fallback
juliusmarminge Oct 23, 2026
fb10e05
refactor(issues): keep the tracker detail fallback private to the ser…
juliusmarminge Oct 23, 2026
e9773e8
fix(issues): require a created GitHub issue to come back with a URL
juliusmarminge Oct 23, 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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@muse-code/sdk": "1.4.6",
"@napi-rs/keyring": "^1.3.0",
"@opencode-ai/sdk": "^1.3.15",
"@t3tools/issue-tracker-core": "workspace:*",
"@t3tools/provider-acp": "workspace:*",
"@t3tools/provider-acp-registry": "workspace:*",
"@t3tools/provider-core": "workspace:*",
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ describe("RPC authorization scopes", () => {
);
});

it("reads issues under the orchestration read scope and writes them like pull requests", () => {
for (const method of [
WS_METHODS.issuesList,
WS_METHODS.issuesDetail,
WS_METHODS.issuesComments,
WS_METHODS.issuesTemplates,
WS_METHODS.issuesCandidates,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
}
for (const method of [
WS_METHODS.issuesCreate,
WS_METHODS.issuesComment,
WS_METHODS.issuesUpdate,
WS_METHODS.issuesSetState,
WS_METHODS.issuesReact,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope);
}
});

it("requires source control writes to start, retry, or cancel project clones", () => {
for (const method of [
WS_METHODS.projectCloneStart,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
[WS_METHODS.issuesList]: AuthOrchestrationReadScope,
[WS_METHODS.issuesDetail]: AuthOrchestrationReadScope,
[WS_METHODS.issuesComments]: AuthOrchestrationReadScope,
[WS_METHODS.issuesTemplates]: AuthOrchestrationReadScope,
[WS_METHODS.issuesCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
Expand Down
268 changes: 268 additions & 0 deletions apps/server/src/issue/IssueService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
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 {
IssueTrackerKind,
type OrchestrationProjectShell,
type ProjectId,
} from "@t3tools/contracts";
import {
IssueTrackerError,
type IssueTrackerApi,
type TrackerIssueDetail,
} from "@t3tools/issue-tracker-core/server/IssueTracker";

import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as IssueService from "./IssueService.ts";
import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts";

const GITHUB = IssueTrackerKind.make("github");

function project(input: {
readonly id: string;
readonly repository?: string;
readonly provider?: string;
readonly host?: string;
}): OrchestrationProjectShell {
const host = input.host ?? "github.com";
return {
id: input.id as ProjectId,
title: input.id,
workspaceRoot: `/w/${input.id}`,
...(input.repository
? {
repositoryIdentity: {
canonicalKey: `${host}/${input.repository}`,
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: `https://${host}/${input.repository}.git`,
},
provider: input.provider ?? "github",
displayName: input.repository,
},
}
: {}),
defaultModelSelection: null,
scripts: [],
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
};
}

function issue(key: string, updatedAt = "2026-07-02T00:00:00Z"): TrackerIssueDetail {
return {
key,
displayKey: `#${key}`,
title: `Issue ${key}`,
url: `https://github.com/acme/web/issues/${key}`,
author: null,
state: { category: "open", name: "open" },
createdAt: "2026-07-01T00:00:00Z",
updatedAt,
closedAt: null,
assignees: [],
labels: [],
commentCount: 0,
body: "",
viewer: "julius",
viewerPermissions: { setState: true, comment: true, edit: true, labels: true, assignees: true },
comments: [],
commentsCursor: null,
events: [],
linkedPullRequests: [],
};
}

function fakeTracker(overrides: Partial<IssueTrackerApi> = {}): IssueTrackerApi {
return {
kind: GITHUB,
capabilities: {
create: true,
edit: true,
comment: true,
labels: true,
assignees: true,
reactions: false,
templates: false,
linkedPullRequests: true,
events: true,
search: true,
states: [
{ category: "open", name: "open" },
{ category: "closed", name: "closed" },
],
closeReasons: ["completed"],
},
getViewer: () => Effect.succeed("julius"),
listIssues: () => Effect.succeed({ items: [], nextCursor: null }),
getIssue: (input) => Effect.succeed(issue(input.key)),
getComments: () => Effect.succeed({ comments: [], nextCursor: null }),
getViewerPermissions: () => Effect.die("unused"),
comment: () => Effect.void,
update: () => Effect.void,
setState: () => Effect.void,
...overrides,
};
}

function makeService(input: {
readonly projects: ReadonlyArray<OrchestrationProjectShell>;
readonly trackers: ReadonlyArray<IssueTrackerApi>;
}) {
return IssueService.make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(
IssueTrackerRegistry.IssueTrackerRegistry,
IssueTrackerRegistry.fromTrackers(input.trackers),
),
Layer.mock(ProjectService.ProjectService)({
listShells: () => Effect.succeed(input.projects),
getShell: (projectId) =>
Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))),
}),
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: () => Effect.succeed(null),
}),
),
),
);
}

describe("IssueService", () => {
it.effect("reads another repository on the same host but refuses to write to it", () =>
Effect.gen(function* () {
const writes: string[] = [];
const service = yield* makeService({
projects: [project({ id: "web", repository: "acme/web" })],
trackers: [
fakeTracker({
comment: (input) =>
Effect.sync(() => {
writes.push(`${input.scope}#${input.key}`);
}),
}),
],
});
const own = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" };
const external = { ...own, scope: "other/backend", key: "7" };

expect((yield* service.detail(own)).writable).toBe(true);
// The external issue is served by the project's checkout and credentials, read-only.
const detail = yield* service.detail(external);
expect(detail).toMatchObject({ writable: false, scope: "other/backend", key: "7" });

yield* service.comment({ ...own, body: "ok" });
const refused = yield* Effect.flip(service.comment({ ...external, body: "no" }));
expect(refused).toMatchObject({ _tag: "IssueOperationError", reason: "read-only" });
expect(writes).toEqual(["acme/web#1"]);

// Another host is not reachable through this project's credentials at all.
const elsewhere = yield* Effect.flip(service.detail({ ...own, host: "ghe.example.test" }));
expect(elsewhere).toMatchObject({
_tag: "IssueUnavailableError",
reason: "tracker-unsupported",
});
}),
);

it.effect("refuses what the tracker did not declare before calling it", () =>
Effect.gen(function* () {
let calls = 0;
const service = yield* makeService({
projects: [project({ id: "web", repository: "acme/web" })],
trackers: [
fakeTracker({
setState: () =>
Effect.sync(() => {
calls += 1;
}),
setReaction: () =>
Effect.sync(() => {
calls += 1;
}),
}),
],
});
const ref = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" };

yield* service.setState({ ...ref, state: "closed", closeReason: "completed" });
const unknownState = yield* Effect.flip(service.setState({ ...ref, state: "In Review" }));
expect(unknownState._tag).toBe("IssueOperationError");
const unknownReason = yield* Effect.flip(
service.setState({ ...ref, state: "closed", closeReason: "not-planned" }),
);
expect(unknownReason._tag).toBe("IssueOperationError");
const reaction = yield* Effect.flip(
service.react({ ...ref, content: "heart", reacted: true }),
);
expect(reaction._tag).toBe("IssueOperationError");
expect(calls).toBe(1);
}),
);

it.effect("lists each repository once and reports unusable trackers per host", () =>
Effect.gen(function* () {
const listed: string[] = [];
const service = yield* makeService({
projects: [
project({ id: "web", repository: "acme/web" }),
project({ id: "web-worktree", repository: "acme/web" }),
project({ id: "api", repository: "acme/api" }),
project({ id: "gitlab", repository: "acme/gl", provider: "gitlab", host: "gitlab.com" }),
project({ id: "plain" }),
],
trackers: [
fakeTracker({
listIssues: (input) =>
Effect.gen(function* () {
listed.push(input.scope);
if (input.scope === "acme/api")
return yield* new IssueTrackerError({
tracker: GITHUB,
operation: "listIssues",
reason: "failed",
detail: "boom",
});
return {
items: [issue("2", "2026-07-05T00:00:00Z"), issue("1")],
nextCursor: "next",
};
}),
}),
],
});
const result = yield* service.list({ state: "open" });
expect(listed.sort()).toEqual(["acme/api", "acme/web"]);
expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([
["web", "2"],
["web", "1"],
]);
expect(result.nextCursors).toEqual({ "github.com acme/web": "next" });
expect(result.errors).toEqual([{ projectId: "api", projectTitle: "api", message: "boom" }]);
expect(result.viewers).toEqual({ "github.com": "julius" });
expect(result.trackers).toEqual([
expect.objectContaining({ host: "github.com", projectCount: 3, configured: true }),
expect.objectContaining({
host: "gitlab.com",
tracker: "gitlab",
projectCount: 1,
configured: false,
capabilities: null,
}),
]);

// A continuation only reads the scopes it names.
listed.length = 0;
const next = yield* service.list({
state: "open",
cursors: { "github.com acme/web": "next" },
});
expect(listed).toEqual(["acme/web"]);
expect(next.errors).toEqual([]);
}),
);
});
Loading
Loading