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
27 changes: 27 additions & 0 deletions .oac/contributions/2026-03-05-0616-github-issue-56.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"version": "1.0",
"runId": "e16408e0-4fd6-46ff-8561-a0942afe493f",
"timestamp": "2026-03-05T06:16:24.690Z",
"contributor": "jiun",
"task": {
"id": "github-issue-56",
"title": "Duplicate GitHub API calls for OAC PR deduplication across scanner and PR modules",
"source": "github-issue",
"complexity": "moderate",
"linkedIssue": {
"number": 56,
"url": "https://github.com/Open330/open-agent-contribution/issues/56"
}
},
"execution": {
"success": true,
"tokensUsed": 896918,
"duration": 164.127,
"filesChanged": [
"src/cli/commands/run/pr.ts",
"src/core/github-pr-cache.ts",
"src/core/index.ts",
"src/discovery/scanners/github-issues-scanner.ts"
]
}
}
157 changes: 26 additions & 131 deletions src/cli/commands/run/pr.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { execa } from "execa";
import {
fetchOpenOacPRs,
findOacPRByTitle,
findOacPRForIssue,
} from "../../../core/github-pr-cache.js";
import type { Task } from "../../../core/index.js";
import type { ExecutionOutcome, SandboxInfo, TaskRunResult } from "./types.js";
import { PR_CREATION_TIMEOUT_MS } from "./types.js";

const GITHUB_API_BASE_URL = "https://api.github.com";
const OAC_PR_TITLE_PREFIX = "[OAC]";

export async function createPullRequest(input: {
Expand All @@ -30,35 +34,27 @@ export async function createPullRequest(input: {
try {
const ghEnv = buildGhEnv(input.ghToken);

// Pre-PR guard: skip if another OAC instance already created a PR for this issue
if (input.task.linkedIssue && input.ghToken) {
const duplicate = await findExistingOacPR(
input.repoFullName,
input.task.linkedIssue.number,
input.ghToken,
);
if (duplicate) {
console.warn(
`[oac] Skipping PR: existing OAC PR #${duplicate} already targets issue #${input.task.linkedIssue.number}`,
);
return undefined;
}
}

// Pre-PR guard for non-issue tasks (e.g. TODO): skip if an open OAC PR
// with the same title already exists
if (!input.task.linkedIssue && input.ghToken) {
const prTitle = `${OAC_PR_TITLE_PREFIX} ${input.task.title}`;
const duplicate = await findExistingOacPRByTitle(
input.repoFullName,
prTitle,
input.ghToken,
);
if (duplicate) {
console.warn(
`[oac] Skipping PR: existing OAC PR #${duplicate} already has title "${prTitle}"`,
);
return undefined;
// Pre-PR guard: check for duplicate OAC PRs (single API call for both checks)
if (input.ghToken) {
const oacPRs = await fetchOpenOacPRs(input.repoFullName, input.ghToken);

if (input.task.linkedIssue) {
const duplicate = findOacPRForIssue(oacPRs, input.task.linkedIssue.number);
if (duplicate) {
console.warn(
`[oac] Skipping PR: existing OAC PR #${duplicate} already targets issue #${input.task.linkedIssue.number}`,
);
return undefined;
}
} else {
const prTitle = `${OAC_PR_TITLE_PREFIX} ${input.task.title}`;
const duplicate = findOacPRByTitle(oacPRs, prTitle);
if (duplicate) {
console.warn(
`[oac] Skipping PR: existing OAC PR #${duplicate} already has title "${prTitle}"`,
);
return undefined;
}
}
}

Expand Down Expand Up @@ -228,104 +224,3 @@ function readContextAck(task: Task):
return { files, summary, digest };
}

/**
* Pre-PR guard: checks for an existing open OAC pull request targeting
* the given issue number. Returns the PR number if found.
*/
async function findExistingOacPR(
repoFullName: string,
issueNumber: number,
token: string,
): Promise<number | undefined> {
const url = `${GITHUB_API_BASE_URL}/repos/${repoFullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`;

try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
},
signal: AbortSignal.timeout(15_000),
});

if (!response.ok) {
return undefined;
}

const pulls: unknown = await response.json();
if (!Array.isArray(pulls)) {
return undefined;
}

const issueRefPattern = /(?:Fixes|Closes|Resolves)\s+#(\d+)/gi;
for (const pr of pulls) {
if (!pr || typeof pr !== "object") continue;

const record = pr as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title : "";
if (!title.startsWith(OAC_PR_TITLE_PREFIX)) continue;

const prNumber = typeof record.number === "number" ? record.number : undefined;
const body = typeof record.body === "string" ? record.body : "";

for (const match of body.matchAll(issueRefPattern)) {
const num = Number.parseInt(match[1], 10);
if (num === issueNumber) {
return prNumber;
}
}
}

return undefined;
} catch {
// Guard failure should not block PR creation.
return undefined;
}
}

/**
* Pre-PR guard for non-issue tasks: checks for an existing open OAC pull
* request with the exact same title. Returns the PR number if found.
*/
async function findExistingOacPRByTitle(
repoFullName: string,
prTitle: string,
token: string,
): Promise<number | undefined> {
const url = `${GITHUB_API_BASE_URL}/repos/${repoFullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`;

try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
},
signal: AbortSignal.timeout(15_000),
});

