Skip to content

Refactor duplicated issue/PR update payload normalization into shared helper#40624

Merged
pelikhan merged 5 commits into
mainfrom
copilot/duplicate-code-fix-update-payload-builder
Jun 21, 2026
Merged

Refactor duplicated issue/PR update payload normalization into shared helper#40624
pelikhan merged 5 commits into
mainfrom
copilot/duplicate-code-fix-update-payload-builder

Conversation

Copilot AI commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

update_issue and update_pull_request both implemented near-identical normalization for title, body, operation selection, and footer toggles, creating drift risk. This change consolidates shared normalization while keeping entity-specific fields and semantics in their original handlers.

  • Shared normalization extraction

    • Added actions/setup/js/update_entity_helpers.cjs with buildCommonEntityUpdateData(...).
    • Centralizes:
      • title sanitization
      • allow_body gating
      • _operation/_rawBody shaping
      • _includeFooter parsing
  • Issue handler integration

    • update_issue.cjs now uses the shared helper for title/body/footer normalization.
    • Preserves issue-specific behavior:
      • default body operation remains "append"
      • warning path when body updates are disallowed
      • issue-only fields (state/status, labels, assignees, milestone, title prefix) remain local
  • PR handler integration

    • update_pull_request.cjs now uses the shared helper for title/body/footer normalization.
    • Preserves PR-specific behavior:
      • allow_title control
      • operation precedence: item → config default → "replace"
      • body passthrough for API payload
      • PR-only fields (state, base, draft, update_branch) remain local
  • Focused helper coverage

    • Added update_entity_helpers.test.cjs to validate shared defaults, operation fallback, and disallowed-body callback handling.
const { updateData } = buildCommonEntityUpdateData(item, config, {
  allowTitle: canUpdateTitle,
  defaultOperation: "replace",
  configDefaultOperation: config.default_operation,
  includeBodyInApiData: true,
});

@github-actions

Copy link
Copy Markdown
Contributor

Hey @copilot 👋 — thanks for picking up the duplicate payload builder refactor from #40588! The issue analysis is solid and the plan to extract shared title/body/footer normalization into a helper is well-scoped.

A couple of things to address before this is ready for review:

  • No diff yet — the PR currently has 0 additions and 0 deletions. Once the implementation lands, reviewers can assess the refactor properly.
  • Add tests — the shared helper (e.g. update_entity_helpers.cjs) will need test coverage. At minimum, cover: title sanitization passthrough, body update with allow_body: false guard, footer toggling via parseBoolTemplatable, and the entity-specific field passthrough for both issue and PR paths.

If you'd like a hand finishing this up, you can assign the prompt below to your coding agent:

Complete the refactor described in PR #40624 and issue #40588.

1. Extract the shared update payload construction logic from:
   - actions/setup/js/update_issue.cjs (lines 133–189)
   - actions/setup/js/update_pull_request.cjs (lines 188–242)
   into a new helper: actions/setup/js/update_entity_helpers.cjs

2. The helper should accept: item, config, and an optional entity-specific fields handler.
   It should handle: title sanitization, body update guarded by config.allow_body, operation/rawBody assignment, and footer via parseBoolTemplatable.

3. Update both update_issue.cjs and update_pull_request.cjs to call the shared helper, keeping their entity-specific fields separate.

4. Add unit tests covering:
   - Title sanitization passthrough
   - Body update blocked when allow_body is false
   - Footer toggling (truthy and falsy config values)
   - Entity-specific fields (assignees for issues, reviewers for PRs) are preserved

Generated by ✅ Contribution Check · 163 AIC · ⌖ 8.26 AIC · ⊞ 5.9K ·

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor duplicate payload builder logic in issue and PR handlers Refactor duplicated issue/PR update payload normalization into shared helper Jun 21, 2026
Copilot AI requested a review from pelikhan June 21, 2026 13:45
@pelikhan pelikhan marked this pull request as ready for review June 21, 2026 14:36
Copilot AI review requested due to automatic review settings June 21, 2026 14:36
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #40624 does not have the 'implementation' label (has_implementation_label=false) and has 0 new lines of code in business logic directories (default_business_additions=0, threshold=100). The changes touch skill scripts, shared MCP server, and workflow lock files outside enforced business-logic paths.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces drift risk between update_issue and update_pull_request by extracting their shared update-payload normalization (title sanitization, body gating/operation shaping, and footer toggle parsing) into a single helper while keeping entity-specific semantics local to each handler.

