From e8a5437cd3bc9a937948e09c5b1357d88c040af4 Mon Sep 17 00:00:00 2001
From: Eric Durand-Tremblay <eric.durand-tremblay@equisoft.com>
Date: Fri, 13 Jan 2023 08:33:49 -0500
Subject: [PATCH] feat(manager/composer): support git-tags hostRules for
 github.com when updating artifacts (#18004)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
fixes undefined
---
 .../usage/getting-started/private-packages.md |  19 ++
 .../manager/composer/artifacts.spec.ts        | 124 ++++++++++-
 lib/modules/manager/composer/artifacts.ts     |  19 +-
 lib/modules/manager/composer/utils.spec.ts    | 199 ++++++++++++++++++
 lib/modules/manager/composer/utils.ts         |  64 ++++++
 5 files changed, 420 insertions(+), 5 deletions(-)

diff --git a/docs/usage/getting-started/private-packages.md b/docs/usage/getting-started/private-packages.md
index 61343ad5f8..b0dbd6db7d 100644
--- a/docs/usage/getting-started/private-packages.md
+++ b/docs/usage/getting-started/private-packages.md
@@ -179,6 +179,25 @@ The following details the most common/popular manager artifacts updating and how
 
 Any `hostRules` token for `github.com` or `gitlab.com` are found and written out to `COMPOSER_AUTH` in env for Composer to parse.
 Any `hostRules` with `hostType=packagist` are also included.
+For dependencies on `github.com` without a Packagist server: use a Personal Access Token for `hostRule` with `hostType=git-tags`, do not use an application token.
+Avoid adding a `hostRule` with `hostType=github` because:
+
+- it overrides the default Renovate application token for everything else
+- it causes unwanted side effects
+
+The repository in `composer.json` should have the `vcs` type with a `https` URL.
+For example:
+
+```json
+{
+  "repositories": [
+    {
+      "type": "vcs",
+      "url": "https://github.com/organization/private-repository"
+    }
+  ]
+}
+```
 
 ### gomod
 
diff --git a/lib/modules/manager/composer/artifacts.spec.ts b/lib/modules/manager/composer/artifacts.spec.ts
index 603a26a9ba..2191ab49e9 100644
--- a/lib/modules/manager/composer/artifacts.spec.ts
+++ b/lib/modules/manager/composer/artifacts.spec.ts
@@ -7,6 +7,7 @@ import * as docker from '../../../util/exec/docker';
 import type { StatusResult } from '../../../util/git/types';
 import * as hostRules from '../../../util/host-rules';
 import * as _datasource from '../../datasource';
+import { GitTagsDatasource } from '../../datasource/git-tags';
 import { PackagistDatasource } from '../../datasource/packagist';
 import type { UpdateArtifactsConfig } from '../types';
 import * as composer from '.';
@@ -112,7 +113,13 @@ describe('modules/manager/composer/artifacts', () => {
     hostRules.add({
       hostType: 'github',
       matchHost: 'api.github.com',
-      token: 'github-token',
+      token: 'ghp_github-token',
+    });
+    // This rule should not affect the result the Github rule has priority to avoid breaking changes.
+    hostRules.add({
+      hostType: GitTagsDatasource.id,
+      matchHost: 'github.com',
+      token: 'ghp_git-tags-token',
     });
     hostRules.add({
       hostType: 'gitlab',
@@ -164,7 +171,14 @@ describe('modules/manager/composer/artifacts', () => {
           cwd: '/tmp/github/some/repo',
           env: {
             COMPOSER_AUTH:
-              '{"github-oauth":{"github.com":"github-token"},"gitlab-token":{"gitlab.com":"gitlab-token"},"gitlab-domains":["gitlab.com"],"http-basic":{"packagist.renovatebot.com":{"username":"some-username","password":"some-password"},"artifactory.yyyyyyy.com":{"username":"some-other-username","password":"some-other-password"}},"bearer":{"packages-bearer.example.com":"abcdef0123456789"}}',
+              '{"github-oauth":{"github.com":"ghp_git-tags-token"},' +
+              '"gitlab-token":{"gitlab.com":"gitlab-token"},' +
+              '"gitlab-domains":["gitlab.com"],' +
+              '"http-basic":{' +
+              '"packagist.renovatebot.com":{"username":"some-username","password":"some-password"},' +
+              '"artifactory.yyyyyyy.com":{"username":"some-other-username","password":"some-other-password"}' +
+              '},' +
+              '"bearer":{"packages-bearer.example.com":"abcdef0123456789"}}',
             COMPOSER_CACHE_DIR: '/tmp/renovate/cache/others/composer',
           },
         },
@@ -172,6 +186,112 @@ describe('modules/manager/composer/artifacts', () => {
     ]);
   });
 
+  it('git-tags hostRule for github.com set github-token in COMPOSER_AUTH', async () => {
+    hostRules.add({
+      hostType: GitTagsDatasource.id,
+      matchHost: 'github.com',
+      token: 'ghp_token',
+    });
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const execSnapshots = mockExecAll();
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const authConfig = {
+      ...config,
+      registryUrls: ['https://packagist.renovatebot.com'],
+    };
+    git.getRepoStatus.mockResolvedValueOnce(repoStatus);
+    expect(
+      await composer.updateArtifacts({
+        packageFileName: 'composer.json',
+        updatedDeps: [],
+        newPackageFileContent: '{}',
+        config: authConfig,
+      })
+    ).toBeNull();
+
+    expect(execSnapshots).toMatchObject([
+      {
+        options: {
+          env: {
+            COMPOSER_AUTH: '{"github-oauth":{"github.com":"ghp_token"}}',
+          },
+        },
+      },
+    ]);
+  });
+
+  it('Skip github application access token hostRules in COMPOSER_AUTH', async () => {
+    hostRules.add({
+      hostType: 'github',
+      matchHost: 'api.github.com',
+      token: 'ghs_token',
+    });
+    hostRules.add({
+      hostType: GitTagsDatasource.id,
+      matchHost: 'github.com',
+      token: 'ghp_token',
+    });
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const execSnapshots = mockExecAll();
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const authConfig = {
+      ...config,
+      registryUrls: ['https://packagist.renovatebot.com'],
+    };
+    git.getRepoStatus.mockResolvedValueOnce(repoStatus);
+    expect(
+      await composer.updateArtifacts({
+        packageFileName: 'composer.json',
+        updatedDeps: [],
+        newPackageFileContent: '{}',
+        config: authConfig,
+      })
+    ).toBeNull();
+    expect(execSnapshots).toMatchObject([
+      {
+        options: {
+          env: {
+            COMPOSER_AUTH: '{"github-oauth":{"github.com":"ghp_token"}}',
+          },
+        },
+      },
+    ]);
+  });
+
+  it('github hostRule for github.com with x-access-token set github-token in COMPOSER_AUTH', async () => {
+    hostRules.add({
+      hostType: 'github',
+      matchHost: 'https://api.github.com/',
+      token: 'x-access-token:ghp_token',
+    });
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const execSnapshots = mockExecAll();
+    fs.readLocalFile.mockResolvedValueOnce('{}');
+    const authConfig = {
+      ...config,
+      registryUrls: ['https://packagist.renovatebot.com'],
+    };
+    git.getRepoStatus.mockResolvedValueOnce(repoStatus);
+    expect(
+      await composer.updateArtifacts({
+        packageFileName: 'composer.json',
+        updatedDeps: [],
+        newPackageFileContent: '{}',
+        config: authConfig,
+      })
+    ).toBeNull();
+
+    expect(execSnapshots).toMatchObject([
+      {
+        options: {
+          env: {
+            COMPOSER_AUTH: '{"github-oauth":{"github.com":"ghp_token"}}',
+          },
+        },
+      },
+    ]);
+  });
+
   it('returns updated composer.lock', async () => {
     fs.readLocalFile.mockResolvedValueOnce('{}');
     const execSnapshots = mockExecAll();
diff --git a/lib/modules/manager/composer/artifacts.ts b/lib/modules/manager/composer/artifacts.ts
index 32328b39d2..0eae7fefde 100644
--- a/lib/modules/manager/composer/artifacts.ts
+++ b/lib/modules/manager/composer/artifacts.ts
@@ -18,26 +18,39 @@ import {
 import { getRepoStatus } from '../../../util/git';
 import * as hostRules from '../../../util/host-rules';
 import { regEx } from '../../../util/regex';
+import { GitTagsDatasource } from '../../datasource/git-tags';
 import { PackagistDatasource } from '../../datasource/packagist';
 import type { UpdateArtifact, UpdateArtifactsResult } from '../types';
 import type { AuthJson, ComposerLock } from './types';
 import {
   extractConstraints,
+  findGithubToken,
   getComposerArguments,
   getPhpConstraint,
   requireComposerDependencyInstallation,
+  takePersonalAccessTokenIfPossible,
 } from './utils';
 
 function getAuthJson(): string | null {
   const authJson: AuthJson = {};
 
-  const githubCredentials = hostRules.find({
+  const githubToken = findGithubToken({
     hostType: 'github',
     url: 'https://api.github.com/',
   });
-  if (githubCredentials?.token) {
+
+  const gitTagsGithubToken = findGithubToken({
+    hostType: GitTagsDatasource.id,
+    url: 'https://github.com',
+  });
+
+  const selectedGithubToken = takePersonalAccessTokenIfPossible(
+    githubToken,
+    gitTagsGithubToken
+  );
+  if (selectedGithubToken) {
     authJson['github-oauth'] = {
-      'github.com': githubCredentials.token.replace('x-access-token:', ''),
+      'github.com': selectedGithubToken,
     };
   }
 
diff --git a/lib/modules/manager/composer/utils.spec.ts b/lib/modules/manager/composer/utils.spec.ts
index 4f2e0fd5ff..c591825be7 100644
--- a/lib/modules/manager/composer/utils.spec.ts
+++ b/lib/modules/manager/composer/utils.spec.ts
@@ -1,13 +1,24 @@
 import { GlobalConfig } from '../../../config/global';
+import * as hostRules from '../../../util/host-rules';
+import { GitTagsDatasource } from '../../datasource/git-tags';
 import {
   extractConstraints,
+  findGithubToken,
   getComposerArguments,
+  isGithubFineGrainedPersonalAccessToken,
+  isGithubPersonalAccessToken,
+  isGithubServerToServerToken,
   requireComposerDependencyInstallation,
+  takePersonalAccessTokenIfPossible,
 } from './utils';
 
 jest.mock('../../datasource');
 
 describe('modules/manager/composer/utils', () => {
+  beforeEach(() => {
+    hostRules.clear();
+  });
+
   describe('extractConstraints', () => {
     it('returns from require', () => {
       expect(
@@ -288,4 +299,192 @@ describe('modules/manager/composer/utils', () => {
       ).toBeFalse();
     });
   });
+
+  describe('findGithubToken', () => {
+    it('returns the token string when hostRule match search with a valid personal access token', () => {
+      const TOKEN_STRING = 'ghp_TOKEN';
+      hostRules.add({
+        hostType: GitTagsDatasource.id,
+        matchHost: 'github.com',
+        token: TOKEN_STRING,
+      });
+      expect(
+        findGithubToken({
+          hostType: GitTagsDatasource.id,
+          url: 'https://github.com',
+        })
+      ).toEqual(TOKEN_STRING);
+    });
+
+    it('returns undefined when no hostRule match search', () => {
+      expect(
+        findGithubToken({
+          hostType: GitTagsDatasource.id,
+          url: 'https://github.com',
+        })
+      ).toBeUndefined();
+    });
+
+    it('remove x-access-token token prefix', () => {
+      const TOKEN_STRING_WITH_PREFIX = 'x-access-token:ghp_TOKEN';
+      const TOKEN_STRING = 'ghp_TOKEN';
+      hostRules.add({
+        hostType: GitTagsDatasource.id,
+        matchHost: 'github.com',
+        token: TOKEN_STRING_WITH_PREFIX,
+      });
+      expect(
+        findGithubToken({
+          hostType: GitTagsDatasource.id,
+          url: 'https://github.com',
+        })
+      ).toEqual(TOKEN_STRING);
+    });
+  });
+
+  describe('isGithubPersonalAccessToken', () => {
+    it('returns true when string is a github personnal access token', () => {
+      expect(isGithubPersonalAccessToken('ghp_XXXXXX')).toBeTrue();
+    });
+
+    it('returns false when string is a github application token', () => {
+      expect(isGithubPersonalAccessToken('ghs_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is a github fine grained personal access token', () => {
+      expect(isGithubPersonalAccessToken('github_pat_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is not a token at all', () => {
+      expect(isGithubPersonalAccessToken('XXXXXX')).toBeFalse();
+    });
+  });
+
+  describe('isGithubServerToServerToken', () => {
+    it('returns true when string is a github server to server token', () => {
+      expect(isGithubServerToServerToken('ghs_XXXXXX')).toBeTrue();
+    });
+
+    it('returns false when string is a github personal access token token', () => {
+      expect(isGithubServerToServerToken('ghp_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is a github fine grained personal access token', () => {
+      expect(isGithubPersonalAccessToken('github_pat_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is not a token at all', () => {
+      expect(isGithubServerToServerToken('XXXXXX')).toBeFalse();
+    });
+  });
+
+  describe('isGithubFineGrainedPersonalAccessToken', () => {
+    it('returns true when string is a github fine grained personal access token', () => {
+      expect(
+        isGithubFineGrainedPersonalAccessToken('github_pat_XXXXXX')
+      ).toBeTrue();
+    });
+
+    it('returns false when string is a github personnal access token', () => {
+      expect(isGithubFineGrainedPersonalAccessToken('ghp_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is a github application token', () => {
+      expect(isGithubFineGrainedPersonalAccessToken('ghs_XXXXXX')).toBeFalse();
+    });
+
+    it('returns false when string is not a token at all', () => {
+      expect(isGithubFineGrainedPersonalAccessToken('XXXXXX')).toBeFalse();
+    });
+  });
+
+  describe('takePersonalAccessTokenIfPossible', () => {
+    it('returns undefined when both token are undefined', () => {
+      const githubToken = undefined;
+      const gitTagsGithubToken = undefined;
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toBeUndefined();
+    });
+
+    it('returns gitTagsToken when both token are PAT', () => {
+      const githubToken = 'ghp_github';
+      const gitTagsGithubToken = 'ghp_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(gitTagsGithubToken);
+    });
+
+    it('returns githubToken is PAT and gitTagsGithubToken is not a PAT', () => {
+      const githubToken = 'ghp_github';
+      const gitTagsGithubToken = 'ghs_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(githubToken);
+    });
+
+    it('returns gitTagsToken when both token are set but not PAT', () => {
+      const githubToken = 'ghs_github';
+      const gitTagsGithubToken = 'ghs_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(gitTagsGithubToken);
+    });
+
+    it('returns gitTagsToken when gitTagsToken not PAT and gitTagsGithubToken is not set', () => {
+      const githubToken = undefined;
+      const gitTagsGithubToken = 'ghs_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(gitTagsGithubToken);
+    });
+
+    it('returns githubToken when githubToken not PAT and gitTagsGithubToken is not set', () => {
+      const githubToken = 'ghs_gitTags';
+      const gitTagsGithubToken = undefined;
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(githubToken);
+    });
+
+    it('take personal assess token over fine grained token', () => {
+      const githubToken = 'ghp_github';
+      const gitTagsGithubToken = 'github_pat_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(githubToken);
+    });
+
+    it('take fine grained token over server to server token', () => {
+      const githubToken = 'github_pat_github';
+      const gitTagsGithubToken = 'ghs_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(githubToken);
+    });
+
+    it('take git-tags fine grained token', () => {
+      const githubToken = undefined;
+      const gitTagsGithubToken = 'github_pat_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(gitTagsGithubToken);
+    });
+
+    it('take git-tags unknown token type when no other token is set', () => {
+      const githubToken = undefined;
+      const gitTagsGithubToken = 'unknownTokenType_gitTags';
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(gitTagsGithubToken);
+    });
+
+    it('take github unknown token type when no other token is set', () => {
+      const githubToken = 'unknownTokenType';
+      const gitTagsGithubToken = undefined;
+      expect(
+        takePersonalAccessTokenIfPossible(githubToken, gitTagsGithubToken)
+      ).toEqual(githubToken);
+    });
+  });
 });
diff --git a/lib/modules/manager/composer/utils.ts b/lib/modules/manager/composer/utils.ts
index 3571196ad3..330f50bc71 100644
--- a/lib/modules/manager/composer/utils.ts
+++ b/lib/modules/manager/composer/utils.ts
@@ -4,6 +4,7 @@ import { quote } from 'shlex';
 import { GlobalConfig } from '../../../config/global';
 import { logger } from '../../../logger';
 import type { ToolConstraint } from '../../../util/exec/types';
+import { HostRuleSearch, find as findHostRule } from '../../../util/host-rules';
 import { api, id as composerVersioningId } from '../../versioning/composer';
 import type { UpdateArtifactsConfig } from '../types';
 import type { ComposerConfig, ComposerLock } from './types';
@@ -109,3 +110,66 @@ export function extractConstraints(
   }
   return res;
 }
+
+export function findGithubToken(search: HostRuleSearch): string | undefined {
+  return findHostRule(search)?.token?.replace('x-access-token:', '');
+}
+
+export function isGithubPersonalAccessToken(token: string): boolean {
+  return token.startsWith('ghp_');
+}
+
+export function isGithubServerToServerToken(token: string): boolean {
+  return token.startsWith('ghs_');
+}
+
+export function isGithubFineGrainedPersonalAccessToken(token: string): boolean {
+  return token.startsWith('github_pat_');
+}
+
+export function takePersonalAccessTokenIfPossible(
+  githubToken: string | undefined,
+  gitTagsGithubToken: string | undefined
+): string | undefined {
+  if (gitTagsGithubToken && isGithubPersonalAccessToken(gitTagsGithubToken)) {
+    logger.debug('Using GitHub Personal Access Token (git-tags)');
+    return gitTagsGithubToken;
+  }
+
+  if (githubToken && isGithubPersonalAccessToken(githubToken)) {
+    logger.debug('Using GitHub Personal Access Token');
+    return githubToken;
+  }
+
+  if (
+    gitTagsGithubToken &&
+    isGithubFineGrainedPersonalAccessToken(gitTagsGithubToken)
+  ) {
+    logger.debug('Using GitHub Fine-grained Personal Access Token (git-tags)');
+    return gitTagsGithubToken;
+  }
+
+  if (githubToken && isGithubFineGrainedPersonalAccessToken(githubToken)) {
+    logger.debug('Using GitHub Fine-grained Personal Access Token');
+    return githubToken;
+  }
+
+  if (gitTagsGithubToken) {
+    if (isGithubServerToServerToken(gitTagsGithubToken)) {
+      logger.debug('Using GitHub Server-to-Server token (git-tags)');
+    } else {
+      logger.debug('Using unknown GitHub token type (git-tags)');
+    }
+    return gitTagsGithubToken;
+  }
+
+  if (githubToken) {
+    if (isGithubServerToServerToken(githubToken)) {
+      logger.debug('Using GitHub Server-to-Server token');
+    } else {
+      logger.debug('Using unknown GitHub token type');
+    }
+  }
+
+  return githubToken;
+}
-- 
GitLab