Skip to content

Commit

Permalink
Add --csv option to reclaim-mannequin command (#373)
Browse files Browse the repository at this point in the history
* Add --csv option to reclaim-mannequin command. Mannequins can now be claimed in bulk, the CSV file can be generated with the `generate-mannequin-csv` command.

If a given mannequin has already been mapped, an error occurs (unless the `--force` option is used).

Errors do not stop processing; they are logged and the reclaiming process continues (it will be marked as an error if at least one reclaiming has failed).

* While reclaiming a single login, reclaim multiple mannequins with different IDs (use `--mannequin-id` parameter do disambiguate)

Co-authored-by: Dylan Smith <[email protected]>
  • Loading branch information
tspascoal and dylan-smith authored May 6, 2022
1 parent a10a0bb commit 2193254
Show file tree
Hide file tree
Showing 14 changed files with 1,616 additions and 727 deletions.
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Add capability to reclaim mannequins in bulk by using a CSV file
- Added new command `generate-mannequin-csv`
- Updated `reclaim-mannequin` command to accept `--csv` argument
8 changes: 4 additions & 4 deletions src/Octoshift/AzureApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ private Uri GetServiceSasUriForBlob(BlobClient blobClient)
throw new InvalidOperationException("BlobClient object has not been authorized to generate shared key credentials. Verify --azure-storage-connection-key is valid and has proper permissions.");
}

var sasBuilder = new BlobSasBuilder()
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = blobClient.GetParentBlobContainerClient().Name,
BlobName = blobClient.Name,
Resource = "b" // Resource = "b" for blobs, "c" for containers
};
Resource = "b", // Resource = "b" for blobs, "c" for containers

sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddHours(AUTHORIZATION_TIMEOUT_IN_HOURS);
ExpiresOn = DateTimeOffset.UtcNow.AddHours(AUTHORIZATION_TIMEOUT_IN_HOURS)
};
sasBuilder.SetPermissions(BlobSasPermissions.Read | BlobSasPermissions.Write);

return blobClient.GenerateSasUri(sasBuilder);
Expand Down
16 changes: 0 additions & 16 deletions src/Octoshift/GithubApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -480,22 +480,6 @@ public virtual async Task<string> GetArchiveMigrationUrl(string org, int migrati
return response;
}

public virtual async Task<Mannequin> GetMannequin(string orgId, string username)
{
var url = $"{_apiUrl}/graphql";

var payload = GetMannequinsPayload(orgId);

var mannequin = await _client.PostGraphQLWithPaginationAsync(
url,
payload,
data => (JArray)data["data"]["node"]["mannequins"]["nodes"],
data => (JObject)data["data"]["node"]["mannequins"]["pageInfo"])
.FirstOrDefaultAsync(jToken => username.Equals((string)jToken["login"], StringComparison.OrdinalIgnoreCase));

return mannequin is null ? new Mannequin() : BuildMannequin(mannequin);
}