if (!response.ok) {
return undefined;
}

const pulls: unknown = await response.json();
if (!Array.isArray(pulls)) {
return undefined;
}

for (const pr of pulls) {
if (!pr || typeof pr !== "object") continue;

const record = pr as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title : "";
if (title === prTitle) {
return typeof record.number === "number" ? record.number : undefined;
}
}

return undefined;
} catch {
return undefined;
}
}
120 changes: 120 additions & 0 deletions src/core/github-pr-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
const GITHUB_API_BASE_URL = "https://api.github.com";
const OAC_PR_PAGE_SIZE = 100;
const OAC_PR_TITLE_PREFIX = "[OAC]";
const ISSUE_REF_PATTERN = /(?:Fixes|Closes|Resolves)\s+#(\d+)/gi;

export interface OacPRInfo {
number: number | undefined;
title: string;
claimedIssueNumbers: number[];
}

/**
* Fetches all open OAC pull requests for a repository and returns
* parsed PR metadata. Shared by the scanner (deduplication during
* discovery) and the PR module (pre-PR guard).
*/
export async function fetchOpenOacPRs(
repoFullName: string,
token: string,
): Promise<OacPRInfo[]> {
const url =
`${GITHUB_API_BASE_URL}/repos/${repoFullName}` +
`/pulls?state=open&per_page=${OAC_PR_PAGE_SIZE}&sort=updated&direction=desc`;

try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
},
signal: AbortSignal.timeout(15_000),
});

if (!response.ok) {
return [];
}

const pulls: unknown = await response.json();
if (!Array.isArray(pulls)) {
return [];
}

return parseOacPRs(pulls);
} catch {
return [];
}
}

/**
* Convenience: returns a set of issue numbers already claimed by open OAC PRs.
*/
export function extractClaimedIssueNumbers(prs: OacPRInfo[]): Set<number> {
const claimed = new Set<number>();
for (const pr of prs) {
for (const num of pr.claimedIssueNumbers) {
claimed.add(num);
}
}
return claimed;
}

/**
* Convenience: finds an existing OAC PR targeting a specific issue number.
* Returns the PR number if found.
*/
export function findOacPRForIssue(
prs: OacPRInfo[],
issueNumber: number,
): number | undefined {
for (const pr of prs) {
if (pr.claimedIssueNumbers.includes(issueNumber)) {
return pr.number;
}
}
return undefined;
}

/**
* Convenience: finds an existing OAC PR with the exact given title.
* Returns the PR number if found.
*/
export function findOacPRByTitle(
prs: OacPRInfo[],
title: string,
): number | undefined {
for (const pr of prs) {
if (pr.title === title) {
return pr.number;
}
}
return undefined;
}

function parseOacPRs(pulls: unknown[]): OacPRInfo[] {
const result: OacPRInfo[] = [];

for (const pr of pulls) {
if (!pr || typeof pr !== "object") continue;

const record = pr as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title : "";
if (!title.startsWith(OAC_PR_TITLE_PREFIX)) continue;

const prNumber = typeof record.number === "number" ? record.number : undefined;
const body = typeof record.body === "string" ? record.body : "";
const claimedIssueNumbers: number[] = [];

for (const match of body.matchAll(ISSUE_REF_PATTERN)) {
const num = Number.parseInt(match[1], 10);
if (Number.isFinite(num)) {
claimedIssueNumbers.push(num);
}
}

result.push({ number: prNumber, title, claimedIssueNumbers });
}

return result;
}
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./config.js";
export * from "./errors.js";
export * from "./event-bus.js";
export * from "./file-filters.js";
export * from "./github-pr-cache.js";
export * from "./memory.js";
export * from "./types.js";
export * from "./utils.js";
Loading
Loading