Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@

- Added support for reclaiming mannequins to a customer-owned GitHub App / bot account. A target login ending in `[bot]` is resolved to the app's node id via the REST users endpoint and reattributed with the new `reattributeMannequinToBot` GraphQL mutation. The target app must be owned by the acting organization (or, for an enterprise-owned EMU bot, that organization's enterprise) and administered by the acting admin. Because reattributing to a bot is immediate and cannot be undone, the CLI prompts for confirmation (skippable with `--no-prompt`) and warns when the source mannequin does not look like a bot. This is gated behind the `mannequin_claiming_bot` GitHub feature.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public ReclaimMannequinCommandBase() : base(

public virtual Option<string> MannequinUser { get; } = new("--mannequin-user")
{
Description = "The login of the mannequin to be remapped."
Description = "The login of the mannequin to be remapped. When the target is a GitHub App / bot account, a mannequin whose own login does not end in \"[bot]\" triggers an advisory warning but the reclaim still proceeds, since a bot migrated from a non-GitHub source (e.g. Azure DevOps, Bitbucket, GitLab) may not use the \"[bot]\" suffix."
};

public virtual Option<string> MannequinId { get; } = new("--mannequin-id")
Expand All @@ -44,7 +44,7 @@ public ReclaimMannequinCommandBase() : base(

public virtual Option<string> TargetUser { get; } = new("--target-user")
{
Description = "The login of the target user to be mapped."
Description = "The login of the target user to map the mannequin's content to. A regular user is sent an invitation they must accept; a login ending in \"[bot]\" instead maps to a GitHub App / bot account and is reattributed immediately and irreversibly (bot accounts cannot accept an invitation). You are asked to confirm a bot reclaim unless --no-prompt is set."
};

public virtual Option<bool> Force { get; } = new("--force")
Expand All @@ -54,7 +54,7 @@ public ReclaimMannequinCommandBase() : base(

public virtual Option<bool> NoPrompt { get; } = new("--no-prompt")
{
Description = "Overrides all prompts and warnings with 'Y' value."
Description = "Overrides all prompts and warnings with 'Y' value. This includes the confirmation shown before an immediate, irreversible reclaim to a GitHub App / bot account."
};

public virtual Option<string> GithubPat { get; } = new("--github-pat")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -58,14 +59,70 @@ public async Task Handle(ReclaimMannequinCommandArgs args)
throw new OctoshiftCliException($"File {args.Csv} does not exist.");
}

await _reclaimService.ReclaimMannequins(GetFileContent(args.Csv), args.GithubOrg, args.Force, args.SkipInvitation);
var lines = GetFileContent(args.Csv);

ConfirmBotReclaims(args, ParseReclaimTargets(lines));

await _reclaimService.ReclaimMannequins(lines, args.GithubOrg, args.Force, args.SkipInvitation);
}
else
{

_log.LogInformation("Reclaiming Mannequin...");

ConfirmBotReclaims(args, new[] { (args.MannequinUser, args.TargetUser) });

await _reclaimService.ReclaimMannequin(args.MannequinUser, args.MannequinId, args.TargetUser, args.GithubOrg, args.Force, args.SkipInvitation);
}
}

// Reattributing content to a bot auto-accepts and cannot be undone, so we confirm before proceeding.
// The source mannequin's login is our only hint that it represents a bot; a non-"[bot]" source is
// very likely a mis-target (a human's content going to a bot), but the convention is GitHub-specific,
// so we warn and let the admin proceed rather than blocking.
private void ConfirmBotReclaims(ReclaimMannequinCommandArgs args, IReadOnlyList<(string MannequinUser, string TargetUser)> reclaims)
{
var botReclaims = reclaims
.Where(r => ReclaimService.IsBotLogin(r.TargetUser))
.ToList();

if (botReclaims.Count == 0)
{
return;
}

foreach (var source in botReclaims
.Where(r => !ReclaimService.IsBotLogin(r.MannequinUser))
.Select(r => r.MannequinUser)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
_log.LogWarning($"\"{source}\" does not look like a bot mannequin (its login does not end in \"[bot]\"). Are you sure you want to do this?");
}

if (args.NoPrompt)
{
return;
}

var humanCount = reclaims.Count - botReclaims.Count;
var summary = reclaims.Count > 1
? $"You are about to reattribute {botReclaims.Count} mannequin(s) to GitHub App / bot account(s)" +
(humanCount > 0 ? $" and {humanCount} mannequin(s) to user(s)" : string.Empty) + "."
: $"You are about to reattribute mannequin \"{botReclaims[0].MannequinUser}\" to the GitHub App / bot account \"{botReclaims[0].TargetUser}\".";

_confirmationService.AskForConfirmation($"{summary} Reattributing content to a bot is immediate and cannot be undone. Are you sure you wish to continue? [y/N]");
}