public virtual async Task<IEnumerable<Mannequin>> GetMannequins(string orgId)
{
var url = $"{_apiUrl}/graphql";
Expand Down
246 changes: 246 additions & 0 deletions src/Octoshift/ReclaimService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Octoshift.Models;
using OctoshiftCLI;
using OctoshiftCLI.Models;

namespace Octoshift
{
public class ReclaimService
{
private readonly GithubApi _githubApi;
private readonly OctoLogger _log;

public const string CSVHEADER = "mannequin-user,mannequin-id,target-user";

private class Mannequins
{
private readonly Mannequin[] _mannequins;

public Mannequins(IEnumerable<Mannequin> mannequins)
{
_mannequins = mannequins.ToArray();
}

public Mannequin FindFirst(string login, string userid)
{
return _mannequins.FirstOrDefault(m => login.Equals(m.Login, StringComparison.OrdinalIgnoreCase) && userid.Equals(m.Id, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Gets all mannequins by login and (optionally by login and user id)
/// </summary>
/// <param name="mannequinUser"></param>
/// <param name="mannequinId">null to ignore</param>
/// <returns></returns>
internal IEnumerable<Mannequin> GetByLogin(string mannequinUser, string mannequinId)
{
return _mannequins.Where(
m => mannequinUser.Equals(m.Login, StringComparison.OrdinalIgnoreCase) &&
(mannequinId == null || mannequinId.Equals(m.Id, StringComparison.OrdinalIgnoreCase))
);
}

/// <summary>
/// Checks if the user has been claimed at least once (regardless of the last reclaiming result)
/// </summary>
/// <param name="login"></param>
/// <param name="id"></param>
/// <returns></returns>
public bool IsClaimed(string login, string id)
{
return _mannequins.FirstOrDefault(m =>
login.Equals(m.Login, StringComparison.OrdinalIgnoreCase) &&
id.Equals(m.Id, StringComparison.OrdinalIgnoreCase)
&& m.MappedUser != null)?.Login != null;
}

public bool IsClaimed(string login)
{
return _mannequins.FirstOrDefault(m =>
login.Equals(m.Login, StringComparison.OrdinalIgnoreCase)
&& m.MappedUser != null)?.Login != null;
}

public bool IsEmpty()
{
return _mannequins.Length == 0;
}

public IEnumerable<Mannequin> GetUniqueUsers()
{
return _mannequins.DistinctBy(x => $"{x.Id}__{x.Login}");
}
}

public ReclaimService(GithubApi githubApi, OctoLogger logger)
{
_githubApi = githubApi;
_log = logger;
}

public virtual async Task ReclaimMannequin(string mannequinUser, string mannequinId, string targetUser, string githubOrg, bool force)
{
var githubOrgId = await _githubApi.GetOrganizationId(githubOrg);

var mannequins = new Mannequins((await GetMannequins(githubOrgId)).GetByLogin(mannequinUser, mannequinId));
if (mannequins.IsEmpty())
{
throw new OctoshiftCliException($"User {mannequinUser} is not a mannequin.");
}

// (Potentially) Save one call to the API to get the targer user
// // (it also makes it unnecessary to check for claimed users during reclaiming loop)
if (!force && mannequins.IsClaimed(mannequinUser))
{
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);
if (targetUserId == null)
{
throw new OctoshiftCliException($"Target user {targetUser} not found.");
}

var success = true;

// get all unique mannequins by login and id and map them all to the same target
foreach (var mannequin in mannequins.GetUniqueUsers())
{
var result = await _githubApi.ReclaimMannequin(githubOrgId, mannequin.Id, targetUserId);

success &= HandleResult(mannequinUser, targetUser, mannequin, targetUserId, result);
}

if (!success)
{
throw new OctoshiftCliException("Failed to reclaim mannequin(s).");
}
}

public virtual async Task ReclaimMannequins(string[] lines, string githubTargetOrg, bool force)
{
if (lines == null)
{
throw new ArgumentNullException(nameof(lines));
}

if (lines.Length == 0)
{
_log.LogWarning("File is empty. Nothing to reclaim");
return;
}

// Validate Header
if (!CSVHEADER.Equals(lines[0], StringComparison.OrdinalIgnoreCase))
{
throw new OctoshiftCliException($"Invalid Header. Should be: {CSVHEADER}");
}

var githubOrgId = await _githubApi.GetOrganizationId(githubTargetOrg);

var mannequins = await GetMannequins(githubOrgId);

foreach (var line in lines.Skip(1).Where(l => l != null && l.Trim().Length > 0))
{
var (login, userid, claimantLogin) = ParseLine(line);

if (login == null)
{
continue;
}

if (!force && mannequins.IsClaimed(login, userid))
{
_log.LogError($"{login} is already claimed. Skipping (use force if you want to reclaim)");
continue;
}

var mannequin = mannequins.FindFirst(login, userid);

if (mannequin == null)
{
_log.LogError($"Mannequin {login} not found. Skipping.");
continue;
}

var claimantId = await _githubApi.GetUserId(claimantLogin);

if (claimantId == null)
{
_log.LogError($"Claimant \"{claimantLogin}\" not found. Will ignore it.");
continue;
}

var result = await _githubApi.ReclaimMannequin(githubOrgId, userid, claimantId);

HandleResult(login, claimantLogin, mannequin, claimantId, result);
}
}

private async Task<Mannequins> GetMannequins(string githubOrgId)
{
var returnedMannequins = await _githubApi.GetMannequins(githubOrgId);

return new Mannequins(returnedMannequins);
}

private bool HandleResult(string mannequinUser, string targetUser, Mannequin mannequin, string targetUserId, MannequinReclaimResult result)
{
if (result.Errors != null)
{
_log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId}) Reason: {result.Errors[0].Message}");
return false;
}

if (result.Data.CreateAttributionInvitation is null ||
result.Data.CreateAttributionInvitation.Source.Id != mannequin.Id ||
result.Data.CreateAttributionInvitation.Target.Id != targetUserId)
{
_log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})");
return false;
}

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

return true;
}

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

if (components.Length != 3)
{
_log.LogError($"Invalid line: \"{line}\". Will ignore it.");
return (null, null, null);
}

var login = components[0].Trim();
var userId = components[1].Trim();
var claimantLogin = components[2].Trim();

if (string.IsNullOrEmpty(login))
{
_log.LogError($"Invalid line: \"{line}\". Mannequin login is not defined. Will ignore it.");
return (null, null, null);
}

if (string.IsNullOrEmpty(userId))
{
_log.LogError($"Invalid line: \"{line}\". Mannequin Id is not defined. Will ignore it.");
return (null, null, null);
}

if (string.IsNullOrEmpty(claimantLogin))
{
_log.LogError($"Invalid line: \"{line}\". Target User is not defined. Will ignore it.");
return (null, null, null);
}

return (login, userId, claimantLogin);
}
}
}
Loading

0 comments on commit 2193254

Please sign in to comment.