Skip to content

Commit 0d85875

Browse files
authored
Merge branch 'main' into copilot/fix-1286
2 parents 3d315b1 + d7997e4 commit 0d85875

22 files changed

+587
-143
lines changed

.github/copilot-instructions.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
This is a C# based repository that produces several CLIs that are used by customers to interact with the GitHub migration APIs. Please follow these guidelines when contributing:
2+
3+
## Code Standards
4+
5+
### Required Before Each Commit
6+
- Run `dotnet format src/OctoshiftCLI.sln` before committing any changes to ensure proper code formatting. This will run dotnet format on all C# files to maintain consistent style
7+
8+
### Development Flow
9+
- Build: `dotnet build src/OctoshiftCLI.sln /p:TreatWarningsAsErrors=true`
10+
- Test: `dotnet test src/OctoshiftCLI.Tests/OctoshiftCLI.Tests.csproj`
11+
12+
## Repository Structure
13+
- `src/`: Contains the main C# source code for the Octoshift CLI
14+
- `src/ado2gh/`: Contains the ADO to GH CLI commands
15+
- `src/bbs2gh/`: Contains the BBS to GH CLI commands
16+
- `src/gei/`: Contains the GitHub to GitHub CLI commands
17+
- `src/Octoshift/`: Contains shared logic used by multiple commands/CLIs
18+
- `src/OctoshiftCLI.IntegrationTests/`: Contains integration tests for the Octoshift CLI
19+
- `src/OctoshiftCLI.Tests/`: Contains unit tests for the Octoshift CLI
20+
21+
## Key Guidelines
22+
1. Follow C# best practices and idiomatic patterns
23+
2. Maintain existing code structure and organization
24+
4. Write unit tests for new functionality.
25+
5. When making changes that would impact our users (e.g. new features or bug fixes), add a bullet point to `RELEASENOTES.md` with a user friendly brief description of the change
26+
6. Never silently swallow exceptions.
27+
7. If an exception is expected/understood and we can give a helpful user-friendly message, then throw an OctoshiftCliException with a user-friendly message. Otherwise let the exception bubble up and the top-level exception handler will log and handle it appropriately.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Setup Development Environment
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
paths:
7+
- .github/workflows/copilot-setup-steps.yml
8+
pull_request:
9+
paths:
10+
- .github/workflows/copilot-setup-steps.yml
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
# The job name MUST be 'copilot-setup-steps' to be picked up by GitHub Copilot
17+
copilot-setup-steps:
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Setup .NET
24+
uses: actions/setup-dotnet@v2
25+
with:
26+
global-json-file: global.json
27+
28+
- name: Restore dependencies
29+
run: dotnet restore src/OctoshiftCLI.sln

.github/workflows/integration-tests.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ on:
66
pr_number:
77
type: number
88
required: true
9+
sha:
10+
type: string
11+
required: true
12+
13+
permissions:
14+
contents: read
15+
checks: write
16+
pull-requests: write
917

1018
jobs:
1119
build-for-e2e-test:
@@ -20,6 +28,19 @@ jobs:
2028
ref: 'refs/pull/${{ github.event.inputs.pr_number }}/merge'
2129
fetch-depth: 0
2230

31+
- name: Check SHA
32+
run: |
33+
git fetch origin refs/pull/${{ github.event.inputs.pr_number }}/head:pr-head
34+
prsha=`git rev-parse pr-head | awk '{ print $1 }'`
35+
36+
echo "PR SHA: $prsha"
37+
echo "expected SHA: ${{ github.event.inputs.SHA }}"
38+
39+
if [ "$prsha" != "${{ github.event.inputs.SHA }}" ]; then
40+
echo "SHA must match" >&2
41+
exit 1
42+
fi
43+
2344
- name: Setup .NET
2445
uses: actions/setup-dotnet@v2
2546
with:
@@ -67,6 +88,7 @@ jobs:
6788
e2e-test:
6889
needs: [ build-for-e2e-test ]
6990
strategy:
91+
fail-fast: false
7092
matrix:
7193
runner-os: [windows-latest, ubuntu-latest, macos-latest]
7294
source-vcs: [AdoBasic, AdoCsv, Bbs, Ghes, Github]

