Skip to content

Instantly share code, notes, and snippets.

@JEG2
Created February 4, 2018 22:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JEG2/a1cf82869338376daf70e349301b4710 to your computer and use it in GitHub Desktop.
Save JEG2/a1cf82869338376daf70e349301b4710 to your computer and use it in GitHub Desktop.
My OmniFocus <-> Pivotal Tracker integration.
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
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
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
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
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
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
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
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