diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..074d23c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: [master] + pull_request: + +jobs: + check: + name: format / lint / spec + runs-on: ubuntu-24.04-arm + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: install crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: latest + + - name: install shards + run: shards install + + - name: check format + run: crystal tool format --check + + - name: lint with ameba + run: ./bin/ameba + + - name: run specs + run: crystal spec diff --git a/.gitignore b/.gitignore index ed141fe..0248cdc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ jspm_packages .serverless bootstrap +bin lib env.yml - -.idea/ + +.idea/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 765f0e9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: crystal - -# Uncomment the following if you'd like Travis to run specs and check code formatting -# script: -# - crystal spec -# - crystal tool format --check diff --git a/shard.lock b/shard.lock index 4feacb6..f612c4d 100644 --- a/shard.lock +++ b/shard.lock @@ -1,6 +1,6 @@ -version: 1.0 +version: 2.0 shards: - clim: - github: at-grandpa/clim - version: 0.4.1 + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.6.4 diff --git a/shard.yml b/shard.yml index 8bb7853..dec4eb0 100644 --- a/shard.yml +++ b/shard.yml @@ -8,6 +8,11 @@ targets: bootstrap: main: src/main.cr -crystal: 1.11.2 +crystal: ">= 1.20.0" + +development_dependencies: + ameba: + github: crystal-ameba/ameba + version: ~> 1.6 license: MIT diff --git a/spec/github/models_spec.cr b/spec/github/models_spec.cr new file mode 100644 index 0000000..d6759cb --- /dev/null +++ b/spec/github/models_spec.cr @@ -0,0 +1,113 @@ +require "../spec_helper" + +private def subject_from(type : String, url = "", latest_comment_url = "") + Github::Subject.from_json({ + type: type, + title: "title", + url: url, + latest_comment_url: latest_comment_url, + }.to_json) +end + +describe Github::Subject do + describe "#update?" do + it "is true for tracked subject types" do + [ + Github::Subject::Type::PULL_REQUEST, + Github::Subject::Type::ISSUE, + Github::Subject::Type::COMMIT, + Github::Subject::Type::DISCUSSION, + ].each do |type| + subject_from(type).update?.should be_true + end + end + + it "is false for unknown subject types" do + subject_from("Release").update?.should be_false + end + end + + describe "#color" do + it "returns a distinct color per known type" do + subject_from(Github::Subject::Type::PULL_REQUEST).color.should eq "#F6CEE3" + subject_from(Github::Subject::Type::ISSUE).color.should eq "#A9D0F5" + subject_from(Github::Subject::Type::COMMIT).color.should eq "#f5d7a9" + subject_from(Github::Subject::Type::DISCUSSION).color.should eq "#7fffd4" + end + + it "falls back to a default color for unknown types" do + subject_from("Release").color.should eq "#D8D8D8" + end + end + + describe "#comment_url" do + it "prefers latest_comment_url when present" do + subject = subject_from("Issue", url: "u", latest_comment_url: "c") + subject.comment_url.should eq "c" + end + + it "falls back to url when latest_comment_url is blank" do + subject = subject_from("Issue", url: "u", latest_comment_url: "") + subject.comment_url.should eq "u" + end + end +end + +describe Github::Notification do + describe "#mention?" do + it "is true for reasons that mention the user" do + Github::Notification::MENTION_REASONS.each do |reason| + notification_from(reason).mention?.should be_true + end + end + + it "is false for non-mention reasons" do + notification_from("subscribed").mention?.should be_false + notification_from("ci_activity").mention?.should be_false + end + end + + it "parses a GitHub notifications API payload" do + notifications = Array(Github::Notification).from_json(NOTIFICATIONS_FIXTURE) + notifications.size.should eq 1 + + notification = notifications.first + notification.reason.should eq "mention" + notification.subject.type.should eq "Issue" + notification.subject.title.should eq "Spurious failure" + notification.repository.full_name.should eq "octocat/Hello-World" + notification.mention?.should be_true + end +end + +private def notification_from(reason : String) + Github::Notification.from_json({ + reason: reason, + subject: {type: "Issue", title: "title"}, + repository: {owner: {login: "octocat"}}, + }.to_json) +end + +NOTIFICATIONS_FIXTURE = <<-JSON +[ + { + "reason": "mention", + "subject": { + "title": "Spurious failure", + "url": "https://api.github.com/repos/octocat/Hello-World/issues/1", + "latest_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments/1", + "type": "Issue" + }, + "repository": { + "full_name": "octocat/Hello-World", + "html_url": "https://github.com/octocat/Hello-World", + "owner": { + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat.gif", + "html_url": "https://github.com/octocat" + } + }, + "subscription_url": "https://api.github.com/notifications/threads/1/subscription" + } +] +JSON diff --git a/spec/github_notifications_slack_spec.cr b/spec/github_notifications_slack_spec.cr deleted file mode 100644 index 1a4c2f2..0000000 --- a/spec/github_notifications_slack_spec.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./spec_helper" - -describe GithubNotificationsSlack do - # TODO: Write tests - - it "works" do - false.should eq(true) - end -end diff --git a/spec/slack/models_spec.cr b/spec/slack/models_spec.cr new file mode 100644 index 0000000..26da433 --- /dev/null +++ b/spec/slack/models_spec.cr @@ -0,0 +1,22 @@ +require "../spec_helper" + +describe Slack::Attachment do + it "omits unset fields when serialized" do + json = Slack::Attachment.new(text: "hello", color: "#000000").to_json + parsed = JSON.parse(json) + + parsed["text"].should eq "hello" + parsed["color"].should eq "#000000" + parsed.as_h.has_key?("title").should be_false + end +end + +describe Slack::Post do + it "wraps attachments under an attachments key" do + post = Slack::Post.new([Slack::Attachment.new(text: "a")]) + parsed = JSON.parse(post.to_json) + + parsed["attachments"].as_a.size.should eq 1 + parsed["attachments"][0]["text"].should eq "a" + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6ca3d64..eb8812d 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,3 @@ require "spec" -require "../src/github_notifications_slack" +require "../src/github/models" +require "../src/slack/models" diff --git a/src/error/usecase.cr b/src/error/usecase.cr index 178b6c8..6fe7697 100644 --- a/src/error/usecase.cr +++ b/src/error/usecase.cr @@ -3,20 +3,21 @@ require "../slack/repository" module Error class Usecase - def alert(err) - slack = Slack::PostRepository.new ENV["ALERT_WEBHOOK_URL"] + def initialize(@slack_repo : Slack::PostRepository, @slack_id : String, @env : String) + end + def alert(err) message = "エラーみたい…確認してみよっか" attachment = Slack::Attachment.new( fallback: message, - pretext: "<@#{ENV["SLACK_ID"]}> #{message}", + pretext: "<@#{@slack_id}> #{message}", color: "#EB4646", title: err.message, - text: err.backtrace.join('\n'), - footer: "github_notifications_slack (#{ENV["ENV"]})", + text: err.backtrace?.try(&.join('\n')), + footer: "github_notifications_slack (#{@env})", footer_icon: "", ) - slack.send_attachment attachment + @slack_repo.send_attachment attachment {msg: "ng"} end diff --git a/src/github/models.cr b/src/github/models.cr index 685f1fb..67e97db 100644 --- a/src/github/models.cr +++ b/src/github/models.cr @@ -1,39 +1,37 @@ require "json" module Github - class Notifications + class Notification include JSON::Serializable - property subject : Subject - property reason : String - property repository : Repository - property subscription_url : String? + MENTION_REASONS = [ + "assign", + "author", + "comment", + "invitation", + "mention", + "team_mention", + "review_requested", + # "ci_activity", + ] + + getter subject : Subject + getter reason : String + getter repository : Repository + getter subscription_url : String? def mention? : Bool - [ - "assign", - "author", - "comment", - "invitation", - "mention", - "team_mention", - "review_requested", - # "ci_activity", - ].includes?(reason) + reason.in?(MENTION_REASONS) end end class Subject include JSON::Serializable - property type : String - property title : String? - - @[JSON::Field(emit_null: false)] - property url : String = "" - - @[JSON::Field(emit_null: false)] - property latest_comment_url : String = "" + getter type : String + getter title : String? + getter url : String = "" + getter latest_comment_url : String = "" module Type PULL_REQUEST = "PullRequest" @@ -42,13 +40,15 @@ module Github DISCUSSION = "Discussion" end + UPDATE_TYPES = [ + Type::PULL_REQUEST, + Type::ISSUE, + Type::COMMIT, + Type::DISCUSSION, + ] + def update? : Bool - [ - Type::PULL_REQUEST, - Type::ISSUE, - Type::COMMIT, - Type::DISCUSSION, - ].includes?(type) + type.in?(UPDATE_TYPES) end def color : String @@ -67,28 +67,24 @@ module Github end def comment_url : String - if !latest_comment_url.blank? - latest_comment_url - else - url - end + latest_comment_url.presence || url end end class Repository include JSON::Serializable - property full_name : String? - property html_url : String? - property owner : User + getter full_name : String? + getter html_url : String? + getter owner : User end class Comment include JSON::Serializable - property user : User - property html_url : String? - property body : String? + getter user : User + getter html_url : String? + getter body : String? def initialize(@body) @user = User.new @@ -98,9 +94,9 @@ module Github class User include JSON::Serializable - property login : String? - property avatar_url : String? - property html_url : String? + getter login : String? + getter avatar_url : String? + getter html_url : String? def initialize end @@ -109,7 +105,7 @@ module Github class Error include JSON::Serializable - property message : String - property documentation_url : String? + getter message : String + getter documentation_url : String? end end diff --git a/src/github/repository.cr b/src/github/repository.cr index 14012eb..1dcf3ea 100644 --- a/src/github/repository.cr +++ b/src/github/repository.cr @@ -14,26 +14,26 @@ module Github end end - def find_notifications_unread : Array(Notifications) + def find_notifications_unread : Array(Notification) res = @github.get "/notifications" - if res.status_code >= 500 + if res.status.server_error? Serverless::Lambda.print_log "return 5xx error from notifications api" - return Array(Notifications).new - elsif res.status_code == 401 + return [] of Notification + elsif res.status.unauthorized? # GitHub が断続的に 401 を返すことがあるため、毎分の次回実行に任せてスキップする。 # トークン失効などの恒久的な 401 までサイレントに握りつぶす点は本来リトライや # 連続失敗の監視で区別すべきだが、個人用途の通知ツールであり実装コストに # 見合わないため割り切る。 Serverless::Lambda.print_log "return 401 error from notifications api, skip" - return Array(Notifications).new - elsif res.status_code >= 400 + return [] of Notification + elsif res.status.client_error? Serverless::Lambda.print_log "return 4xx error from notifications api" err = Error.from_json res.body - raise "notifications api retrun client error: #{err.message}" + raise "notifications api return client error: #{err.message}" end Serverless::Lambda.print_log "notifications body: #{res.body}" - Array(Notifications).from_json(res.body) + Array(Notification).from_json(res.body) end def find_comment_by_url(url : String) : Comment @@ -43,21 +43,21 @@ module Github end res = @github.get url - if res.status_code >= 500 + if res.status.server_error? Serverless::Lambda.print_log "return 5xx error from comments api" - return Comment.new "comments api retrun server error" - elsif res.status_code >= 400 + return Comment.new "comments api return server error" + elsif res.status.client_error? Serverless::Lambda.print_log "return 4xx error from comments api" err = Error.from_json res.body - return Comment.new "comments api retrun client error: #{err.message}" + return Comment.new "comments api return client error: #{err.message}" end begin Serverless::Lambda.print_log "comment body: #{res.body}" Comment.from_json res.body rescue - Serverless::Lambda.print_log "faild parse comment data" - Comment.new "faild parse comment data" + Serverless::Lambda.print_log "failed parse comment data" + Comment.new "failed parse comment data" end end diff --git a/src/github/usecase.cr b/src/github/usecase.cr index fd749a5..4857ba0 100644 --- a/src/github/usecase.cr +++ b/src/github/usecase.cr @@ -4,21 +4,22 @@ require "../slack/models" module Github class Usecase - def to_slack_attachment(notify : Notifications, pretext : String, message : String) : Slack::Attachment - repo = NotificationRepository.new ENV["GITHUB_TOKEN"] + def initialize(@repo : NotificationRepository, @slack_id : String) + end - comment = repo.find_comment_by_url notify.subject.comment_url + def to_slack_attachment(notify : Notification, pretext : String, message : String) : Slack::Attachment + comment = @repo.find_comment_by_url notify.subject.comment_url Slack::Attachment.new( fallback: pretext, author_name: comment.user.login, author_icon: comment.user.avatar_url, author_link: comment.user.html_url, - pretext: "#{notify.mention? ? "<@#{ENV["SLACK_ID"]}> " : ""}#{pretext}", + pretext: "#{notify.mention? ? "<@#{@slack_id}> " : ""}#{pretext}", color: notify.subject.color, title: notify.subject.title, title_link: comment.html_url, text: comment.body, - footer: !notify.repository.full_name.nil? ? notify.repository.full_name : "github", + footer: notify.repository.full_name || "github", footer_icon: notify.repository.owner.avatar_url, ) end diff --git a/src/main.cr b/src/main.cr index 99505ad..497e72d 100644 --- a/src/main.cr +++ b/src/main.cr @@ -1,14 +1,27 @@ require "./runtime/lambda" +require "./github/repository" +require "./github/usecase" require "./notify/usecase" +require "./slack/repository" require "./error/usecase" +# 必須の環境変数は起動時に解決し、欠如していればこの時点で失敗させる。 +GITHUB_TOKEN = ENV["GITHUB_TOKEN"] +WEBHOOK_URL = ENV["WEBHOOK_URL"] +ALERT_WEBHOOK_URL = ENV["ALERT_WEBHOOK_URL"] +SLACK_ID = ENV["SLACK_ID"] +APP_ENV = ENV["ENV"] + Serverless::Lambda.handler "github_notifications_slack" do |_| begin - notify_uc = Notify::Usecase.new - notify_uc.check_notifications - rescue err - err_uc = Error::Usecase.new - err_uc.alert err - raise err + github_repo = Github::NotificationRepository.new GITHUB_TOKEN + github_uc = Github::Usecase.new github_repo, SLACK_ID + slack_repo = Slack::PostRepository.new WEBHOOK_URL + + Notify::Usecase.new(github_repo, github_uc, slack_repo).check_notifications + rescue error + alert_repo = Slack::PostRepository.new ALERT_WEBHOOK_URL + Error::Usecase.new(alert_repo, SLACK_ID, APP_ENV).alert error + raise error end end diff --git a/src/notify/usecase.cr b/src/notify/usecase.cr index 84c059f..d7955b8 100644 --- a/src/notify/usecase.cr +++ b/src/notify/usecase.cr @@ -1,16 +1,18 @@ -require "../github/models" require "../github/repository" require "../github/usecase" -require "../slack/models" require "../slack/repository" module Notify class Usecase - def check_notifications - github_repo = Github::NotificationRepository.new ENV["GITHUB_TOKEN"] - github_uc = Github::Usecase.new + def initialize( + @github_repo : Github::NotificationRepository, + @github_uc : Github::Usecase, + @slack_repo : Slack::PostRepository, + ) + end - notices = github_repo + def check_notifications + notices = @github_repo .find_notifications_unread .map do |item| message = @@ -21,14 +23,12 @@ module Notify end pretext = "[#{item.subject.type}] #{message}" - github_uc.to_slack_attachment item, pretext, message + @github_uc.to_slack_attachment item, pretext, message end - if notices.size != 0 - slack_repo = Slack::PostRepository.new ENV["WEBHOOK_URL"] - slack_repo.send_attachments notices - - github_repo.notification_to_read + unless notices.empty? + @slack_repo.send_attachments notices + @github_repo.notification_to_read end {msg: "ok"} diff --git a/src/runtime/lambda.cr b/src/runtime/lambda.cr index 932fdae..e7a3c0e 100644 --- a/src/runtime/lambda.cr +++ b/src/runtime/lambda.cr @@ -1,11 +1,14 @@ require "json" +require "log" require "http/client" module Serverless module Lambda extend self - def handler(name : String) + Log = ::Log.for("lambda") + + def handler(name : String, &) return if name != ENV["_HANDLER"] ENV["SSL_CERT_FILE"] = "/etc/pki/tls/cert.pem" @@ -19,10 +22,10 @@ module Serverless body = yield event header = nil url = "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/#{request_id}/response" - rescue err + rescue error body = { msg: "Internal Lambda Error", - err: err.message, + err: error.message, } header = HTTP::Headers{"Lambda-Runtime-Function-Error-Type" => "Unhandled"} url = "http://#{ENV["AWS_LAMBDA_RUNTIME_API"]}/2018-06-01/runtime/invocation/#{request_id}/error" @@ -32,10 +35,11 @@ module Serverless end end + # CloudWatch では改行ごとにログエントリが分割されるため、改行を除去して + # 1 エントリにまとめつつ、長い本文は適度なチャンクに分割して出力する。 def print_log(log : String) - log.split(//).each_slice(50000) do |line| - puts `echo '#{line.join.gsub(/(\r\n|\r|\n|\f)/, "")}'` - STDOUT.flush + log.gsub(/(\r\n|\r|\n|\f)/, "").each_char.each_slice(50000) do |chunk| + Log.info { chunk.join } end end end diff --git a/src/slack/models.cr b/src/slack/models.cr index 4711b2a..107777d 100644 --- a/src/slack/models.cr +++ b/src/slack/models.cr @@ -4,17 +4,17 @@ module Slack class Attachment include JSON::Serializable - property fallback : String? - property author_name : String? - property author_icon : String? - property author_link : String? - property pretext : String? - property color : String? - property title : String? - property title_link : String? - property text : String? - property footer : String? - property footer_icon : String? + getter fallback : String? + getter author_name : String? + getter author_icon : String? + getter author_link : String? + getter pretext : String? + getter color : String? + getter title : String? + getter title_link : String? + getter text : String? + getter footer : String? + getter footer_icon : String? def initialize( @fallback = nil, @@ -27,7 +27,7 @@ module Slack @title_link = nil, @text = nil, @footer = nil, - @footer_icon = nil + @footer_icon = nil, ) end end @@ -35,7 +35,7 @@ module Slack class Post include JSON::Serializable - property attachments : Array(Attachment) + getter attachments : Array(Attachment) def initialize(@attachments) end diff --git a/src/slack/repository.cr b/src/slack/repository.cr index a1d04dc..eea7f1f 100644 --- a/src/slack/repository.cr +++ b/src/slack/repository.cr @@ -5,24 +5,20 @@ require "./models" module Slack class PostRepository - def initialize(@url : String) - @uri = URI.parse @url - end - - private def send_post(post : Post) - HTTP::Client.post(@uri, - body: post.to_json - ) + def initialize(url : String) + @uri = URI.parse url end def send_attachment(attachment : Attachment) - post = Post.new [attachment] - send_post post + send_attachments [attachment] end def send_attachments(attachments : Array(Attachment)) - post = Post.new attachments - send_post post + send_post Post.new(attachments) + end + + private def send_post(post : Post) + HTTP::Client.post(@uri, body: post.to_json) end end end