├── .gitignore ├── .env.sample ├── Gemfile ├── workflow.plist ├── work_todos_skip_weekend.rb ├── Gemfile.lock ├── lib └── github │ └── github_client.rb ├── load_titles_from_url.rb ├── gitlab_todos_to_things.rb ├── github_review_requests_to_things.rb └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 2 | GITHUB_ORGANIZATION=rails 3 | THINGS_PROJECT=Pull Requests 4 | 5 | # Gitlab 6 | GITLAB_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 7 | GITLAB_ENDPOINT=https://gitlab.com/api/v4 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gem "pry" 8 | # gem "octokit" 9 | gem "dotenv", require: "dotenv/load" 10 | gem "graphql-client" 11 | gem "activesupport" 12 | gem "nokogiri" 13 | 14 | gem "gitlab" 15 | gem "thingamajig" 16 | -------------------------------------------------------------------------------- /workflow.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.crowdway.workflow 7 | 8 | WorkingDirectory 9 | /Users/david/poetry/workflow 10 | 11 | ProgramArguments 12 | 13 | /Users/david/.rbenv/shims/ruby 14 | review_requests_to_things.rb 15 | 16 | 17 | 23 | 24 | StartInterval 25 | 120 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /work_todos_skip_weekend.rb: -------------------------------------------------------------------------------- 1 | #!/bin/ruby 2 | require 'rubygems' 3 | require 'bundler/setup' 4 | Bundler.require(:default) 5 | 6 | require 'active_support' 7 | require 'active_support/all' 8 | 9 | require "./lib/thingamajig.rb" 10 | 11 | Things = Thingamajig 12 | 13 | def todos_to_move 14 | Things::Area.find_by(name: "Work").projects.flat_map do |project| 15 | project.todos(Things::Todo.predicate_active.and(Things::Todo.predicate_open)) 16 | end 17 | end 18 | 19 | def next_monday 20 | result = Date.parse("Monday").to_time 21 | 22 | if result < Time.now 23 | result += 1.week 24 | end 25 | 26 | result 27 | end 28 | 29 | def friday_evening 30 | friday_before = next_monday - 3.days 31 | friday_before + 18.hours 32 | end 33 | 34 | if Time.now <= friday_evening 35 | # Nothing to do, it's not Friday evening yet 36 | exit 37 | 38 | else 39 | puts "It's weekend! Reschedule work related Todos to Monday" 40 | 41 | todos_to_move.each do |todo| 42 | puts "Moving #{todo.inspect}" 43 | todo.activation_date = next_monday 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (5.2.0) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | coderay (1.1.2) 10 | concurrent-ruby (1.0.5) 11 | dotenv (2.4.0) 12 | gitlab (4.4.0) 13 | httparty (>= 0.14.0) 14 | terminal-table (>= 1.5.1) 15 | graphql (1.7.14) 16 | graphql-client (0.12.3) 17 | activesupport (>= 3.0, < 6.0) 18 | graphql (~> 1.6) 19 | httparty (0.16.2) 20 | multi_xml (>= 0.5.2) 21 | i18n (1.0.0) 22 | concurrent-ruby (~> 1.0) 23 | method_source (0.9.0) 24 | mini_portile2 (2.7.1) 25 | minitest (5.11.3) 26 | multi_xml (0.6.0) 27 | nokogiri (1.13.1) 28 | mini_portile2 (~> 2.7.0) 29 | racc (~> 1.4) 30 | pry (0.11.3) 31 | coderay (~> 1.1.0) 32 | method_source (~> 0.9.0) 33 | racc (1.6.0) 34 | rb-scpt (1.0.3) 35 | terminal-table (1.8.0) 36 | unicode-display_width (~> 1.1, >= 1.1.1) 37 | thingamajig (0.1.0) 38 | rb-scpt 39 | thread_safe (0.3.6) 40 | tzinfo (1.2.5) 41 | thread_safe (~> 0.1) 42 | unicode-display_width (1.4.0) 43 | 44 | PLATFORMS 45 | ruby 46 | 47 | DEPENDENCIES 48 | activesupport 49 | dotenv 50 | gitlab 51 | graphql-client 52 | nokogiri 53 | pry 54 | thingamajig 55 | 56 | BUNDLED WITH 57 | 2.1.4 58 | -------------------------------------------------------------------------------- /lib/github/github_client.rb: -------------------------------------------------------------------------------- 1 | require "graphql/client" 2 | require "graphql/client/http" 3 | 4 | module GithubGraphQL 5 | GITHUB_TOKEN = ENV["GITHUB_TOKEN"] 6 | ORGANIZATION = ENV["GITHUB_ORGANIZATION"] 7 | SCHEMA_PATH = "lib/github/schema.json" 8 | 9 | # Configure GraphQL endpoint using the basic HTTP network adapter. 10 | HTTP = GraphQL::Client::HTTP.new("https://api.github.com/graphql") do 11 | def headers(context) 12 | { Authorization: "bearer #{GITHUB_TOKEN}" } 13 | end 14 | end 15 | 16 | if !File.file?(SCHEMA_PATH) 17 | puts "schema.json doesn't exist yet, download and create it" 18 | GraphQL::Client.dump_schema(HTTP, SCHEMA_PATH) 19 | end 20 | 21 | Schema = GraphQL::Client.load_schema(SCHEMA_PATH) 22 | Client = GraphQL::Client.new(schema: Schema, execute: HTTP) 23 | 24 | OrganizationPullRequestsWithReviewRequestsQuery = Client.parse <<-"GRAPHQL" 25 | query { 26 | organization(login:"#{ORGANIZATION}") { 27 | repositories(first:30) { 28 | nodes { 29 | name 30 | pullRequests(first:50, states:OPEN) { 31 | nodes { 32 | id 33 | title 34 | url 35 | reviewRequests(first:100) { 36 | nodes { 37 | requestedReviewer { 38 | ... on User { 39 | isViewer 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | GRAPHQL 51 | end 52 | -------------------------------------------------------------------------------- /load_titles_from_url.rb: -------------------------------------------------------------------------------- 1 | # Script which loads all Todos with an empty title, 2 | # checks if the notes is exactly a URL, and if so, 3 | # loads the URL and uses the page's title as the new 4 | # todo's title. 5 | # 6 | # - Adds "Link" tag to the todos it manipulates 7 | # - Uses "(No title found)" if the results didn't contain a title 8 | # - Uses "A Tweet" if it's from twitter 9 | 10 | require 'rubygems' 11 | require 'bundler/setup' 12 | Bundler.require(:default) 13 | 14 | require 'active_support' 15 | require 'active_support/all' 16 | 17 | require 'uri' 18 | require 'open-uri' 19 | require 'nokogiri' 20 | 21 | def empty_todos 22 | # This is 3x slower: hingamajig.new.app.to_dos[Appscript.its.name.eq("")] 23 | Thingamajig.new.todos.select { |todo| todo.name == "" } 24 | end 25 | 26 | def empty_todos_with_url_in_notes 27 | empty_todos.select do |todo| 28 | todo.notes =~ /\A#{URI::regexp}\z/ 29 | end 30 | end 31 | 32 | # Will create or retrieve existing tag with this name 33 | def assert_tag_exists(tag_name = "Link") 34 | Thingamajig.new.app.make(new: :tag, with_properties: {name: "Link"}) 35 | end 36 | 37 | def add_tag_to_todo(todo, tag_name) 38 | todo.tag_names += ", #{tag_name}" 39 | end 40 | 41 | def download_url(url) 42 | tries = 3 43 | 44 | uri = URI.parse(url) 45 | 46 | begin 47 | uri.open(redirect: false, "User-Agent" => "Thingamajig/1.0") 48 | rescue OpenURI::HTTPRedirect => redirect 49 | uri = redirect.uri # assigned from the "Location" response header 50 | retry if (tries -= 1) > 0 51 | raise 52 | end 53 | end 54 | 55 | def get_title_from_url(url) 56 | html = download_url(url) 57 | 58 | dom = Nokogiri::HTML(html) 59 | dom.css("title").text&.strip 60 | end 61 | 62 | def update_todo(todo) 63 | url = todo.notes 64 | title = get_title_from_url(url) 65 | 66 | add_tag_to_todo(todo, "Link") 67 | 68 | if title.present? 69 | todo.name = title 70 | else 71 | # Twitter gives no data for crawlers 72 | if todo.notes =~ /twitter.com/ 73 | todo.name = "A Tweet" 74 | else 75 | todo.name = "(No title found)" 76 | end 77 | end 78 | end 79 | 80 | empty_todos_with_url_in_notes.each do |todo| 81 | begin 82 | update_todo(todo) 83 | rescue => e 84 | puts "Problem with URL #{todo.notes} #{e.inspect}" 85 | # skip this todo 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /gitlab_todos_to_things.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | Bundler.require(:default) 4 | 5 | require 'active_support' 6 | require 'active_support/all' 7 | 8 | Gitlab.configure do |config| 9 | config.endpoint = ENV["GITLAB_ENDPOINT"] 10 | config.private_token = ENV["GITLAB_TOKEN"] 11 | end 12 | 13 | def title_from_todo(todo) 14 | case todo.target_type 15 | when "MergeRequest" 16 | "#{todo.target.title.capitalize} (Merge Request)" 17 | when "Issue" 18 | "#{todo.target.title.capitalize} (Issue)" 19 | else 20 | todo.target.title 21 | end 22 | end 23 | 24 | def description_from_todo(todo) 25 | body = todo.body 26 | author = todo.author.name 27 | 28 | "#{author}: #{body}" 29 | end 30 | 31 | def todos_from_gitlab 32 | Gitlab.todos.map do |todo| 33 | # possible fields on todo: ["id", "project", "author", "action_name", "target_type", "target", "target_url", "body", "state", "created_at"] 34 | url = todo.target_url 35 | title = title_from_todo(todo) 36 | description = description_from_todo(todo) 37 | 38 | {url: url, title: title, description: description} 39 | end 40 | end 41 | 42 | def things_project 43 | Thingamajig::Project.find_by(name: "Gitlab Todos") 44 | end 45 | 46 | def things_todos 47 | things_project.todos.map do |todo| 48 | { 49 | url: todo.notes.split.first.strip, 50 | todo: todo 51 | } 52 | end 53 | end 54 | 55 | currently_known_todos = things_todos 56 | currently_known_todo_urls = currently_known_todos.map { |todo| todo[:url] } 57 | current_gitlab_todos = todos_from_gitlab 58 | current_gitlab_todo_urls = current_gitlab_todos.map { |todo| todo[:url] } 59 | 60 | complete_todos = 61 | currently_known_todos.select do |things_todo| 62 | !current_gitlab_todo_urls.include?(things_todo[:url]) 63 | end 64 | 65 | new_todos = 66 | current_gitlab_todos.select do |gitlab_todo| 67 | !currently_known_todo_urls.include?(gitlab_todo[:url]) 68 | end 69 | 70 | complete_todos.each do |things_todo| 71 | things_todo[:todo].complete! 72 | end 73 | 74 | new_todos.each do |gitlab_todo| 75 | notes = gitlab_todo[:url] + "\n\n" + gitlab_todo[:description] 76 | 77 | things_project.create_todo!(gitlab_todo[:title], notes) 78 | end 79 | 80 | last_checked = Time.new.strftime("%A, %e/%m/%Y @ %H:%M") 81 | things_project.notes = "Last checked at #{last_checked}" 82 | -------------------------------------------------------------------------------- /github_review_requests_to_things.rb: -------------------------------------------------------------------------------- 1 | #!/bin/ruby 2 | # encoding: utf-8 3 | Encoding.default_internal = Encoding::UTF_8 4 | Encoding.default_external = Encoding::UTF_8 5 | # ^ Encoding set to make GraphQL schema dump work 6 | 7 | require 'rubygems' 8 | require 'bundler/setup' 9 | Bundler.require(:default) 10 | 11 | require "./lib/github/github_client" 12 | 13 | THINGS_PROJECT = ENV.fetch("THINGS_PROJECT", "Pull Requests") 14 | 15 | def things_project 16 | @things_project ||= Appscript.app("Things3").projects[THINGS_PROJECT] 17 | end 18 | 19 | # Returns an array of the form 20 | # 21 | # [{title: "Title of PR", url: "URL to PR"}] 22 | # 23 | def retrieve_pull_requests_i_need_to_review 24 | client = GithubGraphQL::Client 25 | result = client.query(GithubGraphQL::OrganizationPullRequestsWithReviewRequestsQuery) 26 | 27 | result.data.organization.repositories.nodes.flat_map do |node| 28 | node.pull_requests.nodes.select do |pull_request| 29 | pull_request.review_requests.nodes.any? do |review_request| 30 | review_request.requested_reviewer.is_viewer 31 | end 32 | end 33 | end.map do |pr| 34 | {title: pr.title, url: pr.url} 35 | end 36 | end 37 | 38 | # Returns an array of the form 39 | # 40 | # [{name: NAME, url: URL, todo: TODO object}] 41 | # 42 | def list_pull_request_todos 43 | things_project.to_dos.get.map do |todo| 44 | { 45 | name: todo.name.get, 46 | url: todo.notes.get.split.first.strip, 47 | todo: todo 48 | } 49 | end 50 | end 51 | 52 | def new_todo!(name, notes) 53 | things_project.make(new: :to_do, with_properties: {name: name, notes: notes}) 54 | end 55 | 56 | def complete_todo!(todo) 57 | todo.status.set(:completed) 58 | end 59 | 60 | github_pull_requests = retrieve_pull_requests_i_need_to_review 61 | existing_todos = list_pull_request_todos 62 | 63 | new_pull_requests = github_pull_requests.select do |pr| 64 | existing_todos.none? do |todo| 65 | todo[:url] == pr[:url] 66 | end 67 | end 68 | 69 | completed_pull_request_todos = existing_todos.select do |todo| 70 | github_pull_requests.none? do |pr| 71 | todo[:url] == pr[:url] 72 | end 73 | end 74 | 75 | new_pull_requests.each do |pr| 76 | new_todo!(pr[:title].capitalize, pr[:url]) 77 | end 78 | 79 | completed_pull_request_todos.each do |todo| 80 | complete_todo!(todo[:todo]) 81 | end 82 | 83 | last_checked = Time.new.strftime("%A, %e/%m/%Y @ %H:%M") 84 | things_project.notes.set "Last checked #{last_checked}" 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github to Things 3 2 | 3 | The first workflow loads all PR review requests from Github from a specific organization specified in an ENV variable, as todos into a specific project in Things 3. 4 | 5 | When the review request is fulfilled, it also automatically marks that todo as done. 6 | 7 | # Setup 8 | 9 | - Create a .env file and fill in the required parameters (see sample file) 10 | - Run it manually to check that it works: `ruby review_requests_to_things` 11 | 12 | # Launchctl 13 | 14 | To automatically make it run, edit the `workflow.plist` file with the correct paths for your installation, then link it into the `LaunchAgents` folder, with a custom name, for example: 15 | 16 | ``` 17 | ln workflow.plist ~/Library/LaunchAgents/com.crowdway.workflow.plist 18 | ``` 19 | 20 | And then load it: 21 | ``` 22 | launchctl load ~/Library/LaunchAgents/com.crowdway.workflow.plist 23 | ``` 24 | 25 | This will cause the ruby script to run every two minutes, or according to the interval specified in `workflow.plist` 26 | 27 | 28 | # Things in Applescript 29 | 30 | Things have their own Documentation on Applescript automation: https://support.culturedcode.com/customer/en/portal/articles/2803572-using-applescript-with-things 31 | 32 | ## Misc 33 | 34 | Check `lists` for todos in Evening and Today etc 35 | 36 | ``` 37 | [25] pry(main)> things.osa_object.lists.get 38 | => [app("/Applications/Things3.app").lists.ID("TMInboxListSource"), 39 | app("/Applications/Things3.app").lists.ID("TMTodayListSource"), 40 | app("/Applications/Things3.app").lists.ID("TMNextListSource"), 41 | app("/Applications/Things3.app").lists.ID("TMCalendarListSource"), 42 | app("/Applications/Things3.app").lists.ID("TMSomedayListSource"), 43 | app("/Applications/Things3.app").lists.ID("THMLonelyLaterProjectsListSource"), 44 | app("/Applications/Things3.app").lists.ID("TMLogbookListSource"), 45 | app("/Applications/Things3.app").lists.ID("TMTrashListSource"), 46 | app("/Applications/Things3.app").areas.ID("THMAreaParentSource/9510E1D3-3F67-4AFC-BDD0-B2AB928AE11A"), 47 | app("/Applications/Things3.app").areas.ID("THMAreaParentSource/689A12C3-6406-43FC-ADE7-7C9AF0453B06"), 48 | app("/Applications/Things3.app").areas.ID("THMAreaParentSource/2BF8B679-1AC4-4423-849E-02DF65E7CCD8"), 49 | app("/Applications/Things3.app").areas.ID("THMAreaParentSource/4823B1EA-3116-49C3-BD88-D4E1F91DC116")] 50 | [26] pry(main)> 51 | 52 | things.osa_object.lists.ID("TMNextListSource").to_dos.get 53 | 54 | ``` 55 | 56 | ## Todo 57 | 58 | Call `_properties.get` to see list of values 59 | 60 | ``` 61 | todo.osa_object.properties_.get 62 | => {:status=>:open, 63 | :tag_names=>"", 64 | :cancellation_date=>:missing_value, 65 | :due_date=>:missing_value, 66 | :class_=>:selected_to_do, 67 | :modification_date=>2018-06-10 14:11:47 +0300, 68 | :contact=>:missing_value, 69 | :project=>app("/Applications/Things3.app").projects.ID("AFE0D059-E6EE-49A6-9879-E87963518FD6"), 70 | :area=>:missing_value, 71 | :notes=>"", 72 | :activation_date=>2018-06-10 00:00:00 +0300, 73 | :id_=>"18F1713E-CF63-4F3E-93CA-72B7A8175DA1", 74 | :completion_date=>:missing_value, 75 | :name=>"todo this evening", 76 | :creation_date=>2018-06-10 14:11:41 +0300} 77 | ``` 78 | 79 | Call `methods` to see a list of possible methods and fields. 80 | 81 | ### status 82 | 83 | - `open`: not completed, not cancelled 84 | - `completed`: completed (completion_date and cancellation_date will be filled in) 85 | - `canceled`: cancelled (completion_date and cancellation_date will be filled in) 86 | 87 | By changing `status = completed` it sets `activation_date` to nil and sets `completion_date`. You cannot set `status = open` but rather you have to set `completion_date` back to `nil`. 88 | 89 | Note: when a todo is logged it is no longer in that project or area, but is in the Logbook. It will not show up in the Project as completed todos. Only non-logged, completed todos show up when filtering for completion. 90 | 91 | ### show 92 | 93 | Shows the todo in the GUI (navigates to the correct project too) 94 | 95 | ### edit 96 | 97 | Opens to todo edit input box 98 | 99 | ### activation_date 100 | 101 | ```ruby 102 | todo.osa_object.activation_date.get 103 | ``` 104 | 105 | - For a todo on Today: `2018-06-10 00:00:00 +0300` (today's date) 106 | - For a todo this Evening: `2018-06-10 00:00:00 +0300` (today's date) 107 | - For a todo for Tomorrow: `2018-06-11 00:00:00 +0300` (tomorrow's date) 108 | --------------------------------------------------------------------------------