Skip to content

Commit 67c9501

Browse files
committed
fix: reuse existing pull requests
1 parent cec6faf commit 67c9501

2 files changed

Lines changed: 130 additions & 7 deletions

File tree

inc/Abilities/GitHubAbilities.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,55 @@ public static function createPullRequest( array $input ): array|\WP_Error {
15661566
}
15671567
$body['base'] = $base;
15681568

1569+
$existing_pull = self::findExistingOpenPullRequest( $repo, $head, $base, $pat );
1570+
if ( is_wp_error( $existing_pull ) ) {
1571+
return $existing_pull;
1572+
}
1573+
1574+
if ( null !== $existing_pull ) {
1575+
$pull = self::normalizePull( $existing_pull );
1576+
$labels = self::mergeProvenanceLabels( isset( $input['labels'] ) && is_array( $input['labels'] ) ? $input['labels'] : array() );
1577+
$labeling = null;
1578+
1579+
if ( ! empty( $labels ) && ! empty( $pull['number'] ) ) {
1580+
$label_response = self::applyLabelsToNumber( $repo, (int) $pull['number'], $labels, $pat );
1581+
if ( is_wp_error( $label_response ) ) {
1582+
$labeling = array(
1583+
'success' => false,
1584+
'labels' => $labels,
1585+
'error_code' => $label_response->get_error_code(),
1586+
'error' => $label_response->get_error_message(),
1587+
'status' => is_array( $label_response->get_error_data() ) ? ( $label_response->get_error_data()['status'] ?? null ) : null,
1588+
);
1589+
} else {
1590+
$labeling = array(
1591+
'success' => true,
1592+
'labels' => $labels,
1593+
'applied_labels' => $label_response['applied_labels'] ?? array(),
1594+
);
1595+
}
1596+
}
1597+
1598+
$result = array(
1599+
'success' => true,
1600+
'kind' => 'pull_request',
1601+
'repo' => $repo,
1602+
'number' => $pull['number'] ?? 0,
1603+
'pull_request' => $pull,
1604+
'pull_number' => $pull['number'] ?? 0,
1605+
'url' => $pull['html_url'] ?? '',
1606+
'html_url' => $pull['html_url'] ?? '',
1607+
'reused' => true,
1608+
'message' => sprintf( 'Pull request #%d already exists in %s.', $pull['number'] ?? 0, $repo ),
1609+
);
1610+
1611+
if ( null !== $labeling ) {
1612+
$result['labeling'] = $labeling;
1613+
}
1614+
1615+
return $result;
1616+
}
1617+
15691618
$body_text = isset( $input['body'] ) ? (string) $input['body'] : '';
15701619
$artifacts = self::preparePullRequestRunArtifacts( $input, $repo, $head, $body_text );
15711620
if ( is_wp_error( $artifacts ) ) {
@@ -1637,6 +1686,50 @@ public static function createPullRequest( array $input ): array|\WP_Error {
16371686
return $result;
16381687
}
16391688

1689+
/**
1690+
* Find an open pull request for the exact head/base pair before creating one.
1691+
*
1692+
* @param string $repo Repository owner/name.
1693+
* @param string $head Pull request head branch or owner:branch.
1694+
* @param string $base Pull request base branch.
1695+
* @param string $pat GitHub token.
1696+
* @return array<string,mixed>|null|\WP_Error
1697+
*/
1698+
private static function findExistingOpenPullRequest( string $repo, string $head, string $base, string $pat ): array|null|\WP_Error {
1699+
$repo_owner = strtok( $repo, '/' );
1700+
$head_query = str_contains( $head, ':' ) ? $head : sprintf( '%s:%s', $repo_owner, $head );
1701+
$head_ref = str_contains( $head, ':' ) ? substr( $head, (int) strpos( $head, ':' ) + 1 ) : $head;
1702+
1703+
$url = sprintf( '%s/repos/%s/pulls', self::API_BASE, $repo );
1704+
$response = self::apiGet(
1705+
$url,
1706+
array(
1707+
'state' => 'open',
1708+
'head' => $head_query,
1709+
'base' => $base,
1710+
'per_page' => 10,
1711+
),
1712+
$pat
1713+
);
1714+
1715+
if ( is_wp_error( $response ) ) {
1716+
return $response;
1717+
}
1718+
1719+
foreach ( $response['data'] ?? array() as $pull ) {
1720+
if ( ! is_array( $pull ) ) {
1721+
continue;
1722+
}
1723+
1724+
$normalized = self::normalizePull( $pull );
1725+
if ( $head_ref === $normalized['head_ref'] && $base === $normalized['base_ref'] ) {
1726+
return $pull;
1727+
}
1728+
}
1729+
1730+
return null;
1731+
}
1732+
16401733
/**
16411734
* Persist and render Data Machine run artifacts for direct PR creation.
16421735
*

tests/smoke-github-create-abilities.php

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ function wp_remote_retrieve_body( $response ): string {
338338

339339
// ---- createPullRequest: success path with explicit base, draft default false
340340
$reset_http();
341+
$queue_response( 200, array() );
341342
$queue_response( 201, array(
342343
'number' => 88,
343344
'title' => 'Open PR',
@@ -362,17 +363,41 @@ function wp_remote_retrieve_body( $response ): string {
362363
$assert( 'createPullRequest exposes pull_request key', is_array( $result ) && isset( $result['pull_request']['number'] ) && 88 === $result['pull_request']['number'] );
363364
$assert( 'createPullRequest normalized head ref', is_array( $result ) && 'feature/x' === ( $result['pull_request']['head'] ?? '' ) );
364365

365-
$call = $GLOBALS['dmc_http_calls'][0] ?? array();
366+
$call = $GLOBALS['dmc_http_calls'][1] ?? array();
366367
$body = is_string( $call['args']['body'] ?? null ) ? json_decode( $call['args']['body'], true ) : null;
368+
$preflight_call = $GLOBALS['dmc_http_calls'][0] ?? array();
369+
$assert( 'createPullRequest preflights open PRs for same head/base', is_string( $preflight_call['url'] ?? null ) && str_contains( $preflight_call['url'], '/repos/owner/repo/pulls' ) && str_contains( $preflight_call['url'], 'head=owner%3Afeature%2Fx' ) && str_contains( $preflight_call['url'], 'base=main' ) );
367370
$assert( 'createPullRequest posts to /repos/owner/repo/pulls', is_string( $call['url'] ?? null ) && str_ends_with( $call['url'], '/repos/owner/repo/pulls' ) );
368371
$assert( 'createPullRequest forwards head and base', is_array( $body ) && 'feature/x' === ( $body['head'] ?? '' ) && 'main' === ( $body['base'] ?? '' ) );
369372
$assert( 'createPullRequest defaults maintainer_can_modify=true', is_array( $body ) && true === ( $body['maintainer_can_modify'] ?? null ) );
370373
$assert( 'createPullRequest does not set draft when omitted', is_array( $body ) && ! array_key_exists( 'draft', $body ) );
371-
$assert( 'createPullRequest does not call labels endpoint without labels or agent context', 1 === count( $GLOBALS['dmc_http_calls'] ) );
374+
$assert( 'createPullRequest does not call labels endpoint without labels or agent context', 2 === count( $GLOBALS['dmc_http_calls'] ) );
375+
376+
// ---- createPullRequest: existing open PR is reused instead of POSTing a duplicate
377+
$reset_http();
378+
$queue_response( 200, array(
379+
array(
380+
'number' => 188,
381+
'title' => 'Existing PR',
382+
'state' => 'open',
383+
'html_url' => 'https://github.com/owner/repo/pull/188',
384+
'head' => array( 'ref' => 'feature/x', 'sha' => 'ccc' ),
385+
'base' => array( 'ref' => 'main', 'sha' => 'ddd' ),
386+
),
387+
) );
388+
$result = GitHubAbilities::createPullRequest( array(
389+
'repo' => 'owner/repo',
390+
'title' => 'Duplicate PR',
391+
'head' => 'feature/x',
392+
'base' => 'main',
393+
) );
394+
$assert( 'createPullRequest reuses existing open PR', is_array( $result ) && true === ( $result['reused'] ?? false ) && 188 === ( $result['number'] ?? 0 ) );
395+
$assert( 'createPullRequest skips POST when existing PR is reused', 1 === count( $GLOBALS['dmc_http_calls'] ) && 'GET' === ( $GLOBALS['dmc_http_calls'][0]['method'] ?? '' ) );
372396

373397
// ---- createPullRequest: agent context applies caller and provenance labels after creation
374398
$reset_http();
375399
PermissionHelper::$acting_agent_slug = 'code-reviewer';
400+
$queue_response( 200, array() );
376401
$queue_response( 201, array(
377402
'number' => 91,
378403
'html_url' => 'https://github.com/owner/repo/pull/91',
@@ -391,7 +416,7 @@ function wp_remote_retrieve_body( $response ): string {
391416
'base' => 'main',
392417
'labels' => array( 'needs-review' ),
393418
) );
394-
$label_call = $GLOBALS['dmc_http_calls'][1] ?? array();
419+
$label_call = $GLOBALS['dmc_http_calls'][2] ?? array();
395420
$label_body = is_string( $label_call['args']['body'] ?? null ) ? json_decode( $label_call['args']['body'], true ) : null;
396421
$assert( 'createPullRequest labels PR through issues labels endpoint', is_string( $label_call['url'] ?? null ) && str_ends_with( $label_call['url'], '/repos/owner/repo/issues/91/labels' ) );
397422
$assert( 'createPullRequest preserves caller label during post-create labeling', is_array( $label_body ) && in_array( 'needs-review', $label_body['labels'] ?? array(), true ) );
@@ -402,6 +427,7 @@ function wp_remote_retrieve_body( $response ): string {
402427

403428
// ---- createPullRequest: run artifacts are committed and rendered on direct ability calls
404429
$reset_http();
430+
$queue_response( 200, array() );
405431
$queue_response( 200, array( 'ref' => 'refs/heads/world-day/memory' ) );
406432
$queue_response( 404, array( 'message' => 'Not Found' ) );
407433
$queue_response( 201, array(
@@ -441,9 +467,9 @@ function wp_remote_retrieve_body( $response ): string {
441467
'daily_memory' => array( 'egress' => array( 'bundle-file', 'pr-body' ) ),
442468
),
443469
) );
444-
$artifact_put_call = $GLOBALS['dmc_http_calls'][2] ?? array();
470+
$artifact_put_call = $GLOBALS['dmc_http_calls'][3] ?? array();
445471
$artifact_put_body = is_string( $artifact_put_call['args']['body'] ?? null ) ? json_decode( $artifact_put_call['args']['body'], true ) : null;
446-
$pr_call = $GLOBALS['dmc_http_calls'][3] ?? array();
472+
$pr_call = $GLOBALS['dmc_http_calls'][4] ?? array();
447473
$pr_body = is_string( $pr_call['args']['body'] ?? null ) ? json_decode( $pr_call['args']['body'], true ) : null;
448474
$assert( 'createPullRequest direct ability commits bundle-file artifact before PR creation', 'PUT' === ( $artifact_put_call['method'] ?? '' ) && str_contains( (string) ( $artifact_put_call['url'] ?? '' ), '/contents/bundles/world-creator/memory/agent/daily/2026/05/09.md' ) );
449475
$assert( 'createPullRequest direct ability writes artifact to head branch', is_array( $artifact_put_body ) && 'world-day/memory' === ( $artifact_put_body['branch'] ?? '' ) );
@@ -454,6 +480,7 @@ function wp_remote_retrieve_body( $response ): string {
454480
// ---- createPullRequest: labeling failure does not mask PR creation success
455481
$reset_http();
456482
PermissionHelper::$acting_agent_slug = 'code-reviewer';
483+
$queue_response( 200, array() );
457484
$queue_response( 201, array(
458485
'number' => 92,
459486
'html_url' => 'https://github.com/owner/repo/pull/92',
@@ -516,6 +543,7 @@ function wp_remote_retrieve_body( $response ): string {
516543

517544
// ---- createPullRequest: explicit draft and maintainer_can_modify=false
518545
$reset_http();
546+
$queue_response( 200, array() );
519547
$queue_response( 201, array(
520548
'number' => 89,
521549
'head' => array( 'ref' => 'feat/y' ),
@@ -529,14 +557,15 @@ function wp_remote_retrieve_body( $response ): string {
529557
'draft' => true,
530558
'maintainer_can_modify' => false,
531559
) );
532-
$call = $GLOBALS['dmc_http_calls'][0] ?? array();
560+
$call = $GLOBALS['dmc_http_calls'][1] ?? array();
533561
$body = is_string( $call['args']['body'] ?? null ) ? json_decode( $call['args']['body'], true ) : null;
534562
$assert( 'createPullRequest forwards draft=true', is_array( $body ) && true === ( $body['draft'] ?? null ) );
535563
$assert( 'createPullRequest forwards maintainer_can_modify=false', is_array( $body ) && false === ( $body['maintainer_can_modify'] ?? null ) );
536564

537565
// ---- createPullRequest: missing base falls back to default branch via GET /repos
538566
$reset_http();
539567
$queue_response( 200, array( 'default_branch' => 'trunk' ) );
568+
$queue_response( 200, array() );
540569
$queue_response( 201, array(
541570
'number' => 90,
542571
'head' => array( 'ref' => 'feat/z' ),
@@ -549,12 +578,13 @@ function wp_remote_retrieve_body( $response ): string {
549578
) );
550579
$assert( 'createPullRequest fallback resolves default branch', is_array( $result ) && true === ( $result['success'] ?? false ) );
551580
$assert( 'createPullRequest fallback issued GET /repos/owner/repo first', is_string( $GLOBALS['dmc_http_calls'][0]['url'] ?? null ) && str_contains( $GLOBALS['dmc_http_calls'][0]['url'], '/repos/owner/repo' ) && 'GET' === ( $GLOBALS['dmc_http_calls'][0]['method'] ?? '' ) );
552-
$pr_call = $GLOBALS['dmc_http_calls'][1] ?? array();
581+
$pr_call = $GLOBALS['dmc_http_calls'][2] ?? array();
553582
$pr_body = is_string( $pr_call['args']['body'] ?? null ) ? json_decode( $pr_call['args']['body'], true ) : null;
554583
$assert( 'createPullRequest sends fallback default base', is_array( $pr_body ) && 'trunk' === ( $pr_body['base'] ?? '' ) );
555584

556585
// ---- createPullRequest: GitHub validation error surfaces as WP_Error
557586
$reset_http();
587+
$queue_response( 200, array() );
558588
$queue_response( 422, array( 'message' => 'A pull request already exists for owner:feat/x.' ) );
559589
$result = GitHubAbilities::createPullRequest( array(
560590
'repo' => 'owner/repo',

0 commit comments

Comments
 (0)