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
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 @@ -110,6 +110,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 @@ -43,6 +43,7 @@
"@ff-labs/fff-node": "0.11.0",
"@muse-code/sdk": "1.4.6",
"@napi-rs/keyring": "^1.3.0",
"@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 @@ -137,6 +137,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 @@ -5,16 +5,19 @@ import * as Option from "effect/Option";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import {
IssueTrackerKind,
type IssueTrackerProjectBinding,
SourceControlProviderKind,
type OrchestrationProjectShell,
type ProjectId,
} from "@t3tools/contracts";
import {
IssueTrackerError,
type IssueTrackerApi,
type StandaloneIssueTrackerApi,
type TrackerIssueDetail,
} from "@t3tools/source-control-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 @@ -23,6 +26,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 @@ -114,12 +141,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 @@ -367,3 +405,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"],
]);
}),
);
});
54 changes: 43 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 @@ -33,9 +34,11 @@ import {
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import {
IssueTrackerError,
isStandaloneIssueTracker,
type IssueTrackerApi,
} from "@t3tools/source-control-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 { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
Expand All @@ -55,7 +58,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 @@ -164,17 +167,45 @@ 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 {
tracker: binding.tracker,
host: api.standalone.host,
scope: binding.scope,
};
}),
Effect.orElseSucceed(() => null),
);

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

const listProjects = (filter: {
readonly projectId?: IssueListInput["projectId"];
readonly host?: string | undefined;
Expand All @@ -196,7 +227,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 @@ -227,15 +258,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