Changes:

  • Added buildCommonEntityUpdateData(...) shared helper to normalize title/body/footer update fields consistently across entity update handlers.
  • Updated update_issue.cjs and update_pull_request.cjs to delegate common normalization to the shared helper while preserving their distinct defaults and behaviors.
  • Added focused unit tests covering shared defaults, operation fallback behavior, and disallowed-body callback handling.
Show a summary per file
File Description
actions/setup/js/update_pull_request.cjs Uses the shared helper for title/body/footer normalization; keeps PR-specific fields (state/base/draft/update_branch) handling local.
actions/setup/js/update_issue.cjs Uses the shared helper for title/body/footer normalization; preserves issue-specific defaults and warning-on-disallowed-body behavior.
actions/setup/js/update_entity_helpers.cjs Introduces shared normalization helper buildCommonEntityUpdateData for title/body/footer fields.
actions/setup/js/update_entity_helpers.test.cjs Adds unit tests for the shared helper’s defaulting, operation fallback, and disallowed-body callback path.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 0

@github-actions github-actions Bot mentioned this pull request Jun 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 80/100 — Excellent

Analyzed 3 test(s): 3 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (3 tests analyzed)
Metric Value
New/modified tests analyzed 3
✅ Design tests (behavioral contracts) 3 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (33%)
Duplicate test clusters 0
Test inflation detected No (1.08 ratio — 52 test lines / 48 production lines)
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
builds shared title/body/footer fields with defaults update_entity_helpers.test.cjs:5 ✅ Design
uses configured operation fallback and can include body api field update_entity_helpers.test.cjs:21 ✅ Design
invokes onBodyDisallowed when body updates are blocked update_entity_helpers.test.cjs:37 ✅ Design

Go: 0 (*_test.go); JavaScript: 1 (*.test.cjs). Other languages detected but not scored.

📝 Analysis Notes

Test 1 — builds shared title/body/footer fields with defaults
Verifies all common output fields (title, _operation, _rawBody, _includeFooter, hasCommonUpdates) for standard inputs. Pure happy-path, but covers 5 distinct output fields — good breadth.

Test 2 — uses configured operation fallback and can include body api field
Verifies the configDefaultOperation fallback chain and the includeBodyInApiData option that places the raw body into updateData.body for the GitHub API. Clear behavioral assertions on a configuration variant path.

Test 3 — invokes onBodyDisallowed when body updates are blocked
Tests the body-restriction path (allow_body: false). Uses vi.fn() legitimately to verify the callback is invoked, then asserts that _rawBody is absent and hasCommonUpdates is false — both side-effect and output state verified. This is the only test covering an error/edge path.

Coverage gaps (informational — not blocking)

  • No test for allowTitle: false (this option drives PR update behavior)
  • No test for explicit item.operation override
  • No test for config.footer = false
  • No test for title-only updates (no body in item)

Verdict

Check passed. 0% implementation tests (threshold: 30%). All 3 tests verify observable behavioral contracts of the shared helper. Edge-case coverage is thin (1/3 tests) — consider adding tests for allowTitle: false and footer configuration to improve confidence in the refactored helper.

References:

🧪 Test quality analysis by Test Quality Sentinel · 54.2 AIC · ⊞ 8.3K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 80/100. Test quality is Excellent — 0% of new tests are implementation tests (threshold: 30%). All 3 tests verify observable behavioral contracts of the new shared helper.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /tdd and /zoom-out — requesting changes on test coverage gaps and two readability improvements.

The consolidation itself is clean and the semantic equivalence is well-preserved for both callers. The main concern is that the shared helper — now a single point of failure for both update_issue and update_pull_request — has test coverage gaps that leave the allowTitle: false branch completely unguarded.

📋 Key Themes & Highlights

Issues Found

  • Missing allowTitle: false test — this is the primary guard in update_pull_request.cjs; a regression here silently affects both handlers with no failing test
  • Second test covers two behaviors — operation-fallback precedence and includeBodyInApiData passthrough should be split into separate cases
  • Test names describe implementation, not behavior — per /tdd, names should read as specifications
  • hasCommonUpdates asymmetry — discarded in issue handler, used in PR handler; invisible at the call site without a comment
  • _includeFooter unconditional — intentional and correct, but worth a brief comment to prevent well-meaning future "fixes"

Positive Highlights

  • ✅ Clean extraction — the helper interface is narrow and the JSDoc is thorough
  • configDefaultOperation intentionally omitted from the issue handler, correctly preserving item.operation || 'append' semantics
  • onBodyDisallowed callback is a good pattern — keeps the warning side-effect out of the helper
  • ✅ Tests exist and cover the happy path, operation fallback, and the disallowed-body callback

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 78.3 AIC · ⌖ 7.79 AIC · ⊞ 6.9K