LATEST-VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v1.15.1
1+
v1.16.0

RELEASENOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1+
- Fixed `gh gei migrate-secret-alerts` command to handle long resolution comments by truncating them to fit within GitHub's 270 character limit while preserving the resolver name prefix.

releasenotes/v1.16.0.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Fixed `ado2gh integrate-boards` command to properly report errors when GitHub PAT permissions are incorrect, instead of incorrectly reporting success.
2+
- Added `--target-api-url` option to `gh ado2gh rewire-pipeline` command to support customers migrating to [GitHub Enterprise Cloud with data residency](https://docs.github.com/en/enterprise-cloud@latest/admin/data-residency/about-github-enterprise-cloud-with-data-residency)
3+
- Updated `ado2gh generate-script` to no longer include ADO Boards integration commands in the generated script. This is a temporary change to fix broken functionality until we can ship a longer term fix. More details can be found [here](https://github.com/github/gh-gei/issues/1357)

src/Octoshift/Services/AdoApi.cs

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -212,16 +212,19 @@ public virtual async Task<string> GetGithubHandle(string org, string teamProject
212212
};
213213

214214
var response = await _client.PostAsync(url, payload);
215-
var data = JObject.Parse(response);
216215

217-
#pragma warning disable IDE0046 // Convert to conditional expression
218-
if (data["dataProviders"]["ms.vss-work-web.github-user-data-provider"] == null)
216+
// Check for error message in the response
217+
var errorMessage = ExtractErrorMessage(response, "ms.vss-work-web.github-user-data-provider");
218+
if (errorMessage.HasValue())
219219
{
220-
throw new OctoshiftCliException("Missing data from 'ms.vss-work-web.github-user-data-provider'. Please ensure the Azure DevOps project has a configured GitHub connection.");
220+
throw new OctoshiftCliException($"Error validating GitHub token: {errorMessage}");
221221
}
222-
#pragma warning restore IDE0046 // Convert to conditional expression
223222

224-
return (string)data["dataProviders"]["ms.vss-work-web.github-user-data-provider"]["login"];
223+
var data = JObject.Parse(response);
224+
var dataProviders = data["dataProviders"] ?? throw new OctoshiftCliException("Missing data from 'ms.vss-work-web.github-user-data-provider'. Please ensure the Azure DevOps project has a configured GitHub connection.");
225+
var dataProvider = dataProviders["ms.vss-work-web.github-user-data-provider"] ?? throw new OctoshiftCliException("Missing data from 'ms.vss-work-web.github-user-data-provider'. Please ensure the Azure DevOps project has a configured GitHub connection.");
226+
227+
return (string)dataProvider["login"];
225228
}
226229

227230
public virtual async Task<(string connectionId, string endpointId, string connectionName, IEnumerable<string> repoIds)> GetBoardsGithubConnection(string org, string teamProject)
@@ -329,7 +332,14 @@ public virtual async Task AddRepoToBoardsGithubConnection(string org, string tea
329332
}
330333
};
331334

332-
await _client.PostAsync(url, payload);
335+
var response = await _client.PostAsync(url, payload);
336+
337+
// Check for error message in the response
338+
var errorMessage = ExtractErrorMessage(response, "ms.vss-work-web.azure-boards-save-external-connection-data-provider");
339+
if (errorMessage.HasValue())
340+
{
341+
throw new OctoshiftCliException($"Error adding repository to boards GitHub connection: {errorMessage}");
342+
}
333343
}
334344

335345
public virtual async Task<string> GetTeamProjectId(string org, string teamProject)
@@ -523,34 +533,59 @@ public virtual async Task ShareServiceConnection(string adoOrg, string adoTeamPr
523533
return (defaultBranch, clean, checkoutSubmodules);
524534
}
525535

