Skip to content

Commit b0540e6

Browse files
authored
Merge pull request #452 from Extra-Chill/fix/gitsync-proposal-branches
fix: support keyed gitsync proposal branches
2 parents 8356151 + 4301a48 commit b0540e6

4 files changed

Lines changed: 98 additions & 24 deletions

File tree

inc/Abilities/GitSyncAbilities.php

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ private function registerAbilities(): void {
218218
'datamachine/gitsync-submit',
219219
array(
220220
'label' => 'Submit GitSync Binding as Pull Request',
221-
'description' => 'Upload changed local files to the sticky proposal branch (gitsync/<slug>) and open or update a PR against the pinned branch.',
221+
'description' => 'Upload changed local files to the sticky proposal branch (gitsync/<slug>) by default, or to a keyed proposal branch (gitsync/<slug>/<proposal-slug>) when proposal is provided, and open or update a PR against the pinned branch.',
222222
'category' => 'datamachine-code-gitsync',
223223
'input_schema' => array(
224224
'type' => 'object',
@@ -231,19 +231,24 @@ private function registerAbilities(): void {
231231
'items' => array( 'type' => 'string' ),
232232
'description' => 'Optional explicit list of relative paths. If omitted, every file with a SHA mismatch against upstream (filtered by allowed_paths) is submitted.',
233233
),
234-
'title' => array( 'type' => 'string' ),
235-
'body' => array( 'type' => 'string' ),
234+
'title' => array( 'type' => 'string' ),
235+
'body' => array( 'type' => 'string' ),
236+
'proposal' => array(
237+
'type' => 'string',
238+
'description' => 'Optional proposal key. Omit to reuse gitsync/<slug>; pass a key to use gitsync/<slug>/<proposal-slug>.',
239+
),
236240
),
237241
),
238242
'output_schema' => array(
239243
'type' => 'object',
240244
'properties' => array(
241-
'success' => array( 'type' => 'boolean' ),
242-
'slug' => array( 'type' => 'string' ),
243-
'branch' => array( 'type' => 'string' ),
244-
'commits' => array( 'type' => 'array' ),
245-
'pr' => array( 'type' => 'object' ),
246-
'message' => array( 'type' => 'string' ),
245+
'success' => array( 'type' => 'boolean' ),
246+
'slug' => array( 'type' => 'string' ),
247+
'proposal' => array( 'type' => array( 'string', 'null' ) ),
248+
'branch' => array( 'type' => 'string' ),
249+
'commits' => array( 'type' => 'array' ),
250+
'pr' => array( 'type' => 'object' ),
251+
'message' => array( 'type' => 'string' ),
247252
),
248253
),
249254
'execute_callback' => array( self::class, 'submit' ),

inc/Cli/Commands/GitSyncCommand.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ public function status( array $args, array $assoc_args ): void {
323323
* Submit local edits as a pull request.
324324
*
325325
* Uploads changed files to the sticky proposal branch (gitsync/<slug>)
326-
* and opens or updates a PR against the pinned branch.
326+
* by default, or to an isolated keyed proposal branch when --proposal is
327+
* provided, and opens or updates a PR against the pinned branch.
327328
*
328329
* ## OPTIONS
329330
*
@@ -337,6 +338,10 @@ public function status( array $args, array $assoc_args ): void {
337338
* : Comma-separated list of relative paths to submit. If omitted,
338339
* every changed file under allowed_paths is included.
339340
*
341+
* [--proposal=<proposal>]
342+
* : Optional proposal key. Omit to reuse the sticky branch gitsync/<slug>;
343+
* pass a key to use an isolated branch gitsync/<slug>/<proposal-slug>.
344+
*
340345
* [--title=<title>]
341346
* : PR title. Defaults to --message.
342347
*
@@ -367,6 +372,9 @@ public function submit( array $args, array $assoc_args ): void {
367372
if ( isset($assoc_args['paths']) && '' !== $assoc_args['paths'] ) {
368373
$input['paths'] = array_values(array_filter(array_map('trim', explode(',', (string) $assoc_args['paths']))));
369374
}
375+
if ( isset($assoc_args['proposal']) ) {
376+
$input['proposal'] = (string) $assoc_args['proposal'];
377+
}
370378
if ( isset($assoc_args['title']) ) {
371379
$input['title'] = (string) $assoc_args['title'];
372380
}

inc/GitSync/GitSyncProposer.php

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public function __construct( GitSyncRegistry $registry ) {
4848
}
4949

5050
/**
51-
* Submit local edits as a PR on the sticky proposal branch.
51+
* Submit local edits as a PR on the sticky proposal branch, or an
52+
* isolated keyed proposal branch when args.proposal is provided.
5253
*
5354
* @param GitSyncBinding $binding
5455
* @param array<string, mixed> $args {
@@ -58,6 +59,8 @@ public function __construct( GitSyncRegistry $registry ) {
5859
* is included.
5960
* @type string $title PR title. Defaults to $message.
6061
* @type string $body PR body. Defaults to an auto-summary.
62+
* @type string $proposal Optional proposal key. When present,
63+
* submits to gitsync/<slug>/<proposal-slug>.
6164
* }
6265
* @return array<string, mixed>|\WP_Error
6366
*/
@@ -90,7 +93,14 @@ public function submit( GitSyncBinding $binding, array $args ): array|\WP_Error
9093
);
9194
}
9295

96+
$proposal = $this->normalizeProposalKey( (string) ( $args['proposal'] ?? '' ));
97+
if ( is_wp_error($proposal) ) {
98+
return $proposal;
99+
}
93100
$feature_branch = self::BRANCH_PREFIX . $binding->slug;
101+
if ( null !== $proposal ) {
102+
$feature_branch .= '/' . $proposal;
103+
}
94104

95105
// Ensure the feature branch exists and points at the current
96106
// pinned-branch HEAD. If it already existed (previous submit),
@@ -150,12 +160,13 @@ public function submit( GitSyncBinding $binding, array $args ): array|\WP_Error
150160
$this->registry->save($binding);
151161

152162
return array(
153-
'success' => true,
154-
'slug' => $binding->slug,
155-
'branch' => $feature_branch,
156-
'commits' => $commits,
157-
'pr' => $pr,
158-
'message' => sprintf('Proposed %d file(s) on "%s" via PR #%d.', count($commits), $binding->slug, (int) ( $pr['number'] ?? 0 )),
163+
'success' => true,
164+
'slug' => $binding->slug,
165+
'proposal' => $proposal,
166+
'branch' => $feature_branch,
167+
'commits' => $commits,
168+
'pr' => $pr,
169+
'message' => sprintf('Proposed %d file(s) on "%s" via PR #%d.', count($commits), $binding->slug, (int) ( $pr['number'] ?? 0 )),
159170
);
160171
}
161172

@@ -292,6 +303,23 @@ private function validateMessage( string $message ): true|\WP_Error {
292303
return true;
293304
}
294305

306+
private function normalizeProposalKey( string $proposal ): string|null|\WP_Error {
307+
$proposal = trim($proposal);
308+
if ( '' === $proposal ) {
309+
return null;
310+
}
311+
312+
$normalized = strtolower($proposal);
313+
$normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized);
314+
$normalized = null === $normalized ? '' : trim($normalized, '-');
315+
316+
if ( '' === $normalized ) {
317+
return new \WP_Error('invalid_proposal', 'Proposal key must contain at least one letter or number.', array( 'status' => 400 ));
318+
}
319+
320+
return $normalized;
321+
}
322+
295323
/**
296324
* Assemble the context every write path needs up front:
297325
* - resolved absolute path under ABSPATH

tests/smoke-gitsync.php

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ public static function apiRequest( string $method, string $url, array $body, str
396396
// Tree for submit's context — same as last pull.
397397
// (Already mocked above.)
398398
// Feature branch doesn't exist yet → 404
399-
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync/wiki'] = array(
399+
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync%2Fwiki'] = array(
400400
'response' => array( 'code' => 404 ),
401401
'body' => json_encode(array( 'message' => 'Not Found' )),
402402
);
@@ -420,18 +420,19 @@ public static function apiRequest( string $method, string $url, array $body, str
420420
$submit = $gs->submit('wiki', array( 'message' => 'update article a content' ));
421421
$assert(! is_wp_error($submit), 'submit succeeded — ' . ( is_wp_error($submit) ? $submit->get_error_message() : '' ));
422422
$assert('gitsync/wiki' === ( $submit['branch'] ?? null ), 'feature branch is gitsync/wiki');
423+
$assert(null === ( $submit['proposal'] ?? null ), 'default submit has no proposal key');
423424
$assert(7 === ( $submit['pr']['number'] ?? null ), 'PR #7 opened');
424425
$assert('opened' === ( $submit['pr']['action'] ?? null ), 'PR reported as opened');
425426

426427
// =========================================================================
427428
// 8. Submit again — existing branch + PR is updated in place.
428429
// =========================================================================
429430
echo "\nSubmit (update existing PR)\n";
430-
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync/wiki'] = array(
431+
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync%2Fwiki'] = array(
431432
'response' => array( 'code' => 200 ),
432433
'body' => json_encode(array( 'object' => array( 'sha' => 'feature-old-sha' ) )),
433434
);
434-
$GLOBALS['__dmc_http_mock']['PATCH https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/refs/heads/gitsync/wiki'] = array(
435+
$GLOBALS['__dmc_http_mock']['PATCH https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/refs/heads/gitsync%2Fwiki'] = array(
435436
'response' => array( 'code' => 200 ),
436437
'body' => json_encode(array( 'object' => array( 'sha' => 'base-sha-1' ) )),
437438
);
@@ -453,7 +454,39 @@ public static function apiRequest( string $method, string $url, array $body, str
453454
$assert('updated' === ( $submit2['pr']['action'] ?? null ), 'PR reported as updated');
454455

455456
// =========================================================================
456-
// 9. Submit with nothing changed → nothing_to_submit.
457+
// 9. Submit with a proposal key — isolated branch and PR lookup.
458+
// =========================================================================
459+
echo "\nSubmit (keyed proposal branch)\n";
460+
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync%2Fwiki%2Fscript-modules'] = array(
461+
'response' => array( 'code' => 404 ),
462+
'body' => json_encode(array( 'message' => 'Not Found' )),
463+
);
464+
$GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls'] = array(
465+
'response' => array( 'code' => 200 ),
466+
'body' => '[]',
467+
);
468+
$GLOBALS['__dmc_http_mock']['POST https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls'] = array(
469+
'response' => array( 'code' => 201 ),
470+
'body' => json_encode(array( 'number' => 8, 'html_url' => 'https://github.com/a/b/pull/8', 'state' => 'open' )),
471+
);
472+
file_put_contents(ABSPATH . 'content/wiki/articles/a.md', "article a script modules proposal\n");
473+
$capture_before_keyed = count($GLOBALS['__dmc_http_capture']);
474+
$keyed = $gs->submit('wiki', array( 'message' => 'script modules update', 'proposal' => 'Script Modules' ));
475+
$assert(! is_wp_error($keyed), 'keyed submit succeeded — ' . ( is_wp_error($keyed) ? $keyed->get_error_message() : '' ));
476+
$assert('script-modules' === ( $keyed['proposal'] ?? null ), 'proposal key normalized to script-modules');
477+
$assert('gitsync/wiki/script-modules' === ( $keyed['branch'] ?? null ), 'keyed feature branch includes proposal slug');
478+
$assert(8 === ( $keyed['pr']['number'] ?? null ), 'keyed proposal opened separate PR');
479+
$keyed_requests = array_slice($GLOBALS['__dmc_http_capture'], $capture_before_keyed);
480+
$keyed_pr_body = null;
481+
foreach ( $keyed_requests as $request ) {
482+
if ( 'POST' === $request['method'] && str_ends_with($request['url'], '/pulls') ) {
483+
$keyed_pr_body = json_decode((string) $request['body'], true);
484+
}
485+
}
486+
$assert('gitsync/wiki/script-modules' === ( $keyed_pr_body['head'] ?? null ), 'keyed PR uses keyed branch head');
487+
488+
// =========================================================================
489+
// 10. Submit with nothing changed → nothing_to_submit.
457490
// =========================================================================
458491
echo "\nSubmit (nothing to propose)\n";
459492
// File's current SHA must match upstream tree's reported SHA.
@@ -473,7 +506,7 @@ public static function apiRequest( string $method, string $url, array $body, str
473506
$assert(is_wp_error($nothing) && 'nothing_to_submit' === $nothing->get_error_code(), 'submit refuses when nothing changed');
474507

475508
// =========================================================================
476-
// 10. Direct push — two-key auth.
509+
// 11. Direct push — two-key auth.
477510
// =========================================================================
478511
echo "\nPush (two-key auth)\n";
479512
file_put_contents(ABSPATH . 'content/wiki/articles/a.md', "article a v5 direct\n");
@@ -503,7 +536,7 @@ public static function apiRequest( string $method, string $url, array $body, str
503536
$assert(1 === count((array) ( $push['commits'] ?? array() )), 'push recorded 1 commit');
504537

505538
// =========================================================================
506-
// 11. Status + list + unbind round-trip.
539+
// 12. Status + list + unbind round-trip.
507540
// =========================================================================
508541
echo "\nStatus + list + unbind\n";
509542
$st = $gs->status('wiki');
@@ -524,7 +557,7 @@ public static function apiRequest( string $method, string $url, array $body, str
524557
$assert(! is_dir(ABSPATH . 'content/wiki/'), 'directory removed by purge');
525558

526559
// =========================================================================
527-
// 12. Policy validation.
560+
// 13. Policy validation.
528561
// =========================================================================
529562
echo "\nPolicy validation\n";
530563
$gs->bind(array( 'slug' => 'p', 'local_path' => '/content/p/', 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce' ));

0 commit comments

Comments
 (0)