Pull to refresh

Простая Kanban-доска для Jira

Reading time 14 min
Views 35K
Здесь я расскажу, как сделать канбан-доску для проекта в Jira, пользуясь только QML и JavaScript. С небольшими доработками вместо Jira вы можете использовать любой другой трекер, имеющий REST API.



Предыстория


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

Если у вашей организации уже диагностировали kanban в хронической стадии и вы тяготеете ко всему натуральному и осязаемому, то, скорее всего, ваша доска выглядит вроде этой, и у нее тоже есть разделение по этапам процесса:

Взято отсюда.

В моем случае такой вариант не прокатил бы по нескольким причинам.

Во-первых, вся команда, за исключением пары человек, находилась в другом городе, а устраивать видеомитинги мне не казалось рациональным.

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

В-третьих, и самое главное, глядя на эту доску, можно видеть общее состояние дел, находить узкие места на участках конвейера по производству ПО, но в ней совершенно не видно людей и их загрузки.

Поэтому мне нужна была доска:

а) электронная
б) связанная с трекером, т.е. отражающую текущую ситуацию
в) и чтобы столбец на доске соответствовал конкретному человеку


Короче говоря, эту задачу я на тот момент решил, сделал представление на web-страничке. Но о ней ничего вам не расскажу — и трекер тот (PVCS Tracker) не слишком распространен, API у него на dll, да и код странички сейчас не найти.

А сейчас я решил повторить упражнение, взяв в качестве инструментария QML. Выбор объясняется просто — мне он чуть более знаком, чем веб-технологии, и я знаю, как встроить получившийся модуль в свой инструмент, написанный на Python и PyQt.

Альтернативы для умных и богатых


Да, я знаю, что для Jira существует энное количество плагинов, в которых есть Kanban-доска — поиск в marketplace по слову «kanban» находит 33 варианта.

Но использовать плагин означает, что нужно выбить у руководства его покупку по цене соответствующей числу всех пользователей на Jira, договориться с админами, что они поставят его на сервере и будут поддерживать, и… невозможность его кастомизации под мои нужды, т.к. плагин будет общим для всех. А мне хотелось иметь инструмент, который можно использовать независимо от того, установлено что-то на сервере или нет, и менять, ни на кого не оглядываясь.

Необходимые оговорки


Чтобы не утяжелять статью, здесь не будет сказано о том, как сделать:
— авторизацию в Jira
— операции над карточками в QML с передачей вызова в JIRA — редактирование, смена статусов и исполнителей путем drag&drop и т.п.
— работа с фильтрами Jira

Если что-то из этого вам действительно интересно — отпишите об этом в комментариях. Не буду обещать, что немедленно сделаю и распишу в деталях, но, как сказал nmivan, «поставлю в план».

Терминология еще не устоялась, так issue в одних компаниях называют запросом, в других задачей, еще бывают тикеты и заявки. Для сущности filter, которым в Jira отбирают issues, тоже есть куча названий — фильтр, запрос, выборка, список.

Я буду использовать терминологию, принятую в локализованной Jira: issue буду называть запросом, а filterсписком.

Начало работы с Jira REST API


Типичный адрес запроса в веб-интерфейсе Jira выглядит так:
https://jira.mycompany.ru/browse/PROJECT-1234

Берем протокол и имя хоста, то есть с начала адреса до browse, дописываем к нему rest/api/2/ — и у нас получается базовая часть адреса REST API
https://jira.mycompany.ru/rest/api/2/

Полное описание Jira REST API на сайте Atlassian. Там много всяких функций, которых с каждой версией становится все больше, но реально требуется знать лишь небольшое количество методов:

GET https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
Получить запрос PROJECT-1234 — вернется JSON с полями запроса. Учтите, что для названия полей будут использоваться внутренние имена, а не те, что вы видите в веб-интерфейсе. Так поле «Статус тестирования» может оказаться customfield_10234. Чтобы понять, какое поле какому соответствует, воспользуйтесь запросом /rest/api/2/field.

POST https://jira.mycompany.ru/rest/api/2/issue
Создать новый запрос. В теле вызова передается JSON с заполняемыми полями запроса. Те поля, что вы не передали, заполнятся значениями по умолчанию.

