python

Статистика встроенных комментариев (inline comments) в Confluence

  • вторник, 28 января 2020 г. в 00:22:56
https://habr.com/ru/post/485652/
  • Python
  • Atlassian


Как собрать статистику комментариев к страницам в Confluence?

Да и зачем это может понадобиться?



Зачем и почему


В проекте, в котором мне довелось работать, родился следующий процесс подготовки и согласования требований:

  • Для ведения документации использовался Confluence.
  • Команда поставщика готовила описания бизнес-процессов и их шагов в виде отдельных страниц по шаблону.
  • Раз в неделю партия подготовленных описаний передавалась заказчику для вычитки.
  • Команда заказчика все вопросы и дополнения оставляла на соответствующей странице в виде встроенных комментариев.
  • А команда поставщика дополняла содержание, отвечала на комментарии, фиксировала вопросы для дополнительной проработки.
  • Если вопрос отвечен и содержание обновлено или зафиксирована задача для проработки, команда заказчика должна закрыть комментарий.
  • Вопросы по партии документов с текущей недели должны быть закрыты к моменту передачи следующей партии на следующей неделе.

Каждую неделю у менеджеров проекта возникает задача понять, на сколько проработана поставленная партия документов, и что из нее можно считать условно готовым. А у участников команды поставщика периодически возникает потребность проверить состояние комментариев по своим документам и целенаправленно поработать с оставшимися. Но как их найти? Можно открывать каждую страницу, искать на ней первый комментарий глазами (или с помощью маленькой хитрости), потом щелкать все комментарии, т.к. команда заказчика не спешит их закрывать, задуматься над каждым и проверить наличие ответа.

В еженедельной партии содержится 50-100 отдельных страниц, и сделать это руками — значительные трудозатраты. А если еще попытаться собрать аргументы для убеждения второй стороны, то становится совсем грустно. А еще есть повисшие комментарии, которые получаются в результате неаккуратной правки страницы, когда исходный текст случайно удаляется. Такой комментарий виден в решенных, но его нельзя повторно открыть (можно, если воссоздать невидимый маркер в тексте страницы).

Поиски инструментов успехом не увенчались. К тому же, Confluence развернут на стороне заказчика, плагины не установишь, не говоря уже о купить. Экспертизы по разработке макросов нет.

В какой-то момент я вспомнил о наличии у Confluence REST API и предыдущем опыте использования аналогичного API Jira. Поиск и эксперименты с вызовом функций из браузера показали, что добраться до комментариев и их свойств можно. Дальше нужно было выбрать инструмент для автоматизации, и можно приступать к решению. У меня есть кое-какой опыт создания скриптов на инструментах, которые, скорее, ближе администраторам, вроде Bash, Perl, JScript. Я не разработчик, хорошо знакомых или привычных инструментов у меня не было. Хотелось попробовать что-то более распространенное или подходящее. Тут обнаружил обертку для API на Python и решил попробовать с ним.

Общий принцип


Задачу сформулировал так. Нужно найти все страницы, относящиеся к определенной недельной поставке. Собрать комментарии к ним в список: страница, ссылка на комментарий, автор и дата комментария, исходный текст на странице, комментарий и ответы, автор и дата последнего ответа, состояние комментария. Дополнительно собрать статистику по каждой странице, сколько комментариев всего, сколько повисших, сколько открытых. Сохранить это все на специальную страницу статистики.

Ставлю Python, просматриваю азы работы с ним, и поехали. Первым делом создаем подключение:

from atlassian import Confluence
UserLogin = 'xxxxxx'  # input("Login: ")
UserPwd = 'xxxxxx'  # input("Password: ")
confluenceURL = 'http://wiki.xxxxxx'
confluence = Confluence(
    url=confluenceURL,
    username=UserLogin,
    password=UserPwd)

Для поиска страниц из недельной партии решил использовать метки. Как их массово поставить — отдельная задача.

