Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a2de948
feat(issues): add Linear as a standalone issue tracker
juliusmarminge Oct 14, 2026
73008e5
feat(web): connect standalone issue trackers and bind projects in set…
juliusmarminge Oct 14, 2026
ffc2ba2
refactor(issues): keep Linear package internals private and drop trac…
juliusmarminge Oct 14, 2026
624e4bc
chore(issues): refresh the Linear lockfile entries for Effect 4.0.4
juliusmarminge Oct 14, 2026
322107b
chore(issues): drop unused exports and register new packages in CI lists
juliusmarminge Oct 14, 2026
d75bc47
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 22, 2026
3a4497b
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 22, 2026
6f99f93
refactor(issues): name Linear reads and writes as Effect functions
juliusmarminge Oct 22, 2026
835c30b
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 22, 2026
628f6ee
fix(issues): typecheck issue-tracker-linear with tsc like main's pack…
juliusmarminge Oct 22, 2026
9ce624e
fix(web): let the React Compiler compile the issue tracker settings
juliusmarminge Oct 22, 2026
10bf7c8
chore(issues): retrigger CI after a stale conflict state
juliusmarminge Oct 22, 2026
3b27665
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 22, 2026
44014a5
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 22, 2026
e7bf1ee
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 23, 2026
ddf3fac
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 23, 2026
02615e4
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 23, 2026
6c27158
fix(issues): give Linear connection failures a detail when Linear sen…
juliusmarminge Oct 23, 2026
4053f99
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 23, 2026
119f15b
refactor(issues): share the tracker detail fallback with Linear conne…
juliusmarminge Oct 23, 2026
5a89e79
Merge branch 'issues/5-threads' into issues/5-linear
juliusmarminge Oct 23, 2026
20703ed
refactor(issues): generate the Linear client with graphqlgen
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
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
# Batch files need CRLF for cmd.exe to parse them reliably.
*.cmd text eol=crlf
*.bat text eol=crlf
# GitHub's and Expo's GraphQL schemas and the operations graphqlgen writes from them.
# GitHub's, Linear's and Expo's GraphQL schemas and the operations graphqlgen writes from them.
packages/source-control-github/schema/** linguist-generated
packages/issue-tracker-linear/schema/** linguist-generated
apps/server/schema/** linguist-generated
**/*.graphql.ts linguist-generated
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ jobs:
- name: Check generated Expo GraphQL operations
run: vp run --filter t3 generate --check

- name: Check generated Linear GraphQL operations
run: vp run --filter @t3tools/issue-tracker-linear generate --check

