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
41 changes: 26 additions & 15 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { withRetry } = require("./error_recovery.cjs");
const { renderTemplateFromFile } = require("./messages_core.cjs");
const { createExpirationLine, addExpirationToFooter } = require("./ephemerals.cjs");
const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs");
Expand Down Expand Up @@ -167,13 +168,18 @@ async function findOrCreateParentIssue({ githubClient, groupId, owner, repo, tit
core.info(`Creating new parent issue for group: ${groupId}`);
try {
const template = createParentIssueTemplate(groupId, titlePrefix, workflowName, workflowSourceURL, expiresHours);
const { data: parentIssue } = await githubClient.rest.issues.create({
owner,
repo,
title: template.title,
body: template.body,
labels: labels,
});
const { data: parentIssue } = await withRetry(
() =>
githubClient.rest.issues.create({
owner,
repo,
title: template.title,
body: template.body,
labels: labels,
}),
{ initialDelayMs: 15000, maxDelayMs: 45000, jitterMs: 10000 },
`create_parent_issue for group ${groupId}`
);

core.info(`Created new parent issue #${parentIssue.number}: ${parentIssue.html_url}`);
return parentIssue.number;
Comment on lines +171 to 185
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same non-idempotency concern as the main issue creation: retrying issues.create for the parent issue on network/timeout errors can create multiple parent issues for the same group. Consider narrowing shouldRetry for this call or adding an idempotency guard (e.g., re-search for an existing parent marker before retrying).

This issue also appears on line 597 of the same file.

Suggested change
const { data: parentIssue } = await withRetry(
() =>
githubClient.rest.issues.create({
owner,
repo,
title: template.title,
body: template.body,
labels: labels,
}),
{ initialDelayMs: 15000, maxDelayMs: 45000, jitterMs: 10000 },
`create_parent_issue for group ${groupId}`
);
core.info(`Created new parent issue #${parentIssue.number}: ${parentIssue.html_url}`);
return parentIssue.number;
const parentIssueNumber = await withRetry(
async () => {
const existingParentOnRetry = await searchForExistingParent(githubClient, owner, repo, markerComment);
if (existingParentOnRetry) {
core.info(`Found existing parent issue for group ${groupId} during retry; reusing #${existingParentOnRetry}`);
return existingParentOnRetry;
}
const { data: parentIssue } = await githubClient.rest.issues.create({
owner,
repo,
title: template.title,
body: template.body,
labels: labels,
});
core.info(`Created new parent issue #${parentIssue.number}: ${parentIssue.html_url}`);
return parentIssue.number;
},
{ initialDelayMs: 15000, maxDelayMs: 45000, jitterMs: 10000 },
`create_parent_issue for group ${groupId}`
);
return parentIssueNumber;

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -588,14 +594,19 @@ async function main(config = {}) {
}

try {
const { data: issue } = await githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title,
body,
labels,
assignees,
});
const { data: issue } = await withRetry(
() =>
githubClient.rest.issues.create({
owner: repoParts.owner,
repo: repoParts.repo,
title,
body,
labels,
assignees,
}),
{ initialDelayMs: 15000, maxDelayMs: 45000, jitterMs: 10000 },
`create_issue in ${qualifiedItemRepo}`
);

core.info(`Created issue ${qualifiedItemRepo}#${issue.number}: ${issue.html_url}`);
createdIssues.push({ ...issue, _repo: qualifiedItemRepo });
Expand Down
93 changes: 92 additions & 1 deletion actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ describe("create_issue", () => {
const result = await handler({ title: "Test" });

expect(result.success).toBe(false);
expect(result.error).toBe("API Error");
expect(result.error).toContain("API Error");
});
});

Expand Down Expand Up @@ -667,4 +667,95 @@ describe("create_issue", () => {
expect(mockGithub.rest.issues.create).not.toHaveBeenCalled();
});
});

describe("retry on rate limit errors", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("should retry issue creation on transient rate limit error and succeed", async () => {
mockGithub.rest.issues.create = vi
.fn()
.mockRejectedValueOnce(new Error("Secondary rate limit hit"))
.mockResolvedValue({
data: {
number: 456,
html_url: "https://github.com/owner/repo/issues/456",
title: "Retried Issue",
},
});

const handler = await main({});
const resultPromise = handler({
title: "Retried Issue",
body: "Test body",
});

await vi.runAllTimersAsync();
const result = await resultPromise;

expect(result.success).toBe(true);
expect(result.number).toBe(456);
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(2);
});

it("should fail after exhausting retries on persistent rate limit error", async () => {
mockGithub.rest.issues.create = vi.fn().mockRejectedValue(new Error("Secondary rate limit hit"));

const handler = await main({});
const resultPromise = handler({
title: "Failing Issue",
body: "Test body",
});

await vi.runAllTimersAsync();
const result = await resultPromise;

expect(result.success).toBe(false);
expect(result.error).toBeDefined();
// 1 initial + 3 retries = 4 calls
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(4);
});
Comment on lines +680 to +722
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new retry tests only cover the success/failure paths, but don’t assert that the retry delays respect the configured bounds when jitter is enabled. Consider adding an assertion that the scheduled sleeps never exceed the intended maximum delay (especially given the new jitter behavior).

Copilot uses AI. Check for mistakes.

it("should have retry delays that never exceed maxDelayMs + jitterMs", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

mockGithub.rest.issues.create = vi
.fn()
.mockRejectedValueOnce(new Error("Secondary rate limit hit"))
.mockRejectedValueOnce(new Error("Secondary rate limit hit"))
.mockResolvedValue({
data: {
number: 789,
html_url: "https://github.com/owner/repo/issues/789",
title: "Bounded Delay Issue",
},
});

const handler = await main({});
const resultPromise = handler({
title: "Bounded Delay Issue",
body: "Test body",
});

await vi.runAllTimersAsync();
await resultPromise;

// create_issue uses { initialDelayMs: 15000, maxDelayMs: 45000, jitterMs: 10000 }
// Maximum possible delay per retry = maxDelayMs + jitterMs = 55000ms
const maxBound = 55000;
// Filter out short setTimeout calls (e.g. from test infrastructure) to isolate retry delays
const sleepDelays = setTimeoutSpy.mock.calls.filter(([, ms]) => ms > 1000).map(([, ms]) => ms);

for (const delay of sleepDelays) {
expect(delay).toBeLessThanOrEqual(maxBound);
}

setTimeoutSpy.mockRestore();
});
});
});
8 changes: 6 additions & 2 deletions actions/setup/js/error_recovery.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { ERR_API } = require("./error_codes.cjs");
* @property {number} initialDelayMs - Initial delay in milliseconds (default: 1000)
* @property {number} maxDelayMs - Maximum delay in milliseconds (default: 10000)
* @property {number} backoffMultiplier - Backoff multiplier for exponential backoff (default: 2)
* @property {number} jitterMs - Maximum random jitter in milliseconds added to each retry delay (default: 100)
* @property {(error: any) => boolean} shouldRetry - Function to determine if error is retryable
*/

