@teambit/ci 1.0.632 → 1.0.634

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { expect } from 'chai';
2
- import { GitHubClient, isGitHubRemote, parseGitHubRepo } from './github-client';
2
+ import { GitHubClient, GitHubHostProvider, gitHubApiUrl, isGitHubRemote, parseGitHubRepo } from './github-client';
3
3
 
4
4
  /**
5
5
  * A claimed remote the parse then fails on silently degrades the whole run to PR-less mode, so the two
@@ -332,3 +332,336 @@ describe('GitHubClient', () => {
332
332
  });
333
333
  });
334
334
  });
335
+
336
+ /**
337
+ * GitHub Enterprise Server: Actions sets `GITHUB_SERVER_URL` and `GITHUB_API_URL` on every runner, and
338
+ * those two variables are all that turns a GHES remote into a GitHub one. On github.com Actions sets them
339
+ * to github.com's own values, which must read exactly like leaving them unset.
340
+ */
341
+ describe('GitHub Enterprise Server', () => {
342
+ const GHES_SERVER = 'https://ghe.corp.example';
343
+ const GHES_API = 'https://ghe.corp.example/api/v3';
344
+ const envKeys = ['GITHUB_TOKEN', 'BIT_GITHUB_TOKEN', 'GITHUB_REPOSITORY', 'GITHUB_API_URL', 'GITHUB_SERVER_URL'];
345
+ const saved: Record<string, string | undefined> = {};
346
+
347
+ beforeEach(() => {
348
+ envKeys.forEach((key) => {
349
+ saved[key] = process.env[key];
350
+ delete process.env[key];
351
+ });
352
+ process.env.GITHUB_TOKEN = 'tok';
353
+ });
354
+
355
+ afterEach(() => {
356
+ envKeys.forEach((key) => {
357
+ if (saved[key] === undefined) delete process.env[key];
358
+ else process.env[key] = saved[key];
359
+ });
360
+ });
361
+
362
+ /** Records every request a client built from the environment makes, answering with `body`. */
363
+ function recordingFetch(body: unknown = []) {
364
+ const calls: Array<{ url: string; init: any }> = [];
365
+ const fetchImpl = (async (url: any, init: any) => {
366
+ calls.push({ url: String(url), init });
367
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
368
+ }) as typeof fetch;
369
+ return { calls, fetchImpl };
370
+ }
371
+
372
+ const PR = { number: 7, state: 'open', labels: [], head: { ref: 'lane-x' }, html_url: 'http://pr/7' };
373
+
374
+ describe('with neither variable set, or both naming github.com', () => {
375
+ const GITHUB_DOT_COM_ENVS: Array<[string, Record<string, string>]> = [
376
+ ['unset', {}],
377
+ [
378
+ 'set by a github.com runner',
379
+ { GITHUB_API_URL: 'https://api.github.com', GITHUB_SERVER_URL: 'https://github.com' },
380
+ ],
381
+ ];
382
+
383
+ GITHUB_DOT_COM_ENVS.forEach(([name, env]) => {
384
+ it(`talks to api.github.com and claims github.com remotes only (${name})`, async () => {
385
+ Object.assign(process.env, env);
386
+ expect(gitHubApiUrl()).to.deep.equal({ url: 'https://api.github.com' });
387
+ REMOTES.forEach(([url, repo, isGitHub]) => {
388
+ expect(parseGitHubRepo(url), url).to.equal(repo);
389
+ expect(isGitHubRemote(url), url).to.equal(isGitHub);
390
+ });
391
+ expect(isGitHubRemote('https://ghe.corp.example/acme/store.git')).to.equal(false);
392
+ expect(GitHubClient.fromEnv('https://ghe.corp.example/acme/store.git')).to.equal(undefined);
393
+
394
+ const client = GitHubClient.fromEnv('git@github.com:acme/shop.git');
395
+ expect(client?.apiUrl).to.equal('https://api.github.com');
396
+ const { calls, fetchImpl } = recordingFetch(PR);
397
+ await new GitHubClient({ token: 'tok', repo: client!.repo, apiUrl: client!.apiUrl, fetchImpl }).createPr({
398
+ head: 'lane-x',
399
+ base: 'main',
400
+ title: 't',
401
+ body: 'b',
402
+ });
403
+ expect(calls[0].url).to.equal('https://api.github.com/repos/acme/shop/pulls');
404
+ });
405
+ });
406
+ });
407
+
408
+ it('a github.com repository always talks to api.github.com, whatever GITHUB_API_URL says', () => {
409
+ process.env.GITHUB_API_URL = 'https://evil.example/api/v3';
410
+ expect(gitHubApiUrl()).to.deep.equal({ url: 'https://api.github.com' });
411
+ expect(gitHubApiUrl('github.com')).to.deep.equal({ url: 'https://api.github.com' });
412
+ expect(GitHubClient.fromEnv('git@github.com:acme/shop.git')?.apiUrl).to.equal('https://api.github.com');
413
+ });
414
+
415
+ describe('on a GHES runner', () => {
416
+ beforeEach(() => {
417
+ process.env.GITHUB_API_URL = GHES_API;
418
+ process.env.GITHUB_SERVER_URL = GHES_SERVER;
419
+ });
420
+
421
+ /** url, parseGitHubRepo, isGitHubRemote — the GHES host joins github.com, and nothing else does. */
422
+ const GHES_REMOTES: Array<[string, string | undefined, boolean]> = [
423
+ ['https://ghe.corp.example/acme/store.git', 'acme/store', true],
424
+ ['https://ghe.corp.example/acme/store', 'acme/store', true],
425
+ ['git@ghe.corp.example:acme/store.git', 'acme/store', true],
426
+ ['ssh://git@ghe.corp.example/acme/store.git', 'acme/store', true],
427
+ ['https://x-access-token:tok@GHE.corp.example:443/acme/store.git', 'acme/store', true],
428
+ // github.com stays GitHub's on a GHES runner
429
+ ['git@github.com:acme/shop.git', 'acme/shop', true],
430
+ // the GHES host as a path segment, as a prefix of another host, or as userinfo is a look-alike
431
+ ['https://evil.example/ghe.corp.example/x.git', undefined, false],
432
+ ['https://ghe.corp.example.evil.com/x.git', undefined, false],
433
+ ['git@evil.example:ghe.corp.example/x.git', undefined, false],
434
+ ['https://ghe.corp.example@evil.example/acme/store.git', undefined, false],
435
+ ['https://corp.example/acme/store.git', undefined, false],
436
+ ];
437
+
438
+ it('claims and parses GHES remotes, and still rejects every look-alike', () => {
439
+ GHES_REMOTES.forEach(([url, repo, isGitHub]) => {
440
+ expect(parseGitHubRepo(url), url).to.equal(repo);
441
+ expect(isGitHubRemote(url), url).to.equal(isGitHub);
442
+ });
443
+ // the REMOTES table's verdicts hold unchanged alongside the GHES host
444
+ REMOTES.forEach(([url, repo, isGitHub]) => {
445
+ expect(parseGitHubRepo(url), url).to.equal(repo);
446
+ expect(isGitHubRemote(url), url).to.equal(isGitHub);
447
+ });
448
+ });
449
+
450
+ it('matchesRemote accepts the GHES remote in both url forms, and rejects look-alikes', () => {
451
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/store' });
452
+ expect(client.matchesRemote('https://ghe.corp.example/acme/store.git')).to.equal(true);
453
+ expect(client.matchesRemote('git@ghe.corp.example:acme/store.git')).to.equal(true);
454
+ expect(client.matchesRemote('https://evil.example/ghe.corp.example/x.git')).to.equal(false);
455
+ expect(client.matchesRemote('https://ghe.corp.example.evil.com/x.git')).to.equal(false);
456
+ });
457
+
458
+ it('sends every pull-request call to the GHES API base', async () => {
459
+ const client = GitHubClient.fromEnv('git@ghe.corp.example:acme/store.git');
460
+ expect(client?.repo).to.equal('acme/store');
461
+ expect(client?.apiUrl).to.equal(GHES_API);
462
+
463
+ const { calls, fetchImpl } = recordingFetch(PR);
464
+ const wired = new GitHubClient({ token: 'tok', repo: client!.repo, apiUrl: client!.apiUrl, fetchImpl });
465
+ await wired.findPrByBranch('lane-x');
466
+ await wired.createPr({ head: 'lane-x', base: 'main', title: 't', body: 'b' });
467
+ await wired.addLabel(7, 'bit-sync-conflict');
468
+ await wired.closePr(7, 'bye');
469
+ expect(calls.map((c) => `${c.init.method} ${c.url}`)).to.deep.equal([
470
+ 'GET https://ghe.corp.example/api/v3/repos/acme/store/pulls?state=open&head=acme%3Alane-x',
471
+ 'POST https://ghe.corp.example/api/v3/repos/acme/store/pulls',
472
+ 'POST https://ghe.corp.example/api/v3/repos/acme/store/issues/7/labels',
473
+ 'POST https://ghe.corp.example/api/v3/repos/acme/store/issues/7/comments',
474
+ 'PATCH https://ghe.corp.example/api/v3/repos/acme/store/pulls/7',
475
+ ]);
476
+ });
477
+
478
+ it('uses the client built from the environment, with no fetch override, against the GHES base', async () => {
479
+ const realFetch = globalThis.fetch;
480
+ const { calls, fetchImpl } = recordingFetch();
481
+ globalThis.fetch = fetchImpl;
482
+ try {
483
+ await GitHubClient.fromEnv('https://ghe.corp.example/acme/store.git')?.findPrByBranch('lane-x');
484
+ } finally {
485
+ globalThis.fetch = realFetch;
486
+ }
487
+ expect(calls).to.have.lengthOf(1);
488
+ expect(calls[0].url).to.match(/^https:\/\/ghe\.corp\.example\/api\/v3\/repos\/acme\/store\/pulls\?/);
489
+ expect(calls[0].init.headers.authorization).to.equal('Bearer tok');
490
+ });
491
+
492
+ it('a github.com remote on a GHES runner still talks to api.github.com', () => {
493
+ expect(GitHubClient.fromEnv('git@github.com:acme/shop.git')?.apiUrl).to.equal('https://api.github.com');
494
+ expect(gitHubApiUrl('github.com')).to.deep.equal({ url: 'https://api.github.com' });
495
+ expect(gitHubApiUrl('GHE.corp.example')).to.deep.equal({ url: GHES_API });
496
+ expect(gitHubApiUrl()).to.deep.equal({ url: GHES_API });
497
+ });
498
+
499
+ it('without GITHUB_API_URL, derives the GHES convention <server origin>/api/v3 — never api.github.com', () => {
500
+ delete process.env.GITHUB_API_URL;
501
+ expect(gitHubApiUrl()).to.deep.equal({ url: GHES_API });
502
+ expect(gitHubApiUrl('ghe.corp.example')).to.deep.equal({ url: GHES_API });
503
+ expect(GitHubClient.fromEnv('git@ghe.corp.example:acme/store.git')?.apiUrl).to.equal(GHES_API);
504
+ // the server's protocol and port carry over; its path does not
505
+ process.env.GITHUB_SERVER_URL = 'http://ghe.corp.example:8080/';
506
+ expect(gitHubApiUrl('ghe.corp.example')).to.deep.equal({ url: 'http://ghe.corp.example:8080/api/v3' });
507
+ // github.com is untouched by the enterprise default
508
+ expect(gitHubApiUrl('github.com')).to.deep.equal({ url: 'https://api.github.com' });
509
+ });
510
+
511
+ it('accepts a GITHUB_API_URL on api.<server host> (GHE.com data residency)', () => {
512
+ process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com';
513
+ process.env.GITHUB_API_URL = 'https://api.acme.ghe.com';
514
+ expect(gitHubApiUrl('acme.ghe.com')).to.deep.equal({ url: 'https://api.acme.ghe.com' });
515
+ expect(GitHubClient.fromEnv('https://acme.ghe.com/acme/store.git')?.apiUrl).to.equal('https://api.acme.ghe.com');
516
+ });
517
+
518
+ it('refuses a GITHUB_API_URL on another host: no client, a warning naming both values, no request', async () => {
519
+ const MISMATCHED = [
520
+ 'https://api.github.com',
521
+ 'https://evil.example/api/v3',
522
+ 'https://ghe.corp.example.evil.com/api/v3',
523
+ 'https://api.ghe.corp.example.evil.com',
524
+ 'not a url',
525
+ ];
526
+ for (const apiUrl of MISMATCHED) {
527
+ process.env.GITHUB_API_URL = apiUrl;
528
+ const result = gitHubApiUrl('ghe.corp.example');
529
+ expect(result, apiUrl).to.not.have.property('url');
530
+ expect((result as { error: string }).error, apiUrl)
531
+ .to.contain(apiUrl)
532
+ .and.contain(GHES_SERVER);
533
+
534
+ const warnings: string[] = [];
535
+ expect(
536
+ GitHubClient.fromEnv('git@ghe.corp.example:acme/store.git', (m) => warnings.push(m)),
537
+ apiUrl
538
+ ).to.equal(undefined);
539
+ // a repository from GITHUB_REPOSITORY alone lives on the enterprise instance too
540
+ process.env.GITHUB_REPOSITORY = 'acme/store';
541
+ expect(
542
+ GitHubClient.fromEnv(undefined, (m) => warnings.push(m)),
543
+ apiUrl
544
+ ).to.equal(undefined);
545
+ delete process.env.GITHUB_REPOSITORY;
546
+ expect(warnings, apiUrl).to.have.lengthOf(2);
547
+ warnings.forEach((w) => expect(w, apiUrl).to.contain(`"${apiUrl}"`).and.contain(`"${GHES_SERVER}"`));
548
+
549
+ // the provider reports "not configured", warns once across retries, and sends nothing
550
+ const realFetch = globalThis.fetch;
551
+ const { calls, fetchImpl } = recordingFetch();
552
+ globalThis.fetch = fetchImpl;
553
+ const providerWarnings: string[] = [];
554
+ try {
555
+ const provider = new GitHubHostProvider((m) => providerWarnings.push(m));
556
+ expect(provider.isConfigured('git@ghe.corp.example:acme/store.git'), apiUrl).to.equal(false);
557
+ expect(provider.isConfigured('git@ghe.corp.example:acme/store.git'), apiUrl).to.equal(false);
558
+ let thrown: unknown;
559
+ await provider.findPrByBranch('lane-x').catch((e) => {
560
+ thrown = e;
561
+ });
562
+ expect(thrown, apiUrl).to.be.instanceOf(Error);
563
+ } finally {
564
+ globalThis.fetch = realFetch;
565
+ }
566
+ expect(calls, apiUrl).to.have.lengthOf(0);
567
+ expect(providerWarnings, apiUrl).to.have.lengthOf(1);
568
+ }
569
+ // a github.com remote on the same misconfigured runner is unaffected
570
+ expect(GitHubClient.fromEnv('git@github.com:acme/shop.git')?.apiUrl).to.equal('https://api.github.com');
571
+ });
572
+
573
+ it('never lets an https server hand its token to a plaintext or non-web API', () => {
574
+ ['http://ghe.corp.example/api/v3', 'http://api.ghe.corp.example', 'ftp://ghe.corp.example/api/v3'].forEach(
575
+ (apiUrl) => {
576
+ process.env.GITHUB_API_URL = apiUrl;
577
+ const result = gitHubApiUrl('ghe.corp.example');
578
+ expect(result, apiUrl).to.not.have.property('url');
579
+ expect((result as { error: string }).error, apiUrl)
580
+ .to.contain(apiUrl)
581
+ .and.contain('is not https');
582
+ const warnings: string[] = [];
583
+ expect(
584
+ GitHubClient.fromEnv('git@ghe.corp.example:acme/store.git', (m) => warnings.push(m)),
585
+ apiUrl
586
+ ).to.equal(undefined);
587
+ expect(warnings, apiUrl).to.have.lengthOf(1);
588
+ }
589
+ );
590
+ });
591
+
592
+ it('lets an http server use an http or https API on its host, but no other protocol', () => {
593
+ process.env.GITHUB_SERVER_URL = 'http://ghe.corp.example';
594
+ process.env.GITHUB_API_URL = 'http://ghe.corp.example/api/v3';
595
+ expect(gitHubApiUrl('ghe.corp.example')).to.deep.equal({ url: 'http://ghe.corp.example/api/v3' });
596
+ process.env.GITHUB_API_URL = 'https://ghe.corp.example/api/v3';
597
+ expect(gitHubApiUrl('ghe.corp.example')).to.deep.equal({ url: 'https://ghe.corp.example/api/v3' });
598
+ process.env.GITHUB_API_URL = 'ftp://ghe.corp.example/api/v3';
599
+ expect(gitHubApiUrl('ghe.corp.example')).to.not.have.property('url');
600
+ // a non-web server URL derives no API at all
601
+ delete process.env.GITHUB_API_URL;
602
+ process.env.GITHUB_SERVER_URL = 'ftp://ghe.corp.example';
603
+ expect(gitHubApiUrl('ghe.corp.example')).to.not.have.property('url');
604
+ });
605
+
606
+ it('refuses a GITHUB_API_URL with a query or fragment, which would swallow the request path', () => {
607
+ ['https://ghe.corp.example/api/v3?x=1', 'https://ghe.corp.example/api/v3#frag'].forEach((apiUrl) => {
608
+ process.env.GITHUB_API_URL = apiUrl;
609
+ const result = gitHubApiUrl('ghe.corp.example');
610
+ expect(result, apiUrl).to.not.have.property('url');
611
+ expect((result as { error: string }).error, apiUrl)
612
+ .to.contain(apiUrl)
613
+ .and.contain('query or fragment');
614
+ expect(GitHubClient.fromEnv('git@ghe.corp.example:acme/store.git'), apiUrl).to.equal(undefined);
615
+ });
616
+ });
617
+
618
+ it('one provider reused for an enterprise origin and then a github.com origin talks to each one', async () => {
619
+ const realFetch = globalThis.fetch;
620
+ const { calls, fetchImpl } = recordingFetch();
621
+ globalThis.fetch = fetchImpl;
622
+ try {
623
+ const provider = new GitHubHostProvider();
624
+ // a PR call carries no remote: it follows the remote last selected
625
+ expect(provider.matchesRemote('git@ghe.corp.example:acme/store.git')).to.equal(true);
626
+ expect(provider.isConfigured('git@ghe.corp.example:acme/store.git')).to.equal(true);
627
+ await provider.findPrByBranch('lane-x');
628
+ expect(provider.isConfigured('git@github.com:acme/shop.git')).to.equal(true);
629
+ await provider.findPrByBranch('lane-x');
630
+ } finally {
631
+ globalThis.fetch = realFetch;
632
+ }
633
+ expect(calls.map((c) => c.url)).to.deep.equal([
634
+ `${GHES_API}/repos/acme/store/pulls?state=open&head=acme%3Alane-x`,
635
+ 'https://api.github.com/repos/acme/shop/pulls?state=open&head=acme%3Alane-x',
636
+ ]);
637
+ });
638
+
639
+ it('drops a trailing slash from GITHUB_API_URL', () => {
640
+ process.env.GITHUB_API_URL = `${GHES_API}/`;
641
+ expect(gitHubApiUrl()).to.deep.equal({ url: GHES_API });
642
+ });
643
+
644
+ it('follows pagination on the GHES API, and refuses a next link that leaves it', async () => {
645
+ const calls: Array<{ url: string; init: any }> = [];
646
+ const fetchImpl = (async (url: any, init: any) => {
647
+ calls.push({ url: String(url), init });
648
+ const page2 = String(url).includes('page=2');
649
+ return new Response(JSON.stringify([{ id: page2 ? 2 : 1, body: 'unrelated' }]), {
650
+ status: 200,
651
+ headers: {
652
+ 'content-type': 'application/json',
653
+ link: page2
654
+ ? '<https://api.github.com/repos/acme/store/issues/7/comments?page=3>; rel="next"'
655
+ : `<${GHES_API}/repos/acme/store/issues/7/comments?per_page=100&page=2>; rel="next"`,
656
+ },
657
+ });
658
+ }) as typeof fetch;
659
+ const client = new GitHubClient({ token: 'tok', repo: 'acme/store', apiUrl: GHES_API, fetchImpl });
660
+ await client.upsertComment(7, '<!-- marker -->', 'cleared', { createIfAbsent: false });
661
+ expect(calls.map((c) => c.url)).to.deep.equal([
662
+ `${GHES_API}/repos/acme/store/issues/7/comments?per_page=100`,
663
+ `${GHES_API}/repos/acme/store/issues/7/comments?per_page=100&page=2`,
664
+ ]);
665
+ });
666
+ });
667
+ });
@@ -1,21 +1,79 @@
1
1
  import type { GitHostProvider, PrInfo } from './git-host-provider';
