Skip to content

Commit

Permalink
introduces new automations for managing incident-based discussions
Browse files Browse the repository at this point in the history
  • Loading branch information
alidacodes committed Nov 19, 2024
1 parent 00cb405 commit b241588
Show file tree
Hide file tree
Showing 14 changed files with 526 additions and 1 deletion.
16 changes: 16 additions & 0 deletions .github/actions/check_open_incident_discussions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative "../lib/github"
require_relative "../lib/discussion"
require "active_support"
require "active_support/core_ext/date_and_time/calculations"
require "active_support/core_ext/numeric/time"

# this action checks for any open incident discussions older than 2 days, returns an array of discussion IDs

discussions = Discussion.find_open_incident_discussions(owner: "community", repo: "community")

discussions.keep_if { |d| Time.parse(d["createdAt"]) < 2.days.ago }.map! { |d| d["id"] }

`echo "DISCUSSION_IDS"=#{discussions} >> $GITHUB_OUTPUT`
32 changes: 32 additions & 0 deletions .github/actions/close_incident_discussions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative "../lib/github"
require_relative "../lib/discussion"

# This script finds the incident discussion, ensures an answer has been marked, and then closes the discussion.

discussion_ids = ENV["DISCUSSION_IDS"]

discussion_ids = discussion_ids.delete("[]")&.split(", ")

if discussion_ids.length == 0
puts "No discussion IDs provided, exiting"
exit
end

discussion_ids.each do |d_id|
# if a public summary has not been provided, find the most recent incident comment and mark it as the answer
unless Discussion.is_answered?(id: d_id)
comment_id = Discussion.find_most_recent_incident_comment_id(id: d_id, actor_login: "github-actions")

unless comment_id.nil?
Discussion.mark_comment_as_answer(comment_id:)
end

body = "![A dark background with two security-themed abstract shapes positioned in the top left and bottom right corners. In the center of the image, bold white text reads \\\"Incident Resolved\\\" with a white Octocat logo.](https://github.com/community/incident-discussion-bot/blob/main/.github/src/incident_resolved.png?raw=true) \n #{Discussion.find_by_id(id: d_id)["body"]}"
Discussion.update_discussion(id: d_id, body:)
end

Discussion.close_as_resolved(id: d_id)
end
38 changes: 38 additions & 0 deletions .github/actions/open_incident_discussion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative "../lib/github"
require_relative "../lib/discussion"
require "active_support/core_ext/date_time"

# This script takes context from a received webhook and creates a new discussion in the correct discussion category

repo_id = "MDEwOlJlcG9zaXRvcnkzMDE1NzMzNDQ="
announcements_category_id = "DIC_kwDOEfmk4M4CQbR2"
incident_label_id = "LA_kwDOEfmk4M8AAAABpaZlTA"

date = Time.now.strftime("%Y-%m-%d")

# we need to take the provided input and generate a new post
title = "[#{date}] Incident Thread"

