Created
February 4, 2018 22:44
-
-
Save JEG2/a1cf82869338376daf70e349301b4710 to your computer and use it in GitHub Desktop.
My OmniFocus <-> Pivotal Tracker integration.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.CLI do | |
alias FocusTracker.{OmniFocus, PivotalTracker} | |
def main(_args) do | |
owners = PivotalTracker.fetch_owners | |
labels = PivotalTracker.fetch_labels | |
omnifocus_tasks = OmniFocus.parse_tasks | |
pivotal_tracker_tasks = PivotalTracker.fetch_tasks(owners) | |
task_changes = | |
FocusTracker.Task.sync(omnifocus_tasks, pivotal_tracker_tasks) | |
add_to_omnifocus(task_changes.add_to_omnifocus) | |
add_to_pivotal_tracker( | |
task_changes.add_to_pivotal_tracker, | |
owners, | |
labels | |
) | |
start_in_pivotal_tracker(task_changes.start_in_pivotal_tracker) | |
update_in_omnifocus(task_changes.update_in_omnifocus) | |
disown_in_pivotal_tracker(task_changes.disown_in_pivotal_tracker, owners) | |
finish_in_pivotal_tracker(task_changes.finish_in_pivotal_tracker) | |
remove_from_omnifocus(task_changes.remove_from_omnifocus) | |
end | |
defp add_to_omnifocus(pivotal_tracker_tasks) do | |
Enum.each(pivotal_tracker_tasks, &OmniFocus.create/1) | |
unless pivotal_tracker_tasks == [ ] do | |
OmniFocus.show_work_notes | |
end | |
end | |
defp add_to_pivotal_tracker(omnifocus_tasks, owners, labels) do | |
Enum.each(omnifocus_tasks, fn task -> | |
task | |
|> PivotalTracker.create(owners, labels) | |
|> OmniFocus.update | |
end) | |
end | |
defp start_in_pivotal_tracker(pivotal_tracker_tasks) do | |
Enum.each(pivotal_tracker_tasks, &PivotalTracker.start/1) | |
end | |
defp update_in_omnifocus(pivotal_tracker_tasks) do | |
Enum.each(pivotal_tracker_tasks, &OmniFocus.update/1) | |
end | |
defp disown_in_pivotal_tracker(pivotal_tracker_tasks, owners) do | |
Enum.each(pivotal_tracker_tasks, fn task -> | |
PivotalTracker.disown(task, owners) | |
OmniFocus.delete(task) | |
end) | |
end | |
defp finish_in_pivotal_tracker(pivotal_tracker_tasks) do | |
Enum.each(pivotal_tracker_tasks, fn task -> | |
PivotalTracker.finish(task) | |
OmniFocus.delete(task) | |
end) | |
end | |
defp remove_from_omnifocus(omnifocus_tasks) do | |
Enum.each(omnifocus_tasks, &OmniFocus.delete/1) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.OmniFocus do | |
alias FocusTracker.OmniFocus.Parser | |
def parse_tasks(io_device \\ :stdio) do | |
io_device | |
|> IO.stream(:line) | |
|> Parser.parse_all_from_omnifocus | |
end | |
def create(task) do | |
task = FocusTracker.Task.to_omnifocus(task) | |
~s""" | |
set task_name to #{inspect task.name} | |
set task_note to #{inspect task.note} | |
set task_flagged to #{inspect task.flagged} | |
tell application "OmniFocus" | |
tell front document | |
set stories_project to first flattened project where its name = "Close PivotalTracker Stories" | |
tell stories_project to make new task with properties {name:task_name, note:task_note, flagged:task_flagged} | |
end tell | |
end tell | |
""" | |
|> run_applescript | |
end | |
def update(task) do | |
task = FocusTracker.Task.to_omnifocus(task) | |
~s""" | |
set task_name to #{inspect task.name} | |
set task_note to #{inspect task.note} | |
tell application "OmniFocus" | |
tell front document | |
set stories_project to first flattened project where its name = "Close PivotalTracker Stories" | |
set the_task to first flattened task of stories_project whose name = task_name | |
set the note of the the_task to task_note | |
end tell | |
end tell | |
""" | |
|> run_applescript | |
end | |
def show_work_notes do | |
System.cmd("open", ["omnifocus:///task/jU6f2T4iM8N"]) | |
~s""" | |
tell application "System Events" | |
tell process "OmniFocus" | |
set frontmost to true | |
click menu item "Show All Notes" of menu "View" of menu bar 1 | |
end tell | |
end tell | |
""" | |
|> run_applescript | |
end | |
def delete(task) do | |
task = FocusTracker.Task.to_omnifocus(task) | |
~s""" | |
set task_name to #{inspect task.name} | |
tell application "OmniFocus" | |
tell front document | |
set stories_project to first flattened project where its name = "Close PivotalTracker Stories" | |
set the_task to first flattened task of stories_project whose name = task_name | |
delete the_task | |
end tell | |
end tell | |
""" | |
|> run_applescript | |
end | |
defp run_applescript(script) do | |
port = Port.open({:spawn, "osascript"}, [:binary]) | |
send(port, {self(), {:command, script}}) | |
send(port, {self(), :close}) | |
wait_for_applescript(port) | |
end | |
defp wait_for_applescript(port) do | |
receive do | |
{^port, {:data, _data}} -> | |
wait_for_applescript(port) | |
{^port, :closed} -> | |
:ok | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.OmniFocus.Parser do | |
def parse_all_from_omnifocus(stream, tasks \\ [ ]) do | |
Enum.take_while(stream, fn line -> | |
line != "<<<<<<<<<<START_TASK>>>>>>>>>>\n" | |
end) | |
task = | |
Enum.take_while(stream, fn line -> | |
line != "<<<<<<<<<<END_TASK>>>>>>>>>>\n" | |
end) | |
if task != [ ] do | |
parse_all_from_omnifocus(stream, parse_one_from_omnifocus(task, tasks)) | |
else | |
Enum.reverse(tasks) | |
end | |
end | |
defp parse_one_from_omnifocus(lines, tasks) do | |
i = | |
Enum.find_index(lines, fn "note=" <> _note -> true; _attr -> false end) | |
{attributes, note} = Enum.split(lines, i) | |
task = | |
attributes | |
|> Enum.map(fn attribute -> | |
attribute | |
|> String.split("=", parts: 2) | |
|> parse_attribute_from_omnifocus | |
end) | |
|> Enum.reduce(&Map.merge/2) | |
|> Map.merge(parse_note_from_omnifocus(note)) | |
[struct!(FocusTracker.Task, task) | tasks] | |
end | |
defp parse_note_from_omnifocus(["note=\n"]), do: Map.new | |
defp parse_note_from_omnifocus(note) do | |
i = | |
Enum.find_index(note, fn "\n" -> true; _line -> false end) | |
{attributes, description} = Enum.split(note, i || length(note)) | |
description = | |
description | |
|> Enum.join | |
|> String.trim | |
attributes | |
|> Enum.map(fn "note=" <> note -> note; attribute -> attribute end) | |
|> Enum.map(fn attribute -> | |
attribute | |
|> String.split(":", parts: 2) | |
|> parse_attribute_from_omnifocus | |
end) | |
|> Enum.reduce(&Map.merge/2) | |
|> Map.merge(%{description: description}) | |
end | |
defp parse_attribute_from_omnifocus([name, content]) do | |
{String.to_atom(name), String.trim(content)} | |
|> cast_attribute_from_omnifocus | |
|> List.wrap | |
|> Enum.into(%{ }) | |
end | |
defp cast_attribute_from_omnifocus({:has_activity, "true"}) do | |
{:has_activity, true} | |
end | |
defp cast_attribute_from_omnifocus({:has_activity, "false"}) do | |
{:has_activity, false} | |
end | |
defp cast_attribute_from_omnifocus({:labels, ""}) do | |
{:labels, [ ]} | |
end | |
defp cast_attribute_from_omnifocus({:labels, labels}) do | |
{:labels, String.split(labels, ", ")} | |
end | |
defp cast_attribute_from_omnifocus({:owners, owners}) do | |
{:owners, String.split(owners, ", ")} | |
end | |
defp cast_attribute_from_omnifocus(key_and_value), do: key_and_value | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.PivotalTracker do | |
alias FocusTracker.PivotalTracker.Parser | |
@pivotal_tracker_config Application.fetch_env!( | |
:focus_tracker, | |
:pivotal_tracker_config | |
) | |
def fetch_labels(pivotal_tracker_config \\ @pivotal_tracker_config) do | |
with {:ok, %HTTPoison.Response{status_code: 200, body: json}} <- | |
fetch_json_labels(pivotal_tracker_config), | |
{:ok, raw_labels} <- Poison.decode(json) do | |
Enum.map(raw_labels, fn label -> Map.fetch!(label, "name") end) | |
end | |
end | |
defp fetch_json_labels( | |
%{project_id: project_id} = pivotal_tracker_config | |
) do | |
get(pivotal_tracker_config, "projects/#{project_id}/labels") | |
end | |
def fetch_owners(pivotal_tracker_config \\ @pivotal_tracker_config) do | |
with {:ok, %HTTPoison.Response{status_code: 200, body: json}} <- | |
fetch_json_owners(pivotal_tracker_config), | |
{:ok, raw_owners} <- Poison.decode(json) do | |
Enum.into(raw_owners, Map.new, fn owner -> | |
person = Map.fetch!(owner, "person") | |
{Map.fetch!(person, "id"), Map.fetch!(person, "initials")} | |
end) | |
end | |
end | |
defp fetch_json_owners( | |
%{project_id: project_id} = pivotal_tracker_config | |
) do | |
get(pivotal_tracker_config, "projects/#{project_id}/memberships") | |
end | |
def fetch_tasks( | |
owners, | |
pivotal_tracker_config \\ @pivotal_tracker_config | |
) do | |
with {:ok, %HTTPoison.Response{status_code: 200, body: json}} <- | |
fetch_json_tasks(pivotal_tracker_config), | |
{:ok, raw_tasks} <- Poison.decode(json), | |
{:ok, decorated} <- | |
decorate_with_activity(pivotal_tracker_config, raw_tasks, [ ]) do | |
Parser.parse_all_from_pivotal_tracker(decorated, owners) | |
end | |
end | |
defp fetch_json_tasks( | |
%{project_id: project_id, initials: initials} = pivotal_tracker_config | |
) do | |
get( | |
pivotal_tracker_config, | |
"projects/#{project_id}/stories", | |
[ params: %{ filter: ~s|mywork:"#{initials}" AND | <> | |
~s|-state:accepted AND | <> | |
~s|-state:delivered AND | <> | |
~s|-state:finished| } ] | |
) | |
end | |
defp decorate_with_activity( | |
%{project_id: project_id} = pivotal_tracker_config, | |
[%{"id" => task_id} = task | tasks], | |
decorated | |
) do | |
with {:ok, %HTTPoison.Response{status_code: 200, body: json}} <- | |
get( pivotal_tracker_config, | |
"projects/#{project_id}/stories/#{task_id}/activity", | |
[limit: 1] ), | |
{:ok, raw_activity} <- Poison.decode(json) do | |
decorated_task = Map.put(task, "has_activity", length(raw_activity) > 0) | |
decorate_with_activity( | |
pivotal_tracker_config, | |
tasks, | |
[decorated_task | decorated] | |
) | |
end | |
end | |
defp decorate_with_activity(_pivotal_tracker_config, [ ], decorated) do | |
{:ok, Enum.reverse(decorated)} | |
end | |
def create( | |
task, | |
owners, | |
labels, | |
%{project_id: project_id} = | |
pivotal_tracker_config \\ @pivotal_tracker_config | |
) do | |
inverted_owners = invert(owners) | |
with {:ok, %HTTPoison.Response{status_code: 200, body: json}} <- | |
post( | |
pivotal_tracker_config, | |
"projects/#{project_id}/stories", | |
%{ | |
"name" => task.name, | |
"description" => to_string(task.description), | |
"story_type" => task.story_type || "feature", | |
"owner_ids" => [Map.fetch!(inverted_owners, "JEG2")], | |
"labels" => | |
task.labels | |
|> List.wrap | |
|> Enum.filter(fn label -> label in labels end) | |
} | |
), | |
{:ok, raw_task} <- Poison.decode(json), | |
{:ok, [decorated]} <- | |
decorate_with_activity(pivotal_tracker_config, [raw_task], [ ]) do | |
Parser.parse_one_from_pivotal_tracker(decorated, owners) | |
end | |
end | |
def start( | |
task, | |
%{project_id: project_id} = | |
pivotal_tracker_config \\ @pivotal_tracker_config | |
) do | |
task_id = FocusTracker.Task.task_id(task) | |
put( | |
pivotal_tracker_config, | |
"projects/#{project_id}/stories/#{task_id}", | |
%{"current_state" => "started"} | |
) | |
end | |
def disown( | |
task, | |
owners, | |
%{project_id: project_id} = | |
pivotal_tracker_config \\ @pivotal_tracker_config | |
) do | |
inverted_owners = invert(owners) | |
owner_ids = | |
task.owners | |
|> Enum.map(fn initials -> Map.fetch!(inverted_owners, initials) end) | |
|> Kernel.--([Map.fetch!(inverted_owners, "JEG2")]) | |
task_id = FocusTracker.Task.task_id(task) | |
put( | |
pivotal_tracker_config, | |
"projects/#{project_id}/stories/#{task_id}", | |
%{"owner_ids" => owner_ids} | |
) | |
end | |
def finish( | |
task, | |
%{project_id: project_id} = | |
pivotal_tracker_config \\ @pivotal_tracker_config | |
) do | |
task_id = FocusTracker.Task.task_id(task) | |
new_state = | |
if task.story_type == "chore" do | |
"accepted" | |
else | |
"delivered" | |
end | |
put( | |
pivotal_tracker_config, | |
"projects/#{project_id}/stories/#{task_id}", | |
%{"current_state" => new_state} | |
) | |
end | |
defp invert(map) do | |
Enum.into(map, Map.new, fn {k, v} -> {v, k} end) | |
end | |
defp get(%{api_token: api_token}, path, options \\ [ ]) do | |
HTTPoison.get( | |
"https://www.pivotaltracker.com/services/v5/#{path}", | |
["X-TrackerToken": api_token], | |
options | |
) | |
end | |
defp post(%{api_token: api_token}, path, params, options \\ [ ]) do | |
HTTPoison.post( | |
"https://www.pivotaltracker.com/services/v5/#{path}", | |
Poison.encode!(params), | |
["X-TrackerToken": api_token, "Content-Type": "application/json"], | |
options | |
) | |
end | |
defp put(%{api_token: api_token}, path, params, options \\ [ ]) do | |
HTTPoison.put( | |
"https://www.pivotaltracker.com/services/v5/#{path}", | |
Poison.encode!(params), | |
["X-TrackerToken": api_token, "Content-Type": "application/json"], | |
options | |
) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.PivotalTracker.Parser do | |
def parse_all_from_pivotal_tracker(tasks, owners) do | |
Enum.map(tasks, fn task -> | |
parse_one_from_pivotal_tracker(task, owners) | |
end) | |
end | |
def parse_one_from_pivotal_tracker(task, owners) do | |
%FocusTracker.Task{ | |
name: Map.fetch!(task, "name") | |
|> String.trim, | |
description: Map.get(task, "description"), | |
url: Map.fetch!(task, "url"), | |
story_type: Map.fetch!(task, "story_type"), | |
status: Map.fetch!(task, "current_state"), | |
labels: task | |
|> Map.fetch!("labels") | |
|> Enum.map(fn label -> Map.fetch!(label, "name") end), | |
owners: task | |
|> Map.fetch!("owner_ids") | |
|> Enum.map(fn id -> Map.fetch!(owners, id) end), | |
has_activity: Map.fetch!(task, "has_activity") | |
} | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.Task do | |
defstruct ~w[ | |
name description url story_type status labels owners has_activity | |
]a | |
def task_id(%{url: url}) do | |
url | |
|> URI.parse | |
|> Map.fetch!(:path) | |
|> String.split("/") | |
|> List.last | |
end | |
def sync(omnifocus_tasks, pivotal_tracker_tasks) do | |
add_to_omnifocus = | |
pivotal_tracker_tasks | |
|> Enum.filter(fn task -> | |
not Enum.any?(omnifocus_tasks, fn other -> | |
task.name == other.name | |
end) | |
end) | |
add_to_pivotal_tracker = | |
omnifocus_tasks | |
|> Enum.filter(fn task -> is_nil(task.url) end) | |
start_in_pivotal_tracker = | |
pivotal_tracker_tasks | |
|> Enum.filter(fn task -> | |
Enum.any?(omnifocus_tasks, fn other -> | |
task.name == other.name and | |
task.status != "started" and | |
other.status == "started" | |
end) | |
end) | |
update_in_omnifocus = | |
pivotal_tracker_tasks | |
|> Enum.filter(fn task -> | |
Enum.any?(omnifocus_tasks, fn other -> | |
task.name == other.name and task != other | |
end) | |
end) | |
disown_in_pivotal_tracker = | |
pivotal_tracker_tasks | |
|> Enum.filter(fn task -> | |
length(task.owners) > 1 and | |
Enum.any?(omnifocus_tasks, fn other -> | |
task.name == other.name and other.status == "delivered" | |
end) | |
end) | |
finish_in_pivotal_tracker = | |
pivotal_tracker_tasks | |
|> Enum.filter(fn task -> | |
length(task.owners) == 1 and | |
Enum.any?(omnifocus_tasks, fn other -> | |
task.name == other.name and other.status == "delivered" | |
end) | |
end) | |
remove_from_omnifocus = | |
omnifocus_tasks | |
|> Enum.filter(fn task -> | |
task.status != "delivered" and | |
to_string(task.url) =~ ~r{\S} and | |
not Enum.any?(pivotal_tracker_tasks, fn other -> | |
task.name == other.name | |
end) | |
end) | |
%{ | |
add_to_omnifocus: add_to_omnifocus, | |
add_to_pivotal_tracker: add_to_pivotal_tracker, | |
start_in_pivotal_tracker: start_in_pivotal_tracker, | |
update_in_omnifocus: update_in_omnifocus, | |
disown_in_pivotal_tracker: disown_in_pivotal_tracker, | |
finish_in_pivotal_tracker: finish_in_pivotal_tracker, | |
remove_from_omnifocus: remove_from_omnifocus | |
} | |
end | |
def to_omnifocus(%__MODULE__{ } = task) do | |
labels = Enum.join(task.labels, ", ") | |
owners = Enum.join(task.owners, ", ") | |
description = | |
task.description | |
|> to_string | |
|> String.downcase | |
%{ name: task.name, | |
flagged: task.status == "started", | |
note: "url:#{task.url}\n" <> | |
"story_type:#{task.story_type}\n" <> | |
"labels:#{labels}\n" <> | |
"owners:#{owners}\n" <> | |
"has_activity:#{task.has_activity}\n\n" <> | |
description | |
|> String.trim } | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule FocusTracker.Mixfile do | |
use Mix.Project | |
def project do | |
[ | |
app: :focus_tracker, | |
version: "0.1.0", | |
elixir: "~> 1.5", | |
start_permanent: Mix.env == :prod, | |
deps: deps(), | |
escript: [main_module: FocusTracker.CLI] | |
] | |
end | |
# Run "mix help compile.app" to learn about applications. | |
def application do | |
[ | |
extra_applications: [:logger] | |
] | |
end | |
# Run "mix help deps" to learn about dependencies. | |
defp deps do | |
[ | |
# {:dep_from_hexpm, "~> 0.3.0"}, | |
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, | |
{:httpoison, "~> 0.13.0"}, | |
{:poison, "~> 3.1"} | |
] | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
tell application "OmniFocus" | |
tell default document | |
set work_project to first flattened project whose name is "Close PivotalTracker Stories" | |
set work_tasks to flattened tasks of work_project | |
set tasks_string to "" | |
repeat with the_task in work_tasks | |
set task_name to the name of the_task | |
if completed of the_task then | |
set task_status to "delivered" | |
else | |
if flagged of the_task then | |
set task_status to "started" | |
else | |
set task_status to "unscheduled" | |
end if | |
end if | |
set task_note to the note of the_task | |
set tasks_string to tasks_string & "<<<<<<<<<<START_TASK>>>>>>>>>> | |
" | |
set tasks_string to tasks_string & "name=" & task_name & " | |
" | |
set tasks_string to tasks_string & "status=" & task_status & " | |
" | |
set tasks_string to tasks_string & "note=" & task_note & " | |
" | |
set tasks_string to tasks_string & "<<<<<<<<<<END_TASK>>>>>>>>>> | |
" | |
end repeat | |
do shell script "/Users/jeg2/.asdf/shims/escript /Users/jeg2/Documents/focus_tracker/focus_tracker <<< " & quoted form of tasks_string | |
end tell | |
end tell |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment