From eb19488e728f8831e1a4ee38bed94cdd56e4d5cb Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 19:13:04 +0800 Subject: [PATCH 1/2] [piv-loop] ROY-26: does we check the issues/tasks by project --- README.md | 4 ++++ src/config.ts | 1 + src/linear.ts | 28 +++++++++++++++++++++++++++- src/types.ts | 2 ++ tests/config.test.ts | 2 ++ tests/linear.test.ts | 20 +++++++++++++++++++- 6 files changed, 55 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a5e413ea..7d4c3cdf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ 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. - `projects` contains one or more project entries, each with: - `id` (required) - `name` (optional) @@ -53,6 +54,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` @@ -89,6 +91,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` diff --git a/src/config.ts b/src/config.ts index e2fa0ece..6993708a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"), diff --git a/src/linear.ts b/src/linear.ts index 2ceb0490..d9ef66b6 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -45,7 +45,15 @@ export class LinearClient { const issue = await this.findIssueByIdentifier( normalizeIssueKey(issueArg), ); - return issue ? [issue] : []; + if (!issue) { + return []; + } + return isIssueInConfiguredProject( + issue.projectId, + this.config.linear.projectId, + ) + ? [issue] + : []; } const viewer = await this.client.viewer; @@ -62,6 +70,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) { @@ -358,6 +372,7 @@ export class LinearClient { includeLabels: boolean, ): Promise { const state = await issue.state; + const project = await issue.project; if (!state?.id) { throw new Error( `Issue ${issue.identifier} is missing workflow state data.`, @@ -374,6 +389,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", @@ -444,3 +460,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; +} diff --git a/src/types.ts b/src/types.ts index 4f489ae1..5bf27157 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,7 @@ export interface ProjectRuntimeConfig { linear: { apiKey: string; apiUrl: string; + projectId?: string; teamId?: string; requiredLabel?: string; pollLimit: number; @@ -107,6 +108,7 @@ export interface LinearIssue { identifier: string; title: string; url: string; + projectId?: string; priority: { value: number; name: string; diff --git a/tests/config.test.ts b/tests/config.test.ts index 8669d3af..cd77c734 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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", @@ -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", ); diff --git a/tests/linear.test.ts b/tests/linear.test.ts index 2051464d..f8a02d0e 100644 --- a/tests/linear.test.ts +++ b/tests/linear.test.ts @@ -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, @@ -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); + }); +}); From fc1b9468f87653ae1c452e0970871e204a70ca31 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 23:25:01 +0800 Subject: [PATCH 2/2] [piv-loop] ROY-28: we should assign the task to correct workspace/agents by project id --- README.md | 7 +++ src/linear.ts | 8 +-- src/workflow.ts | 108 ++++++++++++++++++++++++++++++++++++++++- tests/workflow.test.ts | 99 ++++++++++++++++++++++++++++++++++++- 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7d4c3cdf..0af2dbd1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ 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 `, 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) @@ -36,6 +37,12 @@ Legacy fallback for default project: ` .piv-loop/runs/.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 `. + ## Commands ```bash diff --git a/src/linear.ts b/src/linear.ts index d9ef66b6..99fac7f2 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -42,9 +42,7 @@ export class LinearClient { await this.ensureResolvedStatusMap(); if (issueArg) { - const issue = await this.findIssueByIdentifier( - normalizeIssueKey(issueArg), - ); + const issue = await this.fetchIssueByIdentifier(issueArg); if (!issue) { return []; } @@ -90,6 +88,10 @@ export class LinearClient { ); } + async fetchIssueByIdentifier(issueArg: string): Promise { + return this.findIssueByIdentifier(normalizeIssueKey(issueArg)); + } + async isAssignedState(stateId: string): Promise { await this.ensureResolvedStatusMap(); return this.requiredStatusMap().assigned === stateId; diff --git a/src/workflow.ts b/src/workflow.ts index 15dab9e7..6b7bad32 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -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; @@ -83,6 +93,102 @@ function pickProjects( return config.projects.slice(0, 1); } +async function routeProjectContextsForTargetIssue( + contexts: Array<{ config: ResolvedProjectConfig; linear: LinearClient }>, + issueArg: string, +): Promise> { + 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 .", + }; + } + 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 .`, + }; + } + 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 .", + }; + } + return { + skipReason: `No project configured for linear.projectId='${issueProjectId}'.`, + }; +} + export function shouldStopPolling( polling: PollingSettings, options: RunOptions, diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index ca66efbb..10dbbaed 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import type { PollingConfig, RunState } from "../src/types"; +import type { + PollingConfig, + ResolvedProjectConfig, + RunState, +} from "../src/types"; import { appendCodexUsage, buildIssueJobLogFields, @@ -7,6 +11,7 @@ import { formatCodexUsageLine, parseReviewOutcome, resolvePollingSettings, + routeProjectsForIssueProjectId, shouldStopPolling, } from "../src/workflow"; @@ -311,3 +316,95 @@ describe("appendCodexUsage", () => { expect(state.codexUsage).toHaveLength(0); }); }); + +function createProject( + id: string, + linearProjectId?: string, +): ResolvedProjectConfig { + return { + id, + name: id, + workspacePath: "/tmp/workspace", + executionPath: "/tmp/repo", + repo: { + owner: "acme", + name: "repo", + baseBranch: "main", + }, + linear: { + apiKey: "key", + apiUrl: "https://api.linear.app/graphql", + projectId: linearProjectId, + teamId: undefined, + requiredLabel: undefined, + pollLimit: 10, + statusMap: { + assigned: "Todo", + planning: "In Progress", + implementing: "In Progress", + pr_created: "In Review", + reviewing: "In Review", + testing: "In Review", + blocked: "Canceled", + done: "Done", + }, + labelMap: { + pr_created: "PR Created", + reviewing: "Reviewing", + testing: "Testing", + }, + autoCreateLabels: true, + }, + github: { + useGhCli: true, + defaultBugLabel: "bug", + }, + codex: { + binary: "codex", + }, + skills: { + plan: "/tmp/plan.md", + implement: "/tmp/implement.md", + reviewTest: "/tmp/review.md", + }, + dryRun: false, + }; +} + +describe("routeProjectsForIssueProjectId", () => { + it("routes to explicit linear.projectId match", () => { + const result = routeProjectsForIssueProjectId( + [createProject("api", "proj_api"), createProject("web", "proj_web")], + "proj_web", + ); + expect(result).toEqual({ + selectedProjectId: "web", + }); + }); + + it("skips when no configured project matches issue project id", () => { + const result = routeProjectsForIssueProjectId( + [createProject("api", "proj_api"), createProject("web", "proj_web")], + "proj_unknown", + ); + expect(result.selectedProjectId).toBeUndefined(); + expect(result.error).toBeUndefined(); + expect(result.skipReason).toContain("No project configured"); + }); + + it("fails when multiple projects share same linear.projectId", () => { + const result = routeProjectsForIssueProjectId( + [createProject("api", "proj_a"), createProject("web", "proj_a")], + "proj_a", + ); + expect(result.error).toContain("Multiple projects are configured"); + }); + + it("fails when issue has no project id and multiple unscoped projects exist", () => { + const result = routeProjectsForIssueProjectId( + [createProject("api"), createProject("web")], + undefined, + ); + expect(result.error).toContain("multiple unscoped projects"); + }); +});