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..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,11 +2,11 @@ ##### 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. +`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 +versions, and edge cases such as duplicate commit SHAs and substring label matching. ##### Dependencies @@ -30,7 +30,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 +40,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 @@ -57,7 +57,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 +65,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 +74,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 +82,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 +90,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 +98,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 +106,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 +114,7 @@ classification. **Expected**: `BuildInformation.BaselineVersion` is `null`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithDuplicateMergeCommitSha_DoesNotThrow @@ -122,7 +122,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 +130,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 +138,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 +146,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 +154,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 +162,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 +170,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 +178,7 @@ classification. **Expected**: `HasRules` returns `true`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-Rules` ###### GitHubRepoConnector_GetBuildInformationAsync_WithConfiguredRules_PopulatesRoutedSections @@ -186,7 +186,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 +194,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 +202,7 @@ classification. **Expected**: Closed bug appears in `KnownIssues`. -**Requirement coverage**: `BuildMark-RepoConnectors-GitHub` +**Requirement coverage**: `BuildMark-GitHub-BuildInformation` ###### GitHubRepoConnector_GetBuildInformationAsync_WithTokenVariable_UsesCustomVariable @@ -231,3 +231,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..329483f 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,88 @@ 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; + } + + // 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 (authority.StartsWith("api.", StringComparison.OrdinalIgnoreCase)) + { + authority = authority["api.".Length..]; + } + + return $"{uri.Scheme}://{authority}"; + } + + /// + /// 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 +1127,66 @@ 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 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) { // 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 +1225,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 +1241,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); + } +}