From 59e994ad5f71c25650b8812e7deb72d8054437af Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 11 Jun 2026 11:17:42 -0400 Subject: [PATCH 1/3] Add GitHub Enterprise Server and Cloud URL support - Parse SSH and HTTPS remote URLs from any hostname (github.com, *.ghe.com, on-premises GHE) rather than hardcoding github.com - Fix GenerateGitHubChangelogLink to derive web base URL from the actual remote hostname instead of hardcoding https://github.com - Add DeriveWebBaseUrl and DeriveWebBaseUrlFromConfig helpers - Split compound requirements BuildMark-GitHub-ParseUrl into BuildMark-GitHub-ParseUrl-SSH and BuildMark-GitHub-ParseUrl-HTTPS - Split compound BuildMark-AzureDevOps-UrlParsing into -HTTPS and -SSH - Add system-level requirement BuildMark-GitHub-EnterpriseSupport - Add ResolveGraphQLEndpoint and GenerateGitHubChangelogLink to design - Create unit design docs for GitHubConnectorConfig and AzureDevOpsConnectorConfig - Add TemporaryDirectory to introduction.md structure and folder layout - Update verification docs, test counts, and requirement ID references - Add 8 new tests (GHE URL parsing + GHE changelog URL hostname) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/build-mark.md | 5 +- .../azure-devops-connector-config.md | 68 +++++++ .../configuration/github-connector-config.md | 51 +++++ .../build-mark/repo-connectors/github.md | 5 +- .../github/github-repo-connector.md | 63 +++++- .../repo-connectors/repo-connector-factory.md | 5 + docs/design/introduction.md | 6 +- docs/reqstream/build-mark.yaml | 12 ++ .../repo-connectors/azure-devops.yaml | 3 +- .../azure-devops-repo-connector.yaml | 23 ++- .../build-mark/repo-connectors/github.yaml | 2 + .../github/github-repo-connector.yaml | 26 +++ docs/verification/build-mark.md | 12 ++ .../build-mark/repo-connectors/github.md | 2 +- .../github/git-hub-repo-connector.md | 127 +++++++++--- .../GitHub/GitHubRepoConnector.cs | 156 +++++++++++++-- .../GitHub/GitHubRepoConnectorTests.cs | 182 +++++++++++++++++- 17 files changed, 685 insertions(+), 63 deletions(-) create mode 100644 docs/design/build-mark/configuration/azure-devops-connector-config.md create mode 100644 docs/design/build-mark/configuration/github-connector-config.md diff --git a/docs/design/build-mark.md b/docs/design/build-mark.md index d3a21a2..66876e2 100644 --- a/docs/design/build-mark.md +++ b/docs/design/build-mark.md @@ -66,7 +66,10 @@ flowchart TD - *Role*: Consumer — BuildMark queries GitHub for tags, commits, issues, pull requests, and releases. - *Contract*: `GitHubGraphQLClient` sends paginated queries to - `https://api.github.com/graphql` using `Authorization: bearer `. + `https://api.github.com/graphql` by default; the endpoint is overridden by + `GitHubConnectorConfig.BaseUrl` when set, enabling GitHub Enterprise Server support + (e.g., `https://github.mycompany.com/api/graphql`). Authentication uses + `Authorization: bearer `. - *Constraints*: Authentication via `GH_TOKEN` or `GITHUB_TOKEN` environment variable, or `gh auth token` CLI fallback; subject to GitHub API rate limits. diff --git a/docs/design/build-mark/configuration/azure-devops-connector-config.md b/docs/design/build-mark/configuration/azure-devops-connector-config.md new file mode 100644 index 0000000..523ec0b --- /dev/null +++ b/docs/design/build-mark/configuration/azure-devops-connector-config.md @@ -0,0 +1,68 @@ +### AzureDevOpsConnectorConfig + +#### Purpose + +`AzureDevOpsConnectorConfig` is an immutable record that carries Azure DevOps-specific connector +settings read from the `connector.azure-devops:` block of `.buildmark.yaml`. It allows operators +to override the organization URL, organization name, project, repository name, authentication +token variable, and area path that `AzureDevOpsRepoConnector` would otherwise auto-detect from +the environment or the git remote URL. + +#### Data Model + +**OrganizationUrl**: `string?` — Azure DevOps organization URL override (e.g., +`https://dev.azure.com/myorg`). When set, replaces the organization URL parsed from the git +remote URL. When null, the organization URL is derived from the remote URL. + +**Organization**: `string?` — Azure DevOps organization name override. When set, replaces +the organization name derived from the remote URL. When null, the organization name is +parsed from the remote URL. + +**Project**: `string?` — Azure DevOps project name override. When set, replaces the project +name parsed from the git remote URL. When null, the project name is derived from the remote URL. + +**Repository**: `string?` — Repository name override within the project. When set, replaces +the repository name parsed from the git remote URL. When null, the repository name is derived +from the remote URL. + +**TokenVariable**: `string?` — Name of the environment variable that holds the Azure DevOps +access token. When set, `AzureDevOpsRepoConnector` reads the token exclusively from this +variable and does not fall back to well-known variable names (`AZURE_DEVOPS_PAT`, +`SYSTEM_ACCESSTOKEN`, etc.) or the `az` CLI. When the variable is absent or empty, +`InvalidOperationException` is thrown. The token is always treated as a Basic (PAT) credential +when loaded from a custom variable. + +**AreaPath**: `string?` — Area path used to scope the known-issues WIQL query. When null, +the connector scopes the query to the ADO project name (using the project root area path). +When set to an explicit value (e.g., `MyProject\MyRepo`), the query uses +`[System.AreaPath] UNDER '{AreaPath}'` to include the specified area and all descendants. +When set to an empty string, area-path filtering is disabled and all bugs in the project are +queried. + +#### Key Methods + +N/A — `AzureDevOpsConnectorConfig` is an immutable configuration data record with no methods +beyond those auto-generated by C#. + +#### Error Handling + +N/A — Immutable data record. All parsing and validation of the `connector.azure-devops:` YAML +block is performed by `BuildMarkConfigReader`; this type holds only the results. Token +resolution errors are raised by `AzureDevOpsRepoConnector` when `TokenVariable` is set and the +named variable is absent or empty. + +#### Dependencies + +N/A — `AzureDevOpsConnectorConfig` has no dependencies on other local software items. + +#### Callers + +- **BuildMarkConfigReader** — parses the `connector.azure-devops:` YAML block and creates + instances. +- **ConnectorConfig** — holds an `AzureDevOpsConnectorConfig` instance in its `AzureDevOps` + property. +- **RepoConnectorFactory** — forwards `config?.AzureDevOps` to `AzureDevOpsRepoConnector` at + construction. +- **AzureDevOpsRepoConnector** — reads `OrganizationUrl`, `Organization`, `Project`, + `Repository`, `TokenVariable`, and `AreaPath` properties to override auto-detected values + at runtime. diff --git a/docs/design/build-mark/configuration/github-connector-config.md b/docs/design/build-mark/configuration/github-connector-config.md new file mode 100644 index 0000000..01d4630 --- /dev/null +++ b/docs/design/build-mark/configuration/github-connector-config.md @@ -0,0 +1,51 @@ +### GitHubConnectorConfig + +#### Purpose + +`GitHubConnectorConfig` is an immutable record that carries GitHub-specific connector settings +read from the `connector.github:` block of `.buildmark.yaml`. It allows operators to override +the owner, repository name, GraphQL base URL, and authentication token variable that +`GitHubRepoConnector` would otherwise auto-detect from the environment or the git remote URL. + +#### Data Model + +**Owner**: `string?` — Repository owner override. When non-empty, replaces the owner parsed from +the git remote URL. When null or whitespace, the owner is derived from the remote URL. + +**Repo**: `string?` — Repository name override. When non-empty, replaces the repository name +parsed from the git remote URL. When null or whitespace, the repository name is derived from +the remote URL. + +**BaseUrl**: `string?` — Optional GitHub GraphQL API base endpoint override. When set, it is +forwarded to `ResolveGraphQLEndpoint` to derive the correct GraphQL endpoint for the target +instance (supports GitHub Enterprise Server). When null or empty, the default public GitHub +endpoint (`https://api.github.com/graphql`) is used. + +**TokenVariable**: `string?` — Name of the environment variable that holds the GitHub access +token. When set, `GitHubRepoConnector` reads the token exclusively from this variable and does +not fall back to `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`. If the variable is absent or +empty, `InvalidOperationException` is thrown with a message identifying the expected variable. + +#### Key Methods + +N/A — `GitHubConnectorConfig` is an immutable configuration data record with no methods beyond +those auto-generated by C#. + +#### Error Handling + +N/A — Immutable data record. All parsing and validation of the `connector.github:` YAML block +is performed by `BuildMarkConfigReader`; this type holds only the results. Token resolution +errors are raised by `GitHubRepoConnector.GetBuildInformationAsync` when `TokenVariable` is set +and the named variable is absent or empty. + +#### Dependencies + +N/A — `GitHubConnectorConfig` has no dependencies on other local software items. + +#### Callers + +- **BuildMarkConfigReader** — parses the `connector.github:` YAML block and creates instances. +- **ConnectorConfig** — holds a `GitHubConnectorConfig` instance in its `GitHub` property. +- **RepoConnectorFactory** — forwards `config?.GitHub` to `GitHubRepoConnector` at construction. +- **GitHubRepoConnector** — reads `Owner`, `Repo`, `BaseUrl`, and `TokenVariable` properties + to override auto-detected values at runtime. diff --git a/docs/design/build-mark/repo-connectors/github.md b/docs/design/build-mark/repo-connectors/github.md index 472634b..b621a7e 100644 --- a/docs/design/build-mark/repo-connectors/github.md +++ b/docs/design/build-mark/repo-connectors/github.md @@ -36,7 +36,10 @@ All other types in the subsystem are internal. 1. Read the git remote URL and current commit hash via `RunCommandAsync` (inherited from `RepoConnectorBase`). 2. Determine the owner and repository name from `GitHubConnectorConfig` or by parsing the remote - URL. + URL. The URL parser accepts github.com, GitHub Enterprise Cloud (`*.ghe.com`), and GitHub + Enterprise Server (on-premises) hosts in both SSH (`git@:owner/repo.git`) and HTTPS + (`https:///owner/repo`) formats; the hostname is never validated so any host is accepted + uniformly. 3. Resolve a GitHub authentication token (`GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`). 4. Create a `GitHubGraphQLClient` with the resolved token, using `GitHubConnectorConfig.BaseUrl` as the GraphQL endpoint when set (supports GitHub Enterprise Server); fetch tags, releases, diff --git a/docs/design/build-mark/repo-connectors/github/github-repo-connector.md b/docs/design/build-mark/repo-connectors/github/github-repo-connector.md index 35ecc88..7b77ef4 100644 --- a/docs/design/build-mark/repo-connectors/github/github-repo-connector.md +++ b/docs/design/build-mark/repo-connectors/github/github-repo-connector.md @@ -41,7 +41,10 @@ and `security` are preserved as the label name; unlabeled items default to `"oth Steps: (1) get repository URL, branch, and current commit hash from Git via `RunCommandAsync` (inherited from `RepoConnectorBase`); (2) determine owner and repository name from `_config` or -by parsing the remote URL; (3) resolve the GitHub authentication token; (4) create a +by calling `ParseGitHubUrl` on the remote URL — host-agnostic parsing accepts github.com, GitHub +Enterprise Cloud (`*.ghe.com`), and GitHub Enterprise Server (on-premises) instances for both SSH +(`git@:owner/repo.git`) and HTTPS (`https:///owner/repo`) URL formats; (3) resolve +the GitHub authentication token; (4) create a `GitHubGraphQLClient` with the resolved token, using `_config.BaseUrl` as the GraphQL endpoint when set (supports GitHub Enterprise Server); (5) fetch tags, releases, commits, pull requests (with `body`), and issues (with `body`) via GraphQL; (6) determine the target version — if a @@ -57,6 +60,64 @@ affected versions); (10) if routing rules are configured, call `ApplyRules` to p `BuildInformation.RoutedSections`; otherwise use legacy `Changes`, `Bugs`, and `KnownIssues` lists; (11) generate the changelog URL; (12) return the assembled `BuildInformation`. +**ParseGitHubUrl** (private static): Extracts owner and repository name from a git remote URL. + +- *Parameters*: `url` (string) — SSH or HTTPS remote URL; leading and trailing whitespace is + trimmed. +- *Returns*: `(string owner, string repo)` — owner and repository name with `.git` suffix removed. +- *Algorithm*: Dispatches on URL prefix. For SSH format (`git@:owner/repo.git`), the path + segment after the colon is forwarded to `ParseOwnerRepo`. For HTTPS format + (`https:///owner/repo[.git]`), the host is stripped and the last two non-empty + slash-separated path segments are forwarded to `ParseOwnerRepo`. The hostname is never validated, + so github.com, GitHub Enterprise Cloud (`*.ghe.com`), and GitHub Enterprise Server (on-premises) + remotes are accepted uniformly. +- *Postconditions*: Returns a `(owner, repo)` tuple on success; throws `ArgumentException` when + the URL is not a recognized SSH or HTTPS remote. + +**ParseOwnerRepo** (private static): Strips the `.git` suffix from a path segment and splits it +into owner and repository name at the `/` separator. + +- *Parameters*: `path` (string) — path in `owner/repo[.git]` form. +- *Returns*: `(string owner, string repo)`. +- *Postconditions*: Throws `ArgumentException` when the path does not split into exactly two + slash-separated components. + +**ResolveGraphQLEndpoint** (private static): Maps a configured base URL to the correct GraphQL +API endpoint for the target GitHub instance. + +- *Parameters*: `baseUrl` (string?) — the configured base URL from `GitHubConnectorConfig.BaseUrl`. +- *Returns*: string? — the resolved GraphQL endpoint URL, or `null` when `baseUrl` is null or + empty (meaning the client should use its built-in default of `https://api.github.com/graphql`). +- *Routing branches*: + 1. Null or blank `baseUrl` → returns `null` (use GitHub.com default). + 2. URL already ends with `/graphql` (case-insensitive) → returns the trimmed URL unchanged + (explicit passthrough for callers that supply the full endpoint directly). + 3. URL contains `api.github.com` → appends `/graphql` to the trimmed URL. + 4. Otherwise (GitHub Enterprise Server) → appends `/api/graphql` to the trimmed URL. +- *Postconditions*: Returns a fully qualified GraphQL endpoint URL or `null`; never throws. + +**GenerateGitHubChangelogLink** (private static): Builds a GitHub compare URL between two version +tags and wraps it in a `WebLink` record. + +- *Parameters*: + - `owner` (string) — repository owner. + - `repo` (string) — repository name. + - `oldTag` (string?) — the baseline tag; pass `null` when there is no baseline. + - `newTag` (string) — the target (current) tag. + - `branchTagNames` (HashSet\) — set of tag names present on the current branch. + - `webBaseUrl` (string) — the scheme-and-host portion of the repository's web URL + (e.g., `https://github.com` or `https://github.mycompany.com`), derived by + `DeriveWebBaseUrlFromConfig` or `DeriveWebBaseUrl`. +- *Returns*: `WebLink?` — a `WebLink` whose `TargetUrl` is + `{webBaseUrl}/{owner}/{repo}/compare/{oldTag}...{newTag}` and whose label is + `{oldTag}...{newTag}`; returns `null` when `oldTag` is `null` or when either tag is + absent from `branchTagNames`. +- *Algorithm*: (1) return `null` if `oldTag` is `null`; (2) return `null` if either tag is + not in `branchTagNames`; (3) construct comparison label and URL from `webBaseUrl`; (4) return + `new WebLink(label, url)`. +- *Postconditions*: Returns a non-null `WebLink` only when a meaningful comparison range + exists on the current branch; never throws. + ##### Error Handling `GetBuildInformationAsync` throws `InvalidOperationException` when no GitHub token can be resolved, diff --git a/docs/design/build-mark/repo-connectors/repo-connector-factory.md b/docs/design/build-mark/repo-connectors/repo-connector-factory.md index e3badd3..f4d30aa 100644 --- a/docs/design/build-mark/repo-connectors/repo-connector-factory.md +++ b/docs/design/build-mark/repo-connectors/repo-connector-factory.md @@ -41,6 +41,11 @@ bypassing environment-variable checks. `github.com`; `GitHubRepoConnector` as the default when `remoteUrl` is null or unrecognized. - *Preconditions*: None. - *Postconditions*: Returns a non-null `IRepoConnector`. +- *Note*: GitHub Enterprise Cloud (`*.ghe.com`) and GitHub Enterprise Server (on-premises) + remotes do not match the `github.com` substring check and therefore fall through to the + default `GitHubRepoConnector`. This is correct and expected behavior: `GitHubRepoConnector` + is host-agnostic and handles any GitHub remote regardless of hostname; the factory default + ensures that enterprise remotes are processed by the same connector as public GitHub. Exposed internally so that unit tests can exercise URL-based detection logic without requiring a real git process. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index 5cf7edd..aec409f 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -50,7 +50,8 @@ BuildMark (System) │ └── Validation (Unit) ├── Utilities (Subsystem) │ ├── PathHelpers (Unit) -│ └── ProcessRunner (Unit) +│ ├── ProcessRunner (Unit) +│ └── TemporaryDirectory (Unit) ├── Version (Subsystem) │ ├── VersionComparable (Unit) │ ├── VersionSemantic (Unit) @@ -110,7 +111,8 @@ src/DemaConsulting.BuildMark/ │ └── Validation.cs - self-validation test runner ├── Utilities/ │ ├── PathHelpers.cs - safe path combination utilities -│ └── ProcessRunner.cs - process runner for Git commands +│ ├── ProcessRunner.cs - process runner for Git commands +│ └── TemporaryDirectory.cs - temporary directory lifecycle management ├── Version/ │ ├── VersionComparable.cs - core integer-based version comparison │ ├── VersionSemantic.cs - semantic version with build metadata diff --git a/docs/reqstream/build-mark.yaml b/docs/reqstream/build-mark.yaml index 9a11f54..5e1d792 100644 --- a/docs/reqstream/build-mark.yaml +++ b/docs/reqstream/build-mark.yaml @@ -159,6 +159,18 @@ sections: - BuildMark-RepoConnectors-GitHub - BuildMark-Version-Subsystem + - id: BuildMark-GitHub-EnterpriseSupport + title: GitHub Enterprise support + justification: > + The tool shall support GitHub Enterprise Cloud and GitHub Enterprise Server + repositories by accepting SSH and HTTPS remote URLs with any hostname, + enabling owner and repository name resolution regardless of whether the host + is github.com, a GitHub Enterprise Cloud domain (*.ghe.com), or a GitHub + Enterprise Server on-premises instance. + children: + - BuildMark-GitHub-ParseUrl-SSH + - BuildMark-GitHub-ParseUrl-HTTPS + - title: Azure DevOps Integration requirements: - id: BuildMark-AzureDevOps-BuildNotes diff --git a/docs/reqstream/build-mark/repo-connectors/azure-devops.yaml b/docs/reqstream/build-mark/repo-connectors/azure-devops.yaml index 2a02b90..dd9dedb 100644 --- a/docs/reqstream/build-mark/repo-connectors/azure-devops.yaml +++ b/docs/reqstream/build-mark/repo-connectors/azure-devops.yaml @@ -24,7 +24,8 @@ sections: - AzureDevOps_GetBuildInformation_WithOpenWorkItems_IdentifiesKnownIssues - AzureDevOps_GetBuildInformation_ReleaseVersion_SkipsPreReleases children: - - BuildMark-AzureDevOps-UrlParsing + - BuildMark-AzureDevOps-UrlParsing-HTTPS + - BuildMark-AzureDevOps-UrlParsing-SSH - BuildMark-AzureDevOps-ConnectorConfig - BuildMark-AzureDevOps-BuildInformation - BuildMark-AzureDevOps-ItemControls diff --git a/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml b/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml index 0e8bfdc..28ecb55 100644 --- a/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml +++ b/docs/reqstream/build-mark/repo-connectors/azure-devops/azure-devops-repo-connector.yaml @@ -10,25 +10,34 @@ sections: sections: - title: AzureDevOpsRepoConnector Requirements requirements: - - id: BuildMark-AzureDevOps-UrlParsing + - id: BuildMark-AzureDevOps-UrlParsing-HTTPS title: >- The AzureDevOpsRepoConnector class shall parse organization URL, project, and repository - from Azure DevOps Services and on-premises Azure DevOps Server git remote URLs. + from Azure DevOps HTTPS remote URLs, supporting Azure DevOps Services (dev.azure.com, + visualstudio.com) and on-premises Azure DevOps Server instances. justification: | - The connector must support both cloud Azure DevOps Services (dev.azure.com, - visualstudio.com) and on-premises Azure DevOps Server instances. A unified URL - parser using the _git path segment anchor handles all HTTPS URL formats, while - SSH URLs use the git@ssh.dev.azure.com:v3 format. + The connector must support HTTPS URL formats used by cloud Azure DevOps Services + (dev.azure.com, visualstudio.com) and on-premises Azure DevOps Server instances. + A unified URL parser using the _git path segment anchor handles all HTTPS URL formats. tests: - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_DevAzureComHttps_ReturnsCorrectComponents - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_DevAzureComWithGitSuffix_StripsGitSuffix - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_VisualStudioComHttps_ReturnsCorrectComponents - - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_SshUrl_ReturnsCorrectComponents - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_OnPremServer_ReturnsCorrectComponents - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_OnPremWithPort_ReturnsCorrectComponents - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_OnPremWithGitSuffix_StripsGitSuffix - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_UnsupportedFormat_ThrowsArgumentException + - id: BuildMark-AzureDevOps-UrlParsing-SSH + title: >- + The AzureDevOpsRepoConnector class shall parse organization URL, project, and repository + from Azure DevOps SSH remote URLs (git@ssh.dev.azure.com:v3 format). + justification: | + The connector must support the SSH URL format used by Azure DevOps Services + (git@ssh.dev.azure.com:v3/org/project/repo) in addition to HTTPS formats. + tests: + - AzureDevOpsRepoConnector_ParseAzureDevOpsUrl_SshUrl_ReturnsCorrectComponents + - id: BuildMark-AzureDevOps-ConnectorConfig title: >- The AzureDevOpsRepoConnector class shall accept an optional AzureDevOpsConnectorConfig diff --git a/docs/reqstream/build-mark/repo-connectors/github.yaml b/docs/reqstream/build-mark/repo-connectors/github.yaml index 139df65..ad1d677 100644 --- a/docs/reqstream/build-mark/repo-connectors/github.yaml +++ b/docs/reqstream/build-mark/repo-connectors/github.yaml @@ -32,3 +32,5 @@ sections: - BuildMark-GitHub-GraphQLClient - BuildMark-GitHub-Rules - BuildMark-GitHub-TokenVariable + - BuildMark-GitHub-ParseUrl-SSH + - BuildMark-GitHub-ParseUrl-HTTPS diff --git a/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml b/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml index 9ee0e57..5772a75 100644 --- a/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml +++ b/docs/reqstream/build-mark/repo-connectors/github/github-repo-connector.yaml @@ -81,6 +81,32 @@ sections: - GitHubRepoConnector_Configure_WithRules_HasRulesReturnsTrue - GitHubRepoConnector_GetBuildInformationAsync_WithConfiguredRules_PopulatesRoutedSections + - id: BuildMark-GitHub-ParseUrl-SSH + title: GitHub SSH URL parsing + justification: > + The GitHubRepoConnector class shall parse owner and repository name from + SSH-format GitHub remote URLs (git@:owner/repo.git), supporting + github.com, GitHub Enterprise Cloud (*.ghe.com), and GitHub Enterprise + Server (on-premises) hosts. + tests: + - GitHubRepoConnector_ParseGitHubUrl_GitHubCom_SSH_ReturnsOwnerAndRepo + - GitHubRepoConnector_ParseGitHubUrl_GHEServer_SSH_ReturnsOwnerAndRepo + + - id: BuildMark-GitHub-ParseUrl-HTTPS + title: GitHub HTTPS URL parsing + justification: > + The GitHubRepoConnector class shall parse owner and repository name from + HTTPS-format GitHub remote URLs (https:///owner/repo), supporting + github.com, GitHub Enterprise Cloud (*.ghe.com), and GitHub Enterprise + Server (on-premises) hosts. + tests: + - GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_ReturnsOwnerAndRepo + - GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_WithGitSuffix_ReturnsOwnerAndRepo + - GitHubRepoConnector_ParseGitHubUrl_GHECloud_HTTPS_ReturnsOwnerAndRepo + - GitHubRepoConnector_ParseGitHubUrl_GHEServer_HTTPS_ReturnsOwnerAndRepo + - GitHubRepoConnector_ParseGitHubUrl_Invalid_ThrowsArgumentException + - GitHubRepoConnector_GetBuildInformationAsync_GHERemote_ChangelogUrlUsesGHEHost + - id: BuildMark-GitHub-TokenVariable title: >- The GitHubRepoConnector class shall use only the configured token variable when diff --git a/docs/verification/build-mark.md b/docs/verification/build-mark.md index 93baedd..43766d0 100644 --- a/docs/verification/build-mark.md +++ b/docs/verification/build-mark.md @@ -128,3 +128,15 @@ that when the connector factory throws `InvalidOperationException`, an error mes to stderr and the exit code is 1. This scenario is tested by `Program_Run_ConnectorThrowsInvalidOperationException_WritesErrorAndSetsExitCode`. + +**GitHub_EnterpriseSupport_HostAgnosticUrlParsing**: Verifies that `GitHubRepoConnector` correctly +parses owner and repository name from SSH and HTTPS remote URLs using any hostname — covering +github.com (`GitHubRepoConnector_ParseGitHubUrl_GitHubCom_SSH_ReturnsOwnerAndRepo`, +`GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_ReturnsOwnerAndRepo`), GitHub Enterprise +Cloud (`GitHubRepoConnector_ParseGitHubUrl_GHECloud_HTTPS_ReturnsOwnerAndRepo`), and GitHub +Enterprise Server on-premises (`GitHubRepoConnector_ParseGitHubUrl_GHEServer_HTTPS_ReturnsOwnerAndRepo`, +`GitHubRepoConnector_ParseGitHubUrl_GHEServer_SSH_ReturnsOwnerAndRepo`). Additionally, +`GitHubRepoConnector_GetBuildInformationAsync_GHERemote_ChangelogUrlUsesGHEHost` verifies that the +generated changelog URL uses the GHE hostname rather than a hardcoded `github.com` value, confirming +end-to-end enterprise server support. This collection of scenarios provides system-level coverage for +`BuildMark-GitHub-EnterpriseSupport`. diff --git a/docs/verification/build-mark/repo-connectors/github.md b/docs/verification/build-mark/repo-connectors/github.md index f72f8d2..e206e93 100644 --- a/docs/verification/build-mark/repo-connectors/github.md +++ b/docs/verification/build-mark/repo-connectors/github.md @@ -3,7 +3,7 @@ #### Verification Approach The GitHub sub-subsystem is verified through `GitHubTests.cs` (6 subsystem-level tests), -`GitHubRepoConnectorTests.cs` (27 unit tests), and six `GitHubGraphQLClient*Tests.cs` files +`GitHubRepoConnectorTests.cs` (35 unit tests), and six `GitHubGraphQLClient*Tests.cs` files (49 tests). The subsystem tests exercise the full GitHub data pipeline through mock HTTP responses. The unit tests are described in the individual unit chapters. diff --git a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md index 2eb82ef..6c03b3a 100644 --- a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md +++ b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md @@ -1,12 +1,13 @@ -#### GitHubRepoConnector +##### GitHubRepoConnector ##### Verification Approach `GitHubRepoConnector` is tested through `GitHubRepoConnectorTests.cs`, which contains -25 unit tests. The tests exercise constructor behavior (with and without config), -the full `GetBuildInformationAsync` pipeline with various scenarios, visibility and -type overrides, routing configuration, known issues filtering by affected versions, -and edge cases such as duplicate commit SHAs and substring label matching. +35 unit tests. The tests exercise constructor behavior (with and without config), +the full `GetBuildInformationAsync` pipeline with various scenarios, URL parsing for +github.com, GitHub Enterprise Cloud, and GitHub Enterprise Server remotes (SSH and HTTPS), +visibility and type overrides, routing configuration, known issues filtering by affected +versions, and edge cases such as duplicate commit SHAs and substring label matching. ##### Dependencies @@ -30,7 +31,7 @@ All tests in the test class pass with no errors or warnings. **Expected**: Instance is created without error. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ConnectorConfig` ###### GitHubRepoConnector_Constructor_WithConfig_StoresConfigurationOverrides @@ -40,7 +41,7 @@ All tests in the test class pass with no errors or warnings. `ConfigurationOverrides.Repo` equals `"example-repo"`; `ConfigurationOverrides.BaseUrl` equals `"https://api.github.com"`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ConnectorConfig` ###### GitHubRepoConnector_ImplementsInterface_ReturnsTrue @@ -48,7 +49,7 @@ equals `"https://api.github.com"`. **Expected**: Returns `true`. -**Requirement coverage**: `BuildMark-RepoConnectorBase-Interface` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithMockedData_ReturnsValidBuildInformation @@ -57,7 +58,7 @@ equals `"https://api.github.com"`. **Expected**: Returns a `BuildInformation` instance with correct version, baseline, changes, and known issues. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithMultipleVersions_SelectsCorrectPreviousVersionAndGeneratesChangelogLink @@ -65,7 +66,7 @@ changes, and known issues. **Expected**: Selects the correct previous release and generates a GitHub changelog link. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithPullRequests_GathersChangesCorrectly @@ -74,7 +75,7 @@ changes, and known issues. **Expected**: Each pull request is represented as a change item with correct type classification. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithOpenIssues_IdentifiesKnownIssues @@ -82,7 +83,7 @@ classification. **Expected**: Open issues appear in `BuildInformation.KnownIssues`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_PreReleaseWithSameCommitHash_SkipsToNextDifferentHash @@ -90,7 +91,7 @@ classification. **Expected**: Connector skips to the next tag with a different commit hash. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_ReleaseVersion_SkipsAllPreReleases @@ -98,7 +99,7 @@ classification. **Expected**: Pre-release tags are skipped; baseline is the previous release. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_PreReleaseNotInHistory_UsesLatestDifferentHash @@ -106,7 +107,7 @@ classification. **Expected**: Uses the latest tag with a different commit hash as baseline. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_PreReleaseAllPreviousSameHash_ReturnsNullBaseline @@ -114,7 +115,7 @@ classification. **Expected**: `BuildInformation.BaselineVersion` is `null`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithDuplicateMergeCommitSha_DoesNotThrow @@ -122,7 +123,7 @@ classification. **Expected**: No exception is thrown; result is returned normally. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_PrWithSubstringMatchLabel_NotClassifiedAsBug @@ -130,7 +131,7 @@ classification. **Expected**: Pull request is not classified as a bug. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_IssueWithSubstringMatchLabel_NotClassifiedAsKnownIssue @@ -138,7 +139,7 @@ classification. **Expected**: Issue is not classified as a known issue. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_VisibilityInternal_ExcludesItem @@ -146,7 +147,7 @@ classification. **Expected**: Item is excluded from the public output. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ItemControls` ###### GitHubRepoConnector_GetBuildInformationAsync_VisibilityPublic_IncludesItem @@ -154,7 +155,7 @@ classification. **Expected**: Item is included in the output. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ItemControls` ###### GitHubRepoConnector_GetBuildInformationAsync_TypeBugOverride_ClassifiesAsBug @@ -162,7 +163,7 @@ classification. **Expected**: Item is classified as a bug regardless of labels. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ItemControls` ###### GitHubRepoConnector_GetBuildInformationAsync_TypeFeatureOverride_ClassifiesAsFeature @@ -170,7 +171,7 @@ classification. **Expected**: Item is classified as a feature regardless of labels. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-ItemControls` ###### GitHubRepoConnector_Configure_WithRules_HasRulesReturnsTrue @@ -178,7 +179,7 @@ classification. **Expected**: `HasRules` returns `true`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-Rules` ###### GitHubRepoConnector_GetBuildInformationAsync_WithConfiguredRules_PopulatesRoutedSections @@ -186,7 +187,7 @@ classification. **Expected**: `BuildInformation.RoutedSections` is populated with items routed per rules. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-Rules` ###### GitHubRepoConnector_GetBuildInformationAsync_KnownIssues_FilteredByAffectedVersions @@ -194,7 +195,7 @@ classification. **Expected**: Issues outside the affected version range are excluded. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_ClosedBugWithMatchingAffectedVersions_IsKnownIssue @@ -202,7 +203,7 @@ classification. **Expected**: Closed bug appears in `KnownIssues`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithTokenVariable_UsesCustomVariable @@ -231,3 +232,75 @@ corresponding environment variable is not set (null). **Expected**: `InvalidOperationException` is thrown. **Requirement coverage**: `BuildMark-GitHub-TokenVariable` + +###### GitHubRepoConnector_ParseGitHubUrl_GitHubCom_SSH_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a standard github.com SSH URL +(`git@github.com:owner/repo.git`). + +**Expected**: Returns owner `"owner"` and repo `"repo"`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-SSH` + +###### GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a standard github.com HTTPS URL +(`https://github.com/owner/repo`). + +**Expected**: Returns owner `"owner"` and repo `"repo"`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` + +###### GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_WithGitSuffix_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a github.com HTTPS URL that includes a `.git` suffix +(`https://github.com/owner/repo.git`). + +**Expected**: Returns owner `"owner"` and repo `"repo"` with the `.git` suffix stripped. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` + +###### GitHubRepoConnector_ParseGitHubUrl_GHECloud_HTTPS_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a GitHub Enterprise Cloud HTTPS URL +(`https://hiarc.ghe.com/BreakAway/PyStubGenerator`). + +**Expected**: Returns owner `"BreakAway"` and repo `"PyStubGenerator"`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` + +###### GitHubRepoConnector_ParseGitHubUrl_GHEServer_HTTPS_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a GitHub Enterprise Server on-premises HTTPS URL +(`https://github.mycompany.com/myorg/myrepo`). + +**Expected**: Returns owner `"myorg"` and repo `"myrepo"`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` + +###### GitHubRepoConnector_ParseGitHubUrl_GHEServer_SSH_ReturnsOwnerAndRepo + +**Scenario**: `ParseGitHubUrl` is called with a GitHub Enterprise Server on-premises SSH URL +(`git@github.mycompany.com:myorg/myrepo.git`). + +**Expected**: Returns owner `"myorg"` and repo `"myrepo"`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-SSH` + +###### GitHubRepoConnector_ParseGitHubUrl_Invalid_ThrowsArgumentException + +**Scenario**: `ParseGitHubUrl` is called with an unrecognized string (`not-a-url`). + +**Expected**: `ArgumentException` is thrown. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` + +###### GitHubRepoConnector_GetBuildInformationAsync_GHERemote_ChangelogUrlUsesGHEHost + +**Scenario**: `GetBuildInformationAsync` is called with a GitHub Enterprise Server HTTPS remote +URL (`https://github.mycompany.com/myorg/myrepo.git`). + +**Expected**: `CompleteChangelogLink.TargetUrl` starts with `https://github.mycompany.com/`, +confirming the changelog URL uses the GHE hostname instead of the hardcoded `github.com`. + +**Requirement coverage**: `BuildMark-GitHub-ParseUrl-HTTPS` diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs index 62c9699..88474ae 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs @@ -111,7 +111,7 @@ internal virtual GitHubGraphQLClient CreateGraphQLClient(string token) /// /// Gets build information for a release. /// - /// Optional target version. If not provided, uses the most recent tag if it matches current commit. + /// Optional target version. If not provided, uses the most recent GitHub Release whose tag matches the current commit hash. /// BuildInformation record with all collected data. /// Thrown if version cannot be determined. public override async Task GetBuildInformationAsync(VersionTag? version = null) @@ -190,8 +190,11 @@ public override async Task GetBuildInformationAsync(VersionTag ? new VersionCommitTag(fromVersion, fromHash) : null; + // Determine the web base URL for the changelog link + var webBaseUrl = DeriveWebBaseUrlFromConfig() ?? DeriveWebBaseUrl(repoUrl); + // Generate full changelog link for GitHub - var changelogLink = GenerateGitHubChangelogLink(owner, repo, fromVersion?.Tag, toVersion.Tag, lookupData.BranchTagNames); + var changelogLink = GenerateGitHubChangelogLink(owner, repo, fromVersion?.Tag, toVersion.Tag, lookupData.BranchTagNames, webBaseUrl); // Create and return build information with all collected data return new BuildInformation( @@ -236,6 +239,87 @@ public override async Task GetBuildInformationAsync(VersionTag return $"{trimmed}/api/graphql"; } + /// + /// Derives the web base URL from the configured . + /// Strips API-specific paths and subdomains (e.g., api. prefix, /api/v3 path) + /// to produce a plain https://<host> URL suitable for building web links. + /// + /// + /// The derived web base URL (e.g., https://github.com or + /// https://github.mycompany.com), or when + /// is null or empty. + /// + private string? DeriveWebBaseUrlFromConfig() + { + var baseUrl = _config?.BaseUrl; + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return null; + } + + // Parse the URL to extract just scheme://host (dropping any path such as /api/v3) + if (!Uri.TryCreate(baseUrl.TrimEnd('/'), UriKind.Absolute, out var uri)) + { + return null; + } + + var host = uri.Host; + + // Strip the "api." subdomain prefix when present (e.g., api.github.com → github.com) + if (host.StartsWith("api.", StringComparison.OrdinalIgnoreCase)) + { + host = host["api.".Length..]; + } + + return $"{uri.Scheme}://{host}"; + } + + /// + /// Derives the web base URL from a git remote URL by extracting the scheme and hostname. + /// + /// + /// Git remote URL in SSH (git@<host>:owner/repo.git) or HTTPS + /// (https://<host>/owner/repo) format. + /// + /// + /// https://<host> extracted from the remote URL, or + /// https://github.com as the default fallback. + /// + private static string DeriveWebBaseUrl(string remoteUrl) + { + remoteUrl = remoteUrl.Trim(); + + // SSH format: git@:owner/repo.git → https:// + if (remoteUrl.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) + { + var atIndex = remoteUrl.IndexOf('@', StringComparison.Ordinal); + var colonIndex = remoteUrl.IndexOf(':', atIndex + 1, StringComparison.Ordinal); + if (atIndex >= 0 && colonIndex > atIndex) + { + var host = remoteUrl[(atIndex + 1)..colonIndex]; + return $"https://{host}"; + } + } + + // HTTPS format: https:///... → https:// + if (remoteUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + var withoutScheme = remoteUrl["https://".Length..]; + var slashIndex = withoutScheme.IndexOf('/', StringComparison.Ordinal); + if (slashIndex >= 0) + { + var host = withoutScheme[..slashIndex]; + return $"https://{host}"; + } + + // No path segment — use the whole URL as-is + return remoteUrl; + } + + // Default fallback + return "https://github.com"; + } + /// /// Simple commit representation containing only the SHA hash. /// @@ -1042,28 +1126,65 @@ private async Task GetGitHubTokenAsync() } /// - /// Parses GitHub owner and repo from a git remote URL. + /// Parses the repository owner and name from a git remote URL. /// - /// Git remote URL. - /// Tuple of (owner, repo). - /// Thrown if URL format is invalid. + /// + /// Supports SSH format (git@<host>:owner/repo.git) and HTTPS format + /// (https://<host>/owner/repo). The hostname is not validated, so + /// github.com, GitHub Enterprise Cloud (*.ghe.com), and GitHub Enterprise + /// Server (on-premises) remotes are all accepted. For SSH URLs the path segment + /// after the colon is parsed. For HTTPS URLs the last two non-empty path segments + /// are used as owner and repository name. The .git suffix is stripped from + /// the repository name when present. + /// + /// + /// Git remote URL in SSH (git@<host>:owner/repo.git) or HTTPS + /// (https://<host>/owner/repo) format. Leading and trailing whitespace + /// is trimmed before parsing. + /// + /// + /// A tuple of (owner, repo) extracted from the URL, with the + /// .git suffix removed from the repository name. + /// + /// + /// Thrown when is not a recognized SSH or HTTPS remote URL, + /// or when the path segment does not contain exactly two slash-separated components. + /// private static (string owner, string repo) ParseGitHubUrl(string url) { // Normalize URL by trimming whitespace url = url.Trim(); - // Handle SSH URLs: git@github.com:owner/repo.git - if (url.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) + // Handle SSH URLs: git@:owner/repo.git + // The hostname is not validated so that GitHub Enterprise Server remotes are also accepted + if (url.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) { - var path = url["git@github.com:".Length..]; - return ParseOwnerRepo(path); + // Extract the path component after the colon separator + var colonIndex = url.IndexOf(':', StringComparison.Ordinal); + if (colonIndex > 0) + { + var path = url[(colonIndex + 1)..]; + return ParseOwnerRepo(path); + } } - // Handle HTTPS URLs: https://github.com/owner/repo.git - if (url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase)) + // Handle HTTPS URLs: https:///owner/repo[.git] + // Strip the scheme and host, then take the last two non-empty path segments + if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - var path = url["https://github.com/".Length..]; - return ParseOwnerRepo(path); + // Remove scheme and locate the start of the path (first slash after the host) + var withoutScheme = url["https://".Length..]; + var slashIndex = withoutScheme.IndexOf('/', StringComparison.Ordinal); + if (slashIndex >= 0) + { + // Split the path and take the last two non-empty segments as owner and repo + var segments = withoutScheme[(slashIndex + 1)..].Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length >= 2) + { + var ownerRepoPart = $"{segments[^2]}/{segments[^1]}"; + return ParseOwnerRepo(ownerRepoPart); + } + } } throw new ArgumentException($"Unsupported GitHub URL format: {url}", nameof(url)); @@ -1102,8 +1223,9 @@ private static (string owner, string repo) ParseOwnerRepo(string path) /// Old tag name (null if from beginning). /// New tag name. /// Set of tag names on the current branch. + /// Web base URL for the repository host (e.g., https://github.com). /// WebLink to GitHub compare page, or null if no baseline tag or if tags not found in branch. - private static WebLink? GenerateGitHubChangelogLink(string owner, string repo, string? oldTag, string newTag, HashSet branchTagNames) + private static WebLink? GenerateGitHubChangelogLink(string owner, string repo, string? oldTag, string newTag, HashSet branchTagNames, string webBaseUrl) { // Cannot generate comparison link without a baseline tag if (oldTag == null) @@ -1117,9 +1239,9 @@ private static (string owner, string repo) ParseOwnerRepo(string path) return null; } - // Build comparison label and URL + // Build comparison label and URL using the resolved host base URL var comparisonLabel = $"{oldTag}...{newTag}"; - var comparisonUrl = $"https://github.com/{owner}/{repo}/compare/{comparisonLabel}"; + var comparisonUrl = $"{webBaseUrl}/{owner}/{repo}/compare/{comparisonLabel}"; return new WebLink(comparisonLabel, comparisonUrl); } diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs index 7b4c77e..08ec25d 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubRepoConnectorTests.cs @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System.Reflection; using DemaConsulting.BuildMark.Configuration; using DemaConsulting.BuildMark.RepoConnectors; using DemaConsulting.BuildMark.RepoConnectors.GitHub; @@ -37,10 +38,13 @@ public class GitHubRepoConnectorTests [Fact] public void GitHubRepoConnector_Constructor_CreatesInstance() { - // Create connector + // Arrange + // (no setup required) + + // Act var connector = new GitHubRepoConnector(); - // Verify instance + // Assert Assert.NotNull(connector); Assert.IsAssignableFrom(connector); } @@ -75,10 +79,13 @@ public void GitHubRepoConnector_Constructor_WithConfig_StoresConfigurationOverri [Fact] public void GitHubRepoConnector_ImplementsInterface_ReturnsTrue() { - // Create connector + // Arrange + // (no setup required) + + // Act var connector = new GitHubRepoConnector(); - // Verify interface implementation + // Assert Assert.IsAssignableFrom(connector); } @@ -1155,6 +1162,171 @@ await Assert.ThrowsAsync( Environment.SetEnvironmentVariable(varName, savedValue); } } -} + /// + /// Validates that a github.com SSH URL is parsed into the correct owner and repository name. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GitHubCom_SSH_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a standard github.com SSH URL + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "git@github.com:owner/repo.git" })!; + + // Assert: owner and repo are extracted correctly + Assert.Equal("owner", result.owner); + Assert.Equal("repo", result.repo); + } + + /// + /// Validates that a github.com HTTPS URL is parsed into the correct owner and repository name. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a standard github.com HTTPS URL + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "https://github.com/owner/repo" })!; + + // Assert: owner and repo are extracted correctly + Assert.Equal("owner", result.owner); + Assert.Equal("repo", result.repo); + } + + /// + /// Validates that a github.com HTTPS URL with a .git suffix is parsed correctly, stripping the suffix. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GitHubCom_HTTPS_WithGitSuffix_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a github.com HTTPS URL that includes a .git suffix + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "https://github.com/owner/repo.git" })!; + + // Assert: .git suffix is stripped and owner and repo are returned correctly + Assert.Equal("owner", result.owner); + Assert.Equal("repo", result.repo); + } + + /// + /// Validates that a GitHub Enterprise Cloud HTTPS URL is parsed into the correct owner and repository name. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GHECloud_HTTPS_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a GitHub Enterprise Cloud (*.ghe.com) HTTPS URL + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "https://hiarc.ghe.com/BreakAway/PyStubGenerator" })!; + + // Assert: owner and repo are extracted correctly from the GHE Cloud hostname + Assert.Equal("BreakAway", result.owner); + Assert.Equal("PyStubGenerator", result.repo); + } + + /// + /// Validates that a GitHub Enterprise Server HTTPS URL is parsed into the correct owner and repository name. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GHEServer_HTTPS_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a GitHub Enterprise Server (on-premises) HTTPS URL + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "https://github.mycompany.com/myorg/myrepo" })!; + // Assert: owner and repo are extracted correctly from the on-premises hostname + Assert.Equal("myorg", result.owner); + Assert.Equal("myrepo", result.repo); + } + + /// + /// Validates that a GitHub Enterprise Server SSH URL is parsed into the correct owner and repository name. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_GHEServer_SSH_ReturnsOwnerAndRepo() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act: invoke ParseGitHubUrl with a GitHub Enterprise Server (on-premises) SSH URL + var result = ((string owner, string repo))method!.Invoke(null, new object[] { "git@github.mycompany.com:myorg/myrepo.git" })!; + + // Assert: owner and repo are extracted correctly from the on-premises SSH remote + Assert.Equal("myorg", result.owner); + Assert.Equal("myrepo", result.repo); + } + + /// + /// Validates that an unrecognized URL format causes ParseGitHubUrl to throw ArgumentException. + /// + [Fact] + public void GitHubRepoConnector_ParseGitHubUrl_Invalid_ThrowsArgumentException() + { + // Arrange: obtain the private static method via reflection + var method = typeof(GitHubRepoConnector).GetMethod( + "ParseGitHubUrl", + BindingFlags.NonPublic | BindingFlags.Static); + + // Act / Assert: invoking ParseGitHubUrl with an invalid URL must throw ArgumentException + // (TargetInvocationException wraps the inner ArgumentException when using reflection) + var ex = Assert.Throws( + () => method!.Invoke(null, new object[] { "not-a-url" })); + Assert.IsType(ex.InnerException); + } + + /// + /// Test that GetBuildInformationAsync generates a changelog URL using the GHE hostname + /// when the git remote URL points to a GitHub Enterprise Server instance. + /// + [Fact] + public async Task GitHubRepoConnector_GetBuildInformationAsync_GHERemote_ChangelogUrlUsesGHEHost() + { + // Arrange + using var mockHandler = new MockGitHubGraphQLHttpMessageHandler() + .AddCommitsResponse("commit2", "commit1") + .AddReleasesResponse( + new MockRelease("v1.1.0", "2024-02-01T00:00:00Z"), + new MockRelease("v1.0.0", "2024-01-01T00:00:00Z")) + .AddPullRequestsResponse() + .AddIssuesResponse() + .AddTagsResponse( + new MockTag("v1.1.0", "commit2"), + new MockTag("v1.0.0", "commit1")); + + using var mockHttpClient = new HttpClient(mockHandler); + var connector = new MockableGitHubRepoConnector(mockHttpClient); + + connector.SetCommandResponse("git remote get-url origin", "https://github.mycompany.com/myorg/myrepo.git"); + connector.SetCommandResponse("git rev-parse --abbrev-ref HEAD", "main"); + connector.SetCommandResponse("git rev-parse HEAD", "commit2"); + connector.SetCommandResponse("gh auth token", "test-token"); + + // Act + var buildInfo = await connector.GetBuildInformationAsync(VersionTag.Create("v1.1.0")); + + // Assert + Assert.NotNull(buildInfo.CompleteChangelogLink); + Assert.StartsWith("https://github.mycompany.com/", buildInfo.CompleteChangelogLink.TargetUrl); + } +} From 8570f320c19a650d3774ba7fb0df32daaa874e22 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 11 Jun 2026 11:36:21 -0400 Subject: [PATCH 2/3] Address PR review feedback - Fix DeriveWebBaseUrlFromConfig to use uri.Authority instead of uri.Host, preserving non-default ports (e.g., ghe.example.com:8443) - Fix top-level heading in git-hub-repo-connector.md from ##### to #### to be consistent with other connector verification chapters - Fix ImplementsInterface_ReturnsTrue requirement coverage from BuildMark-GitHub-BuildInformation to BuildMark-RepoConnectorBase-Interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repo-connectors/github/git-hub-repo-connector.md | 4 ++-- .../RepoConnectors/GitHub/GitHubRepoConnector.cs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md index 6c03b3a..139dbc2 100644 --- a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md +++ b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md @@ -1,4 +1,4 @@ -##### GitHubRepoConnector +#### GitHubRepoConnector ##### Verification Approach @@ -49,7 +49,7 @@ equals `"https://api.github.com"`. **Expected**: Returns `true`. -**Requirement coverage**: `BuildMark-GitHub-BuildInformation` +**Requirement coverage**: `BuildMark-RepoConnectorBase-Interface` ###### GitHubRepoConnector_GetBuildInformationAsync_WithMockedData_ReturnsValidBuildInformation diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs index 88474ae..d429205 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs @@ -263,15 +263,16 @@ public override async Task GetBuildInformationAsync(VersionTag return null; } - var host = uri.Host; + // Use Authority (host + port) to preserve non-default ports (e.g., ghe.example.com:8443) + var authority = uri.Authority; // Strip the "api." subdomain prefix when present (e.g., api.github.com → github.com) - if (host.StartsWith("api.", StringComparison.OrdinalIgnoreCase)) + if (authority.StartsWith("api.", StringComparison.OrdinalIgnoreCase)) { - host = host["api.".Length..]; + authority = authority["api.".Length..]; } - return $"{uri.Scheme}://{host}"; + return $"{uri.Scheme}://{authority}"; } /// From 093571b3580a3b000e79e6a323f9918adbc9ec4b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 11 Jun 2026 11:51:18 -0400 Subject: [PATCH 3/3] Address second round of PR review feedback - Fix ParseGitHubUrl XmlDoc exception description to accurately reflect that HTTPS paths use the last two segments (not exactly two required) - Fix broken line wrap in git-hub-repo-connector.md Verification Approach paragraph (was splitting 'which contains' from '35 unit tests') Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repo-connectors/github/git-hub-repo-connector.md | 3 +-- .../RepoConnectors/GitHub/GitHubRepoConnector.cs | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md index 139dbc2..bed2eec 100644 --- a/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md +++ b/docs/verification/build-mark/repo-connectors/github/git-hub-repo-connector.md @@ -2,8 +2,7 @@ ##### Verification Approach -`GitHubRepoConnector` is tested through `GitHubRepoConnectorTests.cs`, which contains -35 unit tests. The tests exercise constructor behavior (with and without config), +`GitHubRepoConnector` is tested through `GitHubRepoConnectorTests.cs`, which contains 35 unit tests. The tests exercise constructor behavior (with and without config), the full `GetBuildInformationAsync` pipeline with various scenarios, URL parsing for github.com, GitHub Enterprise Cloud, and GitHub Enterprise Server remotes (SSH and HTTPS), visibility and type overrides, routing configuration, known issues filtering by affected diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs index d429205..329483f 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubRepoConnector.cs @@ -1149,7 +1149,8 @@ private async Task GetGitHubTokenAsync() /// /// /// Thrown when is not a recognized SSH or HTTPS remote URL, - /// or when the path segment does not contain exactly two slash-separated components. + /// or when the HTTPS path contains fewer than two segments (SSH paths must contain + /// exactly two slash-separated components; HTTPS paths use the last two segments). /// private static (string owner, string repo) ParseGitHubUrl(string url) {