PUT https://jira.mycompany.ru/rest/api/2/issue/PROJECT-1234
Изменение (редактирование) полей в запросе. В теле вызова передается JSON, в котором есть два блока — «update» с инструкциями по изменению полей, и «fields» с новыми значениями полей.
Изменяемое поле должно быть только в одном из этих блоков.

Пример
{
    "update": {
        "summary":[
            {"set":"Bug in business logic"}
        ],
        "components":[{"set":""}],
        "timetracking":[
            {"edit":{"originalEstimate":"1w 1d","remainingEstimate":"4d"}}
        ],
        "labels":[
            {"add":"triaged"},
            {"remove":"blocker"}]
    },
    "fields":{
        "summary":"This is a shorthand for a set operation on the summary field",
        "customfield_10010":1,
        "customfield_10000":"This is a shorthand for a set operation on a text custom field"
    }
}


GET https://jira.mycompany.ru/rest/api/2/search?jql=... — получить список запросов, соответствующего условиям на языке JQL

Пример
{
    expand: "schema,names",
    startAt: 0,
    maxResults: 10,
    total: 738,
    issues: [{
            expand: "operations,versionedRepresentations,editmeta,changelog,renderedFields",
            id: "947068",
            self: "https://jira.atlassian.com/rest/api/2/issue/947068",
            key: "JRASERVER-66937",
            fields: {
                customfield_18232: null,
...


POST https://jira.mycompany.ru/rest/api/2/search — тоже самое для сложных условий, не умещающихся в строку URL

GET https://jira.mycompany.ru/rest/api/2/field — получить описания всех полей, которые могут использоваться в запросах.

Пока хватит на первое время.

Поскольку мы ничего менять и редактировать пока не собираемся, то работать будем анонимно, с запросами на сервере Jira в Atlassian, в проекте «JIRA Server (including JIRA Core)», то есть, фигурально выражаясь, в Самом Главном Проекте Jira. Тем более, что там тоже есть наши люди:
image


Первым делом рекомендую зайти в веб-интерфейс проекта и сделать поиск запросов по какому-либо условию, например:
project = JRASERVER and updated <= -1w ORDER BY updated DESC
Это нужно для того, чтобы убедиться, что вы запрос составили правильно — если это не так, то веб-интерфейс вам скажет.
Условие копируем и подставляем в параметр jql функции search, получится такой URL:
https://jira.atlassian.com/rest/api/2/search?jql=project = JRASERVER and updated <= -1w ORDER BY updated DESC
Открываем его в браузере и получаем JSON. JSON сохраняем браузером в файл с расширением .json, открываем его в Qt Creator — оказывается, что весь файл в одной длинной строке, а затем, следите за руками — форматируем его как QML

гифка
image

Сохраните его под другим именем. C полученным файлом удобнее работать, находить в нем нужные поля и смотреть, в какой структуре находится нужное значение. Оригинальный файл нам пригодится в качестве тестового источника, чтобы лишний раз не ходить на сервер Atlassian.

Имеет смысл также получить список всех полей через запрос rest/api/2/field, чтобы определять, под каким идентификатором числится нужное вам поле.

Создаем проект в Qt Creator


Для создания проекта в Qt Creator воспользуемся стандартным шаблоном «Qt Quick Control Application».
Получится проект, состоящий из main.cpp и main.qml в файле ресурсов qml.qrc.

main.qml
import QtQuick 2.3
import QtQuick.Controls 1.2

ApplicationWindow {
    id: applicationWindow1
    visible: true
    width: 649
    height: 480
    title: qsTr("Hello World")

    menuBar: MenuBar {
        Menu {
            title: qsTr("File")
            MenuItem {
                text: qsTr("&Open")
                onTriggered: console.log("Open action triggered");
            }
            MenuItem {
                text: qsTr("Exit")
                onTriggered: Qt.quit();
            }
        }
    }
}


Их мы трогать пока не будем, займемся более насущными проблемами.

Рисуем дизайн карточки с запросом


Создаем новый файл IssueCard.qml, визард по умолчанию закинет его в файл ресурсов.
Дизайн карточки, которой будет отображаться запрос, я сначала по быстрому накидал в режиме дизайнера Qt Creator, затем доработал QML вручную.


Кстати, дизайнер QML относительно неплох, особенно по сравнению с первой версией. Наглядно показывается и легко меняется binding положения элементов, автоматом подтягивает компоненты из других qml-файлов в проекте. Почти не падал — всего два раза валил QtCreator, когда я пытался задать градиент (ничего страшного не случилось — автосохранение работает), и еще не смог пережевать DelegateModel — наверное, среду стоило обновить. У дизайнера QML, как и у дизайнера Qt Widgets, есть функция предпросмотра:


В результате получился QML карточки с запросом, файл IssueCard.qml

Код
import QtQuick 2.0
import "methods.js" as JS

Rectangle {
    id: rectangle1
    color: "#f1dada"
    radius: 10
    gradient: Gradient {
        GradientStop {
            position: 0.00;
            color: "#f5f2d8";
        }
        GradientStop {
            position: 1.00;
            color: "#ffffff";
        }
    }
    border.color: "#abfdf4"
    width: 300
    height: 150

    Text {
        id: keyText
        text: "JIRASERVER-1001"
        property string url: ""
        anchors.top: parent.top
        anchors.topMargin: 8
        anchors.left: parent.left
        anchors.leftMargin: 8
        font.bold: true
        font.pixelSize: 14
        MouseArea {
            anchors.fill: parent
            cursorShape: Qt.PointingHandCursor
            onClicked: Qt.openUrlExternally(parent.url)
        }
    }

    Text {
        id: summaryText
        y: 51
        height: 42
        color: "#002f7b"
        text: "Create a Global permission for Auditing teams to have full read only access to the instance"
        anchors.right: parent.right
        anchors.rightMargin: 8
        anchors.left: parent.left
        anchors.leftMargin: 8
        wrapMode: Text.WordWrap
        font.pixelSize: 15
        textFormat: Text.PlainText
    }

    Image {
        id: priorityImage
        x: 276
        width: 16
        height: 16
        anchors.top: parent.top
        anchors.topMargin: 9
        anchors.right: parent.right
        anchors.rightMargin: 8
        source: "minor.svg"
    }

    Image {
        id: typeImage
        x: 276
        width: 16
        height: 16
        anchors.top: parent.top
        anchors.topMargin: 9
        anchors.right: priorityImage.left
        anchors.rightMargin: 4
        source: ""
    }

    Text {
        id: dateText
        x: 198
        y: 31
        color: "#949090"
        text: "13.03.2018 17:11"
        anchors.right: parent.right
        anchors.rightMargin: 8
        font.pixelSize: 12
    }

    Text {
        id: creatorText
        y: 31
        color: "#949090"
        text: "Chung Park Chan"
        anchors.left: parent.left
        anchors.leftMargin: 8
        font.pixelSize: 12
    }

    Text {
        id: assigneeText
        x: 218
        y: 128
        text: "Kiran Shekhar"
        anchors.bottom: parent.bottom
        anchors.bottomMargin: 8
        anchors.rightMargin: 8
        anchors.right: parent.right
        font.pixelSize: 12
    }

}

Для заполнения карточки по запросу добавим новое свойство (property) issue. Свойство даст нам возможность передавать в карточку запрос со всем его содержимым извне за одно присвоение.

    property var issue: null

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

    onIssueChanged: {
        var self = JS.getValue(issue,"self")
        var re = new RegExp("(https*:\/\/[^\/]+\/).+")
        var key = JS.getValue(issue,"key")
        var url = self.replace(re,'$1')+'browse/'+key
        keyText.text = key
        keyText.url = url
        summaryText.text = JS.getValue(issue,"fields/summary")
        dateText.text = (new Date(JS.getValue(issue,"fields/created"))).toLocaleString()
        creatorText.text = JS.getValue(issue,"fields/creator/displayName")
        var v = JS.getValue(issue,"fields/assignee/displayName")
        assigneeText.text = v === null ? "(no assigned)" : v
        var img = JS.getValue(issue,"fields/priority/iconUrl")
        var txt = JS.getValue(issue,"fields/priority/name")
        priorityImage.source = typeof img == 'undefined' || img === null ? "" : img
        img = JS.getValue(issue,"fields/issuetype/iconUrl")
        typeImage.source = typeof img == 'undefined' || img === null ? "" : img
    }

Как видите, здесь я часто использую функцию JS.getValue, я ее написал для упрощения выборки значения из сложной структуры JSON (если оно там есть), хотя сама функция довольно проста:

function getValue(json, path)
{
    var arr = path.split('/');
    for(var i=0; i<arr.length && json; i++) {
        json = json[arr[i]];
    }
    return json;
}

Функция лежит в файле methods.js, подключенном в начале IssueCard.qml

Описываем колонку карточек


Теперь нужно карточки организовать в прокручиваемую по вертикали колонку. Прокрутка очень удобна, когда карточек много. Для прокрутки нужен ListView. Среди примеров, идущих в комплекте с Qt есть пример «QML Dynamic View Ordering Tutorial 3 — Moving Dragged Items», в нём dynamicview.qml — это практически то, что нам нужно, копируем его в проект под именем KanbanColumn.qml.

Только нужно сделать пару доработок
1) Добавить к колонке заголовок и сделать у объекта верхнего уровня свойство, чтобы присваивать название колонки извне.

Код
Rectangle {
    id: root
    // новое свойство
    property string title: ""

    ... // остальной код

    // Заголовок столбца
    Rectangle {
        id: titleRect
        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
            margins: 2
        }
        color: "#cfe5ff"
        height: titleText.height+10
        Text {
            id: titleText
            text: root.title
            font.bold: true
            horizontalAlignment: Text.AlignHCenter
            font.pointSize: 12
            anchors.centerIn: parent
        }
    }
}

2) Так как карточка запроса у нас теперь отдельный цельный объект, то заменяем вывод, сделанный в примере через Column и несколько Text, на наш IssueCard

Было
            Rectangle {
                id: content
                ...
                width: dragArea.width; height: column.implicitHeight + 4
                color: dragArea.held ? "lightsteelblue" : "white"
                Behavior on color { ColorAnimation { duration: 100 } }
                radius: 2
                ...
                Column {
                    id: column
                    anchors { fill: parent; margins: 2 }

                    Text { text: 'Name: ' + name }
                    Text { text: 'Type: ' + type }
                    Text { text: 'Age: ' + age }
                    Text { text: 'Size: ' + size }
                }
            }


Стало
            Item {
                id: content
                ...
                width: dragArea.width; height: card.height + 4
                ...
                IssueCard {
                    id: card
                    issue: issueRecord
                    anchors { fill: parent; margins: 2 }
                }

                // Закрашивание карточки при перетаскивании мышью
                Rectangle {
                    anchors.fill: parent
                    color: "lightsteelblue"
                    visible: dragArea.held // показывать только при перетаскивании
                    opacity: 0.5
                }
            }


С колонкой дизайнер нам не поможет, потому что он не переваривает DelegateModel. С другой стороны, нам не особо он и нужен, всё можно сделать вручную.

Окно для доски


Теперь нужно собрать колонку в общее окно. Создаем файл KanbanWindow.qml, в нем дизайнером размещаем нужные поля.
В простейшем виде получается так:


KanbanWindow.qml
import QtQuick 2.0
import QtQuick.Controls 1.2

Rectangle {
    id: rectangle1
    width: 640
    height: 480
    color: "#e0edf6"
    clip: true

    Item {
        id: row1

        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
            margins: 4
        }
        height: queryTE.height

        TextField {
            id: queryTE
            text: "file:///C:/Projects/qml/search.json"
            anchors.rightMargin: 4
            anchors.right: goButton.left
            anchors.left: parent.left
            anchors.leftMargin: 0
        }

        Button {
            id: goButton
            text: qsTr("Go")
            anchors.right: parent.right
            onClicked: JS.readIssues(queryTE.text)
        }
    }

    ListView {
        anchors{
            top: row1.bottom
            bottom: parent.bottom
            right: parent.right
            left: parent.left
            margins: 4
        }
        orientation: ListView.Horizontal
        clip: true
    }
}

