Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 7 additions & 9 deletions actions/setup/js/update_handler_factory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,13 @@ function createUpdateHandlerFactory(handlerConfig) {
// Check if we're in staged mode
const isStaged = isStagedMode(config);

// Build configuration log message
const configParts = [`max=${maxCount}`, `target=${updateTarget}`];

// Add additional config items to log
Object.entries(additionalConfig).forEach(([key, value]) => {
if (config[key] !== undefined) {
configParts.push(`${key}=${config[key]}`);
}
});
const configParts = [
`max=${maxCount}`,
`target=${updateTarget}`,
...Object.entries(additionalConfig)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

additionalConfig values are silently ignored — only its keys are used as a whitelist — the refactored form makes this harder to notice.

Both old and new code read config[key] (runtime value) for the log entry, not additionalConfig[key] (schema value). The old code made this slightly visible via the unused value destructure variable; the new compact form hides it entirely.

If a future contributor adds a value to additionalConfig expecting it to appear in the log (e.g., as a default or label), it will be silently ignored. Consider a short comment to document intent:

// additionalConfig keys act as a whitelist; values are logged from the runtime config
...Object.entries(additionalConfig)
  .filter(([key]) => config[key] !== undefined)
  .map(([key]) => `${key}=${config[key]}`),

.filter(([key]) => config[key] !== undefined)
.map(([key]) => `${key}=${config[key]}`),
];

core.info(`Update ${itemTypeName} configuration: ${configParts.join(", ")}`);

Expand Down
127 changes: 127 additions & 0 deletions actions/setup/js/update_handler_factory.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -806,5 +806,132 @@ describe("update_handler_factory.cjs", () => {
// The log should mention "body" even though _rawBody starts with underscore
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining('"body"'));
});

it("should return staged result when config.staged is true", async () => {
const mockExecuteUpdate = vi.fn();

const handlerFactory = factoryModule.createUpdateHandlerFactory({
itemType: "update_test",
Comment on lines +812 to +816
itemTypeName: "test item",
supportsPR: false,
resolveItemNumber: vi.fn().mockReturnValue({ success: true, number: 42 }),
buildUpdateData: vi.fn().mockReturnValue({ success: true, data: { title: "New title" } }),
executeUpdate: mockExecuteUpdate,
formatSuccessResult: vi.fn().mockReturnValue({ success: true }),
});

const handler = await handlerFactory({ staged: true });
const result = await handler({ title: "New title" });

expect(result.success).toBe(true);
expect(result.staged).toBe(true);
expect(result.previewInfo).toMatchObject({ number: 42 });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Staged-mode preview assertions are incomplete — future regressions in what the staged payload captures will not be detected.

💡 Suggested additions

The staged path returns previewInfo: { number, updateFields, hasRawBody }. This test only validates number. A refactor that accidentally emptied updateFields or miscaptured hasRawBody would silently pass.

Consider adding:

expect(result.previewInfo.updateFields).toEqual(["title"]);
expect(result.previewInfo.hasRawBody).toBe(false);
// optionally: formatSuccessResult not called in staged mode
expect(handlerConfig.formatSuccessResult).not.toHaveBeenCalled();

The updateFields list is the core of what staged mode is communicating — it deserves explicit coverage.

// executeUpdate must NOT be called in staged mode
expect(mockExecuteUpdate).not.toHaveBeenCalled();
});

it("should return skipped result when buildUpdateData returns skipped:true", async () => {
const mockExecuteUpdate = vi.fn();

const handlerFactory = factoryModule.createUpdateHandlerFactory({
itemType: "update_test",
itemTypeName: "test item",
supportsPR: false,
resolveItemNumber: vi.fn().mockReturnValue({ success: true, number: 42 }),
buildUpdateData: vi.fn().mockReturnValue({
success: true,
skipped: true,
reason: "No supported fields provided",
}),
executeUpdate: mockExecuteUpdate,
formatSuccessResult: vi.fn().mockReturnValue({ success: true }),
});

const handler = await handlerFactory({});
const result = await handler({ title: "Test" });

expect(result.success).toBe(true);
expect(result.skipped).toBe(true);
expect(result.reason).toBe("No supported fields provided");
expect(mockExecuteUpdate).not.toHaveBeenCalled();
});

