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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.1.1 - 2026-07-13

- Fixed linked issue loading for pull requests in public and private repositories.
- Skipped diff and linked issue requests for draft and closed pull requests.

## 0.1.0 - 2026-07-12

- Added `/code-review` with current-branch, PR number, and PR URL targets.
Expand Down
22 changes: 22 additions & 0 deletions docs/superpowers/plans/2026-07-13-linked-issue-hydration-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Linked Issue Hydration Implementation Plan

## Goal

Make `/code-review` accept GitHub's lightweight `closingIssuesReferences` response and load each linked issue explicitly before model work.

## Tasks

1. Add regression tests for lightweight references, stable multi-issue ordering, hydration failure diagnostics, malformed reference paths, and draft skip behavior.
2. Replace complete-issue parsing with reference parsing plus bounded hydration through `gh issue view`.
3. Gate both diff loading and linked-issue hydration on an open, non-draft pull request.
4. Bump the package and review marker to `0.1.1`, update the changelog, and regenerate the lockfile without lifecycle scripts.
5. Run the targeted test, full checks, full tests, package verification, and a read-only `/code-review 262` smoke test in `packageauth`.

## Acceptance Criteria

- Private repositories work through the existing authenticated `gh` session.
- Missing embedded issue `title` and `body` no longer cause PR parsing to fail.
- Linked issue failures identify `<owner>/<repo>#<number>` and stop before model work.
- At most four issue hydration commands run concurrently and output order matches GitHub's reference order.
- Draft and closed pull requests perform neither diff loading nor linked-issue hydration.
- No unrelated review behavior changes.
55 changes: 55 additions & 0 deletions docs/superpowers/specs/2026-07-13-linked-issue-hydration-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Linked Issue Hydration Fix Design

## Problem

`gh pr view --json closingIssuesReferences` returns lightweight issue references containing fields such as `number`, `url`, and `repository`. It does not guarantee embedded `title` or `body` fields. Version 0.1.0 parses each reference as a complete issue and therefore throws `gh response is missing string field title` for PRs that close an issue.

This is independent of repository visibility. GitHub CLI authentication already provides access to private PR and issue data when the logged-in account has permission.

## Scope

Fix linked-issue parsing and hydration only. Do not add draft review, new authentication settings, GraphQL integration, or unrelated GitHub adapter refactoring.

## Data Flow

1. Parse PR metadata and `closingIssuesReferences` as lightweight references.
2. Extract each reference's issue number, URL, and repository owner/name.
3. If the PR is closed or draft, do not hydrate linked issues or fetch the diff; return the existing skip result inputs.
4. For an open, non-draft PR, hydrate references with:

```text
gh issue view <number> --repo <owner>/<repo> --json number,title,body,url
```

5. Limit linked-issue hydration to four concurrent `gh` processes.
6. Preserve reference order in the final `linkedIssues` array.

Same-repository references use the repository returned by the reference. Cross-repository references use their own repository identity and therefore work when the active `gh` account can access that repository.

## Failure Behavior

Linked issue bodies are required inputs for task-objective review. If an explicitly linked issue cannot be hydrated, fail closed before model work with an error that identifies the exact issue:

```text
Unable to load linked issue Sirfetch-d/packageauth#261: <gh diagnostic>
```

Do not silently continue with incomplete task context. Do not expose authentication tokens or environment variables.

Malformed references fail with field-specific paths, such as `closingIssuesReferences[0].repository.owner.login`, instead of the ambiguous `missing string field title` error.

## Testing

Add regression coverage for:

- A lightweight same-repository reference without embedded title/body, followed by successful `gh issue view` hydration.
- Multiple linked issues hydrated with stable output order.
- Hydration failure reporting the repository and issue number.
- A draft PR returning without diff or issue hydration calls.
- Existing PRs without linked issues retaining their current process call sequence and result.

All tests use injected process runners and make no real GitHub mutations.

## Release

Record the fix in `CHANGELOG.md`, bump the package and runtime marker from `0.1.0` to `0.1.1`, regenerate `package-lock.json` with lifecycle scripts disabled, and publish only after checks, tests, package verification, and a real read-only run against packageauth PR #262 succeed. Because PR #262 is currently draft, that live run must verify clean skip behavior without issue hydration; linked-issue hydration itself is verified with tests or after the PR becomes ready for review.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sirfetch-d/pi-code-review",
"version": "0.1.0",
"version": "0.1.1",
"description": "High-signal multi-agent pull request reviews for Pi",
"type": "module",
"license": "MIT",
Expand Down
127 changes: 115 additions & 12 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import { PACKAGE_VERSION } from "./version.ts";