526-
public virtual async Task ChangePipelineRepo(string adoOrg, string teamProject, int pipelineId, string defaultBranch, string clean, string checkoutSubmodules, string githubOrg, string githubRepo, string connectedServiceId)
536+
public virtual async Task ChangePipelineRepo(string adoOrg, string teamProject, int pipelineId, string defaultBranch, string clean, string checkoutSubmodules, string githubOrg, string githubRepo, string connectedServiceId, string targetApiUrl = null)
527537
{
528538
var url = $"{_adoBaseUrl}/{adoOrg.EscapeDataString()}/{teamProject.EscapeDataString()}/_apis/build/definitions/{pipelineId}?api-version=6.0";
529539

530540
var response = await _client.GetAsync(url);
531541
var data = JObject.Parse(response);
532542

543+
// Determine base URLs
544+
string apiUrl, webUrl, cloneUrl, branchesUrl, refsUrl, manageUrl;
545+
if (targetApiUrl.HasValue())
546+
{
547+
var apiUri = new Uri(targetApiUrl.TrimEnd('/'));
548+
var webHost = apiUri.Host.StartsWith("api.") ? apiUri.Host[4..] : apiUri.Host;
549+
var webScheme = apiUri.Scheme;
550+
var webBase = $"{webScheme}://{webHost}";
551+
apiUrl = $"{targetApiUrl.TrimEnd('/')}/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}";
552+
webUrl = $"{webBase}/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}";
553+
cloneUrl = $"{webBase}/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}.git";
554+
branchesUrl = $"{targetApiUrl.TrimEnd('/')}/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/branches";
555+
refsUrl = $"{targetApiUrl.TrimEnd('/')}/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/git/refs";
556+
manageUrl = webUrl;
557+
}
558+
else
559+
{
560+
apiUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}";
561+
webUrl = $"https://github.com/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}";
562+
cloneUrl = $"https://github.com/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}.git";
563+
branchesUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/branches";
564+
refsUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/git/refs";
565+
manageUrl = webUrl;
566+
}
567+
533568
var newRepo = new
534569
{
535570
properties = new
536571
{
537-
apiUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}",
538-
branchesUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/branches",
539-
cloneUrl = $"https://github.com/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}.git",
572+
apiUrl,
573+
branchesUrl,
574+
cloneUrl,
540575
connectedServiceId,
541576
defaultBranch,
542577
fullName = $"{githubOrg}/{githubRepo}",
543-
manageUrl = $"https://github.com/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}",
578+
manageUrl,
544579
orgName = githubOrg,
545-
refsUrl = $"https://api.github.com/repos/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}/git/refs",
580+
refsUrl,
546581
safeRepository = $"{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}",
547582
shortName = githubRepo,
548583
reportBuildStatus = true
549584
},
550585
id = $"{githubOrg}/{githubRepo}",
551586
type = "GitHub",
552587
name = $"{githubOrg}/{githubRepo}",
553-
url = $"https://github.com/{githubOrg.EscapeDataString()}/{githubRepo.EscapeDataString()}.git",
588+
url = cloneUrl,
554589
defaultBranch,
555590
clean,
556591
checkoutSubmodules
@@ -600,9 +635,26 @@ public virtual async Task<string> GetBoardsGithubRepoId(string org, string teamP
600635
};
601636

602637
var response = await _client.PostAsync(url, payload);
638+
639+
// Check for error message in the response
640+
var errorMessage = ExtractErrorMessage(response, "ms.vss-work-web.github-user-repository-data-provider");
641+
if (errorMessage.HasValue())
642+
{
643+
throw new OctoshiftCliException($"Error getting GitHub repository information: {errorMessage}");
644+
}
645+
603646
var data = JObject.Parse(response);
647+
var dataProviders = data["dataProviders"] ?? throw new OctoshiftCliException("Could not retrieve GitHub repository information. Please verify the repository exists and the GitHub token has the correct permissions.");
648+
var dataProvider = dataProviders["ms.vss-work-web.github-user-repository-data-provider"];
604649

