https://github.com/openjournals/whedon-api
Tip revision: 4fcc30bacdccaf82d47d334753dd728bc8fd8249 authored by Juanjo Bazán on 12 January 2022, 11:12:57 UTC
disable tests
disable tests
Tip revision: 4fcc30b
whedon_api.rb
require_relative 'github'
require_relative 'workers'
require 'chronic'
require 'date'
require 'sinatra/base'
require 'fileutils'
require 'json'
require 'octokit'
require 'rest-client'
require 'securerandom'
require 'sinatra/config_file'
require 'whedon'
require 'yaml'
require 'pry'
include GitHub
class WhedonApi < Sinatra::Base
register Sinatra::ConfigFile
set :views, Proc.new { File.join(root, "responses") }
config_file "config/settings-#{ENV['RACK_ENV']}.yml"
set :configs, {}
set :initialized, false
before do
set_configs unless journal_configs_initialized?
if %w[dispatch].include? request.path_info.split('/')[1]
sleep(2) unless testing? # This seems to help with auto-updating GitHub issue threads
params = JSON.parse(request.env["rack.input"].read)
# Only work with issues. Halt if there isn't an issue in the JSON
halt 422 if params['issue'].nil?
@action = params['action']
@payload = params
if @action == 'created'
@message = params['comment']['body']
end
@sender = params['sender']['login']
@issue_id = params['issue']['number']
@nwo = params['repository']['full_name']
@config = settings.configs[@nwo]
halt 422 unless @config # We probably want to restrict this
else
pass
end
end
def journal_configs_initialized?
settings.initialized
end
def testing?
ENV['RACK_ENV'] == "test"
end
def serialized_config
@config.to_h
end
def set_configs
# 'settings.journals' comes from sinatra/config_file
settings.journals.each do |journal|
journal.each do |nwo, params|
team_id = params["editor_team_id"]
params["editors"] = github_client.team_members(team_id).collect { |e| e.login }.sort
settings.configs[nwo] = OpenStruct.new params
end
end
settings.initialized = true
end
def say_hello
if issue.title.match(/^\[REVIEW\]:/)
respond erb :reviewer_welcome, :locals => { :reviewer => reviewers, :nwo => @nwo, :reviewers => @config.reviewers }
reviewers.each {|r| schedule_reminder(r, '2', 'weeks', quiet=true)}
# Newly created [PRE REVIEW] issue with assignees.
elsif issue.title.match(/^\[PRE REVIEW\]:/) && assignees.any?
respond erb :welcome, :locals => { :editor => assignees.first, :reviewers => @config.reviewers }
# Newly created [PRE REVIEW] issue without assignees.
elsif issue.title.match(/^\[PRE REVIEW\]:/)
respond erb :welcome, :locals => { :editor => nil, :reviewers => @config.reviewers }
# Newly created issue, not created by JOSS, probably as a result of the 'convert to issue' feature on GitHub
else
respond erb :close
close_issue(@nwo, @issue_id)
halt
end
repo_detect(nil)
check_references(nil)
process_pdf(nil)
end
# When an issue is closed we want to encourage authors to add the JOSS status
# badge to their README but also potentially donate to JOSS (and sign up as a
# future reviewer)
def say_goodbye
if review_issue?
# If the REVIEW has been marked as 'accepted'
if issue.labels.collect {|l| l.name }.include?('accepted')
respond erb :goodbye, :locals => {:site_host => @config.site_host,
:site_name => @config.site_name,
:reviewers => @config.reviewers_signup,
:doi_prefix => @config.doi_prefix,
:doi_journal => @config.journal_alias,
:issue_id => @issue_id,
:donate_url => @config.donate_url}
end
end
end
def review_issue?
issue.title.match(/^\[REVIEW\]:/)
end
def assignees
@assignees ||= github_client.issue(@nwo, @issue_id).assignees.collect { |a| a.login }
end
# One giant case statement to decide how to handle an incoming message...
def robawt_respond
case @message
when /\A@whedon/i
respond "I have been decommissioned, my succesor @editorialbot will help you. \n\nPlease use `@editorialbot help` to list available options." unless @sender == "whedon"
end
end
def invite_editor(editor)
editor_handle = editor.gsub(/^\@/, "").strip
url = "#{@config.site_host}/papers/api_editor_invite?id=#{@issue_id}&editor=#{editor_handle}&secret=#{@config.site_api_key}"
response = RestClient.post(url, "")
if response.code == 204
respond "@#{editor_handle} has been invited to edit this submission."
else
respond "There was a problem inviting `@#{editor_handle}` to edit this submission."
end
end
def reject_paper
url = "#{@config.site_host}/papers/api_reject?id=#{@issue_id}&secret=#{@config.site_api_key}"
response = RestClient.post(url, "")
if response.code == 204
label_issue(@nwo, @issue_id, ['rejected'])
respond "Paper rejected."
close_issue(@nwo, @issue_id)
else
respond "There was a problem rejecting the paper."
end
end
def withdraw_paper
url = "#{@config.site_host}/papers/api_withdraw?id=#{@issue_id}&secret=#{@config.site_api_key}"
response = RestClient.post(url, "")
if response.code == 204
label_issue(@nwo, @issue_id, ['withdrawn'])
respond "Paper withdrawn."
close_issue(@nwo, @issue_id)
else
respond "There was a problem withdrawing the paper."
end
end
def schedule_reminder(human, size, unit, quiet=false)
# Check that the person we're expecting to remind is actually
# mentioned in the issue body (i.e. is a reviewer or author)
issue = github_client.issue(@nwo, @issue_id)
unless issue.body.match(/#{human}/m) || @config.editors.include?(@sender)
respond "#{human} doesn't seem to be a reviewer or author for this submission."
halt
end
unless issue.title.match(/^\[REVIEW\]:/)
respond "Sorry, I can't set reminders on PRE-REVIEW issues."
halt
end
schedule_at = target_time(size, unit)
if schedule_at
# Schedule reminder
ReviewReminderWorker.perform_at(schedule_at, human, @nwo, @issue_id, serialized_config)
respond "Reminder set for #{human} in #{size} #{unit}" unless quiet
else
respond "I don't recognize this description of time '#{size}' '#{unit}'."
end
end
# Return Date object + some number of days specified
def target_time(size, unit)
Chronic.parse("in #{size} #{unit}")
end
# How Whedon talks
def respond(comment, nwo=nil, issue_id=nil)
nwo ||= @nwo
issue_id ||= @issue_id
github_client.add_comment(nwo, issue_id, comment)
end
# Check if the review issue has an archive DOI set already
def archive_doi?
archive = issue.body[/(?<=\*\*Archive:\*\*.<a\shref=)"(.*?)"/]
if archive
return true
else
return false
end
end
def check_references(custom_branch=nil)
if custom_branch
respond "```\nAttempting to check references... from custom branch #{custom_branch}\n```"
end
DOIWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch)
end
def deposit(dry_run, custom_branch=nil)
if review_issue?
if !archive_doi?
respond "No archive DOI set. Exiting..."
return
end
if dry_run == true
label_issue(@nwo, @issue_id, ['recommend-accept'])
respond "```\nAttempting dry run of processing paper acceptance...\n```"
DOIWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch)
DepositWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch, dry_run=true)
else
label_issue(@nwo, @issue_id, ['accepted', 'published'])
respond "```\nDoing it live! Attempting automated processing of paper acceptance...\n```"
DepositWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch, dry_run=false)
end
else
respond "I can't accept a paper that hasn't been reviewed!"
end
end
# Download and compile the PDF
def process_pdf(custom_branch=nil)
# TODO refactor this so we're not passing so many arguments to the method
if custom_branch
respond "```\nAttempting PDF compilation from custom branch #{custom_branch}. Reticulating splines etc...\n```"
end
PDFWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch)
end
# Detect the languages and license of the review repository.
# Also checks the paper for a statement of need and the word count.
def repo_detect(custom_branch=nil)
RepoWorker.perform_async(@nwo, @issue_id, serialized_config, custom_branch)
end
# Update the archive on the review issue
def assign_archive(doi_string)
doi = doi_string[/\b(10[.][0-9]{4,}(?:[.][0-9]+)*\/(?:(?!["&\'<>])\S)+)\b/]
if doi
doi_with_url = "<a href=\"https://doi.org/#{doi}\" target=\"_blank\">#{doi}</a>"
new_body = issue.body.gsub(/\*\*Archive:\*\*\s*(.*|Pending)/i, "**Archive:** #{doi_with_url}")
github_client.update_issue(@nwo, @issue_id, issue.title, new_body)
respond "OK. #{doi_with_url} is the archive."
else
respond "#{doi_string} doesn't look like an archive DOI."
end
end
# Update the version on the review issue
def assign_version(version_string)
if version_string
new_body = issue.body.gsub(/\*\*Version:\*\*\s*(.*)/i, "**Version:** #{version_string}")
github_client.update_issue(@nwo, @issue_id, issue.title, new_body)
respond "OK. #{version_string} is the version."
else
respond "#{version_string} doesn't look like a valid version string."
end
end
# Returns a string response with URL to Gist of reviewers
def all_reviewers
"Here's the current list of reviewers: #{@config.reviewers}"
end
# Change the editor on an issue. This is a two-step process:
# 1. Change the review issue assignee
# 2. Update the editor listed at the top of the issue
# TODO: Refactor this mess
def assign_editor(new_editor)
new_editor = @sender if new_editor == "me"
new_editor = new_editor.gsub(/^\@/, "").strip
new_body = issue.body.gsub(/\*\*Editor:\*\*\s*(@\S*|Pending)/i, "**Editor:** @#{new_editor}")
# This line updates the GitHub issue with the new editor
github_client.update_issue(@nwo, @issue_id, issue.title, new_body, :assignees => [])
url = "#{@config.site_host}/papers/api_assign_editor?id=#{@issue_id}&editor=#{new_editor}&secret=#{@config.site_api_key}"
response = RestClient.post(url, "")
reviewer_logins = reviewers.map { |reviewer_name| reviewer_name.sub(/^@/, "") }
update_assignees([new_editor] | reviewer_logins)
new_editor
end
# Change the reviewer listed at the top of the issue (clobber any that exist)
def assign_reviewer(new_reviewer)
set_reviewers([new_reviewer])
end
# Add a reviewer (don't clobber existing ones)
def add_reviewer(reviewer)
set_reviewers(reviewers + [reviewer])
end
# Remove a reviewer from the list
def remove_reviewer(reviewer)
set_reviewers(reviewers - [reviewer])
end
def set_reviewers(reviewer_list)
reviewer_logins = reviewer_list.map { |reviewer_name| reviewer_name.sub(/^@/, "").downcase }.uniq
label = reviewer_list.empty? ? "Pending" : reviewer_list.join(", ")
new_body = issue.body.gsub(/\*\*Reviewers?:\*\*\s*(.+?)\r?\n/i, "**Reviewers:** #{label}\r\n")
reviewer_logins.each do |reviewer_name|
github_client.add_collaborator(@nwo, reviewer_name)
end
github_client.update_issue(@nwo, @issue_id, issue.title, new_body, :assignees => [])
update_assignees([editor] | reviewer_logins)
end
def editor?
!issue.body.match(/\*\*Editor:\*\*\s*.@(\S*)/).nil?
end
def editor
issue.body.match(/\*\*Editor:\*\*\s*.@(\S*)/)[1]
end
def invite_reviewer(reviewer_name)
reviewer_name = reviewer_name.sub(/^@/, "").downcase
existing_invitees = github_client.repository_invitations(@nwo).collect {|i| i.invitee.login.downcase }
if existing_invitees.include?(reviewer_name)
respond "The reviewer already has a pending invite.\n\n@#{reviewer_name} please accept the invite by clicking this link: https://github.com/#{@nwo}/invitations"
elsif github_client.collaborator?(@nwo, reviewer_name)
respond "@#{reviewer_name} already has access."
else
# Ideally we should check if a user exists here... (for another day)
github_client.add_collaborator(@nwo, reviewer_name)
respond "OK, the reviewer has been re-invited.\n\n@#{reviewer_name} please accept the invite by clicking this link: https://github.com/#{@nwo}/invitations"
end
end
def reviewers
issue.body.match(/Reviewers?:\*\*\s*(.+?)\r?\n/)[1].split(", ") - ["Pending"]
end
# Send an HTTP POST to the GitHub API here due to Octokit problems
def update_assignees(logins)
data = { "assignees" => logins }
url = "https://api.github.com/repos/#{@nwo}/issues/#{@issue_id}/assignees"
RestClient.post(url, data.to_json, {:Authorization => "token #{ENV['GH_TOKEN']}"})
end
# This method is called when an editor says: '@whedon start review'.
# At this point, Whedon talks to the JOSS/JOSE application which creates
# the actual review issue and responds with the serialized paper which
# includes the 'review_issue_id' which is posted back into the PRE-REVIEW
def start_review
# Check we have an editor and a reviewer
if review_issue? # Don't start a review if it has already started
respond "Can't start a review when the review has already started"
halt 422
end
if reviewers.empty?
respond "Can't start a review without reviewers"
halt 422
end
if !editor
respond "Can't start a review without an editor"
halt 422
end
reviewer_logins = reviewers.map { |reviewer_name| reviewer_name.sub(/^@/, "") }
url = "#{@config.site_host}/papers/api_start_review?id=#{@issue_id}&editor=#{editor}&reviewers=#{reviewer_logins.join(',')}&secret=#{@config.site_api_key}"
# TODO let's do some error handling here please
response = RestClient.post(url, "")
paper = JSON.parse(response)
return paper['review_issue_id']
end
# Return an Octokit GitHub Issue
def issue
@issue ||= github_client.issue(@nwo, @issue_id)
end
# Check that the person sending the command is an editor
def check_editor
unless @config.editors.include?(@sender)
respond "I'm sorry @#{@sender}, I'm afraid I can't do that. That's something only editors are allowed to do."
halt 403
end
end
# Check that the person sending the command is an editor-in-chief
def check_eic
unless @config.eics.include?(@sender)
respond "I'm sorry @#{@sender}, I'm afraid I can't do that. That's something only editor-in-chiefs are allowed to do."
halt 403
end
end
# The actual Sinatra URL path methods
get '/heartbeat' do
"BOOM boom. BOOM boom. BOOM boom."
end
get '/' do
erb :preview
end
post '/preview' do
sha = SecureRandom.hex
branch = params[:branch].empty? ? nil : params[:branch]
job_id = PaperPreviewWorker.perform_async(params[:repository], params[:journal], branch, sha)
redirect "/preview?id=#{job_id}"
end
get '/preview' do
begin
container = SidekiqStatus::Container.load(params[:id])
erb :status, :locals => { :status => container.status, :payload => container.payload }
rescue SidekiqStatus::Container::StatusNotFound
erb :status, :locals => { :status => 'missing' }
end
end
post '/dispatch' do
robawt_respond if @message
end
end