Skip to content

Commit 9304c11

Browse files
authored
[APMAPI-1615] Add typing stats in PR comment (#4895)
1 parent 0640641 commit 9304c11

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed

.github/scripts/typing_stats.rb

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env ruby
2+
3+
require 'steep'
4+
require 'parser/ruby25'
5+
require 'json'
6+
7+
METHOD_AND_PARAM_NAME = /(?:\w*|`[^`]+`)/
8+
PARAMETER = /(?:\*{1,2})?\s*(?:\??\s*untyped\s*\??\s*|\??#{METHOD_AND_PARAM_NAME}:\s*untyped\s*\??)\s*#{METHOD_AND_PARAM_NAME}/
9+
PARAMETERS = /\(\s*(?:\?|(?:(?:#{PARAMETER})\s*(?:,\s*(?:#{PARAMETER})\s*)*)?)\s*\)/
10+
PROTOTYPE_INITIALIZE = /\s*(?:public|private)?\s*def\s+initialize:\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*void/
11+
PROTOTYPE_METHOD = /\s*(?:public|private)?\s*def\s+(?:self\??\.)?(?:[^\s]+):\s*#{PARAMETERS}(?:\s*\??\{\s*#{PARAMETERS}\s*->\s*untyped\s*\})?\s*->\s*untyped/
12+
13+
# TODO: Find untyped/partially typed attributes, instance variables, class variables, constants
14+
15+
steepfile_path = Pathname(ENV['STEEPFILE_PATH'])
16+
project = Steep::Project.new(steepfile_path: steepfile_path).tap do |project|
17+
Steep::Project::DSL.parse(project, steepfile_path.read, filename: steepfile_path.to_s)
18+
end
19+
datadog_target = project.targets&.find { |target| target.name == :datadog }
20+
loader = ::Steep::Services::FileLoader.new(base_dir: project.base_dir)
21+
22+
ignored_paths = datadog_target&.source_pattern&.ignores
23+
24+
# List signature files that are not related to ignored files
25+
signature_paths_with_ignored_files = loader.each_path_in_patterns(datadog_target.signature_pattern)
26+
signature_paths = signature_paths_with_ignored_files.reject do |sig_path|
27+
# replace sig/ with lib/ and .rbs with .rb
28+
corresponding_lib_file = sig_path.to_s.sub(/^sig/, 'lib').sub(/\.rbs$/, '.rb')
29+
ignored_paths.any? do |ignored|
30+
if ignored.end_with?('/')
31+
# Directory ignore - check if signature file is inside this directory
32+
corresponding_lib_file.start_with?(ignored)
33+
else
34+
# File ignore - check if signature file matches exactly
35+
corresponding_lib_file == ignored
36+
end
37+
end
38+
end
39+
40+
# Ignored files stats
41+
ignored_files_size = ignored_paths.inject(0) do |result, path|
42+
if path.end_with?('/')
43+
result + Dir.glob(path + '**/*.rb').size
44+
else
45+
result + 1
46+
end
47+
end
48+
total_files_size = Dir.glob("#{project.base_dir}/lib/**/*.rb").size
49+
50+
# steep:ignore comments stats
51+
ignore_comments = loader.each_path_in_patterns(datadog_target.source_pattern).each_with_object([]) do |path, result|
52+
buffer = ::Parser::Source::Buffer.new(path.to_s, 1, source: path.read)
53+
_, comments = ::Parser::Ruby25.new.parse_with_comments(buffer)
54+
rbs_buffer = ::RBS::Buffer.new(name: path, content: path.read)
55+
comments.each do |comment|
56+
ignore = ::Steep::AST::Ignore.parse(comment, rbs_buffer)
57+
next if ignore.nil? || ignore.is_a?(::Steep::AST::Ignore::IgnoreEnd)
58+
59+
result << {
60+
path: path.to_s,
61+
line: ignore.line
62+
}
63+
end
64+
end
65+
66+
# sig files stats
67+
untyped_methods = []
68+
partially_typed_methods = []
69+
typed_methods_size = 0
70+
71+
untyped_others = []
72+
partially_typed_others = []
73+
typed_others_size = 0
74+
signature_paths.each do |sig_path|
75+
sig_file_content = sig_path.read
76+
# for each line in the file, check if it matches the regex
77+
sig_file_content.each_line.with_index(1) do |line, index|
78+
next if line.strip.empty? || line.strip.start_with?("#") || line.strip.end_with?("# untyped:accept")
79+
80+
case line
81+
# Methods
82+
when PROTOTYPE_INITIALIZE
83+
untyped_methods << {path: sig_path.to_s, line: index, line_content: line.strip}
84+
when PROTOTYPE_METHOD
85+
untyped_methods << {path: sig_path.to_s, line: index, line_content: line.strip}
86+
when /^\s*(?:public|private)?\s*def\s.*untyped/ # Any line containing untyped
87+
partially_typed_methods << {path: sig_path.to_s, line: index, line_content: line.strip}
88+
when /^\s*(?:public|private)?\s*def\s.*/ # Any line containing a method definition not matched by the other regexes
89+
typed_methods_size += 1
90+
# Attributes
91+
when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*:\s*untyped/
92+
untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip}
93+
when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*untyped/
94+
partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip}
95+
when /^\s*(?:public|private)?\s*attr_(?:reader|writer|accessor)\s.*/
96+
typed_others_size += 1
97+
# Constants
98+
when /[A-Z]\w*\s*:\s*untyped/ # We don't match beginning of string as constant can have a namespace prefix
99+
untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip}
100+
when /[A-Z]\w*\s*:[^:].*untyped/
101+
partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip}
102+
when /[A-Z]\w*\s*:[^:]/
103+
typed_others_size += 1
104+
# Globals
105+
when /^\s*\$[a-zA-Z]\w+\s*:\s*untyped/
106+
untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip}
107+
when /^\s*\$[a-zA-Z]\w+\s*:.*untyped/
108+
partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip}
109+
when /^\s*\$[a-zA-Z]\w+\s*:/
110+
typed_others_size += 1
111+
# Class and instance variables
112+
when /^\s*@?@\w+\s*:\s*untyped/
113+
untyped_others << {path: sig_path.to_s, line: index, line_content: line.strip}
114+
when /^\s*@?@\w+\s*:.*untyped/
115+
partially_typed_others << {path: sig_path.to_s, line: index, line_content: line.strip}
116+
when /^\s*@?@\w+\s*:/
117+
typed_others_size += 1
118+
end
119+
end
120+
end
121+
122+
resulting_stats = {
123+
total_files_size: total_files_size,
124+
ignored_files: {
125+
size: ignored_files_size, # Required as we don't list all ignored files, but only their paths
126+
paths: ignored_paths
127+
},
128+
129+
steep_ignore_comments: ignore_comments,
130+
131+
untyped_methods: untyped_methods,
132+
partially_typed_methods: partially_typed_methods,
133+
typed_methods_size: typed_methods_size, # Location not needed for already typed methods
134+
135+
untyped_others: untyped_others,
136+
partially_typed_others: partially_typed_others,
137+
typed_others_size: typed_others_size # Location not needed for already typed attributes, constants, globals, instance variables
138+
}
139+
140+
puts resulting_stats.to_json

.github/workflows/typing-stats.yml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: Comment typing stats on PR
2+
3+
on: # yamllint disable-line rule:truthy
4+
pull_request:
5+
branches:
6+
- master
7+
8+
# TODO:
9+
# Upload the report somewhere not limited by the 64k char limit (maybe FPD ?) to be able to include links to the files
10+
# Compare the result with master to see if there are any progress/regression
11+
# Create a Datadog dashboard to track the progress on a larger timescale
12+
jobs:
13+
compute-stats:
14+
permissions:
15+
pull-requests: write
16+
runs-on: ubuntu-24.04
17+
steps:
18+
- name: Find existing comment
19+
id: comment
20+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
21+
with:
22+
github-token: ${{secrets.GITHUB_TOKEN}}
23+
script: |
24+
const options = github.rest.issues.listComments.endpoint.merge({
25+
owner: context.repo.owner,
26+
repo: context.repo.repo,
27+
issue_number: context.payload.pull_request.number,
28+
});
29+
const comments = await github.paginate(options)
30+
const comment = comments.find((cmnt) => {
31+
return cmnt.body.startsWith("<!-- TYPING_STATS_HIDDEN_MARKER -->")
32+
})
33+
34+
return undefined === comment ? null : comment.id
35+
36+
- name: Checkout code
37+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
38+
with:
39+
persist-credentials: false
40+
41+
- name: Set up Ruby
42+
uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
43+
with:
44+
ruby-version: "3.3"
45+
46+
- name: Install steep
47+
run: gem install steep -v 1.9.1
48+
49+
- name: Run typing stats
50+
id: typing-stats
51+
env:
52+
STEEPFILE_PATH: ${{ github.workspace }}/Steepfile
53+
run: |
54+
mkdir -p "${{ github.workspace }}/tmp"
55+
ruby .github/scripts/typing_stats.rb >> "${{ github.workspace }}/tmp/typing-stats.json"
56+
57+
- name: Write comment
58+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
59+
with:
60+
github-token: ${{secrets.GITHUB_TOKEN}}
61+
script: |
62+
var fs = require('fs')
63+
64+
const previousCommentId = ${{steps.comment.outputs.result}}
65+
const stats = JSON.parse(fs.readFileSync("${{ github.workspace }}/tmp/typing-stats.json", "utf8"))
66+
67+
const typedFilesPercentage = ((stats.total_files_size - stats.ignored_files.size) / stats.total_files_size * 100).toFixed(2)
68+
69+
const totalMethods = stats.untyped_methods.length + stats.partially_typed_methods.length + stats.typed_methods_size
70+
const typedMethodsPercentage = (stats.typed_methods_size / totalMethods * 100).toFixed(2)
71+
72+
const totalOthers = stats.untyped_others.length + stats.partially_typed_others.length + stats.typed_others_size
73+
const typedOthersPercentage = (stats.typed_others_size / totalOthers * 100).toFixed(2)
74+
75+
const commentBody = `<!-- TYPING_STATS_HIDDEN_MARKER -->
76+
## Typing analysis
77+
78+
### Ignored files
79+
There are **${stats.ignored_files.size}** ignored files in the Steepfile out of ${stats.total_files_size}.
80+
**${typedFilesPercentage}%** of the codebase is type checked.
81+
<details><summary>Ignored files</summary>
82+
<pre><code>${stats.ignored_files.paths.map((path) => `${path}`).join('\n')}</code></pre>
83+
</details>
84+
85+
*__Note__: Ignored files are excluded from the next sections.*
86+
87+
### \`steep:ignore\` comments
88+
There are **${stats.steep_ignore_comments.length}** \`steep:ignore\` comments in the codebase.
89+
<details><summary><code>steep:ignore</code> comments</summary>
90+
<pre><code>${stats.steep_ignore_comments.map((comment) => `${comment.path}:${comment.line}`).join('\n')}</code></pre>
91+
</details>
92+
93+
### Untyped methods
94+
There are **${stats.untyped_methods.length}** untyped and **${stats.partially_typed_methods.length}** partially typed methods out of ${totalMethods}.
95+
**${typedMethodsPercentage}%** of the methods are typed.
96+
<details><summary>Untyped methods</summary>
97+
<pre><code>${stats.untyped_methods.map((method) => `${method.path}:${method.line}\n└── ${method.line_content}`).join('\n')}</code></pre>
98+
</details>
99+
<details><summary>Partially typed methods</summary>
100+
<pre><code>${stats.partially_typed_methods.map((method) => `${method.path}:${method.line}\n└── ${method.line_content}`).join('\n')}</code></pre>
101+
</details>
102+
103+
### Untyped attributes, constants, globals, instance variables
104+
There are **${stats.untyped_others.length}** untyped and **${stats.partially_typed_others.length}** partially typed attributes, constants, globals, instance variables out of **${totalOthers}**.
105+
**${typedOthersPercentage}%** of them are typed.
106+
<details><summary>Untyped attributes, constants, globals, instance variables</summary>
107+
<pre><code>${stats.untyped_others.map((other) => `${other.path}:${other.line}\n└── ${other.line_content}`).join('\n')}</code></pre>
108+
</details>
109+
<details><summary>Partially typed attributes, constants, globals, instance variables</summary>
110+
<pre><code>${stats.partially_typed_others.map((other) => `${other.path}:${other.line}\n└── ${other.line_content}`).join('\n')}</code></pre>
111+
</details>
112+
113+
*If you believe a method or an attribute is rightfully untyped or partially typed, you can add \`# untyped:accept\` to the end of the line to remove it from the stats.*
114+
`
115+
116+
const options = {
117+
owner: context.repo.owner,
118+
repo: context.repo.repo,
119+
issue_number: context.payload.pull_request.number,
120+
body: commentBody
121+
}
122+
123+
if (null === previousCommentId) {
124+
await github.rest.issues.createComment(options)
125+
} else {
126+
await github.rest.issues.updateComment({...options, comment_id: previousCommentId})
127+
}

0 commit comments

Comments
 (0)