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 +1 -0
- comdaan/comdaan.py +569 -0
- comdaan/comdaan_fetch.py +92 -0
- comdaan/display.py +291 -0
- comdaan/gitparsing.py +196 -0
- comdaan/issuesparsing.py +174 -0
- comdaan/mailparsing.py +156 -0
- comdaan/object_types.py +7 -0
- comdaan/rulesetfinding.py +45 -0
- comdaan/rulesets/kde.py +32 -0
- comdaan-0.1.8.dist-info/LICENSE +202 -0
- comdaan-0.1.8.dist-info/METADATA +262 -0
- comdaan-0.1.8.dist-info/RECORD +14 -0
- comdaan-0.1.8.dist-info/WHEEL +4 -0
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)
|
comdaan/comdaan_fetch.py
ADDED
|
@@ -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)
|