https://github.com/openjournals/whedon-api
Tip revision: dbb71d6cf4f96b4a72ab1eb6d9ba2c794367368a authored by Juanjo Bazán on 09 March 2023, 09:19:20 UTC
Merge pull request #152 from openjournals/dependabot/bundler/rack-2.1.4.3
Merge pull request #152 from openjournals/dependabot/bundler/rack-2.1.4.3
Tip revision: dbb71d6
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 == 'opened' || @action == 'closed'
@message = params['issue']['body']
elsif @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 commands/i
if @config.editors.include?(@sender)
respond erb :commands
else
respond erb :commands_public
end
when /\A@whedon assign (.*) as reviewer/i
check_editor
if editor?
assign_reviewer($1)
respond "OK, #{$1} is now a reviewer"
else
respond "You need to assign an editor first."
end
when /\A@whedon add (.*) as reviewer/i
check_editor
if editor?
add_reviewer($1)
respond "OK, #{$1} is now a reviewer"
else
respond "You need to assign an editor first."
end
when /\A@whedon remove (.*) as reviewer/i
check_editor
remove_reviewer($1)
respond "OK, #{$1} is no longer a reviewer"
when /\A@whedon assign (.*) as editor/i
check_editor
new_editor = assign_editor($1)
respond "OK, the editor is @#{new_editor}"
when /\A@whedon invite (.*) as editor/i
check_eic
invite_editor($1)
when /\A@whedon re-invite (.*) as reviewer/i
check_editor
invite_reviewer($1)
when /\A@whedon set (.*) as archive/
check_editor
assign_archive($1)
when /\A@whedon set (.*) as version/
check_editor
assign_version($1)
when /\A@whedon start review/i
check_editor
if editor && reviewers.any?
review_issue_id = start_review
respond erb :start_review, :locals => { :review_issue_id => review_issue_id, :nwo => @nwo }
close_issue(@nwo, @issue_id)
else
respond erb :missing_editor_reviewer
halt
end
when /\A@whedon list editors/i
respond erb :editors, :locals => { :editors => @config.editors }
when /\A@whedon list reviewers/i
respond all_reviewers
when /\A@whedon generate pdf from branch (.\S*)/
process_pdf($1)
when /\A@whedon generate pdf/i
process_pdf(nil)
when /\A@whedon accept deposit=true from branch (.\S*)/i
check_eic
deposit(dry_run=false, $1)
when /\A@whedon accept deposit=true/i
check_eic
deposit(dry_run=false)
when /\A@whedon recommend-accept from branch (.\S*)/i
check_editor
deposit(dry_run=true, $1)
when /\A@whedon recommend-accept/i
check_editor
deposit(dry_run=true)
when /\A@whedon accept/i
check_editor
if editor
respond "To recommend a paper to be accepted use `@whedon recommend-accept`"
end
when /\A@whedon reject/i
check_eic
reject_paper
when /\A@whedon withdraw/i
check_eic
withdraw_paper
when /\A@whedon check references from branch (.\S*)/
check_references($1)
when /\A@whedon check references/i
check_references(nil)
when /\A@whedon check repository from branch (.\S*)/i
repo_detect($1)
when /\A@whedon check repository/i
repo_detect(nil)
# Detect strings like '@whedon remind @arfon in 2 weeks'
when /\A@whedon remind (.*) in (.*) (.*)/i
check_editor
schedule_reminder($1, $2, $3)
when /\A@whedon query scope/
check_editor
label_issue(@nwo, @issue_id, ['query-scope'])
respond "Submission flagged for editorial review."
# We don't understand the command so say as much...
when /\A@whedon/i
respond erb :sorry 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
if @action == "opened"
say_hello
halt
end
if @action == "closed"
say_goodbye
halt
end
robawt_respond if @message
end
end