expect(result.updateData._rawBody).toBeUndefined();
expect(result.hasCommonUpdates).toBe(false);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Missing allowTitle: false test — the key opt-out branch is unguarded.

The allowTitle option controls whether the title is included in the payload. If this logic regresses, both update_issue.cjs and update_pull_request.cjs would silently emit unwanted title updates with no failing test to catch it.

💡 Suggested test
it('skips title when allowTitle is false', () => {
  const result = buildCommonEntityUpdateData(
    { title: 'Should not appear' },
    {},
    { allowTitle: false }
  );
  expect(result.updateData.title).toBeUndefined();
  expect(result.hasCommonUpdates).toBe(false);
});

This is the primary guard used by update_pull_request.cjs (config.allow_title !== false) — a direct specification test makes the contract explicit.

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.

Added "skips title when allowTitle is false and does not set hasCommonUpdates" test in 8cf7e35.

expect(result.hasCommonUpdates).toBe(true);
});

it("uses configured operation fallback and can include body api field", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] This test verifies two distinct behaviors in one case — operation fallback precedence and includeBodyInApiData passthrough. Per the red-green-refactor loop, each it block should exercise exactly one behavior so failures point precisely to the broken contract.

💡 Suggested split
it('prefers configDefaultOperation over defaultOperation for body operation', () => {
  const result = buildCommonEntityUpdateData(
    { body: 'Body text' },
    {},
    { defaultOperation: 'append', configDefaultOperation: 'replace' }
  );
  expect(result.updateData._operation).toBe('replace');
});

it('includes body in api data when includeBodyInApiData is true', () => {
  const result = buildCommonEntityUpdateData(
    { body: 'Body text' },
    {},
    { defaultOperation: 'append', includeBodyInApiData: true }
  );
  expect(result.updateData.body).toBe('Body text');
});

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.

Split into two separate it blocks — one for configDefaultOperation precedence, one for includeBodyInApiData passthrough — in 8cf7e35.

const { buildCommonEntityUpdateData } = require("./update_entity_helpers.cjs");