В ListView надо указать, в свойстве delegate, что элементы модели будут показываться в виде колонок KanbanColumn, в каждую из которых надо передать список запросов, назовем его issueList. Также создадим пустую модель и тоже дадим ей имя model.

Rectangle {
    property var mainModel: []
    ...
    ListView {
        ...
        model: ListModel { id: model }
        delegate: KanbanColumn {
            anchors.top: parent.top
            anchors.bottom: parent.bottom
            // 'groupName' 
            title: groupName
            issues: issueList
        }
    }
}

Выше я еще создал свойство mainModel — оно нам послужит для временного хранения данных.

И не забыть вставить KanbanWindow в окно приложения:

ApplicationWindow {
    id: applicationWindow1
    visible: true
    width: 649
    height: 480
    title: qsTr("Hello World")
    ...

    KanbanWindow {
        anchors.fill: parent
    }
}

Пишем код для вызова REST API


Самое время сделать код, который будет получать список запросов из Jira и заполнять модели в QML.

В QML имеется, хоть и ограниченная, но поддержка XMLHttpRequest и JSON-парсер (на хабре есть подробная статья BlackRaven86). Поэтому у нас есть всё, чтобы написать обращение к серверу и разбор ответа.

function readIssuesSimple(queryUrl)
{
    var doc = new XMLHttpRequest();
    doc.onreadystatechange = function() {
        if (doc.readyState == XMLHttpRequest.DONE) {
            var data = JSON.parse(doc.responseText);
            mainModel = data["issues"]
            model.clear()
            var list = mainModel
            // группируем запросы по исполнителям
            var gPath = "fields/assignee/displayName"
            var models = {}
            for(var i in list) {
                var item = list[i]
                var g = getValue(item, gPath)
                if(!(g in models))
                    models[g] = []
                models[g].push({ issueRecord: item } )
            }
            // собрали списки запросов, передаем их в модель QML
            // модель будет содержать столько записей, сколько найдено групп
            for(g in models) {
                var iss = models[g]
                if(g === null)
                    g = '(null)'
                // здесь 'model' - имя модели в QML
                model.append({
                                 groupName: g,
                                 issueList: iss
                             });
            }
        }
    }
    doc.open("GET", queryUrl);
    doc.send();
}