2
2
 
3
+ const GITHUB_DOT_COM = 'github.com';
4
+ const GITHUB_DOT_COM_API = 'https://api.github.com';
5
+ const WEB_PROTOCOLS = ['http:', 'https:'];
6
+
7
+ /** The GitHub instance `GITHUB_SERVER_URL` names, or undefined when it is unset or malformed. */
8
+ function gitHubServerUrl(): URL | undefined {
9
+ try {
10
+ if (process.env.GITHUB_SERVER_URL) return new URL(process.env.GITHUB_SERVER_URL);
11
+ } catch {
12
+ // a malformed value names no instance
13
+ }
14
+ return undefined;
15
+ }
16
+
3
17
  /**
4
- * The `owner/repo` out of a github.com remote URL, anchored to the URL authority. `:digits` is a port
5
- * only under a scheme; in the scp-like form (`git@github.com:12345/repo`) the colon starts the PATH
6
- * and an all-digits owner is a legal GitHub username.
18
+ * The host of the GitHub instance this run belongs to: the one `GITHUB_SERVER_URL` names (Actions sets
19
+ * it on every runner — `https://github.com`, or the GitHub Enterprise Server URL), else github.com.
7
20
  */
8
- export function parseGitHubRepo(remoteUrl: string): string | undefined {
9
- const url = remoteUrl.trim();
10
- const hasScheme = /^[a-z+]+:\/\//i.test(url);
11
- const match = hasScheme
12
- ? url.match(/(?:\/\/|@)github\.com(?::\d+)?\/([^/]+\/[^/]+?)(?:\.git)?$/i)
13
- : url.match(/(?:^|@)github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
14
- return match?.[1];
21
+ export function gitHubServerHost(): string {
22
+ return gitHubServerUrl()?.hostname.toLowerCase() || GITHUB_DOT_COM;
23
+ }
24
+
25
+ /** The hosts whose remotes are GitHub's: github.com, plus a GitHub Enterprise Server host. */
26
+ function gitHubHosts(): string[] {
27
+ const serverHost = gitHubServerHost();
28
+ return serverHost === GITHUB_DOT_COM ? [GITHUB_DOT_COM] : [GITHUB_DOT_COM, serverHost];
29
+ }
30
+
31
+ /** A resolved REST API base, or why the environment's API cannot be trusted with the repository's token. */
32
+ export type GitHubApiUrl = { url: string } | { error: string };
33
+
34
+ /**
35
+ * The REST API base for a repository on `host` (the `GITHUB_SERVER_URL` instance's host when omitted).
36
+ * Every request to it carries that instance's token, so:
37
+ * - github.com (and any host that is not the enterprise one) is always `https://api.github.com`.
38
+ * - the enterprise host takes `GITHUB_API_URL` only when it lives on that host or on `api.<host>`
39
+ * (GHE.com data residency: `https://<sub>.ghe.com` serves its API at `https://api.<sub>.ghe.com`);
40
+ * unset, it is the GHES convention `<GITHUB_SERVER_URL origin>/api/v3`. An https server's API must be
41
+ * https too, so the token never travels in plaintext. Any other API is refused — never replaced by
42
+ * api.github.com, which must not receive an enterprise token.
43
+ */
44
+ export function gitHubApiUrl(host?: string): GitHubApiUrl {
45
+ const serverUrl = gitHubServerUrl();
46
+ const serverHost = gitHubServerHost();
47
+ if (!serverUrl || serverHost === GITHUB_DOT_COM || (host && host.toLowerCase() !== serverHost)) {
48
+ return { url: GITHUB_DOT_COM_API };
49
+ }
50
+ const refuse = (reason: string): GitHubApiUrl => ({
51
+ error: `${reason} — refusing to send the token of the GITHUB_SERVER_URL instance "${process.env.GITHUB_SERVER_URL}" to it`,
52
+ });
53
+ if (!WEB_PROTOCOLS.includes(serverUrl.protocol)) return refuse(`GITHUB_SERVER_URL is not an http(s) URL`);
54
+ const apiUrl = process.env.GITHUB_API_URL;
55
+ if (!apiUrl) return { url: `${serverUrl.origin}/api/v3` };
56
+ let api: URL | undefined;
57
+ try {
58
+ api = new URL(apiUrl);
59
+ } catch {
60
+ // a malformed value is refused below like any other foreign API
61
+ }
62
+ if (!api || ![serverHost, `api.${serverHost}`].includes(api.hostname.toLowerCase())) {
63
+ return refuse(`GITHUB_API_URL "${apiUrl}" is not on ${serverHost} or api.${serverHost}`);
64
+ }
65
+ // the token travels at least as securely as the server is reached: never in plaintext from an https server
66
+ const protocols = serverUrl.protocol === 'https:' ? ['https:'] : WEB_PROTOCOLS;
67
+ if (!protocols.includes(api.protocol)) {
68
+ return refuse(`GITHUB_API_URL "${apiUrl}" is not ${protocols.map((p) => p.slice(0, -1)).join(' or ')}`);
69
+ }
70
+ // the base is a path prefix: `/repos/...` appended after a query or fragment would never reach the API
71
+ if (api.search || api.hash) return refuse(`GITHUB_API_URL "${apiUrl}" carries a query or fragment`);
72
+ return { url: apiUrl.replace(/\/+$/, '') };
15
73
  }
16
74
 
17
75
  /** The host a git remote URL points at, lower-cased, or undefined when the URL has no authority. */
18
- function remoteHost(remoteUrl: string): string | undefined {
76
+ export function remoteHost(remoteUrl: string): string | undefined {
19
77
  return remoteUrl
20
78
  .trim()
21
79
  .match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^:/]+)/i)?.[1]
@@ -23,27 +81,44 @@ function remoteHost(remoteUrl: string): string | undefined {
23
81
  }
24
82
 
25
83
  /**
26
- * Does this remote point at GitHub? A host test only — an unparseable github.com remote is still
27
- * GitHub's to claim. Host equality, not substring: `github.com` can be a path segment on another host.
84
+ * The `owner/repo` out of a GitHub remote URL, anchored to the URL authority. `:digits` is a port
85
+ * only under a scheme; in the scp-like form (`git@github.com:12345/repo`) the colon starts the PATH
86
+ * and an all-digits owner is a legal GitHub username.
28
87
  */
29
- export function isGitHubRemote(remoteUrl: string): boolean {
30
- return remoteHost(remoteUrl) === 'github.com';
88
+ export function parseGitHubRepo(remoteUrl: string): string | undefined {
89
+ const url = remoteUrl.trim();
90
+ const host = remoteHost(url);
91
+ if (!host || !gitHubHosts().includes(host)) return undefined;
92
+ const hostPattern = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ const hasScheme = /^[a-z+]+:\/\//i.test(url);
94
+ const match = hasScheme
95
+ ? url.match(new RegExp(`(?:\\/\\/|@)${hostPattern}(?::\\d+)?\\/([^/]+\\/[^/]+?)(?:\\.git)?$`, 'i'))
96
+ : url.match(new RegExp(`(?:^|@)${hostPattern}[:/]([^/]+\\/[^/]+?)(?:\\.git)?$`, 'i'));
97
+ return match?.[1];
31
98
  }
32
99
 
33
- const API = 'https://api.github.com';
100
+ /**
101
+ * Does this remote point at GitHub — github.com, or the GitHub Enterprise Server host `GITHUB_SERVER_URL`
102
+ * names? A host test only — an unparseable GitHub remote is still GitHub's to claim. Host equality, not
103
+ * substring: `github.com` can be a path segment on another host.
104
+ */
105
+ export function isGitHubRemote(remoteUrl: string): boolean {
106
+ const host = remoteHost(remoteUrl);
107
+ return Boolean(host && gitHubHosts().includes(host));
108
+ }
34
109
 
35
110
  /**
36
111
  * The `rel="next"` URL out of a GitHub `Link` response header, or undefined on the last page. The
37
112
  * header is response data, and `requestRaw` attaches the bearer token to whatever URL it is handed —
38
113
  * so a next link whose origin is not the API's reads as "last page", never as a request target.
39
114
  */
40
- function nextPageUrl(linkHeader: string | null): string | undefined {
115
+ function nextPageUrl(linkHeader: string | null, apiUrl: string): string | undefined {
41
116
  if (!linkHeader) return undefined;
42
117
  const next = linkHeader.split(',').find((part) => part.includes('rel="next"'));
43
118
  const url = next?.match(/<([^>]+)>/)?.[1];
44
119
  if (!url) return undefined;
45
120
  try {
46
- return new URL(url).origin === API ? url : undefined;
121
+ return new URL(url).origin === new URL(apiUrl).origin ? url : undefined;
47
122
  } catch {
48
123
  return undefined;
49
124
  }
@@ -60,11 +135,17 @@ export class GitHubClient implements GitHostProvider {
60
135
  private token: string;
61
136
  /** `owner/repo`. Readable so callers (and tests) can see *which* repository a resolved client speaks to. */
62
137
  readonly repo: string;
138
+ /**
139
+ * The REST API base, e.g. `https://api.github.com` (the default) or a GitHub Enterprise Server's
140
+ * `https://<host>/api/v3`; `fromEnv` resolves it for the remote's host.
141
+ */
142
+ readonly apiUrl: string;
63
143
  private fetchImpl: typeof fetch;
64
144
 
65
- constructor(opts: { token: string; repo: string; fetchImpl?: typeof fetch }) {
145
+ constructor(opts: { token: string; repo: string; apiUrl?: string; fetchImpl?: typeof fetch }) {
66
146
  this.token = opts.token;
67
147
  this.repo = opts.repo;
148
+ this.apiUrl = opts.apiUrl ?? GITHUB_DOT_COM_API;
68
149
  this.fetchImpl = opts.fetchImpl ?? fetch;
69
150
  }
70
151
 
@@ -90,7 +171,13 @@ export class GitHubClient implements GitHostProvider {
90
171
  `"${repoFromRemote}" — using "${repoFromRemote}", the repository this run pushes its branches to.`
91
172
  );
92
173
  }
93
- return new GitHubClient({ token, repo });
174
+ // Without a remote, the GITHUB_REPOSITORY repository lives on the GITHUB_SERVER_URL instance.
175
+ const api = gitHubApiUrl(gitRemoteUrl ? remoteHost(gitRemoteUrl) : undefined);
176
+ if ('error' in api) {
177
+ warn(`bit ci sync: ${api.error}. The GitHub provider is not configured.`);
178
+ return undefined;
179
+ }
180
+ return new GitHubClient({ token, repo, apiUrl: api.url });
94
181
  }
95
182
 
96
183
  matchesRemote(remoteUrl: string): boolean {
@@ -104,7 +191,7 @@ export class GitHubClient implements GitHostProvider {
104
191
 
105
192
  /** `pathOrUrl` may be a path relative to this repo, or an absolute URL (a `Link` header's next page). */
106
193
  private async requestRaw(method: string, pathOrUrl: string, body?: unknown): Promise<Response> {
107
- const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${API}/repos/${this.repo}${pathOrUrl}`;
194
+ const url = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${this.apiUrl}/repos/${this.repo}${pathOrUrl}`;
108
195
  const res = await this.fetchImpl(url, {
109
196
  method,
110
197
  headers: {
@@ -177,7 +264,7 @@ export class GitHubClient implements GitHostProvider {
177
264
  const body = (await res.json()) as any[];
178
265
  const match = body.find((c: any) => (c.body ?? '').includes(marker));
179
266
  if (match) return { id: match.id };
180
- next = nextPageUrl(res.headers.get('link'));
267
+ next = nextPageUrl(res.headers.get('link'), this.apiUrl);
181
268
  }
182
269
  return next ? 'unknown' : 'absent';
183
270
  }
@@ -217,17 +304,23 @@ export class GitHubClient implements GitHostProvider {
217
304
 
218
305
  /**
219
306
  * The registrable GitHub provider: resolves its `GitHubClient` from the environment lazily, since
220
- * registration happens at aspect load, before credentials are known. A resolved client is memoized;
221
- * an unresolved one is retried so a later call that does carry the remote URL can still succeed.
307
+ * registration happens at aspect load, before credentials are known. A resolved client is memoized per
308
+ * remote; an unresolved one is retried so a later call that does carry the remote URL can still succeed.
222
309
  */
223
310
  export class GitHubHostProvider implements GitHostProvider {
224
311
  readonly name = 'github';
225
312
 
226
313
  private client: GitHubClient | undefined;
227
314
 
315
+ /** The remote `client` was resolved for. */
316
+ private clientRemote: string | undefined;
317
+
228
318
  /** The last `origin` URL seen, so a PR method can still recover `owner/repo` from it. */
229
319
  private remoteHint: string | undefined;
230
320
 
321
+ /** Warnings already emitted: an unresolved client is retried on every call, its warning is not. */
322
+ private warned = new Set<string>();
323
+
231
324
  constructor(private onWarning: WarnFn = noopWarn) {}
232
325
 
233
326
  matchesRemote(remoteUrl: string): boolean {
@@ -279,16 +372,27 @@ export class GitHubHostProvider implements GitHostProvider {
279
372
  }
280
373
 
281
374
  private resolveClient(remoteUrl?: string): GitHubClient | undefined {
282
- if (!this.client) this.client = GitHubClient.fromEnv(remoteUrl ?? this.remoteHint, this.onWarning);
375
+ const remote = remoteUrl ?? this.remoteHint;
376
+ // The client pins the remote's repository and API, so another remote needs its own client.
377
+ if (!this.client || this.clientRemote !== remote) {
378
+ this.client = GitHubClient.fromEnv(remote, this.warnOnce);
379
+ this.clientRemote = remote;
380
+ }
283
381
  return this.client;
284
382
  }
285
383
 
384
+ private warnOnce: WarnFn = (message) => {
385
+ if (this.warned.has(message)) return;
386
+ this.warned.add(message);
387
+ this.onWarning(message);
388
+ };
389
+
286
390
  private requireClient(): GitHubClient {
287
391
  const client = this.resolveClient();
288
392
  if (!client) {
289
393
  throw new Error(
290
394
  `the github host provider is not configured: set BIT_GITHUB_TOKEN (or GITHUB_TOKEN), and either ` +
291
- `GITHUB_REPOSITORY or a github.com "origin" remote`
395
+ `GITHUB_REPOSITORY or a GitHub "origin" remote (github.com, or the GITHUB_SERVER_URL host)`
292
396
  );
293
397
  }
294
398
  return client;