Skip to content

Commit

Permalink
feat(github): add links to children and related issues
Browse files Browse the repository at this point in the history
Refs: #33
  • Loading branch information
Phil91 committed Jul 24, 2023
1 parent 73cec49 commit f970b3f
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 50 deletions.
40 changes: 31 additions & 9 deletions jihub.Base/JihubOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ public class JihubOptions
/// <summary>
/// Username of the github user / organisation that hosts the project
/// </summary>
[Option(shortName: 'o', longName: "owner", Required = true, HelpText = "Github Repository Owner (User or Organisation)")]
[Option(shortName: 'o', longName: "owner", Required = true,
HelpText = "Github Repository Owner (User or Organisation)")]
public string Owner { get; set; } = null!;

/// <summary>
/// The max results when requesting jira
/// </summary>
[Option(shortName: 'm', longName: "max-results", Required = false, HelpText = "The max jira results", Default = 500)]
[Option(shortName: 'm', longName: "max-results", Required = false, HelpText = "The max jira results",
Default = 500)]
public int MaxResults { get; set; } = 500;

/// <summary>
Expand All @@ -34,42 +36,62 @@ public class JihubOptions
/// <summary>
/// The search query to get only the needed jira tickets
/// </summary>
[Option(shortName: 'l', longName: "link", Required = false, HelpText = "If set all external resources such as images will be refered as a link in the description", Default = false)]
[Option(shortName: 'l', longName: "link", Required = false,
HelpText = "If set all external resources such as images will be refered as a link in the description",
Default = false)]
public bool Link { get; set; }

/// <summary>
/// The search query to get only the needed jira tickets
/// </summary>
[Option(shortName: 'c', longName: "content-link", Required = false, HelpText = "If set all external resources such as images will be linked as content in the description", Default = false)]
[Option(shortName: 'c', longName: "content-link", Required = false,
HelpText = "If set all external resources such as images will be linked as content in the description",
Default = false)]
public bool ContentLink { get; set; }

/// <summary>
/// The search query to get only the needed jira tickets
/// </summary>
[Option(shortName: 'e', longName: "export", Required = false, HelpText = "If set all external resources such as images will be exported to the given repository", Default = false)]
[Option(shortName: 'e', longName: "export", Required = false,
HelpText = "If set all external resources such as images will be exported to the given repository",
Default = false)]
public bool Export { get; set; }

/// <summary>
/// The repository where the assets of jira gets uploaded
/// </summary>
[Option(shortName: 'u', longName: "upload-repo", Required = false, HelpText = "Upload repository for the jira assets.")]
[Option(shortName: 'u', longName: "upload-repo", Required = false,
HelpText = "Upload repository for the jira assets.")]
public string? UploadRepo { get; set; } = null;

/// <summary>
/// The repository where the assets of jira gets uploaded
/// </summary>
[Option(shortName: 'i', longName: "import-owner", Required = false, HelpText = "Owner of the repository the assets should be uploaded to.")]
[Option(shortName: 'i', longName: "import-owner", Required = false,
HelpText = "Owner of the repository the assets should be uploaded to.")]
public string? ImportOwner { get; set; } = null;

/// <summary>
/// The path of the directory the attachments should be imported to
/// </summary>
[Option(shortName: 'p', longName: "import-path", Required = false, HelpText = "The path of the directory the attachments should be imported to.")]
[Option(shortName: 'p', longName: "import-path", Required = false,
HelpText = "The path of the directory the attachments should be imported to.")]
public string? ImportPath { get; set; } = null;

[Option(shortName: 'b', longName: "branch", Required = false, HelpText = "The branch of the repository the attachments should be imported to.")]
[Option(shortName: 'b', longName: "branch", Required = false,
HelpText = "The branch of the repository the attachments should be imported to.")]
public string? Branch { get; set; }

[Option(longName: "linkChildren", Required = false,
HelpText = "If set the children will be linked in the body.",
Default = false)]
public bool LinkChildren { get; set; }

[Option(longName: "linkRelated", Required = false,
HelpText = "If set the related issues will be linked in the comments.",
Default = false)]
public bool LinkRelated { get; set; }