605-
return (string)data["dataProviders"]["ms.vss-work-web.github-user-repository-data-provider"]["additionalProperties"]["nodeId"];
650+
#pragma warning disable IDE0046 // Convert to conditional expression
651+
if (dataProvider == null || dataProvider["additionalProperties"] == null || dataProvider["additionalProperties"]["nodeId"] == null)
652+
#pragma warning restore IDE0046 // Convert to conditional expression
653+
{
654+
throw new OctoshiftCliException("Could not retrieve GitHub repository information. Please verify the repository exists and the GitHub token has the correct permissions.");
655+
}
656+
657+
return (string)dataProvider["additionalProperties"]["nodeId"];
606658
}
607659

608660
public virtual async Task CreateBoardsGithubConnection(string org, string teamProject, string endpointId, string repoId)
@@ -641,7 +693,14 @@ public virtual async Task CreateBoardsGithubConnection(string org, string teamPr
641693
}
642694
};
643695

644-
await _client.PostAsync(url, payload);
696+
var response = await _client.PostAsync(url, payload);
697+
698+
// Check for error message in the response
699+
var errorMessage = ExtractErrorMessage(response, "ms.vss-work-web.azure-boards-save-external-connection-data-provider");
700+
if (errorMessage.HasValue())
701+
{
702+
throw new OctoshiftCliException($"Error creating boards GitHub connection: {errorMessage}");
703+
}
645704
}
646705

647706
public virtual async Task DisableRepo(string org, string teamProject, string repoId)
@@ -700,6 +759,24 @@ public virtual async Task<bool> IsCallerOrgAdmin(string org)
700759
return await HasPermission(org, collectionSecurityNamespaceId, genericWritePermissionBitMaskValue);
701760
}
702761