Функция запрашивает с сервера (или из локального файла) список запросов, парсит json из ответа, группирует запросы по исполнителям и заполняет модель в QML.

Подключаем функцию к кнопке

        Button {
            id: goButton
            text: qsTr("Go")
            anchors.right: parent.right
            onClicked: JS.readIssuesSimple(queryTE.text)
        }

И проверяем работу:



гифка


В принципе, доска готова. Далее можно заниматься ее улучшениями и развитием.

Чуть не забыл — когда вы попробуете указать URL к настоящему серверу Jira, например, такой:
https://jira.atlassian.com/rest/api/2/search?maxResults=50&jql=project = JRASERVER and updated <= -1w and assignee is not empty ORDER BY updated ASC
и вы под Windows, то у вас, скорее всего, ничего не получится. Проблема в SSL — Qt Creator, запуская программу под отладчиком, не прописывает в окружении путь к библиотекам OpenSSL. Скопируйте libeay32.dll и ssleay32.dll к созданному экзешнику и наслаждайтесь.

LocalStorage для сохранения и восстановления параметров


Чтобы не вводить каждый раз URL к серверу Jira, стоит сохранять введенную строку и восстанавливать ее при запуске. И да, QML умеет в LocalStorage.

Напишем функции чтения и сохранения параметров.

