| 1 | /** |
| 2 | * Azure DevOps's client definition. Browser- and React Native-safe. |
| 3 | * |
| 4 | * @module source-control-azure-devops/client/definition |
| 5 | */ |
| 6 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 7 | import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; |
| 8 | import { |
| 9 | defineSourceControlClient, |
| 10 | isChangeRequestAuthorityOfProject, |
| 11 | isChangeRequestPath, |
| 12 | } from "@t3tools/source-control-core/client/definition"; |
| 13 | |
| 14 | const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; |
| 15 | const CHANGE_REQUEST_REFERENCE = |
| 16 | /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; |
| 17 | |
| 18 | /** `az repos pr checkout` names the pull request by `--id`, `-i`, or its first plain argument. */ |
| 19 | function checkoutCommandId(args: string): string | null { |
| 20 | const parts = args.trim().split(/\s+/).filter(Boolean); |
| 21 | for (const [index, part] of parts.entries()) { |
| 22 | if (part === "--id" || part === "-i") return parts[index + 1] ?? null; |
| 23 | if (part.startsWith("--id=")) return part.slice("--id=".length) || null; |
| 24 | } |
| 25 | return parts.find((part) => !part.startsWith("-")) ?? null; |
| 26 | } |
| 27 | |
| 28 | export const definition = defineSourceControlClient({ |
| 29 | kind: SourceControlProviderKind.make("azure-devops"), |
| 30 | label: "Azure DevOps", |
| 31 | pickerLabel: "Azure DevOps", |
| 32 | icon: "azure-devops", |
| 33 | changeRequest: { shortLabel: "PR", singular: "pull request" }, |
| 34 | repositoryPathHint: "project/repository", |
| 35 | // `az repos` reads need the checkout's organization and project, so a bare hostname names no |
| 36 | // repository this client can act on. |
| 37 | publicHost: null, |
| 38 | publishDescription: "dev.azure.com", |
| 39 | publishHost: () => "dev.azure.com", |
| 40 | // A new repository belongs to a project, which the account does not name. |
| 41 | newRepositoryOwner: () => null, |
| 42 | defaultCloneTransport: "ssh", |
| 43 | changeRequestUrl: ({ host, repository, number }) => |
| 44 | `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, |
| 45 | changeRequestActions: new Set([ |
| 46 | "merge", |
| 47 | "ready", |
| 48 | "draft", |
| 49 | "close", |
| 50 | "reopen", |
| 51 | "enable-auto-merge", |
| 52 | "disable-auto-merge", |
| 53 | ] as const), |
| 54 | checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, |
| 55 | authorProfileUrl: () => null, |
| 56 | referenceAutolinkRepositoryUrl: () => null, |
| 57 | reviewSummaryRequired: () => false, |
| 58 | checkoutCommandArgument: (input) => { |
| 59 | const args = CHECKOUT_COMMAND.exec(input)?.[1]; |
| 60 | return args ? checkoutCommandId(args) : null; |
| 61 | }, |
| 62 | isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), |
| 63 | changeRequestUrlHost: (url) => url.hostname, |
| 64 | checkoutChangeRequestHost: () => null, |
| 65 | // The legacy and SSH spellings of one repository all canonicalise to dev.azure.com. |
| 66 | isChangeRequestInRepository: (identity, link) => |
| 67 | isChangeRequestAuthorityOfProject(identity, link) && |
| 68 | canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === |
| 69 | canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), |
| 70 | // `az repos` reads use the checkout's organization and project, not host-wide credentials. |
| 71 | canReadChangeRequestOnHost: () => false, |
| 72 | isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), |
| 73 | }); |