whatbug 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []