blob: e00620302483cfb507e49eef4ce59254db36c2bf [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-"
# Copyright 2021 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Create rebase statistics spreadsheet
Required python modules:
google-api-python-client google-auth-httplib2 google-auth-oauthlib
The Google Sheets API needs to be enabled to run this script.
Also, you'll need to generate access credentials and store those
in credentials.json.
"""
from __future__ import print_function
import sqlite3
import re
import datetime
import time
from config import topiclist_consolidated
from common import rebasedb, nextdb, upstreamdb, rebase_baseline
import genlib
stats_filename = 'rebase-stats.id'
rp = re.compile(
'(CHROMIUM: *|CHROMEOS: *|UPSTREAM: *|FROMGIT: *|FROMLIST: *|BACKPORT: *)+(.*)'
)
stats_colors = [
{
'red': 0,
'green': 0.8,
'blue': 0
}, # Queued: green
{
'blue': 1
}, # Upstream: blue
{
'red': 0.5,
'green': 0.5,
'blue': 1
}, # Backport: light blue
{
'red': 0.9,
'green': 0.9
}, # yellow
{
'red': 1,
'green': 0.6
}, # Fromlist: orange
{
'red': 1,
'green': 0.3,
'blue': 0.3
}, # Chromium: red
{
'red': 0.8,
'green': 0.8,
'blue': 0.8
} # Other: gray
]
def NOW():
"""Return current time"""
return int(time.time())
def get_consolidated_topic_name(topic_name):
"""Return consolidated topic name"""
for [consolidated_name, topic_names] in topiclist_consolidated:
for elem in topic_names:
if topic_name == elem:
return consolidated_name
return topic_name
def get_consolidated_topic(c, topic_name):
"""Return consolidated topic"""
for (_, topic_names) in topiclist_consolidated:
for elem in topic_names:
if topic_name == elem:
c.execute("select topic from topics where name is '%s'" %
topic_names[0])
topic = c.fetchone()
if topic:
return topic[0]
c.execute("select topic from topics where name is '%s'" % topic_name)
topic = c.fetchone()
if topic:
return topic[0]
# oops
print('No topic found for %s' % topic_name)
return 0
def get_consolidated_topics(c):
"""Return dict of consolidated topics"""
topics = {}
other_topic_id = None
c.execute('SELECT topic, name FROM topics ORDER BY name')
for topic, name in c.fetchall():
if name:
consolidated_name = get_consolidated_topic_name(name)
consolidated_topic = get_consolidated_topic(c, name)
topics[topic] = consolidated_name
if consolidated_name == 'other':
other_topic_id = consolidated_topic
if not other_topic_id:
topics[genlib.get_other_topic_id(c)] = 'other'
return topics
def get_tags(cu=None):
"""Get dictionary with list of tags.
Index is tag, content is tag timestamp
"""
uconn = None
if not cu:
uconn = sqlite3.connect(upstreamdb)
cu = uconn.cursor()
tag_list = {}
largest_ts = 0
cu.execute('SELECT tag, timestamp FROM tags ORDER BY timestamp')
for (tag, timestamp) in cu.fetchall():
tag_list[tag] = timestamp
if timestamp > largest_ts:
largest_ts = timestamp
tag_list[u'ToT'] = largest_ts + 1
if uconn:
uconn.close()
return tag_list
def do_topic_stats_count(topic_stats, tags, topic, committed_ts, integrated_ts):
"""Count commit in topic stats if appropriate"""
for tag in tags:
tag_ts = tags[tag]
if committed_ts < tag_ts < integrated_ts:
topic_stats[topic][tag] += 1
def get_topic_stats(c):
"""Return dict with commit statistics"""
uconn = sqlite3.connect(upstreamdb)
cu = uconn.cursor()
tags = get_tags(cu)
topics = get_consolidated_topics(c)
topic_stats = {}
for topic in list(set(topics.values())):
topic_stats[topic] = {}
for tag in tags:
topic_stats[topic][tag] = 0
c.execute(
'SELECT usha, dsha, committed, topic, disposition, reason from commits')
for (
usha,
dsha,
committed,
topic,
disposition,
reason,
) in c.fetchall():
# Skip entries with topic 0 immediately.
if topic == 0:
continue
topic_name = topics.get(topic, 'other')
if disposition != 'drop':
do_topic_stats_count(topic_stats, tags, topic_name, committed,
NOW())
continue
# disposition is drop.
if reason == 'fixup/reverted':
# This patch is a fixup of a reverted and dropped patch, identified
# by dsha. Count from its commit time up to the revert time.
# First find the companion (dsha)
c.execute("SELECT dsha from commits where sha is '%s'" % dsha)
reverted_dsha, = c.fetchone()
# Now get the revert time, and count for time in between
c.execute("SELECT committed from commits where sha is '%s'" %
reverted_dsha)
revert_committed, = c.fetchone()
do_topic_stats_count(topic_stats, tags, topic_name, committed,
revert_committed)
continue
if reason == 'reverted' and dsha:
# This patch reverts dsha, or it was reverted by dsha.
# Count only if it was committed after its companion to ensure that
# it is counted only once.
c.execute("SELECT committed from commits where sha is '%s'" % dsha)
revert_committed, = c.fetchone()
if revert_committed > committed:
do_topic_stats_count(topic_stats, tags, topic_name, committed,
revert_committed)
continue
# This is not a revert, or the revert companion is unknown (which can
# happen if we reverted an upstream patch). Check if we have a matching
# upstream or replacement SHA. If so, count accordingly. Don't count
# if we don't have a matching upstream/replacement SHA.
if not usha:
usha = dsha
if usha:
cu.execute("SELECT integrated from commits where sha is '%s'" %
usha)
integrated = cu.fetchone()
if integrated:
integrated = integrated[0] if integrated[0] else None
if integrated and integrated in tags:
do_topic_stats_count(topic_stats, tags, topic_name, committed,
tags[integrated])
elif not integrated:
# Not yet integrated.
# We know that disposition is 'drop', suggesting that the patch was accepted
# upstream after the most recent tag. Therefore, count against ToT.
do_topic_stats_count(topic_stats, tags, topic_name, committed,
tags['ToT'])
else:
print('sha %s: integrated tag %s not in database' %
(usha, integrated))
uconn.close()
return topic_stats
def add_topics_summary_row(requests, conn, nconn, rowindex, topic, name):
"""Add topics summary row"""
c = conn.cursor()
c2 = conn.cursor()
cn = nconn.cursor()
age = 0
now = NOW()
if topic:
search = ('select topic, patchid, usha, authored, subject, disposition '
'from commits where topic=%d') % topic
else:
search = ('select topic, patchid, usha, authored, subject, disposition '
'from commits where topic != 0')
c.execute(search)
rows = 0
effrows = 0
queued = 0
upstream = 0
fromlist = 0
fromgit = 0
chromium = 0
backport = 0
other = 0
for (t, patchid, usha, a, subject, d) in c.fetchall(): # pylint: disable=too-many-nested-blocks
if topic == 0:
# We are interested if the topic name is 'other',
# or if the topic is not in the named topic list.
c2.execute('select name from topics where topic is %d' % t)
topics = c2.fetchone()
if topics and topics[0] != 'other':
continue
rows += 1
if d == 'pick':
effrows += 1
age += (now - a)
# Search for the patch ID in the next database.
# If it is found there, count it as "Queued".
cmd = 'SELECT sha FROM commits WHERE patchid IS "%s"' % patchid
if usha:
cmd += ' OR sha IS "%s"' % usha
cn.execute(cmd)
if cn.fetchone():
queued += 1
else:
m = rp.search(subject)
if m:
what = m.group(1).replace(' ', '')
if what == 'BACKPORT:':
m = rp.search(m.group(2))
if m:
what = m.group(1).replace(' ', '')
if what in ('CHROMIUM:', 'CHROMEOS:'):
chromium += 1
elif what == 'UPSTREAM:':
upstream += 1
elif what == 'FROMLIST:':
fromlist += 1
elif what == 'FROMGIT:':
fromgit += 1
elif what == 'BACKPORT:':
backport += 1
else:
other += 1
else:
other += 1
# Only add summary entry if there are active commits associated with this topic.
# Since the summary entry is used to generate statistics, do not add rows
# where all commits have been pushed upstream or have been reverted.
if effrows:
age /= effrows
age /= (3600 * 24) # Display age in days
requests.append({
'pasteData': {
'data':
'%s;%d;%d;%d;%d;%d;%d;%d;%d;%d;%d' %
(name, queued, upstream, backport, fromgit, fromlist,
chromium, other, effrows, rows, age),
'type':
'PASTE_NORMAL',
'delimiter':
';',
'coordinate': {
'sheetId': 0,
'rowIndex': rowindex
}
}
})
return effrows
def add_topics_summary(requests):
"""Add topics summary"""
conn = sqlite3.connect(rebasedb)
nconn = sqlite3.connect(nextdb)
c = conn.cursor()
# Handle 'chromeos' first and separately so we can exclude it from the
# backlog chart later.
c.execute("select topic from topics where name is 'chromeos'")
topic = c.fetchone()
if topic:
add_topics_summary_row(requests, conn, nconn, 1, topic[0], 'chromeos')
c.execute('select topic, name from topics order by name')
rowindex = 2
for (topic, name) in c.fetchall():
if name not in ('chromeos', 'other'):
added = add_topics_summary_row(requests, conn, nconn, rowindex,
topic, name)
if added:
rowindex += 1
# Finally, do the same for 'other' topics, identified as topic==0.
added = add_topics_summary_row(requests, conn, nconn, rowindex, 0, 'other')
conn.close()
return rowindex
def create_summary(sheet):
"""Create summary"""
requests = []
requests.append({
'updateSheetProperties': {
'properties': {
'sheetId': 0,
'title': 'Data',
},
'fields': 'title'
}
})
header = 'Topic, Queued, Upstream, Backport, Fromgit, Fromlist, \
Chromium, Untagged/Other, Net, Total, Average Age (days)'
genlib.add_sheet_header(requests, 0, header)
# Now add all topics
rows = add_topics_summary(requests)
# As final step, resize it
genlib.resize_sheet(requests, 0, 0, 11)
# and execute
genlib.doit(sheet, requests)
return rows
def update_one_cell(request, sheetId, row, column, data):
"""Update data in a a single cell"""
print('update_one_cell(id=%d row=%d column=%d data=%s type=%s' %
(sheetId, row, column, data, type(data)))
if isinstance(data, int):
fieldtype = 'numberValue'
else:
fieldtype = 'stringValue'
request.append({
'updateCells': {
'rows': {
'values': [{
'userEnteredValue': {
fieldtype: '%s' % data
}
}]
},
'fields': 'userEnteredValue(stringValue)',
'range': {
'sheetId': sheetId,
'startRowIndex': row,
'startColumnIndex': column
# 'endRowIndex': 1
# 'endColumnIndexIndex': column + 1
},
}
})
def add_topic_stats_column(request, sheetId, column, tag, data):
"""Add one column of topic statistics to request"""
row = 0
update_one_cell(request, sheetId, row, column, tag)
data.pop(0) # First entry is topic 0, skip
for f in data:
row += 1
update_one_cell(request, sheetId, row, column, f)
def create_topic_stats(sheet):
"""Create tab with topic statistics.
We'll use it later to create a chart.
"""
conn = sqlite3.connect(rebasedb)
c = conn.cursor()
topic_stats = get_topic_stats(c)
tags = get_tags()
sorted_tags = sorted(tags, key=tags.get)
topics = get_consolidated_topics(c)
topic_list = list(set(topics.values()))
request = []
request.append({
'addSheet': {
'properties': {
# 'sheetId': 1,
'title': 'Topic Statistics Data',
},
}
})
response = genlib.doit(sheet, request)
reply = response.get('replies')
sheetId = reply[0]['addSheet']['properties']['sheetId']
request = []
# One column per topic
header = ''
columns = 1
for topic in topic_list:
header += ', %s' % topic
columns += 1
genlib.add_sheet_header(request, sheetId, header)
rowindex = 1
for tag in sorted_tags:
# topic = topics[topic_num]
# rowdata = topic
rowdata = tag
for topic in topic_list:
rowdata += ';%d' % topic_stats[topic][tag]
request.append({
'pasteData': {
'data': rowdata,
'type': 'PASTE_NORMAL',
'delimiter': ';',
'coordinate': {
'sheetId': sheetId,
'rowIndex': rowindex
}
}
})
rowindex = rowindex + 1
# As final step, resize sheet
# [not really necessary; drop if confusing]
genlib.resize_sheet(request, sheetId, 0, columns)
# and execute
genlib.doit(sheet, request)
conn.close()
return sheetId, rowindex, columns
def colored_scope(name, sheetId, rows, column):
"""Add colored scope"""
return {
name: genlib.source_range(sheetId, rows, column),
'targetAxis': 'LEFT_AXIS',
'color': stats_colors[column - 1]
}
def colored_sscope(name, sheetId, rows, start, end):
"""Add colored sscope"""
s = [colored_scope(name, sheetId, rows, start)]
while start < end:
start += 1
s += [colored_scope(name, sheetId, rows, start)]
return s
def add_backlog_chart(sheet, rows):
"""Add backlog chart"""
request = []
# chart start with summary row 2. Row 1 is assumed to be 'chromeos'
# which is not counted as backlog.
request.append({
'addChart': {
'chart': {
'chartId': 1,
'spec': {
'title':
'Upstream Backlog (updated %s)' %
datetime.datetime.now().strftime('%x'),
'basicChart': {
'chartType': 'COLUMN',
'stackedType': 'STACKED',
'headerCount': 1,
# "legendPosition": "BOTTOM_LEGEND",
'axis': [{
'position': 'BOTTOM_AXIS',
'title': 'Topic'
}, {
'position': 'LEFT_AXIS',
'title': 'Backlog'
}],
'domains': [genlib.scope('domain', 0, rows + 1, 0)],
'series': colored_sscope('series', 0, rows + 1, 1, 7),
}
},
'position': {
'newSheet': True,
}
}
}
})
response = genlib.doit(sheet, request)
# Extract sheet Id from response
reply = response.get('replies')
sheetId = reply[0]['addChart']['chart']['position']['sheetId']
request = []
request.append({
'updateSheetProperties': {
'properties': {
'sheetId': sheetId,
'title': 'Backlog Count',
},
'fields': 'title',
}
})
genlib.doit(sheet, request)
def add_age_chart(sheet, rows):
"""Add age chart"""
request = []
request.append({
'addChart': {
'chart': {
'chartId': 2,
'spec': {
'title':
'Upstream Backlog Age (updated %s)' %
datetime.datetime.now().strftime('%x'),
'basicChart': {
'chartType': 'COLUMN',
'headerCount': 1,
# "legendPosition": "BOTTOM_LEGEND",
'axis': [{
'position': 'BOTTOM_AXIS',
'title': 'Topic'
}, {
'position': 'LEFT_AXIS',
'title': 'Average Age (days)'
}],
'domains': [genlib.scope('domain', 0, rows + 1, 0)],
'series': [genlib.scope('series', 0, rows + 1, 10)]
}
},
'position': {
'newSheet': True,
}
}
}
})
response = genlib.doit(sheet, request)
# Extract sheet Id from response
reply = response.get('replies')
sheetId = reply[0]['addChart']['chart']['position']['sheetId']
request = []
request.append({
'updateSheetProperties': {
'properties': {
'sheetId': sheetId,
'title': 'Backlog Age',
},
'fields': 'title',
}
})
genlib.doit(sheet, request)
def add_stats_chart(sheet, sheetId, rows, columns):
"""Add statistics chart"""
request = []
if columns > 25:
print('########### Limiting number of columns to 25 from %d' % columns)
columns = 25
request.append({
'addChart': {
'chart': {
'chartId': 3,
'spec': {
'title':
'Topic Statistics (updated %s)' %
datetime.datetime.now().strftime('%x'),
'basicChart': {
'chartType':
'AREA',
'stackedType':
'STACKED',
'headerCount':
1,
# "legendPosition": "BOTTOM_LEGEND",
'axis': [{
'position': 'BOTTOM_AXIS',
'title': 'Upstream Release Tag'
}, {
'position': 'LEFT_AXIS',
'title': 'Patches'
}],
'domains': [genlib.scope('domain', sheetId, rows, 0)],
'series':
genlib.sscope('series', sheetId, rows, 1, columns),
}
},
'position': {
'newSheet': True,
}
}
}
})
response = genlib.doit(sheet, request)
# Extract sheet Id from response
reply = response.get('replies')
sheetId = reply[0]['addChart']['chart']['position']['sheetId']
request = []
request.append({
'updateSheetProperties': {
'properties': {
'sheetId': sheetId,
'title': 'Topic Statistics',
},
'fields': 'title',
}
})
genlib.doit(sheet, request)
def main():
"""Main function"""
sheet = genlib.init_spreadsheet(
stats_filename,
'Backlog Status for chromeos-%s' % rebase_baseline().strip('v'))
summary_rows = create_summary(sheet)
topic_stats_sheet, topic_stats_rows, topic_stats_columns = create_topic_stats(
sheet)
add_backlog_chart(sheet, summary_rows)
add_age_chart(sheet, summary_rows)
add_stats_chart(sheet, topic_stats_sheet, topic_stats_rows,
topic_stats_columns)
genlib.move_sheet(sheet, 0, 4)
genlib.hide_sheet(sheet, 0, True)
genlib.move_sheet(sheet, topic_stats_sheet, 5)
genlib.hide_sheet(sheet, topic_stats_sheet, True)
if __name__ == '__main__':
main()