Skip to content

Commit f8683b7

Browse files
refactor: centralize GitHub repository descriptors (#758)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 729b3f0 commit f8683b7

8 files changed

Lines changed: 239 additions & 75 deletions

inc/Abilities/GitHubAbilities.php

Lines changed: 42 additions & 37 deletions
Large diffs are not rendered by default.

inc/CodeTask/CodeTaskCreator.php

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace DataMachineCode\CodeTask;
99

10+
use DataMachineCode\Support\GitHubRemote;
1011
use DataMachineCode\Workspace\Workspace;
1112
use DataMachineCode\Workspace\WorkspaceWriter;
1213

@@ -122,25 +123,17 @@ public function create( EvidencePacket $packet, array $args = array() ): array|\
122123
*/
123124
public static function resolve_repo( string $repo ): array|\WP_Error {
124125
$repo = trim($repo);
125-
if ( preg_match('#^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$#', $repo, $matches) ) {
126-
$slug = $matches[1] . '/' . $matches[2];
126+
$descriptor = GitHubRemote::descriptor($repo);
127+
if ( null !== $descriptor ) {
128+
$slug = $descriptor['slug'];
127129
return array(
128130
'slug' => $slug,
129131
'name' => self::repo_name_from_slug($slug),
130-
'url' => 'https://github.com/' . $slug . '.git',
132+
'url' => $descriptor['https_clone_url'],
131133
);
132134
}
133135

134-
if ( preg_match('#^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$#', $repo, $matches) ) {
135-
$slug = $matches[1] . '/' . $matches[2];
136-
return array(
137-
'slug' => $slug,
138-
'name' => self::repo_name_from_slug($slug),
139-
'url' => 'https://github.com/' . $slug . '.git',
140-
);
141-
}
142-
143-
return new \WP_Error('invalid_repo', 'Repository must be a GitHub owner/repo slug or https://github.com/owner/repo URL.', array( 'status' => 400 ));
136+
return new \WP_Error('invalid_repo', 'Repository must be a GitHub owner/repo slug or supported GitHub URL.', array( 'status' => 400 ));
144137
}
145138

146139
public static function build_branch_name( EvidencePacket $packet ): string {

inc/Support/GitHubRemote.php

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
/**
33
* GitHub Remote
44
*
5-
* One place for GitHub-specific URL manipulation:
6-
* - "is this a GitHub remote?"
7-
* - "parse owner/repo out of the URL"
8-
* - shared API URL helpers for authenticated GitHub operations
9-
*
10-
* Shared by Workspace GitHub lookups so the regex + host-detection logic has
11-
* a single source of truth.
5+
* One place for GitHub-specific repository URL manipulation:
6+
* - detect supported GitHub/GitHub Enterprise remotes
7+
* - parse owner/repo/host descriptors out of clone or web URLs
8+
* - render clone, web, and API URLs from the same descriptor
129
*
1310
* @package DataMachineCode\Support
1411
* @since 0.7.0
@@ -20,18 +17,83 @@
2017

2118
final class GitHubRemote {
2219

20+
public const PUBLIC_WEB_BASE_URL = 'https://github.com';
21+
public const PUBLIC_API_BASE_URL = 'https://api.github.com';
22+
public const PUBLIC_SSH_HOST = 'github.com';
2323

2424

2525
/**
26-
* Detect a GitHub remote. Matches https://github.com/... and
27-
* git@github.com:... Both forms count.
26+
* Detect a supported GitHub remote. Matches public GitHub and
27+
* GitHub Enterprise-style hosts such as github.a8c.com.
2828
*/
2929
public static function isGitHubRemote( string $url ): bool {
30-
return (bool) preg_match('#(https?://github\.com/|git@github\.com:)#', $url);
30+
return null !== self::descriptor($url);
31+
}
32+
33+
/**
34+
* Build a repository descriptor from a slug, clone URL, or web URL.
35+
*
36+
* @return array{
37+
* host:string,
38+
* web_base_url:string,
39+
* api_base_url:string,
40+
* ssh_host:string,
41+
* owner:string,
42+
* repo:string,
43+
* slug:string,
44+
* https_clone_url:string,
45+
* ssh_clone_url:string,
46+
* web_url:string
47+
* }|null
48+
*/
49+
public static function descriptor( string $remote_or_slug ): ?array {
50+
$value = trim($remote_or_slug);
51+
if ( '' === $value ) {
52+
return null;
53+
}
54+
55+
$host = self::PUBLIC_SSH_HOST;
56+
$owner = '';
57+
$repo = '';
58+
59+
if ( preg_match('#^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$#', $value, $m) ) {
60+
$owner = $m[1];
61+
$repo = $m[2];
62+
} elseif ( preg_match('#^https?://([^/]+)/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?(?:/.*)?$#', $value, $m) ) {
63+
$host = strtolower($m[1]);
64+
$owner = $m[2];
65+
$repo = $m[3];
66+
} elseif ( preg_match('#^(?:ssh://)?git@([^:/]+)[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$#', $value, $m) ) {
67+
$host = strtolower($m[1]);
68+
$owner = $m[2];
69+
$repo = $m[3];
70+
} else {
71+
return null;
72+
}
73+
74+
if ( ! self::isGitHubHost($host) || '' === $owner || '' === $repo ) {
75+
return null;
76+
}
77+
78+
$repo = preg_replace('/\.git$/', '', $repo) ?? $repo;
79+
$slug = $owner . '/' . $repo;
80+
81+
return array(
82+
'host' => $host,
83+
'web_base_url' => self::webBaseUrl($host),
84+
'api_base_url' => self::apiBaseUrl($host),
85+
'ssh_host' => $host,
86+
'owner' => $owner,
87+
'repo' => $repo,
88+
'slug' => $slug,
89+
'https_clone_url' => self::webBaseUrl($host) . '/' . $slug . '.git',
90+
'ssh_clone_url' => 'git@' . $host . ':' . $slug . '.git',
91+
'web_url' => self::webBaseUrl($host) . '/' . $slug,
92+
);
3193
}
3294

3395
/**
34-
* Extract `owner/repo` from a GitHub URL.
96+
* Extract `owner/repo` from a GitHub URL or slug.
3597
*
3698
* Accepts both `https://github.com/owner/repo(.git)(/)?` and
3799
* `git@github.com:owner/repo(.git)`. Returns null for any URL that
@@ -41,10 +103,43 @@ public static function isGitHubRemote( string $url ): bool {
41103
* @return string|null `owner/repo` or null.
42104
*/
43105
public static function slug( string $url ): ?string {
44-
if ( preg_match('#github\.com[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?/?$#', $url, $m) ) {
45-
return $m[1] . '/' . $m[2];
106+
$descriptor = self::descriptor($url);
107+
return null !== $descriptor ? $descriptor['slug'] : null;
108+
}
109+
110+
/**
111+
* Build a clone URL from a GitHub descriptor input.
112+
*/
113+
public static function cloneUrl( string $remote_or_slug, string $protocol = 'https' ): ?string {
114+
$descriptor = self::descriptor($remote_or_slug);
115+
if ( null === $descriptor ) {
116+
return null;
117+
}
118+
119+
return 'ssh' === $protocol ? $descriptor['ssh_clone_url'] : $descriptor['https_clone_url'];
120+
}
121+
122+
/**
123+
* Build a GitHub web URL for a repo, optionally under a path.
124+
*/
125+
public static function webUrl( string $remote_or_slug, string $path = '' ): ?string {
126+
$descriptor = self::descriptor($remote_or_slug);
127+
if ( null === $descriptor ) {
128+
return null;
129+
}
130+
131+
if ( '' === $path ) {
132+
return $descriptor['web_url'];
46133
}
47-
return null;
134+
135+
return $descriptor['web_url'] . '/' . ltrim($path, '/');
136+
}
137+
138+
/**
139+
* Build a GitHub branch web URL for a repo.
140+
*/
141+
public static function branchUrl( string $remote_or_slug, string $branch ): ?string {
142+
return self::webUrl($remote_or_slug, 'tree/' . rawurlencode($branch));
48143
}
49144

50145
/**
@@ -58,11 +153,29 @@ public static function slug( string $url ): ?string {
58153
* `slug()` or a value the caller already trusts). No sanitization
59154
* is attempted here beyond string concatenation.
60155
*/
61-
public static function apiUrl( string $slug, string $path = '' ): string {
62-
$base = 'https://api.github.com/repos/' . $slug;
156+
public static function apiUrl( string $remote_or_slug, string $path = '' ): string {
157+
$descriptor = self::descriptor($remote_or_slug);
158+
$slug = null !== $descriptor ? $descriptor['slug'] : $remote_or_slug;
159+
$base = ( null !== $descriptor ? $descriptor['api_base_url'] : self::PUBLIC_API_BASE_URL ) . '/repos/' . $slug;
63160
if ( '' === $path ) {
64161
return $base;
65162
}
66163
return $base . '/' . ltrim($path, '/');
67164
}
165+
166+
/**
167+
* Build a GitHub REST API URL not scoped to a repository.
168+
*/
169+
public static function apiBaseUrl( string $host = self::PUBLIC_SSH_HOST ): string {
170+
return self::PUBLIC_SSH_HOST === strtolower($host) ? self::PUBLIC_API_BASE_URL : self::webBaseUrl($host) . '/api/v3';
171+
}
172+
173+
private static function webBaseUrl( string $host ): string {
174+
return 'https://' . strtolower($host);
175+
}
176+
177+
private static function isGitHubHost( string $host ): bool {
178+
$host = strtolower($host);
179+
return self::PUBLIC_SSH_HOST === $host || str_starts_with($host, 'github.');
180+
}
68181
}

inc/Workspace/RemoteWorkspaceBackend.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace DataMachineCode\Workspace;
99

1010
use DataMachineCode\Abilities\GitHubAbilities;
11+
use DataMachineCode\Support\GitHubRemote;
1112
use DataMachineCode\Support\PathSecurity;
1213

1314
defined('ABSPATH') || exit;
@@ -691,7 +692,7 @@ public function show( string $handle ): array|\WP_Error {
691692
? '#' . $context['branch']
692693
: '' ),
693694
'branch' => '' !== (string) $context['branch'] ? (string) $context['branch'] : null,
694-
'remote' => 'https://github.com/' . $context['repo'] . '.git',
695+
'remote' => GitHubRemote::cloneUrl((string) $context['repo']),
695696
'commit' => '' !== $context['last_commit_sha'] ? $context['last_commit_sha'] : null,
696697
'dirty' => count($files),
697698
'files' => $files,
@@ -784,7 +785,7 @@ public function git_status( string $handle ): array|\WP_Error {
784785
'is_context' => ! empty($context['read_only_context']),
785786
'path' => 'github://' . $context['repo'] . '#' . $context['branch'],
786787
'branch' => $context['branch'],
787-
'remote' => 'https://github.com/' . $context['repo'] . '.git',
788+
'remote' => GitHubRemote::cloneUrl((string) $context['repo']),
788789
'commit' => '' !== $context['last_commit_sha'] ? $context['last_commit_sha'] : null,
789790
'dirty' => count($files),
790791
'files' => $files,
@@ -1348,7 +1349,7 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br
13481349
}
13491350

13501351
$push_branch = null !== $branch && '' !== $branch ? $branch : $context['branch'];
1351-
$branch_url = '' !== $push_branch ? 'https://github.com/' . $context['repo'] . '/tree/' . rawurlencode($push_branch) : null;
1352+
$branch_url = '' !== $push_branch ? GitHubRemote::branchUrl((string) $context['repo'], (string) $push_branch) : null;
13521353

13531354
return array(
13541355
'success' => true,

inc/Workspace/WorkspaceGitOperations.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br
659659
if ( null !== $remote_url ) {
660660
$github_repo = GitHubRemote::slug($remote_url);
661661
if ( null !== $github_repo ) {
662-
$branch_url = 'https://github.com/' . $github_repo . '/tree/' . rawurlencode($target_branch);
662+
$branch_url = GitHubRemote::branchUrl($remote_url, $target_branch);
663663
}
664664
}
665665

inc/Workspace/WorkspaceRepositoryLifecycle.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace DataMachineCode\Workspace;
99

1010
use DataMachineCode\Support\GitRunner;
11+
use DataMachineCode\Support\GitHubRemote;
1112
use DataMachineCode\Support\ProcessRunner;
1213

1314
defined('ABSPATH') || exit;
@@ -670,7 +671,7 @@ public function show_repo( string $handle ): array|\WP_Error {
670671
'is_context' => true,
671672
'path' => null,
672673
'branch' => '' !== $ref ? $ref : null,
673-
'remote' => '' !== (string) ( $context_policy['repo'] ?? '' ) ? 'https://github.com/' . (string) $context_policy['repo'] . '.git' : null,
674+
'remote' => '' !== (string) ( $context_policy['repo'] ?? '' ) ? GitHubRemote::cloneUrl((string) $context_policy['repo']) : null,
674675
'commit' => null,
675676
'dirty' => 0,
676677
'workspace_policy' => WorkspaceAliasResolver::policy_attestation($handle),

inc/Workspace/WorktreeContextInjector.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969

7070
namespace DataMachineCode\Workspace;
7171

72+
use DataMachineCode\Support\GitHubRemote;
73+
7274
defined('ABSPATH') || exit;
7375

7476
class WorktreeContextInjector {
@@ -880,10 +882,15 @@ public static function parse_pr_reference( ?string $pr ): array {
880882
}
881883

882884
$metadata = array( 'pr_ref' => $pr );
883-
if ( preg_match('~^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)(?:[/?#].*)?$~', $pr, $matches) ) {
884-
$metadata['pr_url'] = sprintf('https://github.com/%s/%s/pull/%d', $matches[1], $matches[2], (int) $matches[3]);
885-
$metadata['pr_number'] = (int) $matches[3];
886-
$metadata['pr_repo'] = $matches[1] . '/' . $matches[2];
885+
if ( preg_match('~^https?://([^/]+)/([^/]+)/([^/]+)/pull/(\d+)(?:[/?#].*)?$~', $pr, $matches) ) {
886+
$descriptor = GitHubRemote::descriptor(sprintf('https://%s/%s/%s', $matches[1], $matches[2], $matches[3]));
887+
if ( null === $descriptor ) {
888+
return $metadata;
889+
}
890+
891+
$metadata['pr_url'] = $descriptor['web_url'] . '/pull/' . (int) $matches[4];
892+
$metadata['pr_number'] = (int) $matches[4];
893+
$metadata['pr_repo'] = $descriptor['slug'];
887894
return $metadata;
888895
}
889896

tests/github-remote.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
require_once dirname(__DIR__) . '/inc/Support/GitHubRemote.php';
10+
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php';
11+
12+
use DataMachineCode\Support\GitHubRemote;
13+
use DataMachineCode\Workspace\WorktreeContextInjector;
14+
15+
function assert_same( mixed $expected, mixed $actual, string $message ): void {
16+
if ( $expected !== $actual ) {
17+
throw new RuntimeException(sprintf("%s\nExpected: %s\nActual: %s", $message, var_export($expected, true), var_export($actual, true)));
18+
}
19+
}
20+
21+
$public = GitHubRemote::descriptor('https://github.com/Extra-Chill/data-machine-code.git');
22+
assert_same('github.com', $public['host'] ?? null, 'Public GitHub host should parse.');
23+
assert_same('Extra-Chill/data-machine-code', $public['slug'] ?? null, 'Public GitHub slug should parse.');
24+
assert_same('https://github.com/Extra-Chill/data-machine-code.git', $public['https_clone_url'] ?? null, 'Public GitHub HTTPS clone URL should render.');
25+
assert_same('git@github.com:Extra-Chill/data-machine-code.git', $public['ssh_clone_url'] ?? null, 'Public GitHub SSH clone URL should render.');
26+
assert_same('https://api.github.com/repos/Extra-Chill/data-machine-code/pulls/1', GitHubRemote::apiUrl('Extra-Chill/data-machine-code', 'pulls/1'), 'Public GitHub repo API URL should render from a slug.');
27+
assert_same('https://github.com/Extra-Chill/data-machine-code/tree/refactor%2Fdescriptor', GitHubRemote::branchUrl('git@github.com:Extra-Chill/data-machine-code.git', 'refactor/descriptor'), 'Public GitHub branch URL should render from SSH remote.');
28+
29+
$enterprise = GitHubRemote::descriptor('git@github.a8c.com:Automattic/data-machine-code.git');
30+
assert_same('github.a8c.com', $enterprise['host'] ?? null, 'GitHub Enterprise host should parse.');
31+
assert_same('Automattic/data-machine-code', $enterprise['slug'] ?? null, 'GitHub Enterprise slug should parse.');
32+
assert_same('https://github.a8c.com/api/v3', $enterprise['api_base_url'] ?? null, 'GitHub Enterprise API base URL should render.');
33+
assert_same('https://github.a8c.com/Automattic/data-machine-code.git', $enterprise['https_clone_url'] ?? null, 'GitHub Enterprise HTTPS clone URL should render.');
34+
assert_same('https://github.a8c.com/api/v3/repos/Automattic/data-machine-code/issues', GitHubRemote::apiUrl('https://github.a8c.com/Automattic/data-machine-code', 'issues'), 'GitHub Enterprise repo API URL should render from web URL.');
35+
assert_same('https://github.a8c.com/Automattic/data-machine-code/tree/feature%2Fbranch', GitHubRemote::branchUrl('git@github.a8c.com:Automattic/data-machine-code.git', 'feature/branch'), 'GitHub Enterprise branch URL should render from SSH remote.');
36+
37+
$pr_metadata = WorktreeContextInjector::parse_pr_reference('https://github.a8c.com/Automattic/data-machine-code/pull/42');
38+
assert_same('https://github.a8c.com/Automattic/data-machine-code/pull/42', $pr_metadata['pr_url'] ?? null, 'GitHub Enterprise PR URL should round-trip.');
39+
assert_same(42, $pr_metadata['pr_number'] ?? null, 'GitHub Enterprise PR number should parse.');
40+
assert_same('Automattic/data-machine-code', $pr_metadata['pr_repo'] ?? null, 'GitHub Enterprise PR repo should parse.');
41+
42+
assert_same(null, GitHubRemote::descriptor('https://gitlab.com/example/project.git'), 'Non-GitHub hosts should not parse.');
43+
44+
echo "GitHubRemote descriptor tests passed.\n";

0 commit comments

Comments
 (0)