describe("update_entity_helpers.cjs - buildCommonEntityUpdateData", () => {
it("builds shared title/body/footer fields with defaults", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test title reads as an implementation summary rather than a behavioral specification. Per the /tdd principle, test names should read like requirements — "returns true for hasCommonUpdates when title and body are provided" communicates the contract more precisely than "builds shared title/body/footer fields with defaults".

A good smell-check: if you deleted the implementation and read only this test name, would you know what the system must do?

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.

Renamed to "returns hasCommonUpdates true and populates title, body fields, and footer when title and body are provided" in 8cf7e35.

onBodyDisallowed();
}

updateData._includeFooter = parseBoolTemplatable(config.footer, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/zoom-out] _includeFooter is always written to updateData — even when both title and body are absent and hasCommonUpdates will be false. This matches pre-refactor behavior in both callers (intentional), but a brief comment here would protect against a future maintainer wrapping it in an if (hasCommonUpdates) guard and inadvertently breaking the footer on no-op calls.

💡 Suggested inline comment
// Always populate _includeFooter: downstream executeUpdate reads it regardless of
// whether title/body changed, matching pre-refactor behavior in both callers.
updateData._includeFooter = parseBoolTemplatable(config.footer, true);

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.

Added the inline comment in 8cf7e35.

@@ -131,23 +130,13 @@ const resolveIssueNumber = createStandardResolveNumber({
* @returns {{success: true, data: Object} | {success: false, error: string}} Update data result
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/zoom-out] hasCommonUpdates is returned by the helper but silently discarded here, while update_pull_request.cjs uses it to gate an early-return path. The asymmetry is invisible at the call site and could trip up a future maintainer who copies the PR pattern here expecting the same guard.

Consider either documenting why it's unused (/* hasCommonUpdates not needed: issue handler always continues to check entity-specific fields */) or destructuring with a comment: const { updateData } = ... is fine as-is, but the intent of the discarded value deserves a note.

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.

Added // hasCommonUpdates is not needed here: the issue handler always continues to check entity-specific fields (state, labels, assignees, milestone, title prefix). in 8cf7e35.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES — three medium issues that should be addressed before merge.

Blocking themes

1. _operation undefined footgun in the helper contract

defaultOperation is documented as optional, but if it (and configDefaultOperation and item.operation) are all absent, updateData._operation silently becomes undefined. Current callers are safe because they always pass defaultOperation, but the optional typing makes future callers fragile. Either harden the guard or remove the optionality. (See inline comment on update_entity_helpers.cjs:33.)

2. Operation-priority chain partially untested

The test covers configDefaultOperation > defaultOperation but never exercises item.operation winning over both. A single precedence-order swap regression would go undetected. (See inline comment on update_entity_helpers.test.cjs:22.)

3. allowTitle: false completely untested

The option that feeds the PR handler's hasUpdates / skip path is never tested with false. The hasCommonUpdates contract is only half-verified. (See inline comment on update_entity_helpers.test.cjs:36.)

🔎 Code quality review by PR Code Quality Reviewer · 75.6 AIC · ⌖ 7.95 AIC · ⊞ 5.1K


const canUpdateBody = config.allow_body !== false;
if (item.body !== undefined && canUpdateBody) {
updateData._operation = item.operation || configDefaultOperation || defaultOperation;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_operation can be silently undefined: when item.operation, configDefaultOperation, and defaultOperation are all absent or falsy, updateData._operation is set to undefined, which propagates to the body-update executor.

💡 Details

The JSDoc marks defaultOperation as optional ([options.defaultOperation]), so callers can legally omit it. If they also pass no item.operation and no configDefaultOperation, the expression:

updateData._operation = item.operation || configDefaultOperation || defaultOperation;

resolves to undefined. Downstream, update_handler_factory calls updateBody with that undefined operation, and the body-update path may silently behave incorrectly.

Fix options:

  1. Remove the [...] optionality from the JSDoc and guard defensively:
const resolvedOperation = item.operation || configDefaultOperation || defaultOperation;
if (!resolvedOperation) throw new Error("buildCommonEntityUpdateData: defaultOperation is required when body may be present");
updateData._operation = resolvedOperation;
  1. Or document that undefined is intentional and verify downstream handles it.

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.

Added a runtime guard that throws "buildCommonEntityUpdateData: defaultOperation is required when body may be present" if all three sources resolve to falsy. Also removed the [...] optional brackets from the JSDoc in 8cf7e35.

});

it("uses configured operation fallback and can include body api field", () => {
const result = buildCommonEntityUpdateData(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

item.operation priority is never exercised in this test: the test only verifies configDefaultOperation > defaultOperation fallback but never tests that an explicit item.operation wins over both.

💡 Suggested addition

The resolution chain item.operation || configDefaultOperation || defaultOperation has three levels, but this test only covers the bottom two. Add a case:

it("item.operation takes precedence over configDefaultOperation and defaultOperation", () => {
  const result = buildCommonEntityUpdateData(
    { body: "Body text", operation: "prepend" },
    {},
    {
      defaultOperation: "append",
      configDefaultOperation: "replace",
    }
  );
  expect(result.updateData._operation).toBe("prepend");
});

Without this, a regression that swaps the precedence order (e.g., configDefaultOperation || item.operation || defaultOperation) would go undetected.

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.

Added "item.operation takes precedence over configDefaultOperation and defaultOperation" test covering all three levels of the resolution chain in 8cf7e35.

expect(result.updateData.body).toBe("Body text");
});

it("invokes onBodyDisallowed when body updates are blocked", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

allowTitle: false is entirely untested: the test suite never passes allowTitle: false, so neither the title-skip behavior nor its effect on hasCommonUpdates is verified.

💡 Suggested addition

hasCommonUpdates controls whether the PR handler skips the update entirely (returns skipped: true). If allowTitle is false and a title is present, that title must not count toward hasCommonUpdates. Without a test, a future regression (e.g., hasCommonUpdates = true despite the title block) would silently affect the PR skip path.

it("blocks title update when allowTitle is false and does not set hasCommonUpdates", () => {
  const result = buildCommonEntityUpdateData(
    { title: "Should be ignored" },
    {},
    { allowTitle: false, defaultOperation: "append" }
  );
  expect(result.updateData.title).toBeUndefined();
  expect(result.hasCommonUpdates).toBe(false);
});

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.

Covered by the same allowTitle: false test added in 8cf7e35 — verifies both updateData.title is undefined and hasCommonUpdates is false.

Copilot AI and others added 2 commits June 21, 2026 14:55
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan

Copy link
Copy Markdown
Collaborator

…check error

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

@copilot fix https://github.com/github/gh-aw/actions/runs/27908175111/job/82581909954

Fixed in the latest commit — the options.defaultOperation JSDoc was incorrectly marked as required, causing TypeScript to reject the options = {} default parameter with TS2741. Restored it to optional ([options.defaultOperation]) while keeping the runtime throw guard that enforces it when item.body is present.

@pelikhan pelikhan merged commit 1e601bb into main Jun 21, 2026
14 checks passed
@pelikhan pelikhan deleted the copilot/duplicate-code-fix-update-payload-builder branch June 21, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants