Useful skill for GitHub customers. Retrieve and assign issues. Get billing info.
Code
Usage:
`@abbot github default {owner}/{repo}` – sets the default repository for this skill for the current room.
`@abbot github user {{mention}} is {{github-username}}` to map a chat user to their GitHub username.
`@abbot github issue triage [{owner}/{repo}]` – Retrieves unassigned issues. Limited to 20.
`@abbot github issue close #{number} [{owner}/{repo}]` – closes the specified issue.
`@abbot github issue #{number} [{owner}/{repo}]` – retrieves the issue by the issue number. `{owner}/{repo}` is not needed if the default repo is set.
`@abbot github issue assign #{number} to {assignee} [{owner}/{repo}]` – assigns {assignee} to the issue. {assignee} could be a chat user, a GitHub username without the `@` prefix, or «me».
`@abbot github issues assigned to {assignee}` – Retrieves open issues assigned to {assignee}. {assignee} could be a chat user, a GitHub username without the `@` prefix, or «me».
`@abbot github billing {org-or-user}` – reports billing info for the specified organization or user. _Requires that a secret named `GitHubToken` be set with `admin:org` permission for orgs and `user` scope if reporting on a user._
*/
using Octokit;
// The key used to store and retrieve the default repository.
// Embedding the room name ensures the default repository is per-room so
// each room can have its own default.
var roomKey = Bot.Room.Id ?? Bot.Room.Name;;
readonly string DefaultRepositoryBrainKey = $»{roomKey}|DefaultRepository»;
// GitHub Developer Token with access to the repository and org to query.
var githubToken = await Bot.Secrets.GetAsync(«GitHubToken»);
if (githubToken is not {Length: > 0}) {
await Bot.ReplyAsync(«This skill requires a GitHub Developer Token set up as a secret. »
+ «Visit https://github.com/settings/tokens to create a token. »
+ $»Then visit {Bot.SkillUrl} and click \»Manage skill secrets\» to add a secret named `GitHubToken` with the token you created at GitHub.com.»);
return;
}
// For GitHub Enterprise, pass in the Base URL after the ProductHeaderValue argument.
// TODO: Make the API url settable so GitHub Enterprise users don’t have to edit the skill.
var github = new GitHubClient(new ProductHeaderValue(«Abbot»)) {
Credentials = new Credentials(githubToken)
};
/*———————————–
MAIN ENTRY POINT
*———————————–*/
var (cmd, arg) = Bot.Arguments;
Task action = (cmd, arg) switch {
({Value: «default»}, _) => GetOrSetDefaultRepoAsync(Bot.Arguments.Skip(1)),
({Value: «billing»}, _) => ReplyWithBillingInfoAsync(Bot.Arguments.Skip(1)),
({Value: «issue»} or {Value: «issues»}, _) => HandleIssueSubCommandAsync(Bot.Arguments.Skip(1)),
({Value: «user»}, _) => MapGitHubUserToChatUser(Bot.Arguments.Skip(1)),
_ => ReplyWithUsage()
};
await action;
if (!Bot.IsChat && !Bot.IsInteraction && !Bot.IsRequest) {
// This was called by a schedule. Yes, we should have a property for this.
await Bot.ReplyAsync(«_Previous message brought to you by a scheduled `github` skill._»);
}
async Task HandleIssueSubCommandAsync(IArguments arguments) {
Task result = arguments switch {
(IMissingArgument, _) => Bot.ReplyAsync(«`@abbot help github` to learn how to use this skill.»),
({Value: «triage»}, _) => ReplyWithIssueTriageAsync(arguments.Skip(1)),
({Value: «assign»}, _) => AssignIssueAndReplyAsync(arguments.Skip(1)),
({Value: «close»}, _) => CloseIssueAndReplyAsync(arguments.Skip(1)),
({Value: «assigned»}, _) => ReplyWithAssignedIssues(arguments.Skip(1)),
({Value: «user»}, _) => MapGitHubUserToChatUser(arguments.Skip(1)),
var (issueNumber, repo) => ReplyWithIssueAsync(issueNumber, repo)
};
await result;
}
async Task MapGitHubUserToChatUser(IArguments args) {
var (user, preposition, mapping) = args;
if (mapping is IMissingArgument) { // Preposition is optional.
mapping = preposition;
}
Task task = (user, mapping) switch {
(IMissingArgument, _) or (_, IMissingArgument) => Bot.ReplyAsync(«Please supply both a GitHub username and a chat user»),
(IMentionArgument, IMentionArgument) => Bot.ReplyAsync(«You specified two chat users. One should be a GitHub username without the `@` sign.»),
(IMentionArgument mention, var githubUser) => MapGitHubUserToChatUser(githubUser.Value, mention.Mentioned),
(var githubUser, IMentionArgument mention) => MapGitHubUserToChatUser(githubUser.Value, mention.Mentioned),
(_, _) => Bot.ReplyAsync(«You specified two GitHub users. One should be a GitHub username without the `@` sign and the other should be a chat user mention.»),
};
await task;
}
async Task MapGitHubUserToChatUser(string username, IChatUser chatUser) {
var gitHubUsername = await EnsureGitHubUsername(username);
if (gitHubUsername is null) {
await Bot.ReplyAsync($»GitHub tells me {username} does not exist.»);
}
await Bot.Brain.WriteAsync(GetUserMapKey(chatUser), gitHubUsername);
await Bot.ReplyAsync($»Mapped {chatUser} to GitHub user {gitHubUsername}.»);
}
async Task<string> GetGitHubUserNameForMention(IChatUser mentioned) {
return await Bot.Brain.GetAsync(GetUserMapKey(mentioned));
}
string GetUserMapKey(IChatUser user) {
return $»user:{user.Id}»;
}
async Task CloseIssueAndReplyAsync(IArguments args) {
var (numArg, prepositionArg, repositoryArg) = args;
// We support @abbot github issue close #123 for aseriousbiz/blog or
// @abbot github issue close #123 aseriousbiz/blog (without the preposition).
if (repositoryArg is IMissingArgument) {
repositoryArg = prepositionArg;
}
var issueNumber = numArg.ToInt32();
if (issueNumber is null) {
await Bot.ReplyAsync(«Please provide an issue number.»);
return;
}
var (owner, repo) = await GetRepoOrDefault(repositoryArg);
if (owner is null || repo is null) {
await Bot.ReplyAsync($»Please specify which repository this is for or set a default repository first. `@abbot help github` to learn more.»);
return;
}
var issue = await github.Issue.Get(owner, repo, issueNumber.Value);
if (issue is null) {
await Bot.ReplyAsync($»Could not find issue #{issueNumber} for {owner}/{repo} or I do not have access to it.»);
return;
}
var update = issue.ToUpdate();
update.State = ItemState.Closed;
await github.Issue.Update(owner, repo, issueNumber.Value, update);
await Bot.ReplyAsync($»Closed issue #{issueNumber}.»);
}
async Task AssignIssueAndReplyAsync(IArguments args) {
var (numArg, prepositionArg, assigneeArg, repositoryArg) = args;
// We support @abbot github issue assign #123 to haacked or
// @abbot github issue assign #123 haacked
// (without the preposition).
if (assigneeArg is IMissingArgument) {
assigneeArg = prepositionArg;
}
var issueNumber = numArg.ToInt32();
var (owner, repo) = await GetRepoOrDefault(repositoryArg);
Task task = (issueNumber, assigneeArg, owner, repo) switch {
(null, _, _, _) => Bot.ReplyAsync($»Please provide an issue number and an assignee. `-{numArg.Value}-` {assigneeArg}»),
(_, IMissingArgument, _, _) => Bot.ReplyAsync(«Please provide an assignee.»),
(_, _, _, _) => AssignIssue(issueNumber.Value, assigneeArg, owner, repo)
};
await task;
}
async Task AssignIssue(int issueNumber, IArgument assigneeArg, string owner, string repo) {
if (owner is null || repo is null) {
await Bot.ReplyAsync($»Please specify which repository this is for or set a default repository first. `@abbot help github` to learn more.»);
return;
}
var assignee = await GetAssigneeFromArgument(assigneeArg);
if (assignee is null) {
await Bot.ReplyAsync(GetUserNotFoundMessage(assigneeArg));
return;
}
var issue = await github.Issue.Get(owner, repo, issueNumber);
if (issue is null) {
await Bot.ReplyAsync($»Could not find issue #{issueNumber} for {owner}/{repo} or I do not have access to it.»);
return;
}
var update = issue.ToUpdate();
update.ClearAssignees();
update.AddAssignee(assignee);
await github.Issue.Update(owner, repo, issueNumber, update);
await Bot.ReplyAsync($»Assigned {issueNumber} to {assignee}.»);
}
static string GetUserNotFoundMessage(IArgument userArg) {
return userArg is IMentionArgument mentioned
? $»I don’t know the GitHub username for {userArg}. `@abbot github user {{mention}} is {{github-username}}` to tell me.»
: $»I could not find the GitHub user with the username `{userArg}. Either the user does not exist or the GitHub Token supplied to this skill does not have permissions for that user.»;
}
async Task ReplyWithAssignedIssues(IArguments args) {
var (prepositionArg, assigneeArg, forArg, repoArg) = args;
// User can ask `@abbot github issues assigned to me` or `@abbot github issues assigned me`
if (prepositionArg is not {Value: «to»}) {
// Move arguments up by one.
repoArg = forArg;
forArg = assigneeArg;
assigneeArg = prepositionArg;
}
if (repoArg is IMissingArgument) {
// User can ask `@abbot github issues assigned to me for aseriousbiz/abbot-skills` or `@abbot github issues assigned me aseriousbiz/abbot-skills`
repoArg = forArg;
}
var assignee = await GetAssigneeFromArgument(assigneeArg);
if (assignee is null) {
await Bot.ReplyAsync(GetUserNotFoundMessage(assigneeArg));
return;
}
var (owner, repo) = await GetRepoOrDefault(repoArg);
var request = new RepositoryIssueRequest {
Assignee = assignee,
State = ItemStateFilter.Open,
Filter = IssueFilter.All
};
var apiOptions = new ApiOptions {
PageSize = 20,
PageCount = 1
};
var issues = await github.Issue.GetAllForRepository(owner, repo, request, apiOptions);
if (issues is {Count: 0}) {
var assigneeOut = assigneeArg is {Value: «me»}
? «you»
: assignee;
await Bot.ReplyAsync($»Good job! No open issues assigned to {assigneeOut}.»);
return;
}
var reply = issues.Select(FormatIssue).ToMarkdownList();
await Bot.ReplyAsync(reply);
}
// Return all open and unassigend issues in the repository.
async Task ReplyWithIssueTriageAsync(IArguments args) {
var (prepositionArg, repoArg) = args;
if (repoArg is IMissingArgument) {
repoArg = prepositionArg;
}
var (owner, repo) = await GetRepoOrDefault(repoArg);
if (repo is null || owner is null) {
await Bot.ReplyAsync(«Repository must set as default or supplied in the form `owner/name`. `@abbot help github` for more information.»);
return;
}
var request = new RepositoryIssueRequest {
Assignee = «none»,
State = ItemStateFilter.Open,
Filter = IssueFilter.All
};
var apiOptions = new ApiOptions {
PageSize = 20,
PageCount = 1
};
var issues = await github.Issue.GetAllForRepository(owner, repo, request, apiOptions);
if (issues is {Count: 0}) {
await Bot.ReplyAsync(«Good job! No issues to triage.»);
return;
}
var reply = issues.Select(FormatIssue).ToMarkdownList();
await Bot.ReplyAsync(reply);
}
async Task ReplyWithIssueAsync(IArgument numArg, IArgument repository) {
var (owner, repo) = await GetRepoOrDefault(repository);
if (repo is null || owner is null) {
await Bot.ReplyAsync(«Repository must set as default or supplied in the form `owner/name`. `@abbot help github` for more information.»);
return;
}
var num = numArg.ToInt32();
if (!num.HasValue) {
await Bot.ReplyAsync(«Issue number must be a number»);
return;
}
try {
var issue = await github.Issue.Get(owner, repo, num.Value);
await Bot.ReplyAsync($»{FormatIssue(issue)}\n{issue.Body}»);
}
catch (NotFoundException) {
await Bot.ReplyAsync($»Could not retrieve issue {num} in {owner}/{repo}.»);
}
}
async Task ReplyWithBillingInfoAsync(IArguments args) {
var (forArg, ownerArg) = args;
if (ownerArg is IMissingArgument) {
ownerArg = forArg;
}
if (ownerArg is IMissingArgument) {
await Bot.ReplyAsync(«Please specify an owner (user or org) to get billing info for. Ex: `@abbot github billing {owner}`.»);
return;
}
var isOrg = await IsOrgAsync(ownerArg.Value);
var billingBaseApiUrl = github.BaseAddress
+ (isOrg ? «orgs» : «users»)
+ $»/{ownerArg}/settings/billing/»;
var apiRequests = new Task<dynamic>[] {
GetBillingInfoAsync(billingBaseApiUrl, «actions»),
GetBillingInfoAsync(billingBaseApiUrl, «packages»),
GetBillingInfoAsync(billingBaseApiUrl, «shared-storage»)
};
var responses = await Task.WhenAll(apiRequests);
var actions = responses[0];
var packages = responses[1];
var storage = responses[2];
await Bot.ReplyAsync($@»Billing Info for `{ownerArg}`.
GitHub Actions:
«`
Total Minutes Used: {actions.total_minutes_used}
Paid Minutes Used : {actions.total_paid_minutes_used}
Included Minutes : {actions.included_minutes}
Minutes Used Breakdown: {actions.minutes_used_breakdown}
«`
GitHub Packages:
«`
Total Bandwidth Used (GB): {packages.total_gigabytes_bandwidth_used}
Paid Bandwidth Used (GB) : {packages.total_paid_gigabytes_bandwidth_used}
Included Bandwidth (GB) : {packages.included_gigabytes_bandwidth}
«`
GitHub Storage:
«`
Days Left in Billing Cycle : {storage.days_left_in_billing_cycle}
Estimated Paid Storage for Month: {storage.estimated_paid_storage_for_month}
Estimated Storage for Month : {storage.estimated_storage_for_month}
«`
«);
}
bool isOrg = true; // Only applies to the Billing API call
async Task<dynamic> GetBillingInfoAsync(string baseApiUrl, string endpoint) {
var headers = new Headers {
{«Authorization», $»token {githubToken}»},
{«Accept», «application/vnd.github.v3+json»}
};
var apiUrl = new Uri($»{baseApiUrl}{endpoint}»);
try {
return await Bot.Http.GetJsonAsync(apiUrl, headers);
}
catch(HttpRequestException) {
isOrg = false;
return new {
total_minutes_used = «unknown»,
total_paid_minutes_used = «unknown»,
included_minutes = «unknown»,
total_gigabytes_bandwidth_used = «unknown»,
total_paid_gigabytes_bandwidth_used = «unknown»,
included_gigabytes_bandwidth = «unknown»,
days_left_in_billing_cycle = «unknown»,
estimated_paid_storage_for_month = «unknown»,
estimated_storage_for_month = «unknown»,
minutes_used_breakdown = «unknown»
};
}
}
async Task<(string, string)> GetRepoOrDefault(IArgument arg) {
var nameWithOwner = arg is IMissingArgument
? await GetDefaultRepoAsync()
: arg.Value;
return ParseNameWithOwner(nameWithOwner);
}
(string, string) ParseNameWithOwner(IArgument arg) {
return ParseNameWithOwner(arg.Value);
}
(string, string) ParseNameWithOwner(string repository) {
if (repository is null) {
return (null, null);
}
var parts = repository.Split(‘/’);
if (parts is {Length: 2}) {
return (parts[0], parts[1]);
}
return (null, null);
}
async Task ReplyWithUsage() {
var usage = $@»`{Bot} help {Bot.SkillName}` to get help using this skill.»;
await Bot.ReplyAsync(usage);
}
string FormatIssue(Issue issue) {
return $»[#{issue.Number} – {issue.Title} ({FormatAssignee(issue.Assignee)} – {issue.State})]({issue.HtmlUrl})»;
}
string FormatAssignee(User user) {
return user is null
? «unassigned»
: «assigned to {user.Name}»;
}
async Task<bool> IsOrgAsync(string org) {
try {
var organization = await github.Organization.Get(org);
return organization is not null;
}
catch (NotFoundException) {
return false;
}
}
async Task GetOrSetDefaultRepoAsync(IArguments args) {
var (isArg, repoArg) = args;
if (repoArg is IMissingArgument) {
// We support «@abbot default is aseriousbiz/abbot-skills» and «@abbot default aseriousbiz/abbot-skills»
repoArg = isArg;
}
if (repoArg is IMissingArgument) {
var currentDefault = await GetDefaultRepoAsync();
if (currentDefault is null) {
await Bot.ReplyAsync(«There is no default repository set for this channel yet. `@abbot github default owner/repo` to set a default repository.»);
}
else {
await Bot.ReplyAsync($»The current default repository is `{currentDefault}`.»);
}
return;
}
var (owner, repo) = ParseNameWithOwner(repoArg);
if (owner is null || repo is null) {
await Bot.ReplyAsync(«To set a default repository, make sure the repository is provided in the `owner/repo` format.»);
return;
}
try {
var repository = await github.Repository.Get(owner, repo);
}
catch (NotFoundException) {
await Bot.ReplyAsync($»{repoArg} doesn’t seem to be a repository or access is denied. Check your GitHub Token permissions.»);
return;
}
await WriteDefaultRepoAsync(repoArg.Value);
await Bot.ReplyAsync($»`{repoArg}` is now the default repository.»);
}
async Task<string> GetDefaultRepoAsync() {
return await Bot.Brain.GetAsync(DefaultRepositoryBrainKey);
}
async Task WriteDefaultRepoAsync(string repo) {
await Bot.Brain.WriteAsync(DefaultRepositoryBrainKey, repo);
}
async Task<string> GetAssigneeFromArgument(IArgument assigneeArg) {
return assigneeArg switch {
{Value: «me»} or {Value: «mi»} or {Value: «moi»} => await GetGitHubUserNameForMention(Bot.From),
IMentionArgument mentioned => await GetGitHubUserNameForMention(mentioned.Mentioned),
_ => await EnsureGitHubUsername(assigneeArg.Value)
};
}
async Task<string> EnsureGitHubUsername(string username) {
try {
var user = await github.User.Get(username);
return user.Name;
}
catch (NotFoundException) {
return null;
}
}