762+
private string ExtractErrorMessage(string response, string dataProviderKey)
763+
{
764+
if (!response.HasValue())
765+
{
766+
return null;
767+
}
768+
769+
var data = JObject.Parse(response);
770+
#pragma warning disable IDE0046 // Convert to conditional expression
771+
if (data["dataProviders"] is not JObject dataProviders)
772+
{
773+
return null;
774+
}
775+
#pragma warning restore IDE0046 // Convert to conditional expression
776+
777+
return dataProviders[dataProviderKey] is not JObject dataProvider ? null : (string)dataProvider["errorMessage"];
778+
}
779+
703780
private async Task<bool> HasPermission(string org, string securityNamespaceId, int permission)
704781
{
705782
var response = await _client.GetAsync($"{_adoBaseUrl}/{org.EscapeDataString()}/_apis/permissions/{securityNamespaceId.EscapeDataString()}/{permission}?api-version=6.0");

src/Octoshift/Services/SecretScanningAlertService.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.Linq;
34
using System.Threading.Tasks;
@@ -74,7 +75,13 @@ public virtual async Task MigrateSecretScanningAlerts(string sourceOrg, string s
7475

7576
_log.LogInformation($" updating target alert:{targetAlert.Alert.Number} to state:{sourceAlert.Alert.State} and resolution:{sourceAlert.Alert.Resolution}");
7677

77-
var targetResolutionComment = $"[@{sourceAlert.Alert.ResolverName}] {sourceAlert.Alert.ResolutionComment}";
78+
var prefix = $"[@{sourceAlert.Alert.ResolverName}] ";
79+
var originalComment = sourceAlert.Alert.ResolutionComment ?? string.Empty;
80+
var prefixedComment = prefix + originalComment;
81+
82+
var targetResolutionComment = prefixedComment.Length <= 270
83+
? prefixedComment
84+
: prefix + originalComment[..Math.Max(0, 270 - prefix.Length)];
7885

7986
await _targetGithubApi.UpdateSecretScanningAlert(targetOrg, targetRepo, targetAlert.Alert.Number, sourceAlert.Alert.State,
8087
sourceAlert.Alert.Resolution, targetResolutionComment);

src/OctoshiftCLI.IntegrationTests/AdoBasicToGithub.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ await retryPolicy.Retry(async () =>
4747
await Helper.AssertGithubRepoExists(githubOrg, $"{teamProject2}-{teamProject2}");
4848
await Helper.AssertGithubRepoInitialized(githubOrg, $"{teamProject1}-{teamProject1}");
4949
await Helper.AssertGithubRepoInitialized(githubOrg, $"{teamProject2}-{teamProject2}");
50-
await Helper.AssertAutolinkConfigured(githubOrg, $"{teamProject1}-{teamProject1}", $"https://dev.azure.com/{adoOrg}/{teamProject1}/_workitems/edit/<num>/");
51-
await Helper.AssertAutolinkConfigured(githubOrg, $"{teamProject2}-{teamProject2}", $"https://dev.azure.com/{adoOrg}/{teamProject2}/_workitems/edit/<num>/");
5250
await Helper.AssertAdoRepoDisabled(adoOrg, teamProject1, adoRepo1);
5351
await Helper.AssertAdoRepoDisabled(adoOrg, teamProject2, adoRepo2);
5452
await Helper.AssertAdoRepoLocked(adoOrg, teamProject1, adoRepo1);
@@ -69,8 +67,6 @@ await retryPolicy.Retry(async () =>
6967
await Helper.AssertServiceConnectionWasShared(adoOrg, teamProject2);
7068
await Helper.AssertPipelineRewired(adoOrg, teamProject1, pipeline1, githubOrg, $"{teamProject1}-{teamProject1}");
7169
await Helper.AssertPipelineRewired(adoOrg, teamProject2, pipeline2, githubOrg, $"{teamProject2}-{teamProject2}");
72-
await Helper.AssertBoardsIntegrationConfigured(adoOrg, teamProject1);
73-
await Helper.AssertBoardsIntegrationConfigured(adoOrg, teamProject2);
7470
Helper.AssertMigrationLogFileExists(githubOrg, $"{teamProject1}-{teamProject1}");
7571
Helper.AssertMigrationLogFileExists(githubOrg, $"{teamProject2}-{teamProject2}");
7672
}

src/OctoshiftCLI.IntegrationTests/AdoCsvToGithub.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ await retryPolicy.Retry(async () =>
4848
await Helper.AssertGithubRepoExists(githubOrg, $"{teamProject2}-{teamProject2}");
4949
await Helper.AssertGithubRepoInitialized(githubOrg, $"{teamProject1}-{teamProject1}");
5050
await Helper.AssertGithubRepoInitialized(githubOrg, $"{teamProject2}-{teamProject2}");
51-
await Helper.AssertAutolinkConfigured(githubOrg, $"{teamProject1}-{teamProject1}", $"https://dev.azure.com/{adoOrg}/{teamProject1}/_workitems/edit/<num>/");
52-
await Helper.AssertAutolinkConfigured(githubOrg, $"{teamProject2}-{teamProject2}", $"https://dev.azure.com/{adoOrg}/{teamProject2}/_workitems/edit/<num>/");
5351
await Helper.AssertAdoRepoDisabled(adoOrg, teamProject1, adoRepo1);
5452
await Helper.AssertAdoRepoDisabled(adoOrg, teamProject2, adoRepo2);
5553
await Helper.AssertAdoRepoLocked(adoOrg, teamProject1, adoRepo1);
@@ -70,8 +68,6 @@ await retryPolicy.Retry(async () =>
7068
await Helper.AssertServiceConnectionWasShared(adoOrg, teamProject2);
7169
await Helper.AssertPipelineRewired(adoOrg, teamProject1, pipeline1, githubOrg, $"{teamProject1}-{teamProject1}");
7270
await Helper.AssertPipelineRewired(adoOrg, teamProject2, pipeline2, githubOrg, $"{teamProject2}-{teamProject2}");
73-
await Helper.AssertBoardsIntegrationConfigured(adoOrg, teamProject1);
74-
await Helper.AssertBoardsIntegrationConfigured(adoOrg, teamProject2);
7571
Helper.AssertMigrationLogFileExists(githubOrg, $"{teamProject1}-{teamProject1}");
7672
Helper.AssertMigrationLogFileExists(githubOrg, $"{teamProject2}-{teamProject2}");
7773
}

0 commit comments

Comments
 (0)