body = <<~BODY
## :exclamation: An incident has been declared:
**#{ENV['PUBLIC_TITLE']}**
_Subscribe to this Discussion for updates on this incident. Please upvote or emoji react instead of commenting +1 on the Discussion to avoid overwhelming the thread. Any account guidance specific to this incident will be shared in thread and on the [Incident Status Page](#{ENV['INCIDENT_URL']})._
BODY

# we need to create a new discussion in the correct category with the correct label
begin
Discussion.create_incident_discussion(
repo_id:,
title:,
body:,
category_id: announcements_category_id,
labels: [incident_label_id]
)
rescue => ArgumentError
puts "ERROR: One or more arguments missing. #{ArgumentError.message}"
end
22 changes: 22 additions & 0 deletions .github/actions/post_incident_summary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative "../lib/github"
require_relative "../lib/discussion"

# This script takes the public incident summary, adds it as a comment to the incident, and then marks that comment as the answer.

# first, we must identify the correct incident to update, in the case where there are multiple open incident discussions.
open_discussions = Discussion.find_open_incident_discussions(owner: "community", repo: "community")
selected_incident = open_discussions.keep_if { |d| d["body"].include?("#{ENV["INCIDENT_SLUG"]}") }.first

# add the summary as a comment to the discussion
summary = "### Incident Summary \n #{ENV["INCIDENT_PUBLIC_SUMMARY"]}"
comment_id = Discussion.add_comment_with_id(id: selected_incident["id"], body: summary)

# mark this new comment as the answer
Discussion.mark_comment_as_answer(comment_id:)

# update the post body to include the resolved picture
updated_body = "![A dark background with two security-themed abstract shapes positioned in the top left and bottom right corners. In the center of the image, bold white text reads \"Incident Resolved\" with a white Octocat logo.](https://github.com/community/incident-discussion-bot/blob/main/.github/src/incident_resolved.png?raw=true) \n \n #{selected_incident["body"]}"
Discussion.update_discussion(id: selected_incident["id"], body: updated_body)
16 changes: 16 additions & 0 deletions .github/actions/update_incident_discussion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative "../lib/github"
require_relative "../lib/discussion"

# This script takes the context from the latest update dispatch event and updates the active incident discussion

# first, we must identify the correct incident to update, in the case where there are multiple open incident discussions.
open_discussions = Discussion.find_open_incident_discussions(owner: "community", repo: "community")
selected_incident = open_discussions.keep_if { |d| d["body"].include?("#{ENV["INCIDENT_SLUG"]}") }.first["id"]

# next, we need to update the discussion with the new information
body = "### Update \n #{ENV["INCIDENT_MESSAGE"]}"

Discussion.add_comment_with_id(id: selected_incident, body:)
252 changes: 252 additions & 0 deletions .github/lib/discussions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,256 @@ def self.should_comment?(discussion_number: nil, owner: nil, repo: nil)

true
end

def self.create_incident_discussion(repo_id:, title:, body:, category_id:, labels:)
# create a new discussion in the specified category, applies the incident label, and returns the discussion id
return if repo_id.nil? || title.nil? || body.nil? || category_id.nil?

query = <<~QUERY
mutation {
createDiscussion(
input: {
categoryId: "#{category_id}",
repositoryId: "#{repo_id}",
clientMutationId: "rubyGraphQL",
title: "#{title}",
body: "#{body}"
}
) {
clientMutationId
discussion {
id
}
}
}
QUERY

incident_discussion_id = GitHub.new.mutate(graphql: query).dig("data", "createDiscussion", "discussion", "id")

if labels
addLabel = <<~QUERY
mutation {
addLabelsToLabelable(
input: {
labelIds: #{labels},
labelableId: "#{incident_discussion_id}",
clientMutationId: "rubyGraphQL"
}
) {
clientMutationId
}
}
QUERY


GitHub.new.mutate(graphql: addLabel)
end

incident_discussion_id
end

def self.add_comment_with_id(id:, body:)
return if id.nil? || body.nil?
# adds a comment to the given discussion
query = <<~QUERY
mutation {
addDiscussionComment(
input: {
body: "#{body}",
discussionId: "#{id}",
clientMutationId: "rubyGraphQL"
}
) {
clientMutationId
comment {
id
body
}
}
}
QUERY

GitHub.new.mutate(graphql: query).dig("data", "addDiscussionComment", "comment", "id")
end

def self.mark_comment_as_answer(comment_id:)
# marks the given comment as the answer
return if comment_id.nil?

query = <<~QUERY
mutation {
markDiscussionCommentAsAnswer(
input: {
id: "#{comment_id}",
clientMutationId: "rubyGraphQL"
}
) {
clientMutationId
discussion {
id
answer {
id
}
}
}
}
QUERY

GitHub.new.mutate(graphql: query)
end

def self.update_discussion(id:, body:)
return if id.nil? || body.nil?
puts "body: #{body}"

query = <<~QUERY
mutation {
updateDiscussion(
input: {
discussionId: "#{id}",
body: "#{body}",
clientMutationId: "rubyGraphQL"
}
) {
clientMutationId
discussion {
id
}
}
}
QUERY

GitHub.new.mutate(graphql: query)
end

def self.find_most_recent_incident_comment_id(id:, actor_login:)
# finds the most recent comment generated by an incident action
return nil if id.nil? || actor_login.nil?

query = <<~QUERY
query {
node(id: "#{id}") {
... on Discussion {
id
comments(last: 10) {
nodes{
id
createdAt
author {
login
}
}
}
}
}
}
QUERY

# with the results, get an array of comments from the given actor login sorted by most recent
comments = GitHub.new.post(graphql: query).first.dig("comments", "nodes")

return nil if comments.empty?

filtered_comments = comments.keep_if { |comment| comment["author"]["login"] == actor_login }
&.sort_by { |comment| comment["createdAt"] }
.reverse

# return the most recent comment's ID
return nil if filtered_comments.empty?
filtered_comments.first["id"]
end

def self.find_open_incident_discussions(owner:, repo:)
return [] if owner.nil? || repo.nil?

searchquery = "repo:#{owner}/#{repo} is:open author:github-actions label:\\\"Incident \:exclamation\:\\\""

query = <<~QUERY
{
search(
first: 100
query: "#{searchquery}"
type: DISCUSSION
) {
discussionCount
...Results
}
rateLimit {
limit
cost
remaining
resetAt
}
}
fragment Results on SearchResultItemConnection {
nodes {
... on Discussion {
id
createdAt
body
}
}
}
QUERY

GitHub.new.post(graphql: query).first&.dig("nodes")
end

def self.find_by_id(id:)
return if id.nil?

query = <<~QUERY
query {
node(id: "#{id}") {
... on Discussion {
id
body
}
}
}
QUERY

GitHub.new.post(graphql: query).first&.dig("node")
end

def self.close_as_resolved(id:)
# closes the post as resolved

query = <<~QUERY
mutation {
closeDiscussion(
input: {
discussionId: "#{id}",
reason: RESOLVED,
clientMutationId: "rubyGraphQL"
}
) {
clientMutationId
discussion {
id
}
}
}
QUERY

GitHub.new.mutate(graphql: query)
end

def self.is_answered?(id:)
# checks if the post is answered

query = <<~QUERY
query {
node(id: #{id}) {
... on Discussion {
id
isAnswered
}
}
}
QUERY

GitHub.new.post(graphql: query)&.first&.dig("isAnswered")
end
end
1 change: 1 addition & 0 deletions .github/lib/github.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def post(graphql:)

node = JSON.parse(response.body).dig("data", "repository")
node = JSON.parse(response.body).dig("data", "search") if node.nil?
node = JSON.parse(response.body).dig("data", "node") if node.nil? # for when the query is not a repository or search
nodes << node

break unless node&.dig("pageInfo", "hasNextPage")
Expand Down
Binary file added .github/src/incident_resolved.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit b241588

Please sign in to comment.