Skip to content
Closed
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
19 changes: 18 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
getGlobalAgentFrozenState,
setGlobalAgentFrozen,
} from "../db/repositories";
import { probeLinearWorkspaceAccess } from "../integrations/linear-adapter";
import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_POLICY } from "../db/retention";
import {
backfillOpenPullRequestDetails,
Expand Down Expand Up @@ -2653,6 +2654,22 @@ export function createApp() {
return c.json({ configured: false });
});

// Maintainer connectivity probe for a configured Linear workspace (#3186). Uses the stored per-repo API key
// to list open projects/milestones without returning any key material or tracker titles.
app.get("/v1/repos/:owner/:repo/linear-workspace-probe", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoWriteAccess(c, fullName);
if (gate instanceof Response) return gate;
const repo = await getRepository(c.env, fullName);
return c.json(
await probeLinearWorkspaceAccess({
env: c.env,
installationId: repo?.installationId ?? 0,
repoFullName: fullName,
}),
);
});

app.post("/v1/repos/:owner/:repo/settings-preview", async (c) => {
const identity = await authenticateRequestIdentity(c);
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
Expand Down Expand Up @@ -5413,7 +5430,7 @@ function isRepoAiConfigPath(path: string): boolean {
// module's own broad path-allowlist BEFORE ever reaching the route's own requireRepoWriteAccess check --
// same shape as isRepoAiConfigPath above, just for the new Linear key route.
function isRepoLinearConfigPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/linear-key$/.test(path);
return /^\/v1\/repos\/[^/]+\/[^/]+\/linear-(?:key|workspace-probe)$/.test(path);
}

async function authenticateRequestIdentity(c: ProtectedRouteContext): Promise<AuthIdentity | null> {
Expand Down
3 changes: 2 additions & 1 deletion src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ export function routeClassForPath(path: string): RateLimitClass {
path === "/v1/opportunities/find" ||
path === "/v1/issue-rag/retrieve" ||
// Maintainer BYOK config: POST /ai-key and /linear-key both run PBKDF2 (100k iters) + an encrypted D1
// upsert; GET /linear-workspace-probe also calls the external Linear API.
// upsert per request.
/\/(?:ai-(?:key|review)|linear-key)$/.test(path) ||
/\/(?:ai-(?:key|review)|linear-(?:key|workspace-probe))$/.test(path) ||
/^\/v1\/installations\/[^/]+\/repair\/refresh$/.test(path) ||
path.includes("/upstream/") ||
path.includes("/internal/jobs/generate-signal-snapshots") ||
Expand Down
63 changes: 53 additions & 10 deletions src/integrations/linear-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,20 @@ async function linearGraphQl<T>(apiKey: string, query: string, variables: Record
}

type LinearProjectNode = { id: string; name: string };
type LinearProjectMilestoneNode = { id: string; name: string };
type ListProjectsResponse = {
projects: { nodes: LinearProjectNode[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } };
};
type ListProjectMilestonesResponse = {
projectMilestones: { nodes: LinearProjectMilestoneNode[]; pageInfo: { hasNextPage: boolean; endCursor: string | null } };
};

/**
* GraphQL implementation of {@link ProjectTrackerAdapter} for Linear (#3186). Only the Project half maps
* naturally -- Linear's milestone-equivalent (`ProjectMilestone`) is scoped WITHIN a project rather than a
* flat, listable workspace collection the way GitHub milestones are, so `listOpenMilestones` stays inert here
* (a milestone-level match still surfaces through {@link findLinearNativeLink}'s `issue.projectMilestone`
* read when Linear's own GitHub integration has already linked the PR). `attachToProject`/`attachToMilestone`
* are also inert: writing to Linear requires resolving or creating a Linear Issue for this PR first, which is
* a materially bigger design question deferred beyond #3186's suggest-only scope.
* GraphQL implementation of {@link ProjectTrackerAdapter} for Linear (#3186). Lists open workspace projects and
* project-milestones for fuzzy fallback matching when Linear's own GitHub integration has not already linked
* the PR via {@link findLinearNativeLink}. A confirmed native link still wins over any fuzzy guess.
* `attachToProject`/`attachToMilestone` are inert: writing to Linear requires resolving or creating a Linear
* Issue for this PR first, which is a materially bigger design question deferred beyond #3186's suggest-only scope.
*/
export class LinearAdapter implements ProjectTrackerAdapter {
async listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
Expand All @@ -68,9 +70,27 @@ export class LinearAdapter implements ProjectTrackerAdapter {
return projects.map((project) => ({ id: project.id, title: project.name }));
}

// Inert -- see the class doc comment above.
async listOpenMilestones(): Promise<ProjectTrackerRef[]> {
return [];
async listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName);
if (!apiKey) return [];
const milestones: LinearProjectMilestoneNode[] = [];
let after: string | null = null;
for (let page = 1; page <= LINEAR_LIST_PAGE_LIMIT; page += 1) {
const data: ListProjectMilestonesResponse = await linearGraphQl(
apiKey,
`query($after: String) {
projectMilestones(first: 100, after: $after, includeArchived: false) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}
}`,
{ after },
);
milestones.push(...data.projectMilestones.nodes);
if (!data.projectMilestones.pageInfo.hasNextPage) break;
after = data.projectMilestones.pageInfo.endCursor;
}
return milestones.map((milestone) => ({ id: milestone.id, title: milestone.name }));
}

// Inert -- see the class doc comment above.
Expand Down Expand Up @@ -109,6 +129,29 @@ export type LinearNativeLinkResult = {
* `{project: null, milestone: null}` on a missing key, a transport error, or no matching attachment/link --
* never throws, so a Linear outage degrades to the fuzzy-matching fallback rather than blocking the feature.
*/
export type LinearWorkspaceProbe = {
reachable: boolean;
openProjectCount: number;
openMilestoneCount: number;
};

/**
* Best-effort connectivity probe for a repo's configured Linear workspace (#3186). Used by maintainer
* diagnostics to confirm a stored API key can list open projects/milestones before enabling the linear backend.
* Never throws -- a misconfigured key or Linear outage returns `{ reachable: false, ... }`.
*/
export async function probeLinearWorkspaceAccess(ctx: ProjectTrackerContext): Promise<LinearWorkspaceProbe> {
const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName);
if (!apiKey) return { reachable: false, openProjectCount: 0, openMilestoneCount: 0 };
const adapter = new LinearAdapter();
try {
const [projects, milestones] = await Promise.all([adapter.listOpenProjects(ctx), adapter.listOpenMilestones(ctx)]);
return { reachable: true, openProjectCount: projects.length, openMilestoneCount: milestones.length };
} catch {
return { reachable: false, openProjectCount: 0, openMilestoneCount: 0 };
}
}

export async function findLinearNativeLink(ctx: ProjectTrackerContext, prUrl: string): Promise<LinearNativeLinkResult> {
const none: LinearNativeLinkResult = { project: null, milestone: null };
const apiKey = await getDecryptedRepositoryLinearKey(ctx.env, ctx.repoFullName);
Expand Down
61 changes: 50 additions & 11 deletions src/integrations/project-tracker-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,41 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter {
}
}

/**
* Bundles {@link GitHubMilestonesAdapter} + {@link GitHubProjectsAdapter} behind the single
* {@link ProjectTrackerAdapter} interface so backend-selection call sites can treat GitHub as one backend (#3186).
*/
export class GitHubCompositeProjectTrackerAdapter implements ProjectTrackerAdapter {
private readonly milestones = new GitHubMilestonesAdapter();
private readonly projects = new GitHubProjectsAdapter();

listOpenProjects(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
return this.projects.listOpenProjects(ctx);
}

listOpenMilestones(ctx: ProjectTrackerContext): Promise<ProjectTrackerRef[]> {
return this.milestones.listOpenMilestones(ctx);
}

attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise<ProjectTrackerAttachResult> {
return this.projects.attachToProject(ctx, pullNumber, projectId);
}

attachToMilestone(ctx: ProjectTrackerContext, pullNumber: number, milestoneId: string): Promise<ProjectTrackerAttachResult> {
return this.milestones.attachToMilestone(ctx, pullNumber, milestoneId);
}
}

/**
* Factory for the configured project/milestone tracker backend (#3186). The orchestration layer
* ({@link resolveProjectTrackerMatches}) still prefers {@link findLinearNativeLink} before fuzzy matching when the
* backend is `"linear"`, but every list/attach surface routes through this selector.
*/
export function createProjectTrackerAdapter(backend: ProjectMilestoneMatchBackendInput): ProjectTrackerAdapter {
if (backend === "linear") return new LinearAdapter();
return new GitHubCompositeProjectTrackerAdapter();
}

// Stricter than the duplicate-PR collision gate's 0.58/2 (src/signals/engine.ts) -- misattaching a PR to the
// wrong tracker item corrupts tracked progress, whereas a missed duplicate just skips an advisory note.
const TRACKER_MATCH_MIN_SCORE = 0.65;
Expand Down Expand Up @@ -342,20 +377,24 @@ type ProjectMilestoneMatchBackendInput = "github" | "linear" | null | undefined;
* projects when no native link is found for either project or milestone. The GitHub path (default, #3183/#3184)
* has no native-link concept -- it always fuzzy-matches both open Milestones and open Projects v2.
*/
async function resolveTrackerMatches(ctx: ProjectTrackerContext, backend: ProjectMilestoneMatchBackendInput, prTitle: string, prBody: string | null | undefined, prUrl: string): Promise<ProjectTrackerMatches> {
export async function resolveProjectTrackerMatches(
ctx: ProjectTrackerContext,
backend: ProjectMilestoneMatchBackendInput,
prTitle: string,
prBody: string | null | undefined,
prUrl: string,
): Promise<ProjectTrackerMatches> {
if (backend === "linear") {
const nativeLink = await findLinearNativeLink(ctx, prUrl);
if (nativeLink.project || nativeLink.milestone) return nativeLink;
const linearAdapter = new LinearAdapter();
const projects = await linearAdapter.listOpenProjects(ctx);
return { milestone: null, project: matchOpenTrackerItems(prTitle, prBody, projects) };
}
const milestonesAdapter = new GitHubMilestonesAdapter();
const projectsAdapter = new GitHubProjectsAdapter();
// Fail-open, independently, for each tracker type (mirrors this repo's established best-effort pattern):
// a transient milestone REST error must never suppress a valid Projects v2 match, and vice versa -- either
// lookup degrading to an empty list is a missed suggestion, not a broken one, matching the doc comment above.
const [milestones, projects] = await Promise.all([milestonesAdapter.listOpenMilestones(ctx).catch(() => []), projectsAdapter.listOpenProjects(ctx).catch(() => [])]);
const adapter = createProjectTrackerAdapter(backend);
// Fail-open, independently, for each tracker type: a transient projects lookup must never suppress a valid
// project-milestone match, and vice versa -- either lookup degrading to an empty list is a missed suggestion.
const [projects, milestones] = await Promise.all([
adapter.listOpenProjects(ctx).catch(() => []),
adapter.listOpenMilestones(ctx).catch(() => []),
]);
return {
milestone: matchOpenTrackerItems(prTitle, prBody, milestones),
project: matchOpenTrackerItems(prTitle, prBody, projects),
Expand All @@ -377,7 +416,7 @@ export async function maybeSuggestProjectOrMilestoneMatch(
backend: ProjectMilestoneMatchBackendInput,
prUrl: string,
): Promise<{ suggested: boolean }> {
const matches = await resolveTrackerMatches(ctx, backend, prTitle, prBody, prUrl);
const matches = await resolveProjectTrackerMatches(ctx, backend, prTitle, prBody, prUrl);
if (!matches.milestone && !matches.project) return { suggested: false };

const { owner, repo } = parseRepoFullName(ctx.repoFullName);
Expand Down
1 change: 1 addition & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ describe("private-beta auth and rate limiting", () => {
expect(routeClassForPath("/v1/repos/acme/widgets/ai-key")).toBe("expensive");
expect(routeClassForPath("/v1/repos/acme/widgets/ai-review")).toBe("expensive");
expect(routeClassForPath("/v1/repos/acme/widgets/linear-key")).toBe("expensive");
expect(routeClassForPath("/v1/repos/acme/widgets/linear-workspace-probe")).toBe("expensive");
expect(routeClassForPath("/v1/repos")).toBe("normal");
});

Expand Down
Loading