private static (string MannequinUser, string TargetUser)[] ParseReclaimTargets(string[] lines)
{
return lines == null || lines.Length == 0
? Array.Empty<(string, string)>()
: lines
.Skip(1) // header
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => l.Split(','))
.Where(c => c.Length == 3)
.Select(c => (c[0].Trim(), c[2].Trim()))
.ToArray();
}
}
17 changes: 17 additions & 0 deletions src/Octoshift/Models/ReattributeMannequinToBotResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Octoshift.Models
{
public class ReattributeMannequinToBotResult : GraphqlResult<ReattributeMannequinToBotData>
{
}

public class ReattributeMannequinToBotData
{
public ReattributeMannequinToBot ReattributeMannequinToBot { get; set; }
}

public class ReattributeMannequinToBot
{
public UserInfo Source { get; set; }
public UserInfo Target { get; set; }
}
}
71 changes: 68 additions & 3 deletions src/Octoshift/Services/GithubApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ mutation startRepositoryMigration(
$lockSource: Boolean)";
var gql = @"
startRepositoryMigration(
input: {
input: {
sourceId: $sourceId,
ownerId: $ownerId,
sourceRepositoryUrl: $sourceRepositoryUrl,
Expand Down Expand Up @@ -480,7 +480,7 @@ mutation startOrganizationMigration (
$targetEnterpriseId: ID!,
$sourceAccessToken: String!)";
var gql = @"
startOrganizationMigration(
startOrganizationMigration(
input: {
sourceOrgUrl: $sourceOrgUrl,
targetOrgName: $targetOrgName,
Expand Down Expand Up @@ -862,6 +862,28 @@ public virtual async Task<string> GetUserId(string login)
return (string)data["data"]["user"]["id"];
}

// Resolves a GitHub App / bot account (e.g. "example-ci[bot]") to its GraphQL node id.
// The GraphQL user(login:) query hides bots, so we use the REST users endpoint, which
// returns bot accounts (type "Bot") along with their node_id.
public virtual async Task<string> GetBotId(string login)
{
var url = $"{_apiUrl}/users/{login.EscapeDataString()}";

try
{
var response = await _client.GetAsync(url);
var data = JObject.Parse(response);

return !string.Equals((string)data["type"], "Bot", StringComparison.OrdinalIgnoreCase)
? throw new OctoshiftCliException($"{login} is not a GitHub App / bot account.")
: (string)data["node_id"];
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
throw new OctoshiftCliException($"Could not resolve to a bot account with the login of '{login}'.", ex);
}
}

public virtual async Task<CreateAttributionInvitationResult> CreateAttributionInvitation(string orgId, string mannequinId, string targetUserId)
{
var url = $"{_apiUrl}/graphql";
Expand Down Expand Up @@ -951,6 +973,49 @@ ... on User {
}
}

public virtual async Task<ReattributeMannequinToBotResult> ReattributeMannequinToBot(string orgId, string mannequinId, string targetBotId)
{
var url = $"{_apiUrl}/graphql";
var mutation = "mutation($orgId: ID!,$sourceId: ID!,$targetId: ID!)";
var gql = @"
reattributeMannequinToBot(
input: { ownerId: $orgId, sourceId: $sourceId, targetId: $targetId }
) {
source {
... on Mannequin {
id
login
}
}

target {
... on Bot {
id
login
}
}
}";

var payload = new
{
query = $"{mutation} {{ {gql} }}",
variables = new { orgId, sourceId = mannequinId, targetId = targetBotId }
};

try
{
// Reattributing to a bot is irreversible and its failures (ineligible
// bot, insufficient ownership, feature disabled) are deterministic, so
// submit once rather than retrying and risking a duplicate mutation.
var data = await _client.PostGraphQLAsync(url, payload);
return data.ToObject<ReattributeMannequinToBotResult>();
}
catch (OctoshiftCliException ex) when (ex.Message.Contains("Field 'reattributeMannequinToBot' doesn't exist on type 'Mutation'"))
{
throw new OctoshiftCliException("Reclaiming mannequins to a GitHub App / bot account is not enabled for your GitHub organization or enterprise. For more details, contact GitHub Support.", ex);
}
}

public virtual async Task<IEnumerable<GithubSecretScanningAlert>> GetSecretScanningAlertsForRepository(string org, string repo)
{
var url = $"{_apiUrl}/repos/{org.EscapeDataString()}/{repo.EscapeDataString()}/secret-scanning/alerts?per_page=100";
Expand Down Expand Up @@ -1118,7 +1183,7 @@ mutation abortRepositoryMigration(
)";
var gql = @"
abortRepositoryMigration(
input: {
input: {
migrationId: $migrationId
})
{ success }";
Expand Down
2 changes: 1 addition & 1 deletion src/Octoshift/Services/GithubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public GithubClient(OctoLogger log, HttpClient httpClient, IVersionProvider vers
if (_httpClient != null)
{
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
_httpClient.DefaultRequestHeaders.Add("GraphQL-Features", "import_api,mannequin_claiming_emu,org_import_api");
_httpClient.DefaultRequestHeaders.Add("GraphQL-Features", "import_api,mannequin_claiming_emu,mannequin_claiming_bot,org_import_api");
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", personalAccessToken);
_httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("OctoshiftCLI", versionProvider?.GetCurrentVersion()));
if (versionProvider?.GetVersionComments() is { } comments)
Expand Down
64 changes: 59 additions & 5 deletions src/Octoshift/Services/ReclaimService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,28 @@ public virtual async Task ReclaimMannequin(string mannequinUser, string mannequi
throw new OctoshiftCliException($"User {mannequinUser} is already mapped to a user. Use the force option if you want to reclaim the mannequin again.");
}

var targetUserId = await _githubApi.GetUserId(targetUser);
var isBot = IsBotLogin(targetUser);

var targetUserId = isBot
? await _githubApi.GetBotId(targetUser)
: await _githubApi.GetUserId(targetUser);

var success = true;

if (skipInvitation)
if (isBot)
{
// Bots cannot accept an emailed invitation; reclaiming to a bot always auto-accepts.
foreach (var mannequin in mannequins.GetUniqueUsers())
{
var result = await _githubApi.ReattributeMannequinToBot(githubOrgId, mannequin.Id, targetUserId);

if (!HandleBotReclaimationResult(mannequin.Login, targetUser, mannequin, targetUserId, result))
{
throw new OctoshiftCliException("Failed to reclaim mannequin.");
}
}
}
else if (skipInvitation)
{
foreach (var mannequin in mannequins.GetUniqueUsers())
{
Expand Down Expand Up @@ -202,19 +219,32 @@ public virtual async Task ReclaimMannequins(string[] lines, string githubTargetO
continue;
}

var isBot = IsBotLogin(mannequin.MappedUser.Login);

string claimantId;

try
{
claimantId = await _githubApi.GetUserId(mannequin.MappedUser.Login);
claimantId = isBot
? await _githubApi.GetBotId(mannequin.MappedUser.Login)
: await _githubApi.GetUserId(mannequin.MappedUser.Login);
}
catch (OctoshiftCliException ex) when (ex.Message.Contains("Could not resolve to a User with the login"))
catch (OctoshiftCliException ex) when (ex.Message.Contains("Could not resolve to a User with the login") || ex.Message.Contains("Could not resolve to a bot account with the login"))
{
_log.LogWarning($"Claimant \"{mannequin.MappedUser.Login}\" not found. Will ignore it.");
continue;
}

if (skipInvitation)
if (isBot)
{
var result = await _githubApi.ReattributeMannequinToBot(githubOrgId, mannequin.Id, claimantId);
Comment thread
dpmex4527 marked this conversation as resolved.

if (!HandleBotReclaimationResult(mannequin.Login, mannequin.MappedUser.Login, mannequin, claimantId, result))
{
return;
}
}
else if (skipInvitation)
{
var result = await _githubApi.ReclaimMannequinSkipInvitation(githubOrgId, mannequin.Id, claimantId);

Expand Down Expand Up @@ -297,6 +327,30 @@ private bool HandleReclaimationResult(string mannequinUser, string targetUser, M
return true; // Indiciates we should continue onto the next mannequin
}

public static bool IsBotLogin(string login) =>
login != null && login.EndsWith("[bot]", StringComparison.OrdinalIgnoreCase);

private bool HandleBotReclaimationResult(string mannequinUser, string targetUser, Mannequin mannequin, string targetBotId, ReattributeMannequinToBotResult result)
{
if (result.Errors != null)
{
_log.LogWarning($"Failed to reattribute content belonging to mannequin {mannequinUser} ({mannequin.Id}) to {targetUser}: {result.Errors[0].Message}");
return true;
}

if (result.Data.ReattributeMannequinToBot is null ||
result.Data.ReattributeMannequinToBot.Source.Id != mannequin.Id ||
result.Data.ReattributeMannequinToBot.Target.Id != targetBotId)
{
_log.LogWarning($"Failed to reattribute content belonging to mannequin {mannequinUser} ({mannequin.Id}) to {targetUser}");
return true;
}

_log.LogInformation($"Successfully reclaimed content belonging to mannequin {mannequinUser} ({mannequin.Id}) to {targetUser}");

return true;
}

private (string MannequinUser, string MannequinId, string TargetUser) ParseLine(string line)
{
var components = line.Split(',');
Expand Down
Loading
Loading