#!/usr/bin/env ruby # Generate GitHub Issues for tasks and deliverables # - Each work package gets a WPX/README.md file # - Each task gets an Issue # - Each deliverable gets a Milestone # - Each deliverable gets an Issue associated with its Milestone # - Each deliverable Issue gets 'deliverable', 'WPX' tags # - Each task Issue gets 'task', 'WPX' tags # Run me from the proposal dir containing proposal.pdata: # cd ~/OpenDreamKit/Proposal # ~/path/to/LaTeX-Proposal/bin/generate-issues require 'date' require 'Octokit' require 'netrc' # constants for running the script are for OpenDreamKit # update these for your own proposal START_DATE = Date::new(2015, 9, 1) # start date of the project HOMEPAGE = "http://opendreamkit.org" PROPOSAL_URL = "https://github.com/OpenDreamKit/OpenDreamKit/raw/master/Proposal/proposal-www.pdf" PROJECT = "OpenDreamKit" REPO = "OpenDreamKit/OpenDreamKit" TARGET = "final" # or 'proposal' # throttle github creation requests to 5 Hz to avoid getting flagged for abuse THROTTLE_SECONDS = 5 NATURES = { 'R' => 'Report', 'DEM' => 'Demonstrator', 'DEC' => 'Websites, Media, etc.', 'OTHER' => 'Other', } DISSEMINATIONS = { 'PU' => 'Public', 'CO' => 'Confidential', 'CI' => 'Classified', } SITES = { 'PS' => 'Université Paris-Sud', 'LL' => 'Logilab', 'UV' => 'Université de Versailles Saint-Quentin', 'UJF' => 'Université Joseph Fourier', 'UB' => 'CNRS', 'UO' => 'University of Oxford', 'USH' => 'University of Sheffield', 'USO' => 'University of Southampton', 'SA' => 'University of St Andrews', 'UW' => 'University of Warwick', 'JU' => 'Jacobs University Bremen', 'UK' => 'University of Kaiserslautern', 'US' => 'University of Silesia', 'ZH' => 'Universität Zürich', 'SR' => 'Simula Research Laboratory', } REVERSE_SITES = {} SITES.each_pair do |key, name| REVERSE_SITES[name] = key end # Some sites would prefer alternative abbreviations REVERSE_SITES['University of St Andrews'] = 'USTAN' REVERSE_SITES['Université de Versailles Saint-Quentin'] = 'UVSQ' #------------------- Parsing proposal.pdata --------------------- def split_line(line) # super primitive state-machine line split (not going to regex this) parts = [] level = 0 buffer = [] line.each_char do |c| case c when '{' if level > 0 buffer.push c end level += 1 when '}' level -= 1 if level > 0 buffer.push c end if level == 0 parts.push(scrub_tex(buffer.join(''))) buffer = [] end else if level > 0 buffer.push c end end end return parts end def scrub_tex(text) # scrub some latex markup from text text.gsub!(/\\\w+/, '') text.gsub!(/[{}]/, '') text.strip.split.join(' ') end def transform_value(key, value) case key when 'lead' return SITES[value] when 'partners' return value.split(',').map { |v| SITES[v] } when 'dissem' return DISSEMINATIONS[value] when 'nature' return NATURES[value] when 'month' return value.to_i when 'delivs' return value.split(',') else return scrub_tex(value) end end def load_pdata(proposal_dir) pdata = File.join(proposal_dir, "#{TARGET}.pdata") deliv_data = File.join(proposal_dir, "#{TARGET}.deliverables") workpackages = {} # mapping of workpackage id => wp info deliverables = {} File.readlines(pdata).each do |line| key, *args = split_line line case key when 'wp' name, key, value = args value = transform_value(key, value) if not workpackages.include? name workpackages[name] = { "tasks" => {}, "unknown-task" => nil, "deliverables" => [], } end wp = workpackages[name] wp[key] = value when 'task' name, key, value = args value = transform_value(key, value) # find my workpackage if name.index('@') wpkey, short_name = name.split('@') if short_name.match(/task\d+/) workpackages[wpkey]['unknown-task'] = short_name name = short_name end else wpkey = workpackages.keys.select { |wpkey| name.start_with? wpkey }.first end # handle workpackage@taskNN weirdness wp = workpackages[wpkey] tasks = wp['tasks'] if not wp['unknown-task'].nil? and wp['unknown-task'] != name tasks[name] = tasks.delete(wp['unknown-task']) wp['unknown-task'] = nil end if not tasks.include? name tasks[name] = {} end tasks[name][key] = value when 'deliv' name, key, value = args value = transform_value(key, value) if not deliverables.include? name deliverables[name] = {} end deliverables[name][key] = value else # DEBUG: # puts " Ignored: #{args}" end end # get deliverable data, workpackage id from proposal.deliverables File.readlines(deliv_data).each do |line| args = split_line line name = args[3] if deliverables.include? name deliverable = deliverables[name] else deliverable = {} end month = args[0].to_i deliverable = (deliverables[name] or {}).merge({ "month" => month, # due date is last day of the given month, so subtract one day "due_date" => (START_DATE >> month) - 1, "label" => scrub_tex(args[2]), "deliv_id" => args[3], "dissem" => transform_value('dissem', args[4]), "nature" => transform_value('nature', args[5]), "title" => scrub_tex(args[6]), "lead" => SITES[scrub_tex(args[8])], }) wpid = scrub_tex(args[7]) wp = workpackages.values.find {|wp| wp['label'] == wpid} wp['deliverables'].push(deliverable) end workpackages.values.each do |wp| wp['deliverables'].sort_by! {|d| d['label']} end return workpackages.values.sort_by {|wp| wp['label']} end #--------------- GitHub-related --------------------- def check_token # get GitHub auth token, creating one if we don't find it. rc = Netrc.read Netrc.default_path if not rc['api.github.com'].nil? return end puts "We need your password to generate an OAuth token. The password will not be stored." username = ask "Username: " password = ask("Password: ") { |q| q.echo = '*' } client = Octokit::Client.new( :login => username, :password => password, ) reply = client.create_authorization( :scopes => ["public_repo"], :note => "Issue Migration", ) token = reply.token rc['api.github.com'] = username, token rc.save end $cache = { 'issues' => {}, 'milestones' => {}, } def get_issues(github, repo) # get issues for a repo (cached) cache = $cache['issues'] if not cache.include? repo cache[repo] = github.issues(repo) end return cache[repo] end def get_milestones(github, repo) # get issues for a repo (cached) cache = $cache['milestones'] if not cache.include? repo cache[repo] = github.list_milestones(repo) end return cache[repo] end # Templates for making readmes, issues README_TPL = <<-END # %{title} Lead Institution: %{lead} See page %{page} of the [proposal](#{PROPOSAL_URL}) for the full description. END TASK_TPL = <<-END - **%{wplabel}:** [%{wptitle}](https://github.com/#{REPO}/tree/master/%{wplabel}) - **Lead Institution:** %{lead} - **Partners:** %{partners} - **Work phases:** %{wphases} See page %{page} of the [proposal](#{PROPOSAL_URL}) for the full description. END DELIV_TPL = <<-END - **%{wplabel}:** [%{wptitle}](https://github.com/#{REPO}/tree/master/%{wplabel}) - **Lead Institution:** %{lead} - **Due:** %{date} (month %{month}) - **Nature:** %{nature} See page %{page} of the [proposal](#{PROPOSAL_URL}) for the full description. END DELIV_MILESTONE_TPL = "# %{title}\n\n#{DELIV_TPL}" def make_task_issue(github, repo, task, workpackage, options) title = "#{task['label']}: #{task['title']}" issues = get_issues(github, repo) issue = issues.find { |i| i.title.start_with?(task['label'] + ':') } if issue.nil? body = TASK_TPL % { wplabel: workpackage['label'], wptitle: workpackage['title'], lead: task['lead'], page: task['page'], wphases: task['wphases'], partners: (task['partners'] or ['None']).join(', ') } puts "\n\nMaking Issue on #{repo}: #{title}" puts body github.create_issue(repo, title, body, options) # throttle creation calls to avoid flags for abuse sleep THROTTLE_SECONDS else puts "Found Issue #{repo}##{issue.number}: #{issue.title}" existing_labels = issue.labels.map { |label| label['name']} missing_labels = options[:labels].reject { |label| existing_labels.include? label } if not missing_labels.empty? puts "Updating milestone, labels on #{repo}##{issue.number}" github.update_issue(repo, issue.number, options) sleep THROTTLE_SECONDS end end end def make_deliverable_issue(github, repo, deliverable, workpackage, options) title = "#{deliverable['label']}: #{deliverable['title']}" issues = get_issues(github, repo) issue = issues.find { |i| i.title.start_with?(deliverable['label'] + ':') } if issue.nil? body = DELIV_TPL % { wplabel: workpackage['label'], wptitle: workpackage['title'], lead: deliverable['lead'], date: deliverable['due_date'], month: deliverable['month'], nature: deliverable['nature'], page: deliverable['page'], } puts "\n\nMaking Issue on #{repo}: #{title}" puts body github.create_issue(repo, title, body, options) # throttle creation calls to avoid flags for abuse sleep THROTTLE_SECONDS else puts "Found Issue #{repo}##{issue.number}: #{issue.title}" existing_labels = issue.labels.map { |label| label['name']} missing_labels = options[:labels].reject { |label| existing_labels.include? label } if issue.milestone.nil? or not missing_labels.empty? puts "Updating milestone, labels on #{repo}##{issue.number}" github.update_issue(repo, issue.number, options) sleep THROTTLE_SECONDS end end end def make_deliverable_milestone(github, repo, deliverable, workpackage) title = deliverable['label'] milestone = get_milestones(github, repo).find { |ms| ms.title == title } if milestone.nil? puts "Making milestone on #{repo}: #{title}" body = DELIV_MILESTONE_TPL % { wplabel: workpackage['label'], wptitle: workpackage['title'], title: deliverable['title'], lead: deliverable['lead'], date: deliverable['due_date'], month: deliverable['month'], nature: deliverable['nature'], page: deliverable['page'], } milestone = github.create_milestone(repo, title, :due_on => deliverable['due_date'], :description => body, ) # throttle creation calls to avoid flags for abuse sleep THROTTLE_SECONDS else puts "Milestone #{repo}@#{title} exists" end return milestone.number end def populate_workpackage(github, repo, workpackage) # populate issues for a given workpackage label = workpackage['label'] readme_path = "#{label}/README.md" begin github.contents(repo, :path => readme_path) rescue Octokit::NotFound readme = README_TPL % { title: "#{workpackage['label']}: #{workpackage['title']}", lead: workpackage['lead'], page: workpackage['page'], } puts "Creating readme at #{repo}/#{readme_path}" puts readme github.create_contents(repo, readme_path, "Creating #{readme_path}", readme) # throttle creation calls to avoid flags for abuse sleep THROTTLE_SECONDS end workpackage['tasks'].each_value do |task| org_labels = [REVERSE_SITES[task['lead']]] + \ (task['partners'] or []).map {|site| REVERSE_SITES[site]} make_task_issue(github, repo, task, workpackage, :labels => [ 'task', workpackage['label'], ] + org_labels ) end workpackage['deliverables'].each do |deliverable| milestone = make_deliverable_milestone(github, repo, deliverable, workpackage) make_deliverable_issue(github, repo, deliverable, workpackage, { :milestone => milestone, :labels => [ 'deliverable', workpackage['label'], REVERSE_SITES[deliverable['lead']] ] }) end end def main(proposal_dir) # run the whole thing # verify that there's a GitHub token in .netrc check_token # create client github = Octokit::Client.new(:netrc => true) github.auto_paginate = true load_pdata(proposal_dir).each do |wp| populate_workpackage(github, REPO, wp) end end if __FILE__ == $0 puts ARGV if ARGV.length > 1 proposal_dir = ARGV[1] else proposal_dir = '.' end main(proposal_dir) end