Skip to content

Commit 2193254

Browse files
Add --csv option to reclaim-mannequin command (#373)
* 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]>
1 parent a10a0bb commit 2193254

14 files changed

+1616
-727
lines changed

RELEASENOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Add capability to reclaim mannequins in bulk by using a CSV file
2+
- Added new command `generate-mannequin-csv`
3+
- Updated `reclaim-mannequin` command to accept `--csv` argument

src/Octoshift/AzureApi.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ private Uri GetServiceSasUriForBlob(BlobClient blobClient)
4949
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.");
5050
}
5151

52-
var sasBuilder = new BlobSasBuilder()
52+
var sasBuilder = new BlobSasBuilder
5353
{
5454
BlobContainerName = blobClient.GetParentBlobContainerClient().Name,
5555
BlobName = blobClient.Name,
56-
Resource = "b" // Resource = "b" for blobs, "c" for containers
57-
};
56+
Resource = "b", // Resource = "b" for blobs, "c" for containers
5857

59-
sasBuilder.ExpiresOn = DateTimeOffset.UtcNow.AddHours(AUTHORIZATION_TIMEOUT_IN_HOURS);
58+
ExpiresOn = DateTimeOffset.UtcNow.AddHours(AUTHORIZATION_TIMEOUT_IN_HOURS)
59+
};
6060
sasBuilder.SetPermissions(BlobSasPermissions.Read | BlobSasPermissions.Write);
6161

6262
return blobClient.GenerateSasUri(sasBuilder);

src/Octoshift/GithubApi.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -480,22 +480,6 @@ public virtual async Task<string> GetArchiveMigrationUrl(string org, int migrati
480480
return response;
481481
}
482482

