├── .github └── dependabot.yml ├── .gitignore ├── .ruby-version ├── .travis.yml ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── twdeps ├── examples └── party.png ├── lib ├── twdeps.rb └── twdeps │ ├── graph.rb │ ├── null_presenter.rb │ ├── presenter.rb │ ├── project_presenter.rb │ ├── task_presenter.rb │ └── version.rb ├── test ├── fixtures │ ├── dead-dependency.json │ ├── no_deps.json │ ├── party.json │ ├── party2.json │ └── party_taxes.json ├── helpers │ └── plain_graph_parser.rb ├── integration │ └── test_dependencies.rb ├── test_helper.rb └── unit │ ├── test_broken_dependencies.rb │ ├── test_graph.rb │ └── test_presenters.rb └── twdeps.gemspec /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .DS_Store 4 | .bundle 5 | .cache_rake_t 6 | .config 7 | .yardoc 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: ruby 3 | rvm: 4 | - 2.7.1 5 | before_script: 6 | - mkdir ~/.task 7 | - echo data.location=~/.task > ~/.taskrc 8 | before_install: 9 | - sudo apt-get update 10 | - sudo apt-get install task 11 | - sudo apt-get install graphviz 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | 1. Fork it 4 | 2. Create your feature branch (`git checkout -b my-new-feature`) 5 | 3. Commit your changes (`git commit -am 'Add some feature'`) 6 | 4. Push to the branch (`git push origin my-new-feature`) 7 | 5. Create new Pull Request 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in twdeps.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | twdeps (1.2.0) 5 | optimist 6 | ruby-graphviz 7 | taskwarrior (~> 1.0.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.0.3.1) 13 | activesupport (= 7.0.3.1) 14 | activesupport (7.0.3.1) 15 | concurrent-ruby (~> 1.0, >= 1.0.2) 16 | i18n (>= 1.6, < 2) 17 | minitest (>= 5.1) 18 | tzinfo (~> 2.0) 19 | byebug (11.1.3) 20 | coderay (1.1.3) 21 | concurrent-ruby (1.1.10) 22 | ffi (1.13.0) 23 | formatador (0.2.5) 24 | guard (2.16.2) 25 | formatador (>= 0.2.4) 26 | listen (>= 2.7, < 4.0) 27 | lumberjack (>= 1.0.12, < 2.0) 28 | nenv (~> 0.1) 29 | notiffany (~> 0.0) 30 | pry (>= 0.9.12) 31 | shellany (~> 0.0) 32 | thor (>= 0.18.1) 33 | guard-bundler (3.0.0) 34 | bundler (>= 2.1, < 3) 35 | guard (~> 2.2) 36 | guard-compat (~> 1.1) 37 | guard-compat (1.2.1) 38 | guard-minitest (2.4.6) 39 | guard-compat (~> 1.2) 40 | minitest (>= 3.0) 41 | i18n (1.12.0) 42 | concurrent-ruby (~> 1.0) 43 | listen (3.2.1) 44 | rb-fsevent (~> 0.10, >= 0.10.3) 45 | rb-inotify (~> 0.9, >= 0.9.10) 46 | lumberjack (1.2.5) 47 | method_source (1.0.0) 48 | minitest (5.16.3) 49 | nenv (0.3.0) 50 | notiffany (0.1.3) 51 | nenv (~> 0.1) 52 | shellany (~> 0.0) 53 | optimist (3.0.1) 54 | pry (0.14.1) 55 | coderay (~> 1.1) 56 | method_source (~> 1.0) 57 | pry-byebug (3.10.1) 58 | byebug (~> 11.0) 59 | pry (>= 0.13, < 0.15) 60 | rake (13.0.6) 61 | rb-fsevent (0.10.4) 62 | rb-inotify (0.10.1) 63 | ffi (~> 1.0) 64 | rexml (3.2.5) 65 | ruby-graphviz (1.2.5) 66 | rexml 67 | shellany (0.0.1) 68 | taskwarrior (1.0.2) 69 | activemodel 70 | thor (1.0.1) 71 | twtest (1.2.0) 72 | minitest 73 | tzinfo (2.0.5) 74 | concurrent-ruby (~> 1.0) 75 | 76 | PLATFORMS 77 | ruby 78 | 79 | DEPENDENCIES 80 | guard-bundler 81 | guard-minitest 82 | minitest 83 | pry-byebug 84 | rake 85 | twdeps! 86 | twtest (~> 1.2.0) 87 | 88 | BUNDLED WITH 89 | 2.1.4 90 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'bundler' do 2 | watch('Gemfile') 3 | watch(/^.+\.gemspec/) 4 | end 5 | 6 | guard :test, :test_paths => ['test/unit', 'test/integration'] do 7 | watch('lib/twdeps.rb'){"test"} 8 | watch(%r{^lib/twdeps/(.+)\.rb$}){|m| "test/unit/test_#{m[1]}.rb"} 9 | watch(%r{^test/unit/test_(.+)\.rb$}) 10 | watch('test/test_helper.rb'){"test"} 11 | watch('test/helpers/**/*'){"test"} 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Nicholas E. Rabenau 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TaskWarrior Dependency Visualization 2 | 3 | Visualizes dependencies between TaskWarrior tasks. 4 | 5 | [![Build Status](https://secure.travis-ci.org/nerab/twdeps.png?branch=master)](http://travis-ci.org/nerab/twdeps) 6 | [![Gem Version](https://badge.fury.io/rb/twdeps.png)](http://badge.fury.io/rb/twdeps) 7 | 8 | ## Example 9 | 10 | Given a set of interdependent tasks, they are 11 | 12 | 1. Exported from TaskWarrior as JSON, then 13 | 1. Piped into `twdeps`, and finally 14 | 1. The output is directed to a PNG file. 15 | 16 | Result: 17 | 18 | ![party](https://raw.github.com/nerab/twdeps/master/examples/party.png) 19 | 20 | For the impatient: The JSON export is also available as [party.json](https://raw.github.com/nerab/twdeps/master/test/fixtures/party.json). Once you installed `twdeps`, the command 21 | 22 | ```bash 23 | $ curl https://raw.githubusercontent.com/nerab/twdeps/master/test/fixtures/party.json | twdeps -f png > party.png 24 | ``` 25 | 26 | will generate `party.png` in the current directory. If you don't want to download the JSON file, try the local oen that comes with `twdeps`: 27 | 28 | ```bash 29 | $ twdeps $(dirname $(gem which twdeps))/../test/fixtures/party.json -f png > party.png 30 | ``` 31 | 32 | ## Installation 33 | 34 | ```bash 35 | $ gem install twdeps 36 | ``` 37 | 38 | ## Usage 39 | 40 | * Create a dependency graph as PNG and pipe it to a file: 41 | 42 | ```bash 43 | task export | twdeps > deps.png 44 | ``` 45 | 46 | See [Limitations](Limitations) below for why we need the extra task parms 47 | 48 | * Same but specify output format 49 | 50 | ```bash 51 | task export | twdeps --format svg > deps.svg 52 | ``` 53 | 54 | * Create a graph from a previously exported file 55 | 56 | ```bash 57 | task export > tasks.json 58 | cat tasks.json | twdeps > deps.png 59 | ``` 60 | 61 | * Display graph in browser without creating an intermediate file 62 | 63 | ```bash 64 | task export | twdeps --format svg | bcat 65 | ``` 66 | 67 | [bcat](https://rtomayko.github.io/bcat/) is required for piping into a browser. 68 | 69 | ## Dependencies 70 | 71 | The graph is generated with [ruby-graphviz](https://github.com/glejeune/Ruby-Graphviz), which in turn requires a local [Graphviz](http://graphviz.org/) installation (e.g. `brew install graphviz` on a Mac or `sudo apt-get install graphviz` on Ubuntu Linux). 72 | 73 | 74 | [bundler](http://bundler.io/) is also required. 75 | 76 | ## Limitations 77 | 78 | TaskWarrior versions before 2.1 need the additional command line options `rc.json.array=on` and `rc.verbose=nothing` due to [two](http://taskwarrior.org/issues/1017) [bugs](http://taskwarrior.org/issues/1013) in the JSON export. 79 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new(:test) do |test| 7 | test.libs << 'lib' << 'test' << 'test/helpers' 8 | test.test_files = FileList['test/**/test_*.rb'] 9 | end 10 | 11 | task default: 'test' 12 | -------------------------------------------------------------------------------- /bin/twdeps: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'twdeps' 6 | require 'optimist' 7 | 8 | def log(msg) 9 | STDERR.puts "#{File.basename($0)}: #{msg}" 10 | end 11 | 12 | def die(msg = nil) 13 | log(msg) unless msg.nil? 14 | exit 1 15 | end 16 | 17 | include TaskWarrior 18 | include TaskWarrior::Dependencies 19 | 20 | opts = Optimist::options do 21 | version "#{File.basename($0)} v#{VERSION} (c) 2012-#{Time.now.year} Nicolas E. Rabenau" 22 | banner <<-EOS 23 | Visualizes dependencies between TaskWarrior tasks. 24 | 25 | Usage: 26 | #{File.basename($0)} [options] 27 | 28 | where [options] are: 29 | 30 | EOS 31 | opt :format, "Specify output format", :default => 'svg' 32 | opt :title, "Specify title", :default => 'Task Dependencies' 33 | opt :trace, "Enable trace output", :default => false 34 | end 35 | 36 | Optimist::die :format, "must be one of #{Graph.formats.join(', ')}" unless Graph.formats.include?(opts[:format]) 37 | 38 | begin 39 | repo = Repository.new(ARGF.read) 40 | master = Graph.new(Presenter.new(opts[:title])) 41 | 42 | # Add all projects (will add their tasks and dependencies recursively) 43 | repo.projects.each do |project| 44 | master << project 45 | end 46 | 47 | # Add all project-less tasks as toplevel nodes 48 | repo.tasks.reject{|t| t.project}.each do |task| 49 | master << task 50 | end 51 | 52 | puts master.render(opts[:format]) 53 | rescue 54 | if opts[:trace] 55 | log($!) 56 | $@.each{|line| log(line)} 57 | else 58 | die($!) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /examples/party.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nerab/twdeps/9c7d3696b1f23ddd36c2f03120502ebafbb3fe69/examples/party.png -------------------------------------------------------------------------------- /lib/twdeps.rb: -------------------------------------------------------------------------------- 1 | require 'twdeps/version' 2 | require 'twdeps/graph' 3 | require 'twdeps/presenter' 4 | require 'twdeps/task_presenter' 5 | require 'twdeps/project_presenter' 6 | require 'twdeps/null_presenter' 7 | 8 | # dependencies 9 | require 'taskwarrior' 10 | require 'graphviz' 11 | require 'json' 12 | 13 | module TaskWarrior 14 | module Dependencies 15 | # Your code goes here... 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/twdeps/graph.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | # Builds a dependency graph 4 | # 5 | # +thing+ is added as node with all of its dependencies. A presenter is used to present the task as node label. 6 | # +thing.id.to_s+ is called for the identifier. It must be unique within the graph and all of its dependencies. 7 | # 8 | # +thing.dependencies(thing)+ is called if +thing+ responds to it. It is expected to return a list 9 | # of things the thing depends on. Each thing may have its own dependencies which will be resolved recursively. 10 | # 11 | # Design influenced by https://github.com/glejeune/Ruby-Graphviz/blob/852ee119e4e9850f682f0a0089285c36ee16280f/bin/gem2gv 12 | # 13 | class Graph 14 | class << self 15 | def formats 16 | GraphViz::Constants::FORMATS 17 | end 18 | end 19 | 20 | # 21 | # Build a new Graph for +thing+ 22 | # 23 | def initialize(presenter_or_id) 24 | if presenter_or_id.respond_to?(:attributes) 25 | @graph = GraphViz::new(presenter_or_id.id, presenter_or_id.attributes) 26 | else 27 | @graph = GraphViz::new(presenter_or_id) 28 | end 29 | 30 | @dependencies = [] 31 | @edges = [] 32 | end 33 | 34 | def <<(task_or_project) 35 | if task_or_project.respond_to?(:dependencies) 36 | task = task_or_project 37 | nodeA = find_or_create_node(task) 38 | create_edges(nodeA, task.dependencies) 39 | 40 | # resolve all dependencies we don't know yet 41 | task.dependencies.each do |dependency| 42 | next if @dependencies.include?(dependency) 43 | next if dependency.nil? 44 | 45 | @dependencies << dependency 46 | self << dependency 47 | end 48 | else 49 | # it's a project 50 | project = task_or_project 51 | cluster = Graph.new(presenter(project)) 52 | 53 | project.tasks.each do |t| 54 | cluster << t 55 | end 56 | 57 | # add all nodes and edges from cluster as a subgraph to @graph 58 | @graph.add_graph(cluster.graph) 59 | end 60 | end 61 | 62 | def render(format) 63 | @graph.output(format => String) 64 | end 65 | 66 | protected 67 | attr_reader :graph 68 | 69 | private 70 | def create_edges(nodeA, nodes) 71 | nodes.each do |node| 72 | nodeB = find_or_create_node(node) 73 | create_edge(nodeB, nodeA) 74 | end 75 | end 76 | 77 | def find_or_create_node(thing) 78 | @graph.get_node(presenter(thing).id) || create_node(thing) 79 | end 80 | 81 | def create_node(thing) 82 | @graph.add_nodes(presenter(thing).id, presenter(thing).attributes) 83 | end 84 | 85 | def create_edge(nodeA, nodeB) 86 | edge = [nodeA, nodeB] 87 | unless @edges.include?(edge) # GraphViz lacks get_edge, so we need to track existing edges ourselfes 88 | @edges << edge 89 | 90 | # We present the edges in the sense of "nodeB depends on nodeA" 91 | @graph.add_edges(nodeA, nodeB, :dir => 'back', :tooltip => "#{nodeB['label']} depends on #{nodeA['label']}") 92 | end 93 | end 94 | 95 | def presenter(thing) 96 | # TODO Will counter-caching the presenters improve performance? 97 | if thing.nil? 98 | NullPresenter.new 99 | else 100 | if thing.respond_to?(:dependencies) 101 | TaskPresenter.new(thing) 102 | else 103 | ProjectPresenter.new(thing) 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/twdeps/null_presenter.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | class NullPresenter < Presenter 4 | def initialize 5 | super('null') 6 | self.attributes = {:label => 'Unknown', :fontcolor => 'red'} 7 | end 8 | end 9 | end 10 | end 11 | 12 | -------------------------------------------------------------------------------- /lib/twdeps/presenter.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | # 4 | # Presents a thing to the graph 5 | # 6 | class Presenter 7 | def initialize(id) 8 | @id = id 9 | @attributes = {:label => id, :labelloc => 'top'} 10 | end 11 | 12 | def attributes 13 | @attributes 14 | end 15 | 16 | def id 17 | @id 18 | end 19 | 20 | protected 21 | attr_writer :id, :attributes 22 | end 23 | end 24 | end 25 | 26 | -------------------------------------------------------------------------------- /lib/twdeps/project_presenter.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | # 4 | # Presents a project's attributes suitable for a GraphViz cluster 5 | # 6 | class ProjectPresenter < Presenter 7 | def initialize(project) 8 | self.id = "cluster_#{project.name}" 9 | self.attributes = {:label => project.name} 10 | end 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/twdeps/task_presenter.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | # 4 | # Presents a task's attributes suitable for a GraphViz node 5 | # 6 | class TaskPresenter < Presenter 7 | def initialize(task) 8 | self.id = task.uuid 9 | self.attributes = { 10 | :label => task.description, 11 | :tooltip => "Status: #{task.status}" 12 | } 13 | 14 | if :completed == task.status 15 | self.attributes.merge!({:fontcolor => 'gray', :color => 'gray'}) 16 | end 17 | end 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/twdeps/version.rb: -------------------------------------------------------------------------------- 1 | module TaskWarrior 2 | module Dependencies 3 | VERSION = '1.2.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/dead-dependency.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 42, 3 | "depends": "9c16b278-6c79-407e-be1b-3eb65e4b11be", 4 | "description": "Handle dead depends", 5 | "entry": "20101121T205311Z", 6 | "project": "FixAllTheBugs", 7 | "status": "pending", 8 | "uuid": "2170fecc-0646-4320-99e0-75ed3c365f1c", 9 | "annotations": [{ 10 | "entry": "20130210T173348Z", 11 | "description": "This is a comment" 12 | }], 13 | "urgency": -1.2 14 | }] 15 | -------------------------------------------------------------------------------- /test/fixtures/no_deps.json: -------------------------------------------------------------------------------- 1 | [{"id":1,"description":"Select a free weekend in November","entry":"20120629T191421Z","priority":"H","project":"party","status":"pending","uuid":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","annotations":[{"entry":"20120629T191534Z","description":"the 13th looks good"}]}, 2 | {"id":2,"description":"Select and book a venue","entry":"20120629T191634Z","priority":"H","project":"party","status":"pending","uuid":"c992448a-f1ea-4982-8461-47f0705ff509"}, 3 | {"id":3,"description":"Mail invitations","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"3b53178e-d5a4-45e0-afc2-1292db58a59a"}, 4 | {"id":4,"description":"Select a caterer","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"c590941b-eb10-4569-bdc9-0e339f79305e"}, 5 | {"id":5,"description":"Design invitations","entry":"20120629T191919Z","priority":"H","project":"party","status":"pending","tags":["mall"],"uuid":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a"}, 6 | {"id":6,"description":"Print invitations","entry":"20120629T191920Z","project":"party","status":"pending","tags":["mall"],"uuid":"9f6f3738-1c08-4f45-8eb4-1e90864c7588"} 7 | ] -------------------------------------------------------------------------------- /test/fixtures/party.json: -------------------------------------------------------------------------------- 1 | [{"id":1,"description":"Select a free weekend in November","entry":"20120629T191421Z","priority":"H","project":"party","status":"pending","uuid":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","annotations":[{"entry":"20120629T191534Z","description":"the 13th looks good"}]}, 2 | {"id":2,"depends":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","description":"Select and book a venue","entry":"20120629T191634Z","priority":"H","project":"party","status":"pending","uuid":"c992448a-f1ea-4982-8461-47f0705ff509"}, 3 | {"id":3,"depends":"9f6f3738-1c08-4f45-8eb4-1e90864c7588","description":"Mail invitations","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"3b53178e-d5a4-45e0-afc2-1292db58a59a"}, 4 | {"id":4,"depends":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a,c992448a-f1ea-4982-8461-47f0705ff509","description":"Select a caterer","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"c590941b-eb10-4569-bdc9-0e339f79305e"}, 5 | {"id":5,"depends":"c992448a-f1ea-4982-8461-47f0705ff509","description":"Design invitations","entry":"20120629T191919Z","priority":"H","project":"party","status":"pending","tags":["mall"],"uuid":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a"}, 6 | {"id":6,"depends":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a","description":"Print invitations","entry":"20120629T191920Z","project":"party","status":"pending","tags":["mall"],"uuid":"9f6f3738-1c08-4f45-8eb4-1e90864c7588"} 7 | ] -------------------------------------------------------------------------------- /test/fixtures/party2.json: -------------------------------------------------------------------------------- 1 | [{"id":1,"description":"Select a free weekend in November","entry":"20120629T191421Z","priority":"H","project":"party","status":"pending","uuid":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","annotations":[{"entry":"20120629T191534Z","description":"the 13th looks good"}]}, 2 | {"id":2,"depends":["6fd0ba4a-ab67-49cd-ac69-64aa999aff8a"],"description":"Select and book a venue","entry":"20120629T191634Z","priority":"H","project":"party","status":"pending","uuid":"c992448a-f1ea-4982-8461-47f0705ff509"}, 3 | {"id":3,"depends":["9f6f3738-1c08-4f45-8eb4-1e90864c7588"],"description":"Mail invitations","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"3b53178e-d5a4-45e0-afc2-1292db58a59a"}, 4 | {"id":4,"depends":["6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","c992448a-f1ea-4982-8461-47f0705ff509"],"description":"Select a caterer","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"c590941b-eb10-4569-bdc9-0e339f79305e"}, 5 | {"id":5,"depends":["c992448a-f1ea-4982-8461-47f0705ff509"],"description":"Design invitations","entry":"20120629T191919Z","priority":"H","project":"party","status":"pending","tags":["mall"],"uuid":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a"}, 6 | {"id":6,"depends":["e5a867b7-0116-457d-ba43-9ac2bee6ad2a"],"description":"Print invitations","entry":"20120629T191920Z","project":"party","status":"pending","tags":["mall"],"uuid":"9f6f3738-1c08-4f45-8eb4-1e90864c7588"} 7 | ] -------------------------------------------------------------------------------- /test/fixtures/party_taxes.json: -------------------------------------------------------------------------------- 1 | [{"id":1,"description":"Select a free weekend in November","entry":"20120629T191421Z","priority":"H","project":"party","status":"pending","uuid":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","annotations":[{"entry":"20120629T191534Z","description":"the 13th looks good"}]}, 2 | {"id":2,"depends":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a","description":"Select and book a venue","entry":"20120629T191634Z","priority":"H","project":"party","status":"pending","uuid":"c992448a-f1ea-4982-8461-47f0705ff509"}, 3 | {"id":3,"depends":"9f6f3738-1c08-4f45-8eb4-1e90864c7588","description":"Mail invitations","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"3b53178e-d5a4-45e0-afc2-1292db58a59a"}, 4 | {"id":4,"depends":"6fd0ba4a-ab67-49cd-ac69-64aa999aff8a,c992448a-f1ea-4982-8461-47f0705ff509","description":"Select a caterer","entry":"20120629T191919Z","project":"party","status":"pending","uuid":"c590941b-eb10-4569-bdc9-0e339f79305e"}, 5 | {"id":5,"depends":"c992448a-f1ea-4982-8461-47f0705ff509","description":"Design invitations","entry":"20120629T191919Z","priority":"H","project":"party","status":"pending","tags":["mall"],"uuid":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a"}, 6 | {"id":6,"depends":"e5a867b7-0116-457d-ba43-9ac2bee6ad2a","description":"Print invitations","entry":"20120629T191920Z","project":"party","status":"pending","tags":["mall"],"uuid":"9f6f3738-1c08-4f45-8eb4-1e90864c7588"}, 7 | {"id":7,"description":"Pay taxes","due":"20130429T220000Z","entry":"20120630T092759Z","mask":"-","recur":"yearly","status":"recurring","uuid":"b587f364-c68e-4438-b4d6-f2af6ad62518"}, 8 | {"id":8,"description":"Pay taxes","due":"20130429T220000Z","entry":"20120630T092800Z","imask":"0","parent":"b587f364-c68e-4438-b4d6-f2af6ad62518","recur":"yearly","status":"pending","tags":["finance"],"uuid":"99c9e1bb-ed75-4525-b05d-cf153a7ee1a1"}, 9 | {"id":9,"description":"Get cash from ATM","entry":"20120702T130056Z","status":"pending","tags":["finance","mall"],"uuid":"67aafe0b-ddd7-482b-9cfa-ac42c43e7559"} 10 | ] 11 | -------------------------------------------------------------------------------- /test/helpers/plain_graph_parser.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | 3 | module TaskWarrior 4 | module Test 5 | class Entry 6 | def initialize(fields) 7 | @fields = fields 8 | end 9 | 10 | protected 11 | attr_reader :fields 12 | end 13 | 14 | class Graph < Entry; end 15 | class Stop < Entry; end 16 | 17 | class Node < Entry 18 | attr_reader :id 19 | 20 | def initialize(fields) 21 | @id = fields.shift 22 | super 23 | end 24 | 25 | def label 26 | fields[4] 27 | end 28 | end 29 | 30 | class Edge < Entry 31 | attr_reader :from, :to 32 | 33 | def initialize(fields) 34 | @from = fields.shift 35 | @to = fields.shift 36 | end 37 | end 38 | 39 | class PlainGraphParser 40 | def initialize(lines) 41 | @bucket = Hash.new{|hash, key| hash[key] = Array.new} 42 | 43 | lines.each_line{|line| 44 | CSV.parse(line, :quote_char => '"', :col_sep => ' ') do |fields| 45 | o = TaskWarrior::Test.const_get(fields.shift.capitalize).new(fields) 46 | @bucket[o.class] << o 47 | end 48 | } 49 | end 50 | 51 | def edges 52 | @bucket[Edge] 53 | end 54 | 55 | def nodes 56 | @bucket[Node] 57 | end 58 | 59 | def node(id) 60 | @bucket[Node].select{|node| id == node.id}.first 61 | end 62 | 63 | def edge(from, to) 64 | @bucket[Edge].select{|edge| from == edge.from && to == edge.to}.first 65 | end 66 | end 67 | end 68 | end -------------------------------------------------------------------------------- /test/integration/test_dependencies.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class DependencyTest < TaskWarrior::Test::Integration::Test 4 | include TaskWarrior::Test::Fixtures 5 | 6 | def test_no_dependencies 7 | task("import #{fixture('no_deps.json')}") 8 | tasks = export_tasks 9 | assert_equal(6, tasks.size) 10 | end 11 | 12 | def test_full_dependencies 13 | task("import #{fixture('party.json')}") 14 | tasks = export_tasks 15 | assert_equal(6, tasks.size) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'pry-byebug' 3 | require 'twtest' 4 | require 'twdeps' 5 | require 'helpers/plain_graph_parser.rb' 6 | 7 | module TaskWarrior 8 | module Test 9 | module Fixtures 10 | def fixture(name) 11 | File.join(File.dirname(__FILE__), 'fixtures', name) 12 | end 13 | end 14 | 15 | module Validations 16 | def assert_valid(task) 17 | assert(task.valid?, error_message(task.errors)) 18 | end 19 | 20 | def assert_invalid(task) 21 | assert(task.invalid?, 'Expect validation to fail') 22 | end 23 | 24 | def error_message(errors) 25 | errors.each_with_object([]){|e, result| 26 | result << e.join(' ') 27 | }.join("\n") 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/unit/test_broken_dependencies.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module TaskWarrior 4 | module Test 5 | class TestBrokenDependencies < MiniTest::Test 6 | include TaskWarrior::Test::Fixtures 7 | 8 | def setup 9 | graph = TaskWarrior::Dependencies::Graph.new(self.class.name) 10 | 11 | TaskWarrior::Repository.new(File.read(fixture('dead-dependency.json'))).tasks.each do |task| 12 | graph << task 13 | end 14 | 15 | @parsed_graph = TaskWarrior::Test::PlainGraphParser.new(graph.render(:plain)) 16 | end 17 | 18 | 19 | def test_node_size 20 | assert_equal(2, @parsed_graph.nodes.size) 21 | end 22 | 23 | def test_node_text 24 | refute_nil(@parsed_graph.node('2170fecc-0646-4320-99e0-75ed3c365f1c')) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/test_graph.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module TaskWarrior 4 | module Test 5 | class TestGraph < MiniTest::Test 6 | include TaskWarrior::Test::Fixtures 7 | 8 | def setup 9 | repo = TaskWarrior::Repository.new(File.read(fixture('party_taxes.json'))) 10 | plain = TaskWarrior::Dependencies::Graph.new(self.class.name) 11 | 12 | repo.tasks.each do |task| 13 | plain << task 14 | end 15 | 16 | @graph = TaskWarrior::Test::PlainGraphParser.new(plain.render(:plain)) 17 | end 18 | 19 | def test_nodes 20 | assert_equal(8, @graph.nodes.size) 21 | 22 | assert_node("6fd0ba4a-ab67-49cd-ac69-64aa999aff8a", "Select a free weekend in November") 23 | assert_node("c992448a-f1ea-4982-8461-47f0705ff509", "Select and book a venue") 24 | assert_node("3b53178e-d5a4-45e0-afc2-1292db58a59a", "Mail invitations") 25 | assert_node("9f6f3738-1c08-4f45-8eb4-1e90864c7588", "Print invitations") 26 | assert_node("e5a867b7-0116-457d-ba43-9ac2bee6ad2a", "Design invitations") 27 | assert_node("c590941b-eb10-4569-bdc9-0e339f79305e", "Select a caterer") 28 | assert_node("b587f364-c68e-4438-b4d6-f2af6ad62518", "Pay taxes") 29 | end 30 | 31 | def test_edges 32 | assert_equal(6, @graph.edges.size) 33 | refute_nil(@graph.edge('6fd0ba4a-ab67-49cd-ac69-64aa999aff8a', 'c992448a-f1ea-4982-8461-47f0705ff509')) 34 | refute_nil(@graph.edge('9f6f3738-1c08-4f45-8eb4-1e90864c7588', '3b53178e-d5a4-45e0-afc2-1292db58a59a')) 35 | refute_nil(@graph.edge('e5a867b7-0116-457d-ba43-9ac2bee6ad2a', '9f6f3738-1c08-4f45-8eb4-1e90864c7588')) 36 | refute_nil(@graph.edge('c992448a-f1ea-4982-8461-47f0705ff509', 'e5a867b7-0116-457d-ba43-9ac2bee6ad2a')) 37 | refute_nil(@graph.edge('6fd0ba4a-ab67-49cd-ac69-64aa999aff8a', 'c590941b-eb10-4569-bdc9-0e339f79305e')) 38 | refute_nil(@graph.edge('c992448a-f1ea-4982-8461-47f0705ff509', 'c590941b-eb10-4569-bdc9-0e339f79305e')) 39 | end 40 | 41 | private 42 | def assert_node(id, label) 43 | node = @graph.node(id) 44 | refute_nil(node) 45 | assert_equal(id, node.id) 46 | assert_equal(label, node.label) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/unit/test_presenters.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module TaskWarrior 4 | module Test 5 | class TestPresenters < Minitest::Test 6 | include TaskWarrior::Dependencies 7 | include TaskWarrior::Test::Fixtures 8 | 9 | def setup 10 | @repo = TaskWarrior::Repository.new(File.read(fixture('party_taxes.json'))) 11 | end 12 | 13 | def test_string_presentation 14 | foo = 'foo' 15 | p = Presenter.new(foo) 16 | assert_equal(foo, p.id) 17 | refute_nil(p.attributes) 18 | assert_equal({:label=>"foo", :labelloc=>"top"}, p.attributes) 19 | end 20 | 21 | def test_null_presentation 22 | p = NullPresenter.new 23 | assert_equal('null', p.id) 24 | assert_equal({:label => 'Unknown', :fontcolor => 'red'}, p.attributes) 25 | end 26 | 27 | def test_project_presentation 28 | p = ProjectPresenter.new(@repo.project('party')) 29 | assert_equal('cluster_party', p.id) 30 | assert_equal({:label => 'party'}, p.attributes) 31 | end 32 | 33 | def test_task_presentation 34 | uuid = '67aafe0b-ddd7-482b-9cfa-ac42c43e7559' 35 | p = TaskPresenter.new(@repo[uuid]) 36 | assert_equal(uuid, p.id) 37 | assert_equal({:label => 'Get cash from ATM', :tooltip => 'Status: pending'}, p.attributes) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /twdeps.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/twdeps/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Nicholas E. Rabenau"] 6 | gem.email = ["nerab@gmx.net"] 7 | gem.description = %q{Takes a TaskWarrior export and emits a graph that visualizes the dependencies between tasks.} 8 | gem.summary = %q{Visualizes dependencies between TaskWarrior tasks.} 9 | 10 | gem.files = `git ls-files`.split($\) 11 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 12 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 13 | gem.name = "twdeps" 14 | gem.require_paths = ["lib"] 15 | gem.version = TaskWarrior::Dependencies::VERSION 16 | 17 | gem.add_dependency 'ruby-graphviz' 18 | gem.add_dependency 'optimist' 19 | gem.add_dependency 'taskwarrior', '~> 1.0.0' 20 | gem.add_development_dependency 'minitest' 21 | gem.add_development_dependency 'twtest', '~> 1.2.0' 22 | gem.add_development_dependency 'guard-minitest' 23 | gem.add_development_dependency 'guard-bundler' 24 | gem.add_development_dependency 'rake' 25 | gem.add_development_dependency 'pry-byebug' 26 | end 27 | --------------------------------------------------------------------------------