Files
Jonathan Hill 0f06e635df feat(platform-azure): initial bearer token auth support for Azure DevOps (#43888)
* feat(platform/azure): support bearer token auth (auto-detect token type)

* refactor(platform/azure): use isPlainObject for JWT header check

Replace manual typeof/null check with isPlainObject() from
@sindresorhus/is in isProbablyJwt(). Remove unused _GlobalConfig
import from test file.

* refactor(platform/azure): use parseJsonc to parse JWT header

* test(platform/azure): use a real JWT in the set bearer token/endpoint test

* feat(platform/azure): log detected token type

* refactor(platform/azure): guard token before JWT check; remove outdated todo comment

* docs(platform/azure): restore TODO note about possibly-undefined token (#22198)

* refactor(platform/azure): move isProbablyJwt() and tests to new files lib/util/http/jwt.ts and lib/util/http/jwt.spec.ts

* refactor(platform/azure): use Json.safeParse() instead of parseJsonc() in isProbablyJwt()

* refactor(platform/azure): accept undefined token in isProbablyJwt and add guard to drop non-null assertions

* test(platform/azure): extract shared buildTestJwt helper and dedupe

* docs(platform/azure): reword the Entra ID token instructions

* chore(platform/azure): remove irrelevant lines from docstring

* refactor(platform/azure): export buildTestJwt() from new test/jwt-util.ts

* docs(platform/azure): use 1 for numbered lists

* refactor(test): extract arrow function from buildTestJwt

---------

Co-authored-by: Bastian Wenske <bastianwenske@outlook.de>
2026-07-19 23:01:52 +00:00

216 lines
5.9 KiB
TypeScript

import type {
GitPullRequest,
GitRepository,
GitStatusContext,
} from 'azure-devops-node-api/interfaces/GitInterfaces.js';
import {
GitPullRequestMergeStrategy,
PullRequestStatus,
} from 'azure-devops-node-api/interfaces/GitInterfaces.js';
import type { MergeStrategy } from '../../../config/types.ts';
import { logger } from '../../../logger/index.ts';
import type { GitOptions } from '../../../types/git.ts';
import type { HostRule, PrState } from '../../../types/index.ts';
import { isProbablyJwt } from '../../../util/http/jwt.ts';
import { addSecretForSanitizing } from '../../../util/sanitize.ts';
import { toBase64 } from '../../../util/string.ts';
import { getPrBodyStruct } from '../pr-body.ts';
import type { AzurePr } from './types.ts';
export function getGitStatusContextCombinedName(
context: GitStatusContext | null | undefined,
): string | undefined {
if (!context) {
return undefined;
}
const combinedName = `${context.genre ? `${context.genre}/` : ''}${
// TODO: types (#22198)
context.name!
}`;
logger.trace(`Got combined context name of ${combinedName}`);
return combinedName;
}
export function getGitStatusContextFromCombinedName(
context: string | undefined | null,
): GitStatusContext | undefined {
if (!context) {
return undefined;
}
let name = context;
let genre: string | undefined;
const lastSlash = context.lastIndexOf('/');
if (lastSlash > 0) {
name = context.substring(lastSlash + 1);
genre = context.substring(0, lastSlash);
}
return {
genre,
name,
};
}
export function getBranchNameWithoutRefsheadsPrefix(
branchPath: string | undefined,
): string | undefined {
if (!branchPath) {
logger.error(`getBranchNameWithoutRefsheadsPrefix(undefined)`);
return undefined;
}
if (!branchPath.startsWith('refs/heads/')) {
logger.trace(
`The refs/heads/ name should have started with 'refs/heads/' but it didn't. (${branchPath})`,
);
return branchPath;
}
return branchPath.substring(11, branchPath.length);
}
export function getBranchNameWithoutRefsPrefix(
branchPath?: string,
): string | undefined {
if (!branchPath) {
logger.error(`getBranchNameWithoutRefsPrefix(undefined)`);
return undefined;
}
if (!branchPath.startsWith('refs/')) {
logger.trace(
`The ref name should have started with 'refs/' but it didn't. (${branchPath})`,
);
return branchPath;
}
return branchPath.substring(5, branchPath.length);
}
const stateMap = {
[PullRequestStatus.Abandoned]: 'closed',
[PullRequestStatus.Completed]: 'merged',
} as Record<PullRequestStatus, PrState | undefined>;
export function getRenovatePRFormat(azurePr: GitPullRequest): AzurePr {
const number = azurePr.pullRequestId;
const sourceBranch = getBranchNameWithoutRefsheadsPrefix(
azurePr.sourceRefName,
);
const targetBranch = getBranchNameWithoutRefsheadsPrefix(
azurePr.targetRefName,
);
const bodyStruct = getPrBodyStruct(azurePr.description);
const createdAt = azurePr.creationDate?.toISOString();
// TODO #22198
const state = stateMap[azurePr.status!] ?? 'open';
const sourceRefName = azurePr.sourceRefName;
return {
...azurePr,
sourceBranch,
state,
number,
bodyStruct,
sourceRefName,
targetBranch,
createdAt,
} as AzurePr;
}
export function getStorageExtraCloneOpts(config: HostRule): GitOptions {
let authType: string;
let authValue: string;
if (!config.token && config.username && config.password) {
authType = 'basic';
authValue = toBase64(`${config.username}:${config.password}`);
} else if (config.token && isProbablyJwt(config.token)) {
authType = 'bearer';
authValue = config.token;
} else {
authType = 'basic';
authValue = toBase64(`:${config.token}`);
}
addSecretForSanitizing(authValue, 'global');
return {
'-c': `http.extraHeader=AUTHORIZATION: ${authType} ${authValue}`,
};
}
export function max4000Chars(str: string): string {
if (str && str.length >= 4000) {
return str.substring(0, 3999);
}
return str;
}
export function getProjectAndRepo(str: string): {
project: string;
repo: string;
} {
logger.trace(`getProjectAndRepo(${str})`);
const strSplit = str.split(`/`);
if (strSplit.length === 1) {
return {
project: str,
repo: str,
};
}
if (strSplit.length === 2) {
return {
project: strSplit[0],
repo: strSplit[1],
};
}
const msg = `Azure repository can be only structured this way : 'repository' or 'projectName/repository'!`;
logger.warn({ repository: str }, msg);
throw new Error(msg);
}
export function getRepoByName(
name: string,
repos: (GitRepository | null | undefined)[] | undefined | null,
): GitRepository | null {
logger.trace(`getRepoByName(${name})`);
let { project, repo } = getProjectAndRepo(name);
project = project.toLowerCase();
repo = repo.toLowerCase();
const foundRepo = repos?.find(
(r) =>
project === r?.project?.name?.toLowerCase() &&
repo === r?.name?.toLowerCase(),
);
if (!foundRepo) {
logger.debug(`Repo not found: ${name}`);
}
return foundRepo ?? null;
}
export function mapMergeStrategy(
mergeStrategy?: MergeStrategy,
): GitPullRequestMergeStrategy {
switch (mergeStrategy) {
case 'rebase':
case 'fast-forward':
return GitPullRequestMergeStrategy.Rebase;
case 'merge-commit':
return GitPullRequestMergeStrategy.NoFastForward;
case 'rebase-merge':
return GitPullRequestMergeStrategy.RebaseMerge;
case 'squash':
return GitPullRequestMergeStrategy.Squash;
default:
return GitPullRequestMergeStrategy.NoFastForward;
}
}
export function getWorkItemTitle(rawTitle: string, repository: string): string {
const repoName = repository.split('/').pop();
const isDependencyDashboard = rawTitle.includes('Dependency Dashboard');
if (isDependencyDashboard && !rawTitle.includes(`[${repoName}]`)) {
return `[${repoName}] ${rawTitle}`;
}
return rawTitle;
}