page_label = 'week123'
cql = 'space.key={} and label = "{}" and type = page '
      'ORDER BY title '.format('YYY', page_label)
pages = confluence.cql(cql, expand=None, start=0, limit=200)

Так мы получили список (list) страниц для проверки. Дальше запускаем обработку каждой отдельной страницы. Собираем с нее данные о комментариях с их параметрами. По этим данным строим статистику, сколько вообще на странице комментариев и в каких состояниях. Дальше подбиваем итог по всем страницам и начинаем форматировать результат. Создаем тело страницы с результатом в виде таблицы статистики и подробного списка открытых комментариев.

Обработка списка страниц
statistics = []
open_comments = []
#нам вообще что-то вернулось?
if pages is not None:
	#а страницы есть в результате?
	if pages['size'] > 0:   
		for page in pages['results']:  
			print(page['title'])
			#вызываем обработку страницы и получаем данные о комментариях, 
			#опишу ниже
			page_comments = page_comments_data(page['content']['id'])
			#формируем статистику по результату страницы			
			statistics.append(page_statistics(page_comments))
			#чистим комментарии и оставляем для отображения только открытые
			#и повисшие от команды заказчика.
			for comment in page_comments:
				if comment['Result'] not in ['resolved', 'nocomment']:
					if not (comment['Result'] == 'dangling' 
							and comment['Author'] in excludeNames):
						open_comments.append(comment)
	#после обработки всех страниц дополняем статистику строкой итогов.
	statistics.append(total_statistics(statistics))

#Ищем специальную страницу для статистики
page_id = confluence.get_page_id(space='YYY', title=page_title)
#собираем тело страницы из макроса оглавления, двух заголовков и таблиц.
page_body = ('<p><ac:structured-macro ac:name="toc" ac:schema-version="1"'
			 '/></p>'
			 '<h1>Comments Statistics</h1>{}'
			 '<h1>Open Comments List</h1>{}'
			 ).format(create_table(statistics), create_table(open_comments))
#обновляем страницу
if page_id is not None:
	status = confluence.update_page(
		page_id=page_id,
		title=page_title,
		body=page_body,
		representation='storage'
	)

Особенности API


Теперь давайте разберемся, как обработать страницу. По умолчанию API выдает только базовую информацию, вроде идентификатора или названия страницы. Все дополнительные свойства нужно прописать явно. Их можно подсмотреть, анализируя результат вызова. Дополнительные данные можно найти в секциях или подсекциях _expandable. Дописываем нужный пункт в expand и смотрим дальше, пока не найдем нужные данные.

Пример выдачи
http://wiki.xxxxxx/rest/api/content/101743895?expand=body,children.comment

{
    "id": "97517865",
    "type": "page",
    "status": "current",
    "title": "w2019-47 comments status",
    "children": {
        "comment": {
            "results": [],
            "start": 0,
            "limit": 25,
            "size": 25,
            "_links": {}
        },
        "_links": {},
        "_expandable": {
            "attachment": "/rest/api/content/97517865/child/attachment",
            "page": "/rest/api/content/97517865/child/page"
        }
    },
    "body": {
        "_expandable": {
            "editor": "",
            "view": "",
            "export_view": "",
            "styled_view": "",
            "storage": "",
            "anonymous_export_view": ""
        }
    },
    "extensions": {
        "position": "none"
    },
    "_links": {},
    "_expandable": {
        "metadata": "",
        "operations": "",
        "restrictions": "/rest/api/content/97517865/restriction/byOperation",
        "history": "/rest/api/content/97517865/history",
        "ancestors": "",
        "version": "",
        "descendants": "/rest/api/content/97517865/descendant",
    }
}	

А еще есть ограничение по количеству выдаваемых результатов, пагинация. Оно конфигурируется на стороне сервера (API?) и в нашем случае равно 25. Для некоторых запросов его можно изменить, указав явно, но оно будет действовать только для верхнего уровня. А раскрыв комментарии к странице, мы получим все равно только 25, при этом еще и size подвирает. В примере их было 29 в реальности. Пагинацию комментариев удалось обойти с помощью отдельной функции в модуле Confluence — get_page_comments с возможностью указания размера страницы.