- name: Check
run: vp check

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@napi-rs/keyring": "^1.3.0",
"@opencode-ai/sdk": "^1.3.15",
"@t3tools/issue-tracker-core": "workspace:*",
"@t3tools/issue-tracker-linear": "workspace:*",
"@t3tools/provider-acp": "workspace:*",
"@t3tools/provider-acp-registry": "workspace:*",
"@t3tools/provider-core": "workspace:*",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.issuesComments]: AuthOrchestrationReadScope,
[WS_METHODS.issuesTemplates]: AuthOrchestrationReadScope,
[WS_METHODS.issuesCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.issueTrackersList]: AuthOrchestrationReadScope,
[WS_METHODS.issueTrackersConnect]: AuthSettingsWriteScope,
[WS_METHODS.issueTrackersBindProject]: AuthSettingsWriteScope,
[WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
Expand Down
112 changes: 112 additions & 0 deletions apps/server/src/issue/IssueService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest";
import * as GitHubIssueTracker from "@t3tools/source-control-github/server/GitHubIssueTracker";
import {
IssueTrackerKind,
type IssueTrackerProjectBinding,
SourceControlProviderKind,
type OrchestrationProjectShell,
type ProjectId,
} from "@t3tools/contracts";
import {
IssueTrackerError,
type IssueTrackerApi,
type StandaloneIssueTrackerApi,
type TrackerIssueDetail,
} from "@t3tools/issue-tracker-core/server/IssueTracker";

import * as IssueTrackerBindings from "../persistence/IssueTrackerBindings.ts";
import { normalizeGitRemoteUrl } from "@t3tools/shared/git";

import * as ProjectService from "../project/ProjectService.ts";
Expand All @@ -29,6 +32,30 @@ import * as IssueService from "./IssueService.ts";
import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts";

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

/** A standalone tracker shaped like Linear: its own host, team keys for scopes, `ENG-1` keys. */
function fakeStandaloneTracker(
overrides: Partial<IssueTrackerApi> = {},
): StandaloneIssueTrackerApi {
return {
...fakeTracker({
getViewer: () => Effect.succeed("usr_julius"),
getIssue: (input) =>
Effect.succeed({ ...issue(input.key), displayKey: input.key, viewer: "usr_julius" }),
...overrides,
}),
kind: LINEAR,
standalone: {
tracker: LINEAR,
displayName: "Linear",
host: "linear.app",
scopeNoun: "team",
credential: { label: "Personal API key", helpUrl: null },
},
probeCredential: () => Effect.succeed({ account: "julius", scopes: [] }),
};
}

function project(input: {
readonly id: string;
Expand Down Expand Up @@ -120,12 +147,23 @@ function fakeTracker(overrides: Partial<IssueTrackerApi> = {}): IssueTrackerApi
function makeService(input: {
readonly projects: ReadonlyArray<OrchestrationProjectShell>;
readonly trackers: ReadonlyArray<IssueTrackerApi>;
readonly bindings?: Record<string, IssueTrackerProjectBinding>;
}) {
return IssueService.IssueService.pipe(
Effect.provide(
IssueService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(IssueTrackerBindings.IssueTrackerBindingsRepository)({
list: Effect.succeed(
new Map(
Object.entries(input.bindings ?? {}).map(([id, binding]) => [
id as ProjectId,
binding,
]),
),
),
}),
Layer.succeed(
IssueTrackerRegistry.IssueTrackerRegistry,
IssueTrackerRegistry.fromTrackers(input.trackers),
Expand Down Expand Up @@ -408,3 +446,77 @@ describe("IssueService", () => {
}),
);
});

describe("IssueService with a standalone tracker", () => {
it.effect("lists a bound project's standalone scope beside its git host and serves both", () =>
Effect.gen(function* () {
const listed: string[] = [];
const record = (tracker: IssueTrackerKind): Partial<IssueTrackerApi> => ({
listIssues: (input) =>
Effect.sync(() => {
listed.push(`${tracker} ${input.host} ${input.scope}`);
return { items: [issue(tracker === LINEAR ? "ENG-1" : "1")], nextCursor: null };
}),
});
const projects = [project({ id: "web", repository: "acme/web" }), project({ id: "notes" })];
const trackers = [fakeTracker(record(GITHUB)), fakeStandaloneTracker(record(LINEAR))];
const bound = yield* makeService({
projects,
trackers,
bindings: { ["web" as ProjectId]: { tracker: LINEAR, scope: "ENG" } },
});

const result = yield* bound.list({ state: "open" });
expect(listed.sort()).toEqual(["github github.com acme/web", "linear linear.app ENG"]);
expect(result.entries.map((entry) => [entry.tracker, entry.scope, entry.key]).sort()).toEqual(
[
["github", "acme/web", "1"],
["linear", "ENG", "ENG-1"],
],
);
expect(result.trackers.map((tracker) => [tracker.tracker, tracker.host]).sort()).toEqual([
["github", "github.com"],
["linear", "linear.app"],
]);
// A reference picks the project's tracker by kind; both are the project's own, so writable.
const linearRef = {
projectId: "web" as ProjectId,
tracker: LINEAR,
scope: "ENG",
key: "ENG-1",
};
expect(yield* bound.detail(linearRef)).toMatchObject({ host: "linear.app", writable: true });
expect(
yield* bound.detail({
projectId: "web" as ProjectId,
tracker: GITHUB,
scope: "acme/web",
key: "1",
}),
).toMatchObject({ host: "github.com", writable: true });

// Unbound, the standalone scope is gone and its references are unsupported.
listed.length = 0;
const unbound = yield* makeService({ projects, trackers });
const after = yield* unbound.list({ state: "open" });
expect(listed).toEqual(["github github.com acme/web"]);
expect(after.trackers.map((tracker) => tracker.tracker)).toEqual(["github"]);
const refused = yield* Effect.flip(unbound.detail(linearRef));
expect(refused).toMatchObject({
_tag: "IssueUnavailableError",
reason: "tracker-unsupported",
});

// A project with no remote at all can still be bound, and then has only the standalone one.
const standaloneOnly = yield* makeService({
projects,
trackers,
bindings: { ["notes" as ProjectId]: { tracker: LINEAR, scope: "OPS" } },
});
const notes = yield* standaloneOnly.list({ state: "open", projectId: "notes" as ProjectId });
expect(notes.entries.map((entry) => [entry.projectId, entry.scope])).toEqual([
["notes", "OPS"],
]);
}),
);
});
52 changes: 41 additions & 11 deletions apps/server/src/issue/IssueService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// A project bound to a standalone tracker (Linear) in settings lists it beside its git host's.
// References outside the project's canonical repository may be read on the same host,
// but every write must pass through requireWritable.
import * as Context from "effect/Context";
Expand Down Expand Up @@ -37,9 +38,11 @@ import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
import { parseIssueUrl } from "@t3tools/shared/issueUrl";
import {
IssueTrackerError,
isStandaloneIssueTracker,
type IssueTrackerApi,
} from "@t3tools/issue-tracker-core/server/IssueTracker";

import * as IssueTrackerBindings from "../persistence/IssueTrackerBindings.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts";
Expand All @@ -58,7 +61,7 @@ const traced =
call(input).pipe(Effect.withSpan(`IssueService.${operation}`));

/** A tracker may fail with no detail; the wire errors carry a non-empty one. */
function trackerDetail(error: IssueTrackerError): string {
export function trackerDetail(error: IssueTrackerError): string {
return error.detail.trim() || "The issue tracker request failed.";
}

Expand Down Expand Up @@ -185,17 +188,43 @@ const make = Effect.gen(function* () {
const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry;
const projects = yield* ProjectService.ProjectService;
const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const bindings = yield* IssueTrackerBindings.IssueTrackerBindingsRepository;

const resolveProjectTracker = (
// Read per resolution so a binding saved in Settings applies without a restart.
const resolveStandaloneBinding = (
project: OrchestrationProjectShell,
): Effect.Effect<ProjectTrackerBinding | null> =>
(project.repositoryIdentity != null
? Effect.succeed(project.repositoryIdentity)
: repositoryIdentities.resolve(project.workspaceRoot)
).pipe(
Effect.map((repositoryIdentity) => resolveGitHostBinding({ ...project, repositoryIdentity })),
bindings.list.pipe(
Effect.map((bound) => {
const binding = bound.get(project.id);
const api = binding === undefined ? null : registry.get(binding.tracker);
if (binding === undefined || api === null || !isStandaloneIssueTracker(api)) return null;
return {
canonicalKey: `${binding.tracker}:${api.standalone.host}/${binding.scope}`.toLowerCase(),
tracker: binding.tracker,
host: api.standalone.host,
scope: binding.scope,
};
}),
Effect.orElseSucceed(() => null),
);

/** Where a project's issues live: its git host's tracker, its bound standalone one, or both. */
const resolveProjectTracker = (
project: OrchestrationProjectShell,
): Effect.Effect<ReadonlyArray<ProjectTrackerBinding>> =>
Effect.all([
(project.repositoryIdentity != null
? Effect.succeed(project.repositoryIdentity)
: repositoryIdentities.resolve(project.workspaceRoot)
).pipe(
Effect.map((repositoryIdentity) =>
resolveGitHostBinding({ ...project, repositoryIdentity }),
),
),
resolveStandaloneBinding(project),
]).pipe(Effect.map((resolved) => resolved.filter((binding) => binding !== null)));

const listProjects = (filter: {
readonly projectId?: IssueListInput["projectId"];
readonly host?: string | undefined;
Expand All @@ -217,7 +246,7 @@ const make = Effect.gen(function* () {
shells,
(project) =>
resolveProjectTracker(project).pipe(
Effect.map((binding) => (binding === null ? [] : [{ project, ...binding }])),
Effect.map((bindings) => bindings.map((binding) => ({ project, ...binding }))),
),
{ concurrency: SCOPE_CONCURRENCY },
),
Expand Down Expand Up @@ -248,15 +277,16 @@ const make = Effect.gen(function* () {
/**
* The project whose checkout and credentials serve a reference, and whether the reference is
* the project's own scope. A reference naming another scope on the same tracker and host is
* served by the project but marked read-only.
* served by the project but marked read-only. A project with two trackers serves the one the
* reference names.
*/
const requireProject = (
ref: IssueScopeRef,
): Effect.Effect<SupportedProject & { readonly writable: boolean }, IssueError> =>
listProjects({ projectId: ref.projectId }).pipe(
Effect.flatMap(({ supported }) => {
const own = supported[0];
if (own === undefined || own.tracker !== ref.tracker) {
const own = supported.find((candidate) => candidate.tracker === ref.tracker);
if (own === undefined) {
return Effect.fail(new IssueUnavailableError({ reason: "tracker-unsupported" }));
}
const host = ref.host?.trim().toLowerCase() ?? own.host;
Expand Down
Loading
Loading