it("should propagate deferred flag from resolveItemNumber", async () => {
const mockExecuteUpdate = vi.fn();

const handlerFactory = factoryModule.createUpdateHandlerFactory({
itemType: "update_test",
itemTypeName: "test item",
supportsPR: false,
resolveItemNumber: vi.fn().mockReturnValue({
success: false,
deferred: true,
error: "Temporary ID not yet resolved: aw_pending",
}),
buildUpdateData: vi.fn(),
executeUpdate: mockExecuteUpdate,
formatSuccessResult: vi.fn(),
});

const handler = await handlerFactory({});
const result = await handler({ issue_number: "aw_pending" });

expect(result.success).toBe(false);
expect(result.deferred).toBe(true);
expect(result.error).toContain("aw_pending");
expect(mockExecuteUpdate).not.toHaveBeenCalled();
});

it("should call itemFilter and skip update when filter returns a result", async () => {
const mockExecuteUpdate = vi.fn();
const mockItemFilter = vi.fn().mockResolvedValue({
success: false,
skipped: true,
reason: "Required label not present",
});

const handlerFactory = factoryModule.createUpdateHandlerFactory({
itemType: "update_test",
itemTypeName: "test item",
supportsPR: false,
resolveItemNumber: vi.fn().mockReturnValue({ success: true, number: 42 }),
buildUpdateData: vi.fn().mockReturnValue({ success: true, data: { title: "Test" } }),
executeUpdate: mockExecuteUpdate,
formatSuccessResult: vi.fn().mockReturnValue({ success: true }),
itemFilter: mockItemFilter,
});

const handler = await handlerFactory({});
const result = await handler({ title: "Test" });

expect(mockItemFilter).toHaveBeenCalled();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

itemFilter short-circuit does not verify buildUpdateData is skipped — a regression leaving buildUpdateData called after filter rejection would be invisible.

When itemFilter returns a truthy result the handler returns immediately (before buildUpdateData is reached). The test should assert expect(mockBuildUpdateData).not.toHaveBeenCalled() to lock that control-flow order down. Without it, a future accidental reorder won't fail the test.

expect(result.success).toBe(false);
expect(result.skipped).toBe(true);
expect(result.reason).toBe("Required label not present");
expect(mockExecuteUpdate).not.toHaveBeenCalled();
Comment thread
Copilot marked this conversation as resolved.
});

it("should call itemFilter and proceed when filter returns null", async () => {
const mockExecuteUpdate = vi.fn().mockResolvedValue({ html_url: "https://example.com", title: "Updated" });
const mockItemFilter = vi.fn().mockResolvedValue(null);

const handlerFactory = factoryModule.createUpdateHandlerFactory({
itemType: "update_test",
itemTypeName: "test item",
supportsPR: false,
resolveItemNumber: vi.fn().mockReturnValue({ success: true, number: 42 }),
buildUpdateData: vi.fn().mockReturnValue({ success: true, data: { title: "Test" } }),
executeUpdate: mockExecuteUpdate,
formatSuccessResult: vi.fn().mockReturnValue({ success: true }),
itemFilter: mockItemFilter,
});

const handler = await handlerFactory({});
const result = await handler({ title: "Test" });

expect(mockItemFilter).toHaveBeenCalled();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both itemFilter tests verify the filter was called but not what it received — a bug passing the wrong repo, item number, or config to the filter would still pass these tests.

Production code calls itemFilter(githubClient, repoResult.repoParts, itemNumber, config). Consider asserting the arguments explicitly:

expect(mockItemFilter).toHaveBeenCalledWith(
  expect.anything(),        // githubClient
  { owner: 'testowner', repo: 'testrepo' },
  42,                       // itemNumber
  expect.objectContaining({}) // config
);

Without this, a cross-repo routing bug that passes the wrong repoParts or a wrong itemNumber to the filter would be completely invisible here.

expect(result.success).toBe(true);
expect(mockExecuteUpdate).toHaveBeenCalled();
});
});
});
Loading