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