#подключаем модуль регулярных выражений
import re

#и меняем спецсимволы на последовательности для XML
def replace_chars2(in_text):
    text = re.sub(r'&', '&', in_text)
    text = re.sub(r'\'', ''', text)
    text = re.sub(r'<', '<', text)
    text = re.sub(r'>', '>', text)
    text = re.sub(r'"', '"', text)
    return text

Следующий подводный камень ждал в особенностях сохранения и выдачи спецсимволов. Тело страницы или комментария можно получить в нескольких представлениях: внутреннем XML — storage, промежуточном HTML без вывода макросов — view и HTML с выводом макросов — export_view. А вот название страницы title и исходный комментируемый текст originalSelection выдаются всегда в виде, пригодном для чтения. Т.к. в дальнейшем эти данные попадают в тело страницы со статистикой, то некоторые символы приводили к ошибкам преобразования. Пришлось написать процедуру замены выше.

Комментарии к странице


Теперь к разбору страницы. Это одна процедура, которая загружает страницу, собирает ее данные, затем вытягивает перечень комментариев с ответами и собирает их в переписку. В результате получается список словарей, где каждому элементу списка соответствует один комментарий с ответами. И все атрибуты этого комментария лежат в соответствующих полях словаря.

Обработка одной страницы
def page_comments_data(page_identifier):
	#все, что нам нужно развернуть из комментария, его тело, 
	#свойства с исходным текстом, 
	#состояние и расположение, версию с данными автора, и то же 
	#самое для потомков (ответов).
    expand_text = ('body.storage,extensions.inlineProperties'
                   ',extensions.resolution,version,children.comment'
                   ',children.comment.version,children.comment.body.storage'
                   )
	#получаем страницу и ее название. Дополнительный разбор тела пока опустим.
    conf_page = confluence.get_page_by_id(page_identifier, 
						expand='body.storage')
    page_title = replace_chars2(conf_page['title'])
	#собираем ссылку на страницу.
    link_base = conf_page['_links']['base']
    page_link = link_base + conf_page['_links']['webui']
    page_code = '<a href="{}">{}</a>'.format(page_link, page_title)
	#получаем перечень комментариев
    page_comments = confluence.get_page_comments(content_id=page_identifier, 
						start=0, limit=1000,
    comments = []
	#и начинаем их разбирать
    for comment in page_comments['results']:
		#если это обычный комментарий внизу страницы - пропускаем.
        if comment['extensions']['location'] == 'footer':
            continue
		#собираем данные
        comment_text = comment['body']['storage']['value']
        comment_result = comment['extensions']['resolution']['status']
        comment_link = '<a href="{}">{}</a>'\
			.format(link_base + comment['_links']['webui'], 'link')
		#правим спецсимвол
        comment_link = re.sub(r'&focusedCommentId=', '&focusedCommentId=', 
							comment_link)
		#правим время
        created_when = re.sub(r'\.000\+', ' GMT+', 
						re.sub(r'T', ' ', comment['version']['when']))
        created_by = comment['version']['by']['displayName']
        orig_text = replace_chars2(comment['extensions']
						['inlineProperties']['originalSelection'])
		#создаем заготовку для последовательности вопросов/ответов.
        thread = '<b>To text: </b>{}<br/><b>At: </b>{}<br/><b>By: '
				 '</b>{}<br/>{}'.format(orig_text, created_when, created_by, 
										comment_text)
        last_by = ''
        last_when = ''
		#перебираем ответы
        answers = comment['children']['comment']['size']
        if answers > 0:
            for message in comment['children']['comment']['results']:
				#собираем данные
                last_when = re.sub(r'\.000\+', ' GMT+', 
								re.sub(r'T', ' ', message['version']['when']))
                last_by = message['version']['by']['displayName']
				#дополняем последовательность
                thread += ('<br/>===next===<br/><b>At: </b>{}<br/><b>By: '
						   '</b>{}<br/>{}'.format(last_when, last_by, 
										message['body']['storage']['value'])
                           )
		#дополняем данные комментариями.
        row_comm = {"Page": page_code, 
                    "Comment": comment_link, "Thread": thread, 
					"Result": comment_result, "Answers count": answers,
                    "Creation Date": created_when, "Author": created_by, 
					"Last Date": last_when, "Last Author": last_by}
        comments.append(row_comm)
	#заготовка ответа на случай, если комментариев нет, 
	#чтобы строка появилась в общей статистике.	
    if len(comments) == 0:
        row_comm = {"Page": page_code, 
                    "Comment": 'nolink', "Thread": 'nocomment', 
					"Result": 'nocomment', "Answers count": 0,
                    "Creation Date": 'never', "Author": 'nobody', 
					"Last Date": 'never', "Last Author": 'nobody'}
        comments.append(row_comm)
    return comments

Статистика и таблицы


Для наглядности представления данных соберем статистику по ним и оформим в виде таблиц.

Обрабатываем статистику
def page_statistics(comments_data):
    open_count = 0
    dang_count = 0
    comment_count = len(comments_data)
    if comment_count > 0:
        for comment in comments_data:
			#посчитаем открыты и повисшие комментарии
            if comment['Result'] not in ['resolved', 'nocomment']:
                if comment['Result'] in ['open', 'reopened']:
                    open_count += 1
                if comment['Result'] == 'dangling' 
						and comment['Author'] not in excludeNames:
                    dang_count += 1
    res_dict = {'Page': comments_data[0]['Page'], 
				'Total': comment_count, 
				'Resolved': comment_count - open_count - dang_count,
                'Dangling': dang_count, 'Open': open_count}
    return res_dict

#дополним итоговой строкой
def total_statistics(stat_data):
    total_comment = 0
    total_resolved = 0
    total_open = 0
    total_dangling = 0
    for statRow in stat_data:
        total_comment += statRow['Total']
        total_resolved += statRow['Resolved']
        total_open += statRow['Open']
        total_dangling += statRow['Dangling']
    res_dict = {'Page': 'All Pages Total', 'Type': '', 'Jira': '', 
				'Status': '', 'Total': total_comment,
                'Resolved': total_resolved, 
				'Dangling': total_dangling, 'Open': total_open}
    return res_dict

Для оформления данных в виде таблиц сделаем еще одну процедуру. Она формирует HTML код таблицы из списка словарей, в качестве заголовков добавляет строку из названий ключей словаря и добавляет колонку с номерами строк.

def create_table(tab_data):
    tab_start = '<table style="width: 100.00%;"><colgroup><col />'
				'<col /></colgroup><tbody>'
    tab_end = '</tbody></table>'
    tab_code = tab_start + '<tr>'
    row_num = 1
    if len(tab_data) > 0:
        tab_code += '<th>Num</th>'
        for key in tab_data[0].keys():
            tab_code += '<th>{}</th>'.format(key)
        tab_code += '</tr>'
        for row in tab_data:
            tab_code += '<tr><td>{}</td>'.format(row_num)
            row_num += 1
            for field in row.values():
                tab_code += '<td>{}</td>'.format(field)
            tab_code += '</tr>'
    tab_code += tab_end + '\n'
    return tab_code

Теперь все готово. После простановки меток и запуска скрипта получим страницу примерно следующего вида:



P.S.

Конечно, это не все, что получилось в итоге. Появился парсинг страниц и поиск макроса с номером задачи в Jira. Появилась автоматическая простановка меток по номерам задач в Jira и ссылкам из них на Confluence. Появилось сравнение и проверка недельных списков поставки. Появилось сохранение комментариев в Excel и сборка общих данных из нескольких недельных Excel файлов. А недавно добавился парсинг комментариев из Word.