const REVIEW_MARKER_PREFIX = "<!-- pi-code-review@";
const LINKED_ISSUE_CONCURRENCY = 4;

interface GitHubContext {
cwd: string;
Expand All @@ -20,6 +21,13 @@ export interface ResolvedPullRequest {
snapshot: PullRequestSnapshot;
}

interface LinkedIssueReference {
number: number;
url: string;
owner: string;
repo: string;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -93,21 +101,110 @@ function parseFiles(value: unknown): ChangedFile[] {
});
}

function parseIssues(value: unknown): LinkedIssue[] {
function referenceRecord(
value: unknown,
path: string,
): Record<string, unknown> {
if (!isRecord(value))
throw new Error(`gh response is missing object ${path}`);
return value;
}

function referenceString(
record: Record<string, unknown>,
key: string,
path: string,
): string {
const value = record[key];
if (typeof value !== "string")
throw new Error(`gh response is missing string field ${path}`);
return value;
}

function referenceNumber(
record: Record<string, unknown>,
key: string,
path: string,
): number {
const value = record[key];
if (typeof value !== "number")
throw new Error(`gh response is missing number field ${path}`);
return value;
}

function parseIssueReferences(value: unknown): LinkedIssueReference[] {
if (value === undefined) return [];
if (!Array.isArray(value))
throw new Error("gh returned invalid linked issues");
return value.map((item) => {
if (!isRecord(item)) throw new Error("gh returned an invalid linked issue");
return value.map((item, index) => {
const path = `closingIssuesReferences[${index}]`;
const reference = referenceRecord(item, path);
const repository = referenceRecord(
reference.repository,
`${path}.repository`,
);
const owner = referenceRecord(repository.owner, `${path}.repository.owner`);
return {
number: numberField(item, "number"),
title: stringField(item, "title"),
body: typeof item.body === "string" ? item.body : "",
url: stringField(item, "url"),
number: referenceNumber(reference, "number", `${path}.number`),
url: referenceString(reference, "url", `${path}.url`),
owner: referenceString(owner, "login", `${path}.repository.owner.login`),
repo: referenceString(repository, "name", `${path}.repository.name`),
};
});
}

async function hydrateLinkedIssue(
reference: LinkedIssueReference,
context: GitHubContext,
): Promise<LinkedIssue> {
const identity = `${reference.owner}/${reference.repo}#${reference.number}`;
try {
const raw = await gh(context, [
"issue",
"view",
String(reference.number),
"--repo",
`${reference.owner}/${reference.repo}`,
"--json",
"number,title,body,url",
]);
const issue = parseJson(raw, `linked issue ${identity}`);
return {
number: numberField(issue, "number"),
title: stringField(issue, "title"),
body: typeof issue.body === "string" ? issue.body : "",
url: stringField(issue, "url"),
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to load linked issue ${identity}: ${detail}`);
}
}

async function hydrateLinkedIssues(
references: LinkedIssueReference[],
context: GitHubContext,
): Promise<LinkedIssue[]> {
const issues = new Array<LinkedIssue>(references.length);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < references.length) {
const index = nextIndex;
nextIndex += 1;
const reference = references[index];
if (reference)
issues[index] = await hydrateLinkedIssue(reference, context);
}
};
await Promise.all(
Array.from(
{ length: Math.min(LINKED_ISSUE_CONCURRENCY, references.length) },
worker,
),
);
return issues;
}

function extractBodies(value: unknown): Array<{ body: string; url?: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
Expand Down Expand Up @@ -203,10 +300,16 @@ export async function resolvePullRequest(
throw new Error("gh response is missing draft state");
const headSha = stringField(data, "headRefOid");
const state = stringField(data, "state");
const diff =
state === "OPEN" && !isDraft
? await gh(context, ["pr", "diff", ...targetArgument(target)])
: "";
const shouldLoadReviewContext = state === "OPEN" && !isDraft;
const linkedIssues = shouldLoadReviewContext
? await hydrateLinkedIssues(
parseIssueReferences(data.closingIssuesReferences),
context,
)
: [];
const diff = shouldLoadReviewContext
? await gh(context, ["pr", "diff", ...targetArgument(target)])
: "";

return {
repositoryRoot,
Expand All @@ -224,7 +327,7 @@ export async function resolvePullRequest(
headSha,
files: parseFiles(data.files),
diff,
linkedIssues: parseIssues(data.closingIssuesReferences),
linkedIssues,
existingReviewUrl: findExistingReview(
headSha,
data.comments,
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const PACKAGE_VERSION = "0.1.0";
export const PACKAGE_VERSION = "0.1.1";
Loading
Loading