483-
public virtual async Task<Mannequin> GetMannequin(string orgId, string username)
484-
{
485-
var url = $"{_apiUrl}/graphql";
486-
487-
var payload = GetMannequinsPayload(orgId);
488-
489-
var mannequin = await _client.PostGraphQLWithPaginationAsync(
490-
url,
491-
payload,
492-
data => (JArray)data["data"]["node"]["mannequins"]["nodes"],
493-
data => (JObject)data["data"]["node"]["mannequins"]["pageInfo"])
494-
.FirstOrDefaultAsync(jToken => username.Equals((string)jToken["login"], StringComparison.OrdinalIgnoreCase));
495-
496-
return mannequin is null ? new Mannequin() : BuildMannequin(mannequin);
497-
}
498-
499483
public virtual async Task<IEnumerable<Mannequin>> GetMannequins(string orgId)
500484
{
501485
var url = $"{_apiUrl}/graphql";

src/Octoshift/ReclaimService.cs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Octoshift.Models;
6+
using OctoshiftCLI;
7+
using OctoshiftCLI.Models;
8+
9+
namespace Octoshift
10+
{
11+
public class ReclaimService
12+
{
13+
private readonly GithubApi _githubApi;
14+
private readonly OctoLogger _log;
15+
16+
public const string CSVHEADER = "mannequin-user,mannequin-id,target-user";
17+
18+
private class Mannequins
19+
{
20+
private readonly Mannequin[] _mannequins;
21+
22+
public Mannequins(IEnumerable<Mannequin> mannequins)
23+
{
24+
_mannequins = mannequins.ToArray();
25+
}
26+
27+
public Mannequin FindFirst(string login, string userid)
28+
{
29+
return _mannequins.FirstOrDefault(m => login.Equals(m.Login, StringComparison.OrdinalIgnoreCase) && userid.Equals(m.Id, StringComparison.OrdinalIgnoreCase));
30+
}
31+
32+
/// <summary>
33+
/// Gets all mannequins by login and (optionally by login and user id)
34+
/// </summary>
35+
/// <param name="mannequinUser"></param>
36+
/// <param name="mannequinId">null to ignore</param>
37+
/// <returns></returns>
38+
internal IEnumerable<Mannequin> GetByLogin(string mannequinUser, string mannequinId)
39+
{
40+
return _mannequins.Where(
41+
m => mannequinUser.Equals(m.Login, StringComparison.OrdinalIgnoreCase) &&
42+
(mannequinId == null || mannequinId.Equals(m.Id, StringComparison.OrdinalIgnoreCase))
43+
);
44+
}
45+
46+
/// <summary>
47+
/// Checks if the user has been claimed at least once (regardless of the last reclaiming result)
48+
/// </summary>
49+
/// <param name="login"></param>
50+
/// <param name="id"></param>
51+
/// <returns></returns>
52+
public bool IsClaimed(string login, string id)
53+
{
54+
return _mannequins.FirstOrDefault(m =>
55+
login.Equals(m.Login, StringComparison.OrdinalIgnoreCase) &&
56+
id.Equals(m.Id, StringComparison.OrdinalIgnoreCase)
57+
&& m.MappedUser != null)?.Login != null;
58+
}
59+
60+
public bool IsClaimed(string login)
61+
{
62+
return _mannequins.FirstOrDefault(m =>
63+
login.Equals(m.Login, StringComparison.OrdinalIgnoreCase)
64+
&& m.MappedUser != null)?.Login != null;
65+
}
66+
67+
public bool IsEmpty()
68+
{
69+
return _mannequins.Length == 0;
70+
}
71+
72+
public IEnumerable<Mannequin> GetUniqueUsers()
73+
{
74+
return _mannequins.DistinctBy(x => $"{x.Id}__{x.Login}");
75+
}
76+
}
77+
78+
public ReclaimService(GithubApi githubApi, OctoLogger logger)
79+
{
80+
_githubApi = githubApi;
81+
_log = logger;
82+
}
83+
84+
public virtual async Task ReclaimMannequin(string mannequinUser, string mannequinId, string targetUser, string githubOrg, bool force)
85+
{
86+
var githubOrgId = await _githubApi.GetOrganizationId(githubOrg);
87+
88+
var mannequins = new Mannequins((await GetMannequins(githubOrgId)).GetByLogin(mannequinUser, mannequinId));
89+
if (mannequins.IsEmpty())
90+
{
91+
throw new OctoshiftCliException($"User {mannequinUser} is not a mannequin.");
92+
}
93+
94+
// (Potentially) Save one call to the API to get the targer user
95+
// // (it also makes it unnecessary to check for claimed users during reclaiming loop)
96+
if (!force && mannequins.IsClaimed(mannequinUser))
97+
{
98+
throw new OctoshiftCliException($"User {mannequinUser} is already mapped to a user. Use the force option if you want to reclaim the mannequin again.");
99+
}
100+
101+
var targetUserId = await _githubApi.GetUserId(targetUser);
102+
if (targetUserId == null)
103+
{
104+
throw new OctoshiftCliException($"Target user {targetUser} not found.");
105+
}
106+
107+
var success = true;
108+
109+
// get all unique mannequins by login and id and map them all to the same target
110+
foreach (var mannequin in mannequins.GetUniqueUsers())
111+
{
112+
var result = await _githubApi.ReclaimMannequin(githubOrgId, mannequin.Id, targetUserId);
113+
114+
success &= HandleResult(mannequinUser, targetUser, mannequin, targetUserId, result);
115+
}
116+
117+
if (!success)
118+
{
119+
throw new OctoshiftCliException("Failed to reclaim mannequin(s).");
120+
}
121+
}
122+
123+
public virtual async Task ReclaimMannequins(string[] lines, string githubTargetOrg, bool force)
124+
{
125+
if (lines == null)
126+
{
127+
throw new ArgumentNullException(nameof(lines));
128+
}
129+
130+
if (lines.Length == 0)
131+
{
132+
_log.LogWarning("File is empty. Nothing to reclaim");
133+
return;
134+
}
135+
136+
// Validate Header
137+
if (!CSVHEADER.Equals(lines[0], StringComparison.OrdinalIgnoreCase))
138+
{
139+
throw new OctoshiftCliException($"Invalid Header. Should be: {CSVHEADER}");
140+
}
141+
142+
var githubOrgId = await _githubApi.GetOrganizationId(githubTargetOrg);
143+
144+
var mannequins = await GetMannequins(githubOrgId);
145+
146+
foreach (var line in lines.Skip(1).Where(l => l != null && l.Trim().Length > 0))
147+
{
148+
var (login, userid, claimantLogin) = ParseLine(line);
149+
150+
if (login == null)
151+
{
152+
continue;
153+
}
154+
155+
if (!force && mannequins.IsClaimed(login, userid))
156+
{
157+
_log.LogError($"{login} is already claimed. Skipping (use force if you want to reclaim)");
158+
continue;
159+
}
160+
161+
var mannequin = mannequins.FindFirst(login, userid);
162+
163+
if (mannequin == null)
164+
{
165+
_log.LogError($"Mannequin {login} not found. Skipping.");
166+
continue;
167+
}
168+
169+
var claimantId = await _githubApi.GetUserId(claimantLogin);
170+
171+
if (claimantId == null)
172+
{
173+
_log.LogError($"Claimant \"{claimantLogin}\" not found. Will ignore it.");
174+
continue;
175+
}
176+
177+
var result = await _githubApi.ReclaimMannequin(githubOrgId, userid, claimantId);
178+
179+
HandleResult(login, claimantLogin, mannequin, claimantId, result);
180+
}
181+
}
182+
183+
private async Task<Mannequins> GetMannequins(string githubOrgId)
184+
{
185+
var returnedMannequins = await _githubApi.GetMannequins(githubOrgId);
186+
187+
return new Mannequins(returnedMannequins);
188+
}
189+
190+
private bool HandleResult(string mannequinUser, string targetUser, Mannequin mannequin, string targetUserId, MannequinReclaimResult result)
191+
{
192+
if (result.Errors != null)
193+
{
194+
_log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId}) Reason: {result.Errors[0].Message}");
195+
return false;
196+
}
197+
198+
if (result.Data.CreateAttributionInvitation is null ||
199+
result.Data.CreateAttributionInvitation.Source.Id != mannequin.Id ||
200+
result.Data.CreateAttributionInvitation.Target.Id != targetUserId)
201+
{
202+
_log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})");
203+
return false;
204+
}
205+
206+
_log.LogInformation($"Successfully reclaimed {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})");
207+
208+
return true;
209+
}
210+
211+
private (string MannequinUser, string MannequinId, string TargetUser) ParseLine(string line)
212+
{
213+
var components = line.Split(',');
214+
215+
if (components.Length != 3)
216+
{
217+
_log.LogError($"Invalid line: \"{line}\". Will ignore it.");
218+
return (null, null, null);
219+
}
220+
221+
var login = components[0].Trim();
222+
var userId = components[1].Trim();
223+
var claimantLogin = components[2].Trim();
224+
225+
if (string.IsNullOrEmpty(login))
226+
{
227+
_log.LogError($"Invalid line: \"{line}\". Mannequin login is not defined. Will ignore it.");
228+
return (null, null, null);
229+
}
230+
231+
if (string.IsNullOrEmpty(userId))
232+
{
233+
_log.LogError($"Invalid line: \"{line}\". Mannequin Id is not defined. Will ignore it.");
234+
return (null, null, null);
235+
}
236+
237+
if (string.IsNullOrEmpty(claimantLogin))
238+
{
239+
_log.LogError($"Invalid line: \"{line}\". Target User is not defined. Will ignore it.");
240+
return (null, null, null);
241+
}
242+
243+
return (login, userId, claimantLogin);
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)