Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Configuration is loaded from `piv-loop.config.ts` and resolved into project-spec

- Root defaults can define shared repo, linear, codex, skills, and dry-run behavior.
- Polling is a single global config at the root `polling` key (`intervalMs`, `maxCycles`, `exitWhenIdle`) and applies to all selected projects in a run.
- Optional `linear.projectId` can scope each PIV project to a specific Linear project when selecting assigned work.
- For targeted runs with `--all-projects --issue <KEY>`, PIV routes the issue to exactly one project by matching `linear.projectId` to the Linear issue's `projectId`.
- `projects` contains one or more project entries, each with:
- `id` (required)
- `name` (optional)
Expand All @@ -35,6 +37,12 @@ Legacy fallback for default project:

` .piv-loop/runs/<LINEAR_KEY>.json `

Routing notes:

- `project.id` is the PIV workspace/agent identifier used for execution path, state namespace, and workflow ownership.
- `linear.projectId` is the Linear project filter/routing key.
- If `--all-projects --issue` cannot resolve to one unique project (for example duplicate mappings or ambiguous unscoped projects), run with `--project <PROJECT_ID>`.

## Commands

```bash
Expand All @@ -53,6 +61,7 @@ bun run src/index.ts projects
Set these variables before running:

- `LINEAR_API_KEY`
- `LINEAR_PROJECT_ID` (optional; when set, only issues from this Linear project are eligible)
- `LINEAR_STATUS_ASSIGNED`
- `LINEAR_STATUS_PLANNING`
- `LINEAR_STATUS_IMPLEMENTING`
Expand Down Expand Up @@ -89,6 +98,8 @@ Optional:

`LINEAR_STATUS_*` values may be either Linear workflow state IDs or exact state names (for example `Todo`, `In Progress`, `Done`). Names are resolved to IDs at runtime.

When `--all-projects` is used, each configured PIV project applies its own `linear.projectId` filter (if configured). If `linear.projectId` is unset, behavior remains unchanged and issues are not filtered by Linear project.

Recommended mapping for your board:

- `assigned`: `Todo`
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function buildEnvBase(cwd: string): ProjectRuntimeConfig {
linear: {
apiKey: env.LINEAR_API_KEY ?? "",
apiUrl: env.LINEAR_API_URL ?? "https://api.linear.app/graphql",
projectId: normalizeOptionalValue(env.LINEAR_PROJECT_ID),
teamId: env.LINEAR_TEAM_ID,
requiredLabel: env.LINEAR_REQUIRED_LABEL,
pollLimit: Number(env.PIV_POLL_LIMIT ?? "10"),
Expand Down
36 changes: 32 additions & 4 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,16 @@ export class LinearClient {
await this.ensureResolvedStatusMap();

if (issueArg) {
const issue = await this.findIssueByIdentifier(
normalizeIssueKey(issueArg),
);
return issue ? [issue] : [];
const issue = await this.fetchIssueByIdentifier(issueArg);
if (!issue) {
return [];
}
return isIssueInConfiguredProject(
issue.projectId,
this.config.linear.projectId,
)
? [issue]
: [];
}

const viewer = await this.client.viewer;
Expand All @@ -62,6 +68,12 @@ export class LinearClient {

return sortIssuesByPriority(
issues
.filter((issue) =>
isIssueInConfiguredProject(
issue.projectId,
this.config.linear.projectId,
),
)
.filter((issue) => issue.state.id === assignedStateId)
.filter((issue) => {
if (!this.config.linear.requiredLabel) {
Expand All @@ -76,6 +88,10 @@ export class LinearClient {
);
}

async fetchIssueByIdentifier(issueArg: string): Promise<LinearIssue | null> {
return this.findIssueByIdentifier(normalizeIssueKey(issueArg));
}

async isAssignedState(stateId: string): Promise<boolean> {
await this.ensureResolvedStatusMap();
return this.requiredStatusMap().assigned === stateId;
Expand Down Expand Up @@ -358,6 +374,7 @@ export class LinearClient {
includeLabels: boolean,
): Promise<LinearIssue> {
const state = await issue.state;
const project = await issue.project;
if (!state?.id) {
throw new Error(
`Issue ${issue.identifier} is missing workflow state data.`,
Expand All @@ -374,6 +391,7 @@ export class LinearClient {
identifier: issue.identifier,
title: issue.title,
url: issue.url,
projectId: project?.id ?? undefined,
priority: {
value: issue.priority ?? 0,
name: issue.priorityLabel ?? "No priority",
Expand Down Expand Up @@ -444,3 +462,13 @@ export function sortIssuesByPriority(issues: LinearIssue[]): LinearIssue[] {
})
.map((entry) => entry.issue);
}

export function isIssueInConfiguredProject(
issueProjectId: string | undefined,
configuredProjectId: string | undefined,
): boolean {
if (!configuredProjectId) {
return true;
}
return issueProjectId === configuredProjectId;
}
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface ProjectRuntimeConfig {
linear: {
apiKey: string;
apiUrl: string;
projectId?: string;
teamId?: string;
requiredLabel?: string;
pollLimit: number;
Expand Down Expand Up @@ -107,6 +108,7 @@ export interface LinearIssue {
identifier: string;
title: string;
url: string;
projectId?: string;
priority: {
value: number;
name: string;
Expand Down
108 changes: 107 additions & 1 deletion src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,20 @@ export async function runWorkflow(
return;
}

const projectContexts = projects.map((project) => ({
let projectContexts = projects.map((project) => ({
config: project,
linear: new LinearClient(project),
}));

if (options.issueArg && options.allProjects && !options.projectId) {
projectContexts = await routeProjectContextsForTargetIssue(
projectContexts,
options.issueArg,
);
if (projectContexts.length === 0) {
return;
}
}
const globalPolling = resolvePollingSettings(config.polling, options);
let cycle = 0;

Expand Down Expand Up @@ -83,6 +93,102 @@ function pickProjects(
return config.projects.slice(0, 1);
}

async function routeProjectContextsForTargetIssue(
contexts: Array<{ config: ResolvedProjectConfig; linear: LinearClient }>,
issueArg: string,
): Promise<Array<{ config: ResolvedProjectConfig; linear: LinearClient }>> {
const routeLogger = logger.child({ issueArg });
const issue = await contexts[0]?.linear.fetchIssueByIdentifier(issueArg);
if (!issue) {
routeLogger.info("Target issue was not found; skipping run.");
return [];
}

const routing = routeProjectsForIssueProjectId(
contexts.map((context) => context.config),
issue.projectId,
);
if (routing.error) {
throw new Error(routing.error);
}
if (!routing.selectedProjectId) {
routeLogger.info(
{
issueKey: issue.identifier,
issueProjectId: issue.projectId ?? null,
reason: routing.skipReason,
},
"Target issue is not routable to any configured project; skipping run",
);
return [];
}

const selected = contexts.filter(
(context) => context.config.id === routing.selectedProjectId,
);
routeLogger.info(
{
issueKey: issue.identifier,
issueProjectId: issue.projectId ?? null,
projectId: routing.selectedProjectId,
},
"Routed target issue to project by Linear project id",
);
return selected;
}

export interface IssueProjectRoutingResult {
selectedProjectId?: string;
skipReason?: string;
error?: string;
}

export function routeProjectsForIssueProjectId(
projects: ResolvedProjectConfig[],
issueProjectId: string | undefined,
): IssueProjectRoutingResult {
const scopedProjects = projects.filter((project) => project.linear.projectId);
const unscopedProjects = projects.filter(
(project) => !project.linear.projectId,
);

if (!issueProjectId) {
if (unscopedProjects.length > 1) {
return {
error:
"Target issue has no Linear project id and multiple unscoped projects are configured. Re-run with --project <PROJECT_ID>.",
};
}
return {
skipReason:
"Target issue has no Linear project id and cannot be safely routed in --all-projects mode.",
};
}

const explicitMatches = scopedProjects.filter(
(project) => project.linear.projectId === issueProjectId,
);
if (explicitMatches.length > 1) {
return {
error: `Multiple projects are configured with linear.projectId='${issueProjectId}'. Re-run with --project <PROJECT_ID>.`,
};
}
if (explicitMatches.length === 1) {
return {
selectedProjectId: explicitMatches[0]?.id,
};
}
if (unscopedProjects.length > 1) {
return {
error:
"No explicit linear.projectId match was found and multiple unscoped projects are configured. Re-run with --project <PROJECT_ID>.",
};
}
return {
skipReason: `No project configured for linear.projectId='${issueProjectId}'.`,
};
}

export function shouldStopPolling(
polling: PollingSettings,
options: RunOptions,
Expand Down
2 changes: 2 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { loadConfig } from "../src/config";

const envKeys = [
"LINEAR_API_KEY",
"LINEAR_PROJECT_ID",
"LINEAR_STATUS_ASSIGNED",
"LINEAR_STATUS_PLANNING",
"LINEAR_STATUS_IMPLEMENTING",
Expand Down Expand Up @@ -55,6 +56,7 @@ describe("loadConfig", () => {
it("loads required env values", async () => {
const config = await loadConfig(process.cwd());
expect(config.projects[0]?.linear.apiKey).toBe("linear_api_key");
expect(config.projects[0]?.linear.projectId).toBe("linear_project_id");
expect(config.projects[0]?.linear.statusMap.assigned).toBe(
"linear_status_assigned",
);
Expand Down
20 changes: 19 additions & 1 deletion tests/linear.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { describe, expect, it } from "bun:test";
import { sortIssuesByPriority } from "../src/linear";
import {
isIssueInConfiguredProject,
sortIssuesByPriority,
} from "../src/linear";
import type { LinearIssue } from "../src/types";

function createIssue(
identifier: string,
priorityValue: number,
priorityName: string,
projectId?: string,
): LinearIssue {
return {
id: identifier,
identifier,
title: identifier,
url: `https://linear.app/roy/issue/${identifier}`,
projectId,
priority: {
value: priorityValue,
name: priorityName,
Expand Down Expand Up @@ -59,3 +64,16 @@ describe("sortIssuesByPriority", () => {
]);
});
});

describe("isIssueInConfiguredProject", () => {
it("accepts all issues when no project filter is configured", () => {
expect(isIssueInConfiguredProject("proj_a", undefined)).toBe(true);
expect(isIssueInConfiguredProject(undefined, undefined)).toBe(true);
});

it("accepts only matching project ids when filter is configured", () => {
expect(isIssueInConfiguredProject("proj_a", "proj_a")).toBe(true);
expect(isIssueInConfiguredProject("proj_b", "proj_a")).toBe(false);
expect(isIssueInConfiguredProject(undefined, "proj_a")).toBe(false);
});
});
Loading
Loading