/// <summary>
/// Checks the options if everything is correct
/// </summary>
Expand Down
1 change: 0 additions & 1 deletion jihub.Github/DependencyInjection/GithubServiceSettings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;

namespace jihub.Github.DependencyInjection;

Expand Down
4 changes: 4 additions & 0 deletions jihub.Github/Models/GithubInformation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public record GitHubLabel
string Color
);

public record GitHubComment(
string Body
);

public record GitHubMilestone(
string Title,
int Number,
Expand Down
173 changes: 140 additions & 33 deletions jihub.Github/Services/GithubService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Web;
using jihub.Github.Models;
Expand All @@ -10,6 +9,9 @@ namespace jihub.Github.Services;

public class GithubService : IGithubService
{
private const int batchSize = 10;
private const int delaySeconds = 20;

private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private readonly ILogger<GithubService> _logger;
private readonly HttpClient _httpClient;
Expand All @@ -32,6 +34,100 @@ public async Task<IEnumerable<GithubContent>> GetRepoContent(string owner, strin
return files;
}

/// <inheritdoc />
public async Task LinkChildren(string owner, string repo,
Dictionary<string, List<string>> linkedIssues,
IEnumerable<GitHubIssue> existingIssues,
IEnumerable<GitHubIssue> createdIssues,
CancellationToken cancellationToken)
{
int counter = 0;
var allIssues = existingIssues.Concat(createdIssues).ToArray();
foreach (var linkedIssue in linkedIssues)
{
if (!linkedIssue.Value.Any())
{
continue;
}

var matchingIssues = linkedIssue.Value
.Select(key => createdIssues.FirstOrDefault(i => i.Title.Contains($"(ext: {key})")))
.Where(i => i != null)
.Select(i => $"- [ ] #{i!.Number}");
var issueToUpdate = allIssues.FirstOrDefault(x => x.Title.Contains($"(ext: {linkedIssue.Key})"));
if (issueToUpdate == null || !matchingIssues.Any())
{
continue;
}

var updatedBody = issueToUpdate.Body ?? string.Empty;
if (!updatedBody.Contains("### Children"))
{
updatedBody = $"{updatedBody}\n\n### Children";
}

updatedBody = $"{updatedBody}\n{string.Join("\n", matchingIssues)}";
await UpdateIssue(owner, repo, issueToUpdate.Number, new { body = updatedBody }, cancellationToken).ConfigureAwait(false);

counter++;
if (counter % batchSize != 0)
{
continue;
}

_logger.LogInformation("Delaying 20 seconds for github to catch some air...");
await Task.Delay(TimeSpan.FromSeconds(20), cancellationToken).ConfigureAwait(false);
counter = 0;
}
}

/// <inheritdoc />
public async Task AddRelatesComment(string owner, string repo, Dictionary<string, List<string>> relatedIssues, IEnumerable<GitHubIssue> existingIssues,
IEnumerable<GitHubIssue> createdIssues, CancellationToken cancellationToken)
{
async Task CreateComment(GitHubComment comment, int issueNumber)
{
var url = $"repos/{owner}/{repo}/issues/{issueNumber}/comments";
var result = await _httpClient.PostAsJsonAsync(url, comment, Options, cancellationToken).ConfigureAwait(false);
if (result.StatusCode != HttpStatusCode.Created)
{
_logger.LogError("Couldn't create comment: {Body}", comment.Body);
}
}

int counter = 0;
var allIssues = existingIssues.Concat(createdIssues).ToArray();
foreach (var relatedIssue in relatedIssues)
{
if (!relatedIssue.Value.Any())
{
continue;
}

var matchingIssues = relatedIssue.Value
.Select(key => createdIssues.FirstOrDefault(i => i.Title.Contains($"(ext: {key})")))
.Where(i => i != null)
.Select(i => $"#{i!.Number}");
var issueToUpdate = allIssues.FirstOrDefault(x => x.Title.Contains($"(ext: {relatedIssue.Key})"));
if (issueToUpdate == null || !matchingIssues.Any())
{
continue;
}

await CreateComment(new GitHubComment($"Relates to: {string.Join(", ", matchingIssues)}"), issueToUpdate.Number).ConfigureAwait(false);

counter++;
if (counter % batchSize != 0)
{
continue;
}

_logger.LogInformation("Delaying 20 seconds for github to catch some air...");
await Task.Delay(TimeSpan.FromSeconds(20), cancellationToken).ConfigureAwait(false);
counter = 0;
}
}

public async Task<GitHubInformation> GetRepositoryData(string owner, string repo, CancellationToken cts)
{
var allIssues = new List<GitHubIssue>();
Expand Down Expand Up @@ -142,17 +238,20 @@ public async Task<GitHubMilestone> CreateMilestoneAsync(string name, string owne
throw new($"Couldn't create milestone: {name}");
}

public async Task CreateIssuesAsync(string owner, string repo, IEnumerable<CreateGitHubIssue> issues, CancellationToken cts)
public async Task<IEnumerable<GitHubIssue>> CreateIssuesAsync(string owner, string repo, IEnumerable<CreateGitHubIssue> issues, CancellationToken cts)
{
const int batchSize = 10;
const int delaySeconds = 20;

var counter = 1;
var counter = 0;
var createdIssues = new List<GitHubIssue>();
foreach (var issue in issues)
{
await CreateIssue(issue, cts).ConfigureAwait(false);
var createdIssue = await CreateIssue(owner, repo, issue, cts).ConfigureAwait(false);
if (createdIssue != null)
{
createdIssues.Add(createdIssue);
}

counter++;
if (counter % batchSize + 1 != 0)
if (counter % batchSize != 0)
{
continue;
}
Expand All @@ -162,36 +261,44 @@ public async Task CreateIssuesAsync(string owner, string repo, IEnumerable<Creat
counter = 0;
}

async Task CreateIssue(CreateGitHubIssue issue, CancellationToken ct)
return createdIssues;
}

private async Task<GitHubIssue?> CreateIssue(string owner, string repo, CreateGitHubIssue issue, CancellationToken ct)
{
var url = $"repos/{owner}/{repo}/issues";
_logger.LogInformation("Creating issue: {issue}", issue.Title);
var result = await _httpClient.PostAsJsonAsync(url, issue, Options, ct).ConfigureAwait(false);
if (!result.IsSuccessStatusCode)
{
var url = $"repos/{owner}/{repo}/issues";
_logger.LogError("Couldn't create issue: {label}", issue.Title);
return null;
}

_logger.LogInformation("Creating issue: {issue}", issue.Title);
var result = await _httpClient.PostAsJsonAsync(url, issue, Options, ct).ConfigureAwait(false);
if (!result.IsSuccessStatusCode)
{
_logger.LogError("Couldn't create issue: {label}", issue.Title);
return;
}
var response = await result.Content.ReadFromJsonAsync<GitHubIssue>(Options, ct).ConfigureAwait(false);
if (response == null)
{
_logger.LogError("State of issue {IssueId} could not be changed", issue.Title);
return null;
}

if (issue.State == GithubState.Open)
{
return;
}
if (issue.State == GithubState.Open)
{
return response;
}

var response = await result.Content.ReadFromJsonAsync<GitHubIssue>(Options, ct).ConfigureAwait(false);
if (response == null)
{
_logger.LogError("State of issue {IssueId} could not be changed", issue.Title);
return;
}
await UpdateIssue(owner, repo, response.Number, new { state = "closed" }, ct);

var jsonContent = new StringContent("{\"state\":\"closed\"}", Encoding.UTF8, "application/json");
var updateResult = await _httpClient.PatchAsync($"{url}/{response.Number}", jsonContent, ct).ConfigureAwait(false);
if (!updateResult.IsSuccessStatusCode)
{
_logger.LogError("State of issue {IssueId} could not be changed", issue.Title);
}
return response;
}

private async Task UpdateIssue(string owner, string repo, int id, object data, CancellationToken ct)
{
var url = $"repos/{owner}/{repo}/issues/{id}";
var updateResult = await _httpClient.PatchAsJsonAsync(url, data, Options, ct).ConfigureAwait(false);
if (!updateResult.IsSuccessStatusCode)
{
_logger.LogError("Update of issue {IssueId} failed", id);
}
}

Expand Down
10 changes: 9 additions & 1 deletion jihub.Github/Services/IGithubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ public interface IGithubService
Task<GitHubInformation> GetRepositoryData(string owner, string repo, CancellationToken cts);
Task<ICollection<GitHubLabel>> CreateLabelsAsync(string owner, string repo, IEnumerable<GitHubLabel> missingLabels, CancellationToken cts);
Task<GitHubMilestone> CreateMilestoneAsync(string name, string owner, string repo, CancellationToken cts);
Task CreateIssuesAsync(string owner, string repo, IEnumerable<CreateGitHubIssue> issues, CancellationToken cts);
Task<IEnumerable<GitHubIssue>> CreateIssuesAsync(string owner, string repo, IEnumerable<CreateGitHubIssue> issues, CancellationToken cts);
Task<Committer> GetCommitter();
Task<GithubAsset> CreateAttachmentAsync(string owner, string repo, string? importPath, string? branch, (string Hash, string FileContent) fileData, string name, CancellationToken cts);
Task<IEnumerable<GithubContent>> GetRepoContent(string owner, string repo, string path, CancellationToken cts);

Task LinkChildren(string owner, string repo, Dictionary<string, List<string>> linkedIssues,
IEnumerable<GitHubIssue> existingIssues, IEnumerable<GitHubIssue> createdIssues,
CancellationToken cancellationToken);

Task AddRelatesComment(string owner, string repo, Dictionary<string, List<string>> relatedIssues,
IEnumerable<GitHubIssue> existingIssues, IEnumerable<GitHubIssue> createdIssues,
CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion jihub.Jira/JiraService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task<IEnumerable<JiraIssue>> GetAsync(string searchQuery, int maxRe

private async Task<JiraResult> RequestJiraIssues(string searchQuery, int start, int maxResults, CancellationToken cts)
{
var url = $"?jql={searchQuery}&start={start}&maxResults={maxResults}&fields=key,labels,issuetype,project,status,description,summary,components,fixVersions,versions,customfield_10028,customfield_10020,attachment,assignee";
var url = $"?jql={searchQuery}&start={start}&maxResults={maxResults}&fields=key,labels,issuetype,project,status,description,summary,components,fixVersions,versions,customfield_10028,customfield_10020,attachment,assignee,issuelinks";

_logger.LogInformation("Requesting Jira Issues");
var result = await _httpClient.GetFromJsonAsync<JiraResult>(url, Options, cts).ConfigureAwait(false);
Expand Down
15 changes: 14 additions & 1 deletion jihub.Jira/Models/JiraResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ public record IssueFields
[property: JsonPropertyName("customfield_10028")]
double? StoryPoints,
[property: JsonPropertyName("customfield_10020")]
IEnumerable<string>? Sprints
IEnumerable<string>? Sprints,
[property: JsonPropertyName("issuelinks")]
IEnumerable<JiraIssueLink>? IssueLinks
);

public record JiraAttachment
Expand All @@ -51,3 +53,14 @@ public record FixVersion(string Name);
public record Version(string Name);

public record Component(string Name);

public record JiraIssueLink
(
JiraIssueLinkType Type,
LinkedIssue? OutwardIssue,
LinkedIssue? InwardIssue
);

public record JiraIssueLinkType(string Inward, string Outward);

public record LinkedIssue(string Key);
5 changes: 2 additions & 3 deletions jihub.Parsers/Jira/JiraParser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using jihub.Base;
using jihub.Github.Models;
using jihub.Github.Services;
Expand Down Expand Up @@ -172,7 +171,7 @@ private GithubState GetGithubState(JiraIssue jiraIssue)
var stateKey = _settings.Jira.StateMapping.Any(kvp => kvp.Value.Contains(jiraIssue.Fields.Status.Name, StringComparer.OrdinalIgnoreCase));
if (!stateKey)
{
_logger.LogError("Could not find {State} in state mapping", jiraIssue.Fields.Status.Name);
_logger.LogError("Could not find {State} in state mapping, automatically set to open", jiraIssue.Fields.Status.Name);
}
else
{
Expand Down
Loading

0 comments on commit f970b3f

Please sign in to comment.