Skip to content

Commit fc08310

Browse files
authored
Merge pull request #150 from github/find-inactive-members
add find-inactive-users
2 parents cef2620 + 77da4e5 commit fc08310

File tree

2 files changed

+317
-0
lines changed

2 files changed

+317
-0
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Find Inactive Organization Members
2+
3+
```
4+
find_inactive_members.rb - Find and output inactive members in an organization
5+
-c, --check Check connectivity and scope
6+
-d, --date MANDATORY Date from which to start looking for activity
7+
-e, --email Fetch the user email (can make the script take longer
8+
-o, --organization MANDATORY Organization to scan for inactive users
9+
-v, --verbose More output to STDERR
10+
-h, --help Display this help
11+
```
12+
13+
This utility finds users inactive since a configured date, writes those users to a file `inactive_users.csv`.
14+
15+
## Installation
16+
17+
### Clone this repository
18+
19+
```shell
20+
git clone https://github.com/github/platform-samples.git
21+
cd api/ruby/find-inactive-members
22+
```
23+
24+
### Install dependencies
25+
26+
```shell
27+
gem install octokit
28+
```
29+
30+
### Configure Octokit
31+
32+
The `OCTOKIT_ACCESS_TOKEN` is required in order to see activities on private repositories. However the `OCTOKIT_API_ENDPOINT` isn't required if connecting to GitHub.com, but is required if connecting to a GitHub Enterprise instance.
33+
34+
```shell
35+
export OCTOKIT_ACCESS_TOKEN=00000000000000000000000 # Required if looking for activity in private repositories.
36+
export OCTOKIT_API_ENDPOINT="https://<your_github_enterprise_instance>/api/v3" # Not required if connecting to GitHub.com.
37+
```
38+
39+
## Usage
40+
41+
```
42+
ruby find_active_members.rb [-cehv] -o ORGANIZATION -d DATE
43+
```
44+
45+
## How Inactivity is Defined
46+
47+
Members are defined as inactive if they haven't, since the specified **DATE**, in any repository in the specified **ORGANIZATION**:
48+
49+
* Have not merged or pushed commits into the default branch
50+
* Have not opened an Issue or Pull Request
51+
* Have not commented on an Issue or Pull Request
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
require "csv"
2+
require "octokit"
3+
require 'optparse'
4+
require 'optparse/date'
5+
6+
class InactiveMemberSearch
7+
attr_accessor :organization, :members, :repositories, :date, :unrecognized_authors
8+
9+
SCOPES=["read:org", "read:user", "repo", "user:email"]
10+
11+
def initialize(options={})
12+
@client = options[:client]
13+
if options[:check]
14+
check_app
15+
check_scopes
16+
check_rate_limit
17+
exit 0
18+
end
19+
20+
raise(OptionParser::MissingArgument) if (
21+
options[:organization].nil? or
22+
options[:date].nil?
23+
)
24+
25+
@date = options[:date]
26+
@organization = options[:organization]
27+
@email = options[:email]
28+
@unrecognized_authors = []
29+
30+
organization_members
31+
organization_repositories
32+
member_activity
33+
end
34+
35+
def check_app
36+
info "Application client/secret? #{@client.application_authenticated?}\n"
37+
info "Authentication Token? #{@client.token_authenticated?}\n"
38+
end
39+
40+
def check_scopes
41+
info "Scopes: #{@client.scopes.join ','}\n"
42+
end
43+
44+
def check_rate_limit
45+
info "Rate limit: #{@client.rate_limit.remaining}/#{@client.rate_limit.limit}\n"
46+
end
47+
48+
def env_help
49+
output=<<-EOM
50+
Required Environment variables:
51+
OCTOKIT_ACCESS_TOKEN: A valid personal access token with Organzation admin priviliges
52+
OCTOKIT_API_ENDPOINT: A valid GitHub/GitHub Enterprise API endpoint URL (Defaults to https://api.github.com)
53+
EOM
54+
output
55+
end
56+
57+
# helper to get an auth token for the OAuth application and a user
58+
def get_auth_token(login, password, otp)
59+
temp_client = Octokit::Client.new(login: login, password: password)
60+
res = temp_client.create_authorization(
61+
{
62+
:idempotent => true,
63+
:scopes => SCOPES,
64+
:headers => {'X-GitHub-OTP' => otp}
65+
})
66+
res[:token]
67+
end
68+
private
69+
def debug(message)
70+
$stderr.print message
71+
end
72+
73+
def info(message)
74+
$stdout.print message
75+
end
76+
77+
def member_email(login)
78+
@email ? @client.user(login)[:email] : ""
79+
end
80+
81+
def organization_members
82+
# get all organization members and place into an array of hashes
83+
info "Finding #{@organization} members "
84+
@members = @client.organization_members(@organization).collect do |m|
85+
email =
86+
{
87+
login: m["login"],
88+
email: member_email(m[:login]),
89+
active: false
90+
}
91+
end
92+
info "#{@members.length} members found.\n"
93+
end
94+
95+
def organization_repositories
96+
info "Gathering a list of repositories..."
97+
# get all repos in the organizaton and place into a hash
98+
@repositories = @client.organization_repositories(@organization).collect do |repo|
99+
repo["full_name"]
100+
end
101+
info "#{@repositories.length} repositories discovered\n"
102+
end
103+
104+
def add_unrecognized_author(author)
105+
@unrecognized_authors << author
106+
end
107+
108+
# method to switch member status to active
109+
def make_active(login)
110+
hsh = @members.find { |member| member[:login] == login }
111+
hsh[:active] = true
112+
end
113+
114+
def commit_activity(repo)
115+
# get all commits after specified date and iterate
116+
info "...commits"
117+
begin
118+
@client.commits_since(repo, @date).each do |commit|
119+
# if commmitter is a member of the org and not active, make active
120+
if commit["author"].nil?
121+
add_unrecognized_author(commit[:commit][:author])
122+
next
123+
end
124+
if t = @members.find {|member| member[:login] == commit["author"]["login"] && member[:active] == false }
125+
make_active(t[:login])
126+
end
127+
end
128+
rescue Octokit::Conflict
129+
info "...no commits"
130+
end
131+
end
132+
133+
def issue_activity(repo, date=@date)
134+
# get all issues after specified date and iterate
135+
info "...Issues"
136+
@client.list_issues(repo, { :since => date }).each do |issue|
137+
# if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION
138+
if issue["user"].nil?
139+
next
140+
end
141+
# if creator is a member of the org and not active, make active
142+
if t = @members.find {|member| member[:login] == issue["user"]["login"] && member[:active] == false }
143+
make_active(t[:login])
144+
end
145+
end
146+
end
147+
148+
def issue_comment_activity(repo, date=@date)
149+
# get all issue comments after specified date and iterate
150+
info "...Issue comments"
151+
@client.issues_comments(repo, { :since => date }).each do |comment|
152+
# if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION
153+
if comment["user"].nil?
154+
next
155+
end
156+
# if commenter is a member of the org and not active, make active
157+
if t = @members.find {|member| member[:login] == comment["user"]["login"] && member[:active] == false }
158+
make_active(t[:login])
159+
end
160+
end
161+
end
162+
163+
def pr_activity(repo, date=@date)
164+
# get all pull request comments comments after specified date and iterate
165+
info "...Pull Request comments"
166+
@client.pull_requests_comments(repo, { :since => date }).each do |comment|
167+
# if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION
168+
if comment["user"].nil?
169+
next
170+
end
171+
# if commenter is a member of the org and not active, make active
172+
if t = @members.find {|member| member[:login] == comment["user"]["login"] && member[:active] == false }
173+
make_active(t[:login])
174+
end
175+
end
176+
end
177+
178+
def member_activity
179+
@repos_completed = 0
180+
# print update to terminal
181+
info "Analyzing activity for #{@members.length} members and #{@repositories.length} repos for #{@organization}\n"
182+
183+
# for each repo
184+
@repositories.each do |repo|
185+
info "rate limit remaining: #{@client.rate_limit.remaining} "
186+
info "analyzing #{repo}"
187+
188+
commit_activity(repo)
189+
issue_activity(repo)
190+
issue_comment_activity(repo)
191+
pr_activity(repo)
192+
193+
# print update to terminal
194+
@repos_completed += 1
195+
info "...#{@repos_completed}/#{@repositories.length} repos completed\n"
196+
end
197+
198+
# open a new csv for output
199+
CSV.open("inactive_users.csv", "wb") do |csv|
200+
# iterate and print inactive members
201+
@members.each do |member|
202+
if member[:active] == false
203+
member_detail = "#{member[:login]},#{member[:email] unless member[:email].nil?}"
204+
info "#{member_detail} is inactive\n"
205+
csv << [member_detail]
206+
end
207+
end
208+
end
209+
210+
CSV.open("unrecognized_authors.csv", "wb") do |csv|
211+
@unrecognized_authors.each do |author|
212+
author_detail = "#{author[:name]},#{author[:email]}"
213+
info "#{author_detail} is unrecognized\n"
214+
csv << [author_detail]
215+
end
216+
end
217+
end
218+
end
219+
220+
options = {}
221+
OptionParser.new do |opts|
222+
opts.banner = "#{$0} - Find and output inactive members in an organization"
223+
224+
opts.on('-c', '--check', "Check connectivity and scope") do |c|
225+
options[:check] = c
226+
end
227+
228+
opts.on('-d', '--date MANDATORY',Date, "Date from which to start looking for activity") do |d|
229+
options[:date] = d.to_s
230+
end
231+
232+
opts.on('-e', '--email', "Fetch the user email (can make the script take longer") do |e|
233+
options[:email] = e
234+
end
235+
236+
opts.on('-o', '--organization MANDATORY',String, "Organization to scan for inactive users") do |o|
237+
options[:organization] = o
238+
end
239+
240+
opts.on('-v', '--verbose', "More output to STDERR") do |v|
241+
@debug = true
242+
options[:verbose] = v
243+
end
244+
245+
opts.on('-h', '--help', "Display this help") do |h|
246+
puts opts
247+
exit 0
248+
end
249+
end.parse!
250+
251+
stack = Faraday::RackBuilder.new do |builder|
252+
builder.use Octokit::Middleware::FollowRedirects
253+
builder.use Octokit::Response::RaiseError
254+
builder.use Octokit::Response::FeedParser
255+
builder.response :logger
256+
builder.adapter Faraday.default_adapter
257+
end
258+
259+
Octokit.configure do |kit|
260+
kit.auto_paginate = true
261+
kit.middleware = stack if @debug
262+
end
263+
264+
options[:client] = Octokit::Client.new
265+
266+
InactiveMemberSearch.new(options)

0 commit comments

Comments
 (0)