function loadSettings()
{
    var dbConn = LocalStorage.openDatabaseSync("JKanban", "1.0", "", 1000000);

    dbConn.transaction(
                function(tx) {
                    // Create the database if it doesn't already exist
                    tx.executeSql('CREATE TABLE IF NOT EXISTS Settings(skey TEXT, svalue TEXT)');
                    var rs = tx.executeSql('select skey, svalue from Settings')

                    var r = ""
                    var c = rs.rows.length
                    for(var i = 0; i < rs.rows.length; i++) {
                        var skey = rs.rows.item(i).skey
                        var svalue = rs.rows.item(i).svalue
                        if(skey === 'query')
                            queryTE.text = svalue
                    }
                }
                )

}

function saveSetting(skey, svalue)
{
    var dbConn = LocalStorage.openDatabaseSync("JKanban", "1.0", "", 1000000);
    dbConn.transaction(
                function(tx)
                {
                    tx.executeSql('delete from Settings where skey = ?', [ skey ]);
                    tx.executeSql('INSERT INTO Settings VALUES(?, ?)', [ skey, svalue ]);
                }
                )
}

Добавим вызов сохранения параметров…

function readIssuesSimple(queryUrl)
{
    saveSetting('query',queryUrl)

… и их восстановление при создании KanbanWindow

Rectangle {
    id: rectangle1
    width: 640
    height: 480
    color: "#e0edf6"
    clip: true

    Component.onCompleted: JS.loadSettings()
    ....


Добавляем варианты группировки


Сделав группировку по исполнителям, логично сделать возможность выбора и других вариантов группировки — по статусу, по приоритету и так далее. Так появилась панелька параметров группировки KanbanParams.qml.



KanbanParams.qml
import QtQuick 2.0
import QtQuick.Controls 1.2
import QtQuick.LocalStorage 2.0
import "methods.js" as JS

Item {
    width: 480
    height: cbGroupField.height
    property alias groupVariant: cbGroupField.currentIndex
    property string groupValuePath: cbGroupField.model.get(cbGroupField.currentIndex).namePath
    property alias groupList: groupsTE.text

    Text {
        id: label
        height: cbGroupField.height
        text: qsTr("Группировать:")
        verticalAlignment: Text.AlignVCenter
    }

    ComboBox {
        id: cbGroupField
        anchors { left: label.right; leftMargin: 4 }
        model: ListModel {
            ListElement {
                text: qsTr("по статусам")
                namePath: "fields/status/name"
            }
            ListElement {
                text: qsTr("по исполнителям")
                namePath: "fields/assignee/displayName"
            }
            ListElement {
                text: qsTr("по создателям")
                namePath: "fields/creator/displayName"
            }
            ListElement {
                text: qsTr("по типам запросов")
                namePath: "fields/issuetype/name"
            }
            ListElement {
                text: qsTr("по приоритетам")
                namePath: "fields/priority/name"
            }
        }
    }
    TextField {
        id: groupsTE
        text: ''
        anchors {
            right: buttonGroups.left
            rightMargin: 4
            left: cbGroupField.right
            leftMargin: 4
        }
    }

    Button {
        id: buttonGroups
        text: qsTr("Перерисовать")
        anchors.right: parent.right
        onClicked: JS.repaintKanban()
    }
}


Как видите, здесь ComboBox содержит модель с возможными вариантами группировки, и в каждом элементе прописан путь в JSON к значению, которое будет использоваться для определения группы. Таким образом количество вариантов группировок по желанию можно расширить.

На верхнем уровне определены свойства, два из которых — алиасы к внутренним значениям. Алиасы нужны, чтобы можно было присвоить нужное значение, начитанное из LocalStorage. Что же касается свойства groupValuePath:

    property string groupValuePath: cbGroupField.model.get(cbGroupField.currentIndex).namePath

то оно просто возвращает путь к значению для текущего варианта группировки.

Вставляем KanbanParams в KanbanWindow и у нас получается такое окошко:


Я не буду подробно расписывать, как обрабатываются параметры, потому что мне надоело писать эту статью, смотрите в коде.

Что дальше?


Получившейся доской уже можно пользоваться для просмотра текущей ситуации с запросами, но можно ее улучшить:

  1. Сделать сортировку карточек в столбцах. Например, по приоритету запроса. И цветовую дифференциацию штанов, пардон, запросов по приоритетам и типам запросов. Я пробовал — очень удобно, рекомендую.
  2. Сделать перетаскивание карточек между столбцами с присвоением значения, соответствующего новому столбцу. Кстати, статус таким образом не изменить, поскольку в Jira статус меняется не присвоением, а переходом (transition).
  3. Сделать ввод новых запросов прямо в доске.
  4. Для предыдущих двух пунктов потребуется авторизация. Надо делать.
  5. Поскольку здесь нет ничего, кроме QML, проект можно собрать под Android и iOS — должно работать без переделок.

Код выложен на GitHub.
Tags:
Hubs:
+9
Comments 10
Comments Comments 10

Articles