whatbug 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1e95630e8ed7dd2bf91f11e7373386edfc20b1b5
4
+ data.tar.gz: 4b8d89d58408c3503ae76d68d89257cc1b35c4f6
5
+ SHA512:
6
+ metadata.gz: e141682dcd4d9511cbb4927a1777a486c00f5800914fbf911c4e95846b604651accc66c437c280b221d385357a8ad3a90c6ce8008192fd374e7e17d7eee5fc71
7
+ data.tar.gz: c1a58b2dafa4085a244bb1dc4a556e5d6ed59ff628cf18c750863be97906ade0d2bde0af0dc5a46b07165cfe4ac287b203d97d67c6592d06c8d8e96bef467eba
data/bin/whatbug ADDED
@@ -0,0 +1,132 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require "dotenv"
4
+
5
+ require_relative "../lib/git_client"
6
+ require_relative "../lib/rollbar_client"
7
+ require_relative "../lib/airbrake_client"
8
+ require_relative "../lib/tracer"
9
+
10
+ Dotenv.load()
11
+
12
+ BLAME_DATE_RGX = /\([^)]*\)/
13
+ DATE_RGX = /\d{1,4}[-.\/]\d{1,2}[-.\/]\d{1,4}/
14
+
15
+ def get_error(id)
16
+ raise "No error ID given" if id.nil? || id.length == 0
17
+
18
+ if ENV.has_key?("ROLLBAR_API_KEY")
19
+ config = {
20
+ "username" => ENV["ROLLBAR_USERNAME"],
21
+ "api_key" => ENV["ROLLBAR_API_KEY"],
22
+ "project" => ENV["ROLLBAR_PROJECT"],
23
+ "environment" => ENV["ROLLBAR_ENVIRONMENT"]
24
+ }
25
+ errorClass = RollbarClient
26
+ elsif ENV.has_key?("AIRBRAKE_API_KEY")
27
+ config = {
28
+ "api_key" => ENV["AIRBRAKE_API_KEY"],
29
+ "project_id" => ENV["AIRBRAKE_PROJECT_ID"],
30
+ "environment" => ENV["AIRBRAKE_ENVIRONMENT"]
31
+ }
32
+ errorClass = AirbrakeClient
33
+ else
34
+ raise "Unable to find ENV VARS for AIRBRAKE_API_KEY or ROLLBAR_API_KEY"
35
+ end
36
+
37
+ errorClass
38
+ .new(config)
39
+ .get_error(id)
40
+ end
41
+
42
+ def map_code(file, func, line, cutoff)
43
+ blames = GitClient
44
+ .new
45
+ .blame(file[:path], func[:start], func[:end])
46
+ .map do |blame_line|
47
+ date = blame_line
48
+ .scan(BLAME_DATE_RGX)
49
+ .first
50
+ .gsub(/\s+\d+\)$/, "")
51
+
52
+ commit = blame_line.split(" ").first
53
+ date_start = date.index(DATE_RGX)
54
+ author = date[1...(date_start - 1)]
55
+ date = DateTime.parse(date[date_start..-1]) rescue DateTime.new
56
+
57
+ {
58
+ commit: commit,
59
+ author: author,
60
+ date: date
61
+ }
62
+ end
63
+
64
+ start = func[:start] - 1
65
+ file[:code][start...func[:end]]
66
+ .each_with_index
67
+ .map do |line, index|
68
+ blame = blames.shift()
69
+ {
70
+ text: line,
71
+ line_num: func[:start] + index,
72
+ changed: blame[:date] > cutoff,
73
+ in_trace: false,
74
+ blame: blame
75
+ }
76
+ end
77
+ end
78
+
79
+ def main(error_id, cutoff)
80
+ cutoff = cutoff.nil? ? (Date.today - 7).to_datetime : DateTime.parse(cutoff)
81
+ tracer = Tracer.new()
82
+
83
+ error = get_error(error_id)
84
+ files = error[:stack_trace]
85
+ .map { |trace| trace[:file] }
86
+ .uniq
87
+ .reduce({}) do |acc, file|
88
+ parts = File.split(ENV["PROJECT_ROOT"]) + file.split("/")
89
+ path = File.join(*parts)
90
+ code = File.read(path)
91
+ acc[file] = {
92
+ path: file,
93
+ code: code.lines,
94
+ funcs: tracer.trace(path, code)
95
+ }
96
+ acc
97
+ end
98
+
99
+ Dir.chdir(ENV["PROJECT_ROOT"])
100
+ traced_functions = error[:stack_trace]
101
+ .reduce({}) do |acc, trace|
102
+ file = files[trace[:file]]
103
+ line = trace[:line]
104
+ func = file[:funcs].detect { |func| func[:start] <= line && func[:end] >= line }
105
+ if not func.nil?
106
+ line_start = line - func[:start]
107
+
108
+ acc[trace[:file]] ||= {}
109
+ acc[trace[:file]][func[:name]] ||= map_code(file, func, line, cutoff)
110
+ acc[trace[:file]][func[:name]][line_start][:in_trace] = true
111
+ end
112
+ acc
113
+ end
114
+
115
+ traced_functions
116
+ .each do |file_name, funcs|
117
+ funcs.each do |func_name, func|
118
+ trace_line = func.detect { |l| l[:in_trace] }
119
+
120
+ puts "#{file_name}:#{trace_line[:line_num]} - #{func_name}"
121
+ func.each do |line|
122
+ line_num = line[:line_num].to_s.rjust(3, " ")
123
+ change_sym = line[:changed] ? "+" : " "
124
+ trace_sym = line[:in_trace] ? "X" : " "
125
+
126
+ puts "#{change_sym}#{trace_sym}#{line_num}) #{line[:text]}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ main(ARGV[0], ARGV[1])
@@ -0,0 +1,67 @@
1
+ require "json"
2
+ require "date"
3
+ require "net/http"
4
+ require "active_support/core_ext/hash"
5
+
6
+ class AirbrakeClient
7
+ PROJECT_ROOT_RGX = /^\[PROJECT_ROOT\]/
8
+
9
+ def initialize(config)
10
+ @key = config["api_key"]
11
+ @project_id = config["project_id"]
12
+ @environment = config["environment"]
13
+ @errors = @key.nil? ? [] : errors()
14
+ @deploys = @key.nil? ? [] : deploys()
15
+ end
16
+
17
+ def get_error(group_id)
18
+ parse_trace(select_traces({"id" => group_id}).first)
19
+ end
20
+
21
+ private
22
+
23
+ def parse_trace(trace)
24
+ stack_trace = (trace["backtrace"] || [])
25
+ .reject { |trace| trace["file"].nil? }
26
+ .map do |trace|
27
+ file = trace["file"].gsub(PROJECT_ROOT_RGX, "")
28
+ line = trace["line"]
29
+ function = trace["function"]
30
+
31
+ {
32
+ file: file,
33
+ line: line,
34
+ function: function
35
+ }
36
+ end
37
+
38
+ {
39
+ error_id: trace["id"].to_sym,
40
+ first_time: DateTime.rfc3339(trace["createdAt"]),
41
+ last_time: DateTime.rfc3339(trace["lastNoticeAt"]),
42
+ link: "https://airbrake.io/projects/#{@project_id}/groups/#{trace["id"]}",
43
+ environment: trace["context"]["environment"],
44
+ type: trace["type"],
45
+ message: trace["message"],
46
+ total_occurrences: trace["noticeTotalCount"],
47
+ stack_trace: stack_trace
48
+ }
49
+ end
50
+
51
+ def select_traces(group)
52
+ notices = fetch_error(group["id"])["notices"]
53
+ error = notices.first["errors"].first
54
+ group["backtrace"] = error["backtrace"]
55
+ group["type"] = error["type"]
56
+ group["message"] = error["message"]
57
+ [group]
58
+ rescue
59
+ []
60
+ end
61
+
62
+ def fetch_error(group_id)
63
+ JSON.parse(Net::HTTP.get(
64
+ URI("https://airbrake.io/api/v4/projects/#{@project_id}/groups/#{group_id}/notices?key=#{@key}")
65
+ ))
66
+ end
67
+ end
@@ -0,0 +1,111 @@
1
+ class CSyntaxTracer
2
+ def trace(code)
3
+ code = strip_comments(code)
4
+ find_funcs(code)
5
+ .map { |func| define_func(func, code) }
6
+ rescue
7
+ []
8
+ end
9
+
10
+ private
11
+
12
+ def find_funcs(code)
13
+ funcs = code.scan(/[a-zA-Z \t]*function\s+[A-Za-z_][A-Za-z0-9_]*/)
14
+ funcs += code.scan(/[a-zA-Z0-9_. \t]*\s*=\s*function/)
15
+
16
+ funcs = funcs
17
+ .each_with_index
18
+ .map do |func, index|
19
+ if func.match(/[a-zA-Z0-9_]*\s+=/)
20
+ name = func
21
+ .split("=")
22
+ .last(2)
23
+ .first
24
+ .split(/[ \t.]/)
25
+ .last
26
+ else
27
+ name = func.split(" ").last
28
+ end
29
+
30
+ [name] + code
31
+ .lines
32
+ .each_with_index
33
+ .select { |line, line_num| line.match(func) }
34
+ .first
35
+ end
36
+
37
+ funcs
38
+ .each_with_index
39
+ .map do |func, index|
40
+ if funcs.count > index + 2
41
+ func << funcs[index + 1].last
42
+ else
43
+ func << code.lines.count
44
+ end
45
+ func
46
+ end
47
+ end
48
+
49
+ def define_func(func, code)
50
+ function_code = code.lines[func[2]...func[3]]
51
+ indent = function_code
52
+ .first
53
+ .match(/^\s*/)[0]
54
+
55
+ close = function_code
56
+ .each_with_index
57
+ .select { |line, line_num| line.match(Regexp.compile("^#{indent}}")) }
58
+ .first
59
+
60
+ close ||= function_code
61
+ .each_with_index
62
+ .map { |line, line_num| [line.match(/}/), line_num] }
63
+ .select { |line| line.first }
64
+ .reverse
65
+ .first
66
+
67
+ end_line = close.nil? ? func[3] : func[2] + close.last + 1
68
+
69
+ {
70
+ name: func.first,
71
+ start: func[2] + 1,
72
+ end: end_line
73
+ }
74
+ end
75
+
76
+ def strip_comments(code)
77
+ block_captures.each do |block_capture|
78
+ matches = code.scan(block_capture[:regex])
79
+ matches.each do |match|
80
+ code = code.gsub(match, "#{block_capture[:start]} #{block_capture[:type]}" + "\n" * (match.lines.count - 1) + block_capture[:end])
81
+ end
82
+ end
83
+
84
+ line_captures.each do |line_capture|
85
+ code = code.gsub(line_capture[:regex], "#{line_capture[:start]} #{line_capture[:type]}")
86
+ end
87
+
88
+ code
89
+ end
90
+
91
+ def line_captures()
92
+ [
93
+ {
94
+ regex: /\/\/.*/,
95
+ type: "LINE COMMENT",
96
+ start: "//"
97
+ }
98
+ ]
99
+ end
100
+
101
+ def block_captures()
102
+ [
103
+ {
104
+ regex: /\/\*.*?\*\//m,
105
+ type: "BLOCK QUOTE",
106
+ start: "/*",
107
+ end: "*/"
108
+ }
109
+ ]
110
+ end
111
+ end
data/lib/git_client.rb ADDED
@@ -0,0 +1,14 @@
1
+ class GitClient
2
+ def blame(file, line_start, line_end)
3
+ exec("blame #{file} -L #{line_start},#{line_end}")
4
+ end
5
+
6
+ private
7
+
8
+ def exec(cmd)
9
+ `git --git-dir=#{@git_dir || ".git"} #{cmd}`
10
+ .encode("UTF-8", {invalid: :replace})
11
+ .lines
12
+ .map(&:chomp)
13
+ end
14
+ end
@@ -0,0 +1,103 @@
1
+ require "byebug"
2
+
3
+ class PythonTracer
4
+ def trace(code)
5
+ code = strip_comments(code)
6
+ find_funcs(code)
7
+ .map { |func| define_func(func, code) }
8
+ rescue => e
9
+ byebug
10
+ []
11
+ end
12
+
13
+ private
14
+
15
+ def find_funcs(code)
16
+ funcs = code.scan(/[ \t]*def\s+[A-Za-z_][A-Za-z0-9_]*.*?\)\:/m)
17
+
18
+ funcs = funcs
19
+ .each_with_index
20
+ .map do |func, index|
21
+ name = func.split("(").first.split(" ").last
22
+
23
+ [name, func.lines.count] + code
24
+ .lines
25
+ .each_with_index
26
+ .select { |line, line_num| line.match(func.split("(").first) }
27
+ .first
28
+ end
29
+
30
+ funcs
31
+ .each_with_index
32
+ .map do |func, index|
33
+ if funcs.count > index + 2
34
+ func << funcs[index + 1].last
35
+ else
36
+ func << code.lines.count
37
+ end
38
+ func
39
+ end
40
+ end
41
+
42
+ def define_func(func, code)
43
+ function_code = code.lines[func[3]...func[4]]
44
+ indent = function_code
45
+ .first[/^\s*/]
46
+ .size
47
+
48
+ close = function_code[func[1]..-1]
49
+ .each_with_index
50
+ .select do |line, line_num|
51
+ line_indent = line[/^\s*/].size
52
+ line.match(/[^\s]/) && line_indent <= indent
53
+ end
54
+ .first
55
+
56
+ end_line = close.nil? ? func[4] : func[3] + close.last + func[1]
57
+ while code.lines[end_line - 1].match(/^\s+$/) do
58
+ end_line -= 1
59
+ end
60
+
61
+ {
62
+ name: func.first,
63
+ start: func[3] + 1,
64
+ end: end_line
65
+ }
66
+ end
67
+
68
+ def strip_comments(code)
69
+ block_captures.each do |block_capture|
70
+ matches = code.scan(block_capture[:regex])
71
+ matches.each do |match|
72
+ code = code.gsub(match, "#{block_capture[:start]} #{block_capture[:type]}" + "\n" * (match.lines.count - 1) + block_capture[:end])
73
+ end
74
+ end
75
+
76
+ line_captures.each do |line_capture|
77
+ code = code.gsub(line_capture[:regex], "#{line_capture[:start]} #{line_capture[:type]}")
78
+ end
79
+
80
+ code.tr("\t", " ")
81
+ end
82
+
83
+ def line_captures()
84
+ [
85
+ {
86
+ regex: /#.*/,
87
+ type: "LINE COMMENT",
88
+ start: "#"
89
+ }
90
+ ]
91
+ end
92
+
93
+ def block_captures()
94
+ [
95
+ {
96
+ regex: /\/\'''.*?\'''\//m,
97
+ type: "BLOCK QUOTE",
98
+ start: "'''",
99
+ end: "'''"
100
+ }
101
+ ]
102
+ end
103
+ end
@@ -0,0 +1,87 @@
1
+ require "json"
2
+ require "date"
3
+ require "net/http"
4
+
5
+ class RollbarClient
6
+ PROJECT_ROOT_RGX = /^\/app\//
7
+
8
+ def initialize(config)
9
+ @user = config["username"]
10
+ @key = config["api_key"]
11
+ @project = config["project"]
12
+ @environment = config["environment"]
13
+ end
14
+
15
+ def get_error(error_id)
16
+ item = fetch_item_by_counter_id(error_id)
17
+ parse_trace(select_traces(item["result"]).first)
18
+ end
19
+
20
+ private
21
+
22
+ def fetch_item_by_counter_id(item_id)
23
+ item = JSON.parse(Net::HTTP.get(
24
+ URI("https://api.rollbar.com/api/1/item_by_counter/#{item_id}/?access_token=#{@key}")
25
+ ))
26
+ uri = item["result"]["uri"]
27
+ JSON.parse(Net::HTTP.get(
28
+ URI("https://api.rollbar.com#{uri}")
29
+ ))
30
+ rescue
31
+ raise "Error #{error_id} not found"
32
+ end
33
+
34
+ def parse_trace(trace)
35
+ stack_trace = trace["frames"].map do |frame|
36
+ file = frame["filename"].gsub(PROJECT_ROOT_RGX, "")
37
+ line = frame["lineno"]
38
+ function = frame["method"]
39
+
40
+ {
41
+ file: file,
42
+ line: line,
43
+ function: function
44
+ }
45
+ end
46
+
47
+ {
48
+ error_id: trace[:id],
49
+ first_time: trace[:first_time],
50
+ last_time: trace[:last_time],
51
+ environment: trace[:environment],
52
+ type: trace[:type],
53
+ message: trace[:message],
54
+ link: "https://rollbar.com/#{@user}/#{@project}/items/#{trace[:counter]}/",
55
+ total_occurrences: trace[:total_occurrences],
56
+ stack_trace: stack_trace.reverse()
57
+ }
58
+ end
59
+
60
+ def select_traces(item)
61
+ error = detail_error(item["id"])["result"]["instances"].first
62
+ traces = error["data"]["body"]["trace"].present? ?
63
+ [error["data"]["body"]["trace"]] :
64
+ error["data"]["body"]["trace_chain"]
65
+
66
+ return nil if traces.nil?
67
+
68
+ traces.map do |trace|
69
+ trace[:id] = item["id"].to_s.to_sym
70
+ trace[:environment] = item["environment"]
71
+ trace[:total_occurrences] = item["total_occurrences"]
72
+ trace[:error] = error
73
+ trace[:type] = item["level"]
74
+ trace[:message] = item["title"]
75
+ trace[:first_time] = Time.at(item["first_occurrence_timestamp"]).to_datetime
76
+ trace[:last_time] = Time.at(item["last_occurrence_timestamp"]).to_datetime
77
+ trace[:counter] = item["counter"]
78
+ trace
79
+ end
80
+ end
81
+
82
+ def detail_error(item_id)
83
+ JSON.parse(Net::HTTP.get(
84
+ URI("https://api.rollbar.com/api/1/item/#{item_id}/instances/?access_token=#{@key}")
85
+ ))
86
+ end
87
+ end
@@ -0,0 +1,64 @@
1
+ require "ripper"
2
+
3
+ class RubyTracer
4
+ # TODO: Handle other languages...
5
+ def trace(file)
6
+ out = Ripper.sexp(file)
7
+
8
+ funcs = find_funcs(out.last)
9
+ .map { |func| define_func(func, file.lines) }
10
+ rescue
11
+ []
12
+ end
13
+
14
+ private
15
+
16
+ def find_funcs(ast)
17
+ ast.reduce([]) do |acc, ast|
18
+ if ast.is_a?(Array)
19
+ if ast.first == :def
20
+ acc << ast
21
+ else
22
+ ast
23
+ .select { |tree| tree.is_a?(Array) }
24
+ .each do |sub_ast|
25
+ acc << sub_ast if sub_ast.first == :def
26
+ acc.concat(find_funcs(sub_ast)) if sub_ast.is_a?(Array)
27
+ end
28
+ end
29
+ end
30
+ acc
31
+ end
32
+ end
33
+
34
+ def trace_func(ast)
35
+ ast
36
+ .reduce([]) do |acc, ast|
37
+ if ast.is_a?(Array)
38
+ lines = ast.last
39
+ if lines.is_a?(Array) && lines.count == 2 && lines.first.is_a?(Integer) && lines.last.is_a?(Integer)
40
+ acc << lines.first
41
+ else
42
+ acc.concat(trace_func(ast))
43
+ end
44
+ end
45
+ acc
46
+ end
47
+ .uniq()
48
+ end
49
+
50
+ def define_func(ast, file_lines)
51
+ name = ast[1][1]
52
+ lines = trace_func(ast)
53
+
54
+ if file_lines[lines.last].strip() == "end"
55
+ lines << lines.last + 1
56
+ end
57
+
58
+ {
59
+ name: name,
60
+ start: lines.first,
61
+ end: lines.last
62
+ }
63
+ end
64
+ end
data/lib/tracer.rb ADDED
@@ -0,0 +1,44 @@
1
+ require "linguist"
2
+
3
+ require_relative "ruby_tracer"
4
+ require_relative "c_syntax_tracer"
5
+ require_relative "python_tracer"
6
+
7
+ class VirtualBlob < Linguist::FileBlob
8
+ def initialize(path, data)
9
+ @path = path
10
+ @data = data
11
+ end
12
+
13
+ def data
14
+ @data
15
+ end
16
+
17
+ def size
18
+ @size ||= data.length
19
+ end
20
+ end
21
+
22
+ class Tracer
23
+ def trace(path, code)
24
+ case recognize(path, code)
25
+ when "ruby"
26
+ RubyTracer.new.trace(code)
27
+ when "php", "javascript"
28
+ CSyntaxTracer.new.trace(code)
29
+ when "python"
30
+ PythonTracer.new.trace(code)
31
+ else
32
+ []
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def recognize(path, code)
39
+ Linguist
40
+ .detect(VirtualBlob.new(path, code))
41
+ &.name
42
+ &.downcase
43
+ end
44
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: whatbug
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Brian Yahn
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-01-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dotenv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.1.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '2.1'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.1.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: github-linguist
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.3'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 5.3.2
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '5.3'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 5.3.2
53
+ description: WhatBug helps you find where a bug was introduced faster by finding all
54
+ lines of code in the relevant functions of a stack trace and identifying which of
55
+ those have changed within a timeframe.
56
+ email: yahn007@outlook.com
57
+ executables:
58
+ - whatbug
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - bin/whatbug
63
+ - lib/airbrake_client.rb
64
+ - lib/c_syntax_tracer.rb
65
+ - lib/git_client.rb
66
+ - lib/python_tracer.rb
67
+ - lib/rollbar_client.rb
68
+ - lib/ruby_tracer.rb
69
+ - lib/tracer.rb
70
+ homepage: https://github.com/cuzzo/whatbug
71
+ licenses:
72
+ - BSD-2-Clause
73
+ metadata: {}
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubyforge_project:
90
+ rubygems_version: 2.5.2
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: Find the source of the bug with WhatBug
94
+ test_files: []