ComDaAn 0.1.8__py3-none-any.whl

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.
comdaan/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .comdaan import *
comdaan/comdaan.py ADDED
@@ -0,0 +1,569 @@
1
+ #
2
+ # Copyright 2019 Christelle Zouein <christellezouein@hotmail.com>
3
+ #
4
+ # The authors license this file to You under the Apache License, Version 2.0
5
+ # (the "License"); you may not use this file except in compliance with
6
+ # the License. You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ import networkx as nx
18
+ import pandas as pd
19
+ from datetime import datetime, timedelta
20
+ from itertools import combinations
21
+ from functools import reduce
22
+
23
+ from bokeh.layouts import gridplot
24
+ from statsmodels.nonparametric.smoothers_lowess import lowess
25
+ from dateutil.relativedelta import relativedelta
26
+ from dateutil.rrule import rrule, MONTHLY, WEEKLY
27
+ from multiprocessing.pool import Pool
28
+ from bokeh.io import output_file
29
+ from bokeh.plotting import show, save
30
+
31
+ from .gitparsing import _GitParser
32
+ from .mailparsing import _MailParser
33
+ from .issuesparsing import _IssuesParser
34
+ from .object_types import Activity, Network, Centrality, TeamSize, Response
35
+ from .display import _display
36
+
37
+
38
+ def _network_from_dataframe(dataframe, author_col_name, target_col_name, source_col_name):
39
+ if dataframe.empty:
40
+ return nx.empty_graph()
41
+
42
+ if "iid" in dataframe:
43
+ edge_list = _get_issues_edge_list(dataframe, author_col_name, target_col_name)
44
+ else:
45
+ edge_list = _get_edge_list(dataframe, author_col_name, target_col_name, source_col_name)
46
+
47
+ g = nx.convert_matrix.from_pandas_edgelist(edge_list, edge_attr=["weight"])
48
+ no_edges = []
49
+ for u, v, weight in g.edges.data("weight"):
50
+ if weight == 0:
51
+ no_edges.append((u, v))
52
+ g.remove_edges_from(no_edges)
53
+
54
+ return g
55
+
56
+
57
+ def parse_repositories(paths, start_date=None, end_date=None):
58
+ """
59
+ This function parses a git repository or git repositories.
60
+
61
+ :param paths: path or list of paths of git repositories to parse.
62
+ :type paths: str or list or str
63
+ :param start_date: Considering entries created after start_date. It should follow the "YYYY-MM-DD" format.
64
+ :type start_date: str
65
+ :param end_date: Considering entries created before end_date. It should follow the "YYYY-MM-DD" format.
66
+ :type start_date: str
67
+ :return: pandas.DataFrame containing all entries of the repositories.
68
+ """
69
+
70
+ parser = _GitParser()
71
+ parser.add_repositories(paths)
72
+ return parser.get_log(start_date, end_date)
73
+
74
+
75
+ def parse_mail(paths, start_date=None, end_date=None):
76
+ """
77
+ This function parses mailing lists in MBOX format.
78
+
79
+ :param paths: path or list of paths of MBOX files to parse.
80
+ :type paths: str or list or str
81
+ :param start_date: Considering messages sent after start_date. It should follow the "YYYY-MM-DD" format.
82
+ :type start_date: str
83
+ :param end_date: Considering messages sent before end_date. It should follow the "YYYY-MM-DD" format.
84
+ :type start_date: str
85
+ :return: pandas.DataFrame containing all messages of the mailing lists.
86
+ """
87
+
88
+ parser = _MailParser()
89
+ parser.add_archives(paths)
90
+ return parser.get_emails(start_date, end_date)
91
+
92
+
93
+ def parse_issues(paths, start_date=None, end_date=None):
94
+ """
95
+ This function parses GitLab Issues stored in JSON files.
96
+
97
+ :param paths: path or list of paths of JSON files to parse.
98
+ :type paths: str or list or str
99
+ :param start_date: Considering issues created after start_date. It should follow the "YYYY-MM-DD" format.
100
+ :type start_date: str
101
+ :param end_date: Considering issues created before end_date. It should follow the "YYYY-MM-DD" format.
102
+ :type start_date: str
103
+ :return: pandas.DataFrame containing all issues.
104
+ """
105
+
106
+ parser = _IssuesParser()
107
+ parser.add_issues_paths(paths)
108
+ return parser.get_issues(start_date, end_date)
109
+
110
+
111
+ def parse_comments(issues):
112
+ """
113
+ This function parses the comments of an issues DataFrame. It creates a DataFrame that stores all the comments in
114
+ *issues*.
115
+
116
+ :param issues: DataFrame containing the issues from which the comments are to be extracted.
117
+ :type issues: pandas.DataFrame
118
+ :return: pandas.DataFrame with the comment fields as columns.
119
+ """
120
+
121
+ exploded_df = issues.explode("discussion").rename(columns={"discussion": "comment"})
122
+ valid_comments = exploded_df["comment"].apply(lambda x: isinstance(x, dict))
123
+ exploded_df = exploded_df[valid_comments]
124
+ commenter_df = pd.DataFrame(exploded_df["comment"].to_list())
125
+ commenter_df.reset_index(inplace=True)
126
+ return commenter_df
127
+
128
+
129
+ def _get_issues_edge_list(dataframe, author_col_name, discussion_col_name):
130
+ """
131
+ This function builds an edge_list representing the graph of the relationships between the authors of an issues
132
+ dataframe.
133
+
134
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
135
+ It must contain at least an *author*, a *target* and a *source* column.
136
+ :type dataframe: pandas.DataFrame
137
+ :param author_col_name: Name of the column containing the authors of the entries.
138
+ :type author_col_name: str
139
+ :param discussion_col_name: Name of the column containing the targets of the relationships to be explored
140
+ :type discussion_col_name: str
141
+ :return: Object of type network containing a *dataframe* field and a *graph* one.
142
+ """
143
+
144
+ dataframe[discussion_col_name] = dataframe[discussion_col_name].apply(
145
+ lambda discussion: [comment[author_col_name] for comment in discussion]
146
+ )
147
+
148
+ authors = list(dataframe[author_col_name])
149
+ commenter_threads = list(dataframe[discussion_col_name])
150
+
151
+ edges = []
152
+
153
+ for i in range(len(authors)):
154
+ edges.extend([(authors[i], commenter) for commenter in commenter_threads[i]])
155
+
156
+ edge_list = pd.DataFrame(edges, columns=["source", "target"])
157
+ edge_list = edge_list.groupby(["source", "target"]).size().reset_index(name="weight")
158
+
159
+ return edge_list
160
+
161
+
162
+ def _get_edge_list(dataframe, author_col_name, target_col_name, source_col_name=None):
163
+ """
164
+ This function builds an edge_list representing the graph of the relationships between the authors of a repositories
165
+ dataframe or that of a mailing list.
166
+
167
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
168
+ It must contain at least an *author*, a *target* and a *source* column.
169
+ :type dataframe: pandas.DataFrame
170
+ :param author_col_name: Name of the column containing the authors of the entries.
171
+ :type author_col_name: str
172
+ :param target_col_name: Name of the column containing the targets of the relationship to be explored
173
+ :type target_col_name: str
174
+ :param source_col_name: Name of the column containing the sources of the relationships to be explored.
175
+ :type source_col_name: str
176
+ :return: Object of type network containing a *dataframe* field and a *graph* one.
177
+ """
178
+
179
+ def to_set(df, col):
180
+ if not isinstance(df[col].iloc[0], set):
181
+ if isinstance(df[col].iloc[0], list):
182
+ df[col] = df[col].apply(lambda x: set(x))
183
+ else:
184
+ # If x isn't an iterable, applying set to it will break it down into one. For example, a str would
185
+ # a list of chars which is why we turn it into a list with only x in it and then into a set.
186
+ df[col] = df[col].apply(lambda x: set([x]))
187
+ return df
188
+
189
+ if source_col_name is None:
190
+ dataframe = to_set(dataframe, target_col_name)
191
+ groups = dataframe.loc[:, [author_col_name, target_col_name]].groupby(author_col_name)
192
+ source_col_name = target_col_name
193
+ else:
194
+ dataframe = to_set(dataframe, target_col_name)
195
+ dataframe = to_set(dataframe, source_col_name)
196
+ groups = dataframe.loc[:, [author_col_name, target_col_name, source_col_name]].groupby(author_col_name)
197
+ targets = groups.aggregate(lambda x: reduce(set.union, x))
198
+ edges = list(combinations(targets.index.tolist(), 2))
199
+ edge_list = pd.DataFrame(edges, columns=["source", "target"])
200
+ if not edge_list.empty:
201
+ edge_list["weight"] = edge_list.apply(
202
+ lambda x: len(
203
+ targets.loc[x["source"]][source_col_name].intersection(targets.loc[x["target"]][target_col_name])
204
+ ),
205
+ axis=1,
206
+ )
207
+ else:
208
+ edge_list = edge_list.reindex(edge_list.columns.tolist() + ["weight"], axis=1)
209
+
210
+ return edge_list
211
+
212
+
213
+ # In the case of commenter activity, id_col_name, author_col_name and date_col_name, are the names of the corresponding
214
+ # fields in dateframe["discussion"]. With parse_issues, they are the same as the ones directly in the dataframe.
215
+ def activity(dataframe, id_col_name, author_col_name, date_col_name):
216
+ """
217
+ This function runs an activity analysis on the dataset provided. It explores the weekly activity of the members of a
218
+ team or community.
219
+
220
+ In the case of issues, this analysis only considers bug reporters. To consider the commenters, the issues
221
+ dataframe's comments can be parsed using the parse_comments function. Said function generates a dataframe with the
222
+ needed columns and so can be used here. To consider both commenters and reporters, the issues and comments
223
+ dataframes can be merged, both having the necessary columns.
224
+
225
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
226
+ It must contain at least an *id*, a *name* and a *date* column.
227
+ :type dataframe: pandas.DataFrame
228
+ :param id_col_name: Name of the column containing unique identifiers for each entry.
229
+ :type id_col_name: str
230
+ :param author_col_name: Name of the column containing the authors of the entries.
231
+ :type author_col_name: str
232
+ :param date_col_name: Name of the column containing the dates of the entries.
233
+ :type date_col_name: str
234
+ :return: Object of type Activity containing a *dataframe* field and an *authors* one.
235
+ """
236
+
237
+ dataframe[date_col_name] = dataframe[date_col_name].apply(lambda x: datetime(year=x.year, month=x.month, day=x.day))
238
+
239
+ start_dates = dataframe.groupby(author_col_name)[[author_col_name, date_col_name]].min()
240
+ start_dates.index.name = "author_name_index"
241
+ authors = (
242
+ start_dates.sort_values([date_col_name, author_col_name], ascending=False).loc[:, author_col_name].tolist()
243
+ )
244
+
245
+ daily_activity = (
246
+ dataframe.loc[:, [author_col_name, date_col_name, id_col_name]]
247
+ .groupby([author_col_name, date_col_name])
248
+ .count()
249
+ )
250
+ daily_activity.columns = ["count"]
251
+
252
+ weekly_activity = daily_activity.groupby(author_col_name).resample("W", level=1).sum()
253
+ weekly_activity = weekly_activity.loc[lambda x: x["count"] > 0]
254
+ weekly_activity = weekly_activity.reset_index(level=[author_col_name, date_col_name])
255
+ weekly_activity[date_col_name] = weekly_activity[date_col_name].apply(lambda x: x - timedelta(days=3))
256
+ weekly_activity["week_name"] = weekly_activity[date_col_name].apply(lambda x: "%s-%s" % x.isocalendar()[:2])
257
+
258
+ weekly_activity = weekly_activity.rename(columns={author_col_name: "name", date_col_name: "date"})
259
+ return Activity(weekly_activity, authors)
260
+
261
+
262
+ def teamsize(dataframe, id_col_name, author_col_name, date_col_name, frac=None):
263
+ """
264
+ This function runs a teamsize analysis on the dataset provided. It explores the evolution of the size and activity
265
+ of a community or a team over time.
266
+
267
+ In the case of issues, this analysis only considers bug reporters. To consider the commenters, the issues
268
+ dataframe's comments can be parsed using the parse_comments function. Said function generates a dataframe with the
269
+ needed columns and so can be used here. To consider both commenters and reporters, the issues and comments
270
+ dataframes can be merged, both having the necessary columns.
271
+
272
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
273
+ It must contain at least an *id*, a *name* and a *date* column.
274
+ :type dataframe: pandas.DataFrame
275
+ :param id_col_name: Name of the column containing unique identifiers for each entry.
276
+ :type id_col_name: str
277
+ :param author_col_name: Name of the column containing the authors of the entries.
278
+ :type author_col_name: str
279
+ :param date_col_name: Name of the column containing the dates of the entries.
280
+ :type date_col_name: str
281
+ :param frac: The fraction of data to use for the curve smoothing factor.
282
+ :type frac: float
283
+ :return: Object of type TeamSize containing a *dataframe* field.
284
+ """
285
+
286
+ dataframe[date_col_name] = dataframe[date_col_name].apply(lambda x: x.date())
287
+ dataframe[date_col_name] = pd.DatetimeIndex(dataframe[date_col_name]).to_period("W").to_timestamp()
288
+ dataframe[date_col_name] = dataframe[date_col_name].apply(lambda x: x - timedelta(days=3))
289
+
290
+ dataframe_by_date = dataframe.groupby(date_col_name)
291
+
292
+ team_size = pd.DataFrame()
293
+
294
+ team_size["entry_count"] = dataframe_by_date[id_col_name].count()
295
+ team_size["author_count"] = dataframe_by_date[author_col_name].nunique()
296
+
297
+ team_size = team_size.groupby(date_col_name).sum()
298
+ team_size = team_size.sort_values(by=date_col_name)
299
+ team_size.reset_index(inplace=True)
300
+
301
+ y_a = team_size["entry_count"].values
302
+ y_ac = team_size["author_count"].values
303
+ x = team_size[date_col_name].apply(lambda date: date.timestamp()).values
304
+
305
+ frac = float(frac) if frac is not None else 10 * len(x) ** (-0.75)
306
+
307
+ team_size["entry_count_lowess"] = lowess(y_a, x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
308
+ team_size["author_count_lowess"] = lowess(y_ac, x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
309
+ team_size = team_size.rename(columns={date_col_name: "date"})
310
+ return TeamSize(team_size)
311
+
312
+
313
+ # If the source and target columns are the same, only the source needs to be given.
314
+ def network(dataframe, author_col_name, target_col_name, source_col_name=None):
315
+ """
316
+ This function runs a Network analysis on the dataset provided.
317
+
318
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
319
+ It must contain at least an *author*, a *target* and a *source* column.
320
+ :type dataframe: pandas.DataFrame
321
+ :param author_col_name: Name of the column containing the authors of the entries.
322
+ :type author_col_name: str
323
+ :param target_col_name: Name of the column containing the targets of the relationship that the network analysis is
324
+ supposed to exploring.
325
+ :type target_col_name: str
326
+ :param source_col_name: Name of the column containing the sources of the relationships that the network analysis is
327
+ supposed to be exploring.
328
+ :type source_col_name: str
329
+ :return: Object of type network containing a *dataframe* field and a *graph* one.
330
+ """
331
+
332
+ graph = _network_from_dataframe(dataframe, author_col_name, target_col_name, source_col_name)
333
+ no_edges = []
334
+ for u, v, weight in graph.edges.data("weight"):
335
+ if weight == 0:
336
+ no_edges.append((u, v))
337
+
338
+ graph.remove_edges_from(no_edges)
339
+ degrees = nx.degree_centrality(graph)
340
+ nodes = pd.DataFrame.from_records([degrees]).transpose()
341
+ nodes.columns = ["centrality"]
342
+
343
+ return Network(nodes, graph)
344
+
345
+
346
+ def centrality(
347
+ dataframe, id_col_name, author_col_name, date_col_name, target_col_name, source_col_name=None, name=None, frac=None
348
+ ):
349
+ """
350
+ This function runs a Centrality analysis on the dataset provided. It explores the evolution of an individuals
351
+ centrality over time as well as their activity and the size of their team or community.
352
+
353
+ :param dataframe: DataFrame containing the data on which to conduct the activity analysis.
354
+ It must contain at least an *id*, a *name*, a *date*, a *target* and a *source* column.
355
+ :type dataframe: pandas.DataFrame
356
+ :param id_col_name: Name of the column containing unique identifiers for each entry.
357
+ :type id_col_name: str
358
+ :param author_col_name: Name of the column containing the authors of the entries.
359
+ :type author_col_name: str
360
+ :param date_col_name: Name of the column containing the dates of the entries.
361
+ :type date_col_name: str
362
+ :param target_col_name: Name of the column containing the targets of the relationship that the network analysis is
363
+ supposed to exploring.
364
+ :type target_col_name: str
365
+ :param source_col_name: Name of the column containing the sources of the relationships that the network analysis is
366
+ supposed to be exploring.
367
+ :type source_col_name: str
368
+ :param name: Name of the author whose centrality is to analyze.
369
+ :type name: str
370
+ :param frac: The fraction of data to use for the curve smoothing factor.
371
+ :type frac: float
372
+ :return: Object of type Centrality containing a *dataframe* field.
373
+ """
374
+
375
+ authors = list(dataframe[author_col_name].sort_values().unique())
376
+ if not name or authors.count(name) == 0:
377
+ return authors
378
+ dataframe[date_col_name] = dataframe[date_col_name].apply(lambda x: datetime(year=x.year, month=x.month, day=1))
379
+ window_radius = 1
380
+ delta = relativedelta(months=window_radius)
381
+ freq = MONTHLY
382
+ min_date = dataframe[date_col_name].min()
383
+ max_date = dataframe[date_col_name].max()
384
+ # Reducing the date interval by two months is problematic when the data source spans over less than two months.
385
+ if max_date - relativedelta(months=2 * window_radius) < min_date:
386
+ delta = relativedelta(weeks=window_radius)
387
+ freq = WEEKLY
388
+
389
+ min_date = min_date + delta
390
+ max_date = max_date - delta
391
+
392
+ date_range = rrule(freq=freq, dtstart=min_date, until=max_date)
393
+ dates = [(date - delta, date + delta) for date in date_range]
394
+
395
+ # Compensating the difference between rrule's last date and the actual max date
396
+ last_date_in_df = dataframe[date_col_name].max()
397
+ last_date_in_list = dates[-1][-1]
398
+ if last_date_in_list < last_date_in_df:
399
+ dates.append((last_date_in_list, last_date_in_df))
400
+
401
+ degrees = []
402
+ sizes = []
403
+ with Pool() as pool:
404
+ results = []
405
+ for start_date, end_date in dates:
406
+ mask = (dataframe[date_col_name] >= start_date) & (dataframe[date_col_name] <= end_date)
407
+ results.append(
408
+ pool.apply_async(
409
+ _network_from_dataframe,
410
+ args=(dataframe.loc[mask], author_col_name, target_col_name, source_col_name),
411
+ )
412
+ )
413
+ for result in results:
414
+ graph = result.get()
415
+ degrees.append(nx.degree_centrality(graph))
416
+ sizes.append(graph.number_of_nodes())
417
+
418
+ date_x = [date for (date, x) in dates]
419
+ x = list(map(lambda date: date.timestamp(), date_x))
420
+ nodes = pd.DataFrame.from_records(degrees, index=date_x)
421
+ nodes.index.name = date_col_name
422
+ nodes.fillna(0.0, inplace=True)
423
+ frac = float(frac) if frac is not None else 7.5 * len(x) ** (-0.75)
424
+ nodes[name] = lowess(nodes[name], x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
425
+
426
+ size_df = pd.DataFrame(data={"value": sizes}, index=date_x)
427
+ size_df.index.name = date_col_name
428
+ size_df = size_df / size_df.max()
429
+ size_df.reset_index(inplace=True)
430
+ x = size_df[date_col_name].apply(lambda date: date.timestamp())
431
+ size_df["value"] = lowess(size_df["value"], x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
432
+
433
+ activity = (
434
+ dataframe.loc[:, [author_col_name, date_col_name, id_col_name]]
435
+ .groupby([author_col_name, date_col_name])
436
+ .count()
437
+ )
438
+ activity.columns = ["count"]
439
+ activity = activity.unstack(level=0)
440
+ activity.columns = [name for (x, name) in activity.columns]
441
+ activity.fillna(0.0, inplace=True)
442
+ activity = activity / activity.max()
443
+
444
+ activity_df = pd.DataFrame(activity[name])
445
+ activity_df.columns = ["value"]
446
+ activity_df.reset_index(inplace=True)
447
+ x = activity_df[date_col_name].apply(lambda date: date.timestamp())
448
+ activity_df["value"] = lowess(activity_df["value"], x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
449
+
450
+ centrality_df = pd.DataFrame(nodes[name])
451
+ centrality_df.columns = ["value"]
452
+ centrality_df.reset_index(inplace=True)
453
+
454
+ return Centrality(
455
+ centrality_df.rename(columns={date_col_name: "date"}),
456
+ activity_df.rename(columns={date_col_name: "date"}),
457
+ size_df.rename(columns={date_col_name: "date"}),
458
+ name,
459
+ )
460
+
461
+
462
+ def response(issues, id_col_name, author_col_name, date_col_name, discussion_col_name, frac=None):
463
+ """
464
+ This function runs an issue response time analysis on the dataset provided. It returns the number of unanswered
465
+ issues at each point in time as well as a curve representing the evolution of the reponse time to the issues of a
466
+ certain project or community.
467
+
468
+ :param issues: DataFrame containing the issues on which to conduct the response analysis.
469
+ It must contain at least an *id*, a *name*, a *date* and a *discussion* column.
470
+ :type issues: pandas.DataFrame
471
+ :param id_col_name: Name of the column containing unique identifiers for each entry.
472
+ :type id_col_name: str
473
+ :param author_col_name: Name of the column containing the authors of the entries.
474
+ :type author_col_name: str
475
+ :param date_col_name: Name of the column containing the dates of the entries.
476
+ :type date_col_name: str
477
+ :param discussion_col_name: Name of the discussion column in the issues DataFrame.
478
+ :type discussion_col_name: str
479
+ :param frac: The fraction of data to use for the curve smoothing factor.
480
+ :type frac: float
481
+ :return: Object of type Response containing an *unanswered_issues* field and *response_time* one.
482
+ """
483
+
484
+ issues = issues.sort_values(by=date_col_name)
485
+ issues = issues.reset_index(drop=True)
486
+
487
+ def filter_notes(issue):
488
+ for comment in issue[discussion_col_name]:
489
+ if comment["system"] and comment[author_col_name] != issue[author_col_name]:
490
+ return comment[date_col_name]
491
+ return None # Issues that are not answered yet
492
+
493
+ def get_rates(issue, issues):
494
+ answered = 0
495
+ for index, i in issues.iterrows():
496
+ if not pd.isna(i[discussion_col_name]) and i[discussion_col_name] <= issue[date_col_name]:
497
+ answered += 1
498
+ # id is a unique identifier and so ensures issue and i are the same
499
+ if issue[id_col_name] == i[id_col_name]:
500
+ return index - answered + 1 # Indices start at 0
501
+ return None
502
+
503
+ issues[discussion_col_name] = issues.apply(filter_notes, axis=1)
504
+ issues["unanswered_to_this_date"] = issues.apply(get_rates, args=(issues,), axis=1)
505
+ issues_answered = issues[pd.notnull(issues[discussion_col_name])]
506
+
507
+ response_time = pd.DataFrame()
508
+ response_time[date_col_name] = issues_answered[date_col_name]
509
+ response_time["response_time"] = (
510
+ issues_answered[discussion_col_name] - issues_answered[date_col_name]
511
+ ) / timedelta(hours=1)
512
+
513
+ y_rt = response_time["response_time"].values
514
+ x = response_time[date_col_name].apply(lambda date: date.timestamp()).values
515
+
516
+ frac = float(frac) if frac is not None else 10 * len(x) ** (-0.75)
517
+ response_time["response_time_lowess"] = lowess(y_rt, x, is_sorted=True, frac=frac if frac < 1 else 0.8, it=0)[:, 1]
518
+
519
+ response_time["response_time_formatted"] = response_time["response_time"].apply(
520
+ lambda x: "{} day(s) and {} hour(s)".format(int(x // 24), int(x % 24))
521
+ )
522
+ issues = issues.rename(columns={date_col_name: "date"})
523
+ response_time = response_time.rename(columns={date_col_name: "date"})
524
+ return Response(issues.loc[:, ["date", "unanswered_to_this_date"]], response_time)
525
+
526
+
527
+ def display(objects, title=None, output="result.html", palette="magma256", show_plots=True):
528
+ """
529
+ This function displays the results of the analyses. When *objects* consists of multiple objects, they all get
530
+ displayed in a grid plot except for objects of type *Centrality*, *TeamSize* and *Response*. These three objects can
531
+ be displayed in the form of plots and thus can be are overlayed. The same can't be said for objects of type
532
+ *Activity* and *Network*.
533
+
534
+ :param objects: An object of type Activity, TeamSize, Network, Centrality or Response or a list of such objects.
535
+ :type objects: Activity, TeamSize, Network, Centrality or Response or a list of them.
536
+ :param title: Title of the figure to display.
537
+ :type title: str
538
+ :param output: Output HTML file, default is *result.html*.
539
+ :type output: str
540
+ :param palette: Name of the bokeh palette to use.
541
+ :type palette: str
542
+ :param show_plots: Flag to either save the result in a file and then show it in a browser when set to *True* or save
543
+ it only and without starting a browser when set to *False*.
544
+ :type show_plots: bool
545
+ :return: No return value but opens the HTML file with the results.
546
+ """
547
+
548
+ if not isinstance(objects, list):
549
+ objects = [objects]
550
+ if palette != "magma256" and palette != "blue4":
551
+ raise NameError("{} palette not found. Please choose either 'magma256' or 'blue4'".format(palette))
552
+ output_file(output)
553
+
554
+ # Grouping objects by their types
555
+ accumulation = {}
556
+ for objs in objects:
557
+ accumulation.setdefault(type(objs), []).append(objs)
558
+ objects_by_type = accumulation.values()
559
+
560
+ plots = []
561
+ for objs in objects_by_type:
562
+ p = _display(objs, title, palette)
563
+ plots.append(p)
564
+
565
+ gp = gridplot(plots, ncols=2, sizing_mode="stretch_both")
566
+ if show_plots:
567
+ show(gp)
568
+ else:
569
+ save(gp)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ #
5
+ # Copyright 2019 Christelle Zouein <christellezouein@hotmail.com>
6
+ #
7
+ # The authors license this file to You under the Apache License, Version 2.0
8
+ # (the "License"); you may not use this file except in compliance with
9
+ # the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ #
19
+
20
+ import os
21
+ import json
22
+ import gitlab
23
+ import argparse
24
+ import subprocess
25
+
26
+
27
+ def get_project_issues(project_ids, instance, token):
28
+ gl = gitlab.Gitlab(instance, private_token=token)
29
+ issues = []
30
+ for project_id in project_ids:
31
+ issue_objects = gl.projects.get(project_id).issues.list(all=True)
32
+ for issue_object in issue_objects:
33
+ issue = vars(issue_object)["_attrs"]
34
+ discussion = issue_object.discussions.list(all=True)
35
+ # A thread is made up of either a comment or a comment and its replies,
36
+ # and thus a discussion is a list of threads and a thread is a list of comments.
37
+ # The other fields of thread.attribute are metadata that aren't of much interest to us.
38
+ issue["discussion"] = list(map(lambda thread: thread.attributes["notes"], discussion))
39
+ issues.append(issue)
40
+
41
+ return issues
42
+
43
+
44
+ def clone_project(access_token, instance, project_ids):
45
+ gl = gitlab.Gitlab(instance, private_token=access_token)
46
+ for project_id in project_ids:
47
+ print(project_id)
48
+ project = gl.projects.get(project_id)
49
+ http_url_to_repo = project.http_url_to_repo.replace("https://", "")
50
+ git_url = "https://oauth2:{}@{}".format(access_token, http_url_to_repo)
51
+ subprocess.call(["git", "clone", git_url])
52
+
53
+
54
+ if __name__ == "__main__":
55
+ arg_parser = argparse.ArgumentParser(add_help=False)
56
+ arg_parser.add_argument("token", metavar="accessToken", help="Personal access token")
57
+ arg_parser.add_argument("ids", metavar="projectID", nargs="+", help="Project ID of desired issues to retrieve")
58
+ arg_parser.add_argument("-g", "--gitlab-instance", help="Instance to retrieve issues from")
59
+ arg_parser.add_argument("-r", "--repository", help="Clone a repository.", action="store_true")
60
+ arg_parser.add_argument("-i", "--issues", help="Fetch issues.", action="store_true")
61
+ arg_parser.add_argument("-d", "--directory", help="Output directory. (Default is the working directory)")
62
+ arg_parser.add_argument("-o", "--output", help="Issues output file name. (Default: 'issues.json')")
63
+
64
+ args = arg_parser.parse_args()
65
+ ins = args.gitlab_instance or "https://gitlab.com/"
66
+ if args.directory:
67
+ if os.path.isdir(args.directory):
68
+ os.chdir(args.directory)
69
+ else:
70
+ print("Please choose a valid directory.")
71
+ exit(2)
72
+ if args.issues:
73
+ output_filename = args.output or "issues.json"
74
+ try:
75
+ with open(output_filename, "w") as f:
76
+ try:
77
+ issues = get_project_issues(args.ids, ins, args.token)
78
+ except Exception as e:
79
+ print(e)
80
+ exit(1)
81
+
82
+ json.dump(issues, f)
83
+ except PermissionError:
84
+ print("Please choose a directory/file with write permissions.")
85
+ exit(2)
86
+
87
+ if args.repository:
88
+ try:
89
+ clone_project(args.token, ins, args.ids)
90
+ except Exception as e:
91
+ print(e)
92
+ exit(1)