Expand All @@ -28,6 +29,7 @@ const DEFAULT_RETRY_CONFIG = {
initialDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
jitterMs: 100,
shouldRetry: isTransientError,
};

Expand Down Expand Up @@ -95,8 +97,10 @@ async function withRetry(operation, config = {}, operationName = "operation") {
for (let attempt = 0; attempt <= fullConfig.maxRetries; attempt++) {
try {
if (attempt > 0) {
core.info(`Retry attempt ${attempt}/${fullConfig.maxRetries} for ${operationName} after ${delay}ms delay`);
await sleep(delay);
const jitter = fullConfig.jitterMs > 0 ? Math.floor(Math.random() * fullConfig.jitterMs) : 0;
const delayWithJitter = delay + jitter;
core.info(`Retry attempt ${attempt}/${fullConfig.maxRetries} for ${operationName} after ${delayWithJitter}ms delay`);
await sleep(delayWithJitter);
}

const result = await operation();
Expand Down
42 changes: 42 additions & 0 deletions actions/setup/js/error_recovery.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("error_recovery", () => {
initialDelayMs: 100,
backoffMultiplier: 2,
maxDelayMs: 1000,
jitterMs: 0,
};

await withRetry(operation, config, "test-operation");
Expand All @@ -130,6 +131,7 @@ describe("error_recovery", () => {
initialDelayMs: 1000,
backoffMultiplier: 10,
maxDelayMs: 2000, // Cap at 2000ms
jitterMs: 0,
};

await withRetry(operation, config, "test-operation");
Expand All @@ -147,6 +149,45 @@ describe("error_recovery", () => {
expect(operation).toHaveBeenCalledTimes(1);
expect(shouldRetry).toHaveBeenCalled();
});

it("should add random jitter to delay when jitterMs is configured", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const operation = vi.fn().mockRejectedValueOnce(new Error("Network timeout")).mockResolvedValue("success");

const config = {
maxRetries: 2,
initialDelayMs: 100,
backoffMultiplier: 2,
maxDelayMs: 10000,
jitterMs: 1000,
};

await withRetry(operation, config, "test-operation");

// Base delay after first failure: 100 * 2 = 200ms
// Jitter: Math.floor(0.5 * 1000) = 500ms
// Total: 200 + 500 = 700ms
expect(core.info).toHaveBeenCalledWith(expect.stringContaining("after 700ms delay"));

randomSpy.mockRestore();
});

it("should not add jitter when jitterMs is 0", async () => {
const operation = vi.fn().mockRejectedValueOnce(new Error("Network timeout")).mockResolvedValue("success");

const config = {
maxRetries: 2,
initialDelayMs: 100,
backoffMultiplier: 2,
maxDelayMs: 10000,
jitterMs: 0,
};

await withRetry(operation, config, "test-operation");

// Base delay after first failure: 100 * 2 = 200ms, no jitter
expect(core.info).toHaveBeenCalledWith(expect.stringContaining("after 200ms delay"));
});
});

describe("enhanceError", () => {
Expand Down Expand Up @@ -288,6 +329,7 @@ describe("error_recovery", () => {
expect(DEFAULT_RETRY_CONFIG.initialDelayMs).toBe(1000);
expect(DEFAULT_RETRY_CONFIG.maxDelayMs).toBe(10000);
expect(DEFAULT_RETRY_CONFIG.backoffMultiplier).toBe(2);
expect(DEFAULT_RETRY_CONFIG.jitterMs).toBe(100);
expect(DEFAULT_RETRY_CONFIG.shouldRetry).toBe(isTransientError);
});
});
Expand Down
2 changes: 1 addition & 1 deletion cmd/gh-aw/argument_syntax_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestArgumentSyntaxConsistency(t *testing.T) {
{
name: "remove command has optional pattern",
command: removeCmd,
expectedUse: "remove [pattern]",
expectedUse: "remove [filter]",
argsValidator: "no validator (all optional)",
shouldValidate: func(cmd *cobra.Command) error { return nil },
},
Expand Down
Loading