Як використати Notion API для спрощення флоу заведення багів

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Вітаю, мандрівнику! Мене звати Микола Журба, я — Senior iOS Engineer у венчур білдері Spalah ✨. Окрім розробки мобільних застосунків, у мої обов’язки входить покращення процесів, налагодження взаємодії всередині продуктової команди та впровадження нових технологічних рішень.

Впевнений, що для більшості людей один із найменш приємних аспектів роботи — це виконання «мавпячої роботи». Це такий тип завдань, коли потрібно виконувати певну послідовність операцій, які не потребують значних розумових зусиль. На мою думку, часте виконання таких завдань може демотивувати працівника, а також призводити до людських помилок, які ускладнюють роботу на наступних етапах.

З мого досвіду, більшу частину такої роботи можна автоматизувати повністю або хоча б частково. У цій статті я наведу один із прикладів такої автоматизації. Зокрема, розглянемо створення функціоналу для заведення багів у Notion-дошці з автоматичним заповненням полів, прикріпленням логів та іншої корисної інформації.

Ця стаття буде найбільш корисною для iOS-спеціалістів, які мають змогу пропонувати та впроваджувати інновації у своїх командах, а також QA-інженерам, які прагнуть до підвищення швидкості delivery.

Вимоги до функціоналу

Плануючи розробку тих чи інших рішень, я зазвичай керуюся методологією OKR. У цьому випадку моєю метою (Objective) було створити рішення, яке спрощує роботу тестувальників, не погіршуючи результат цієї роботи (якість заповнення тікету з багом).

Ключові результати я визначив наступні:

1. Розроблений користувацький інтерфейс, який дозволяє тестувальнику вписати або обрати необхідні для тікета дані (наприклад, назву, preconditions, steps to reproduce, expected result, actual result).

2. Дані, які корисні мені як розробнику, повинні заповнюватися автоматично, без участі тестувальника (наприклад, ідентифікатори користувача, версія iOS, модель пристрою, логи мережі й аналітики).

3. З технічної сторони функціонал має:

  • Бути простим для інтеграції в проєкт;
  • Підтримувати можливість динамічного прокидування даних;
  • Бути легким для повторного використання в інших проєктах.

Початкова точка

Щоб пройти повний цикл розробки цього функціоналу без відволікань на сторонні аспекти, я використовую демопроєкт, який розмістив у GitHub-репозиторії, а також створив і налаштував просту борду.

Репозиторій з проєктом має 3 гілки:

  1. starter — містить невеликий проєкт, який поки не стосується роботи з Notion, але в який пізніше буде інтегровано функціонал створення багів. Поточний функціонал:
    • На головному екрані при натисканні кнопки здійснюється мережевий запит для отримання цікавих фактів. Усі запити й відповіді логуються у мережевому рівні (клас NetworkManager).
    • Історія отриманих фактів доступна через окремий екран.
    • Користувацькі дії (натискання кнопок, перегляд екранів) також логуються.
    • Якщо потрясти пристрій (на симуляторі — комбінація Ctrl+CMD+Z), відкриється дебажний екран із логами та допоміжною інформацією.
  2. notion_feature — містить код для роботи з Notion, але цей код поки не інтегрований у проєкт, тобто викликати його неможливо.
  3. complete — має фінальний результат, де функціонал інтегрований у проект.

Merge requests:

Дослідження Notion API

Повну інформацію щодо роботи з Notion API можна знайти в офіційній документації. Ми ж зосередимося на ключових аспектах, необхідних для реалізації описаного функціоналу.

Ознайомившись із наявними ендпоінтами та їх обмеженнями, я визначив, що для нашої задачі знадобляться два основних:

На жаль, API не дозволяє прикріплювати файли (зображення, текстові документи тощо) до сторінок або коментарів. Тому логи будемо додавати до тікетів у вигляді текстових коментарів.

Також під час розробки нам стануть у пригоді ендпоінти для аналізу структури:

Ці ендпоінти допоможуть зрозуміти структуру елементів у борді. Вони не будуть інтегровані в проєкт, тому працювати з ними будемо через Postman.

Для роботи з Notion API потрібно:

  1. Створити інтеграцію у workspace.
  2. Надати їй необхідні дозволи.

  3. Додати інтеграцію до сторінки (у налаштуваннях сторінки в розділі Connections).

У налаштуваннях інтеграції знаходиться її ключ (Internal Integration Secret), який використовується для запитів.

Для демонстрації я вже створив інтеграцію, надав їй дозволи та додав до сторінки борди. Її ключ:
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.

Подальший алгоритм для роботи буде наступний:

  1. Створити тестовий тікет (баг), заповнивши його всіма потрібними даними.
  2. Читання борди: використати ендпоінт для отримання її структури (нас цікавить об’єкт properties, який містить інформацію про поля). Наприклад, для полів типу select є список можливих значень (типу Spike, Bug, Task).
  3. Читання сторінки: зрозуміти, які дані необхідно заповнювати під час створення сторінки (обов’язкові поля: parent, properties).
  4. Читання коментарів: зрозуміти структуру коментарів. У Notion кожен коментар — це масив об’єктів із текстом і його атрибутами (жирність, колір тощо). Такий самий формат потрібен для заповнення поля Description у сторінці.
  5. Реалізація функціоналу в застосунку, ґрунтуючись на отриманих даних.

Отож, let’s get started. Борда знаходиться за посиланням і має ідентифікатор 18041f07ef9f80e19ee3fb0e7ddbd28b, він знадобиться для запитів.

Тестовий тікет, який я створив і заповнив необхідними даними, має посилання та ідентифікатор 18141f07ef9f80058877ff7becc2aa2f.

Для зручності я створив усі ендпоінти на читання у колекції Postman-а і розмістив її за посиланням.

У запиті на читання бази даних (борди) нас цікавить обʼєкт properties, який містить інформацію про поля, необхідні для заповнення тікета. Для полів типу select також наявна інформація про можливі варіанти для даного поля. Наприклад, поле Task Type має опції Spike, Bug, Task. При подальшій імплементації я захардкоджу більшість цих полів, бо Task Type завжди буде Bug, Team — iOS, Status — Not started тощо. Але ніщо не заважає створити інтерфейс, де користувач може обрати варіант із захардкодженого списку допустимих значень, або взагалі підгружати їх динамічно. Я зроблю такий вибір тільки для поля Priority.

Запит на читання сторінки ілюструє те, з якими даними необхідно її створювати. Надалі при створенні сторінки нам необхідно буде заповнити її parent поле та properties поле. У properties нам потрібно буде заповнити всі дані, окрім ID — воно згенерується автоматично.

Запит на читання коментаря показує модель коментаря. Як можна побачити, кожен коментар — це масив обʼєктів, у кожному із яких є текст та його атрибути (жирність, кольор тощо). Такий самий формат, до речі, потрібен і для заповнення поля Description у сторінці.

Маючи потрібні ендпоінти для створення сторінки та коментарів, а також розуміючи необхідну для кожного з них модель даних, можна нарешті приступити до імплементації на стороні застосунку.

Імплементація функціоналу

Почнемо з реалізації UI для створення багів і можливості його показу.

Деталі імплементації UI я опускаю, але весь код доступний у репозиторії в папці BugReporting/Internal/UI. Надаю лише високорівневий опис.

Як публічний інтерфейс для виклику фічі я створив метод:

func createBug() { ... }

На цей момент метод не приймає жодних вхідних даних. Його завдання:

  1. Знайти останній модально показаний екран в ієрархії.
  2. Показати поверх нього екран для заповнення інформації про баг.

Сам екран реалізовано на SwiftUI: ScrollView з декількома TextField-ами та Picker-ом. Екран має власну ViewModel, яка відповідає за управління даними.

Для вибору пріоритету я створив допоміжну структуру Priority.

Крім цього, додав можливі варіанти як статичні проперті через extension.

import SwiftUI

struct Priority: Identifiable {

    let id: String
    let name: String
    let color: String
    let backgroundColor: Color
}

extension Priority: CaseIterable {

    static let low = Self(
        id: "dDuS",
        name: "Low",
        color: "blue",
        backgroundColor: .blue.opacity(0.5)
    )

...

    static var allCases: [Self] {
        [
            .low,
           ...
        ]
    }
}

Ідентифікатори й інші дані й взяв з ріспонсу на читання борди.

Маючи інтерфейс, можна приступити до реалізації мережевих запитів. Почнімо зі створення моделі для ендпоінту create page. Як згадувалося раніше, для створення сторінки необхідні задати її parent та properties.

struct CreatePageBody: Encodable {
    let parent: Parent
    let properties: Properties
}

Parent в такому випадку — просто обгортка над ідентифікатором бази даних:

extension CreatePageBody {

    struct Parent: Encodable {

        let databaseID: String

        enum CodingKeys: String, CodingKey {
            case databaseID = "database_id"
        }
    }
}

Properties — обʼєкт, що містить інформаційні поля майбутнього багу.

extension CreatePageBody {

    struct Properties: Encodable {

        let description: RichTextProperty?
        let priority: SelectProperty
        let taskType: SelectProperty
        let team: SelectProperty
        let project: SelectProperty
        let assign: PeopleProperty
        let status: StatusProperty
        let name: TitleProperty

        enum CodingKeys: String, CodingKey {
            case description = "Description"
            case priority = "Priority"
            case taskType = "Task Type"
            case team = "Team"
            case project = "Project"
            case assign = "Assign"
            case status = "Status"
            case name = "Name"
        }
    }
}

В залежності від поля, її обʼєкт матиме ту чи іншу структуру.

extension CreatePageBody.Properties {

    struct Select: Encodable {
        let id: String
        let name: String
        let color: String
    }

    struct SelectProperty: Encodable {
        let id: String
        let type = "select"
        let select: Select
    }

    struct StatusProperty: Encodable {
        let id: String
        let type = "status"
        let status: Select
    }

    struct PeopleProperty: Encodable {
        let id: String
        let type = "people"
        let people: [Person]
    }

    struct TitleProperty: Encodable {
        let id = "title"
        let type = "title"
        let title: [RichTextObject]
    }

    struct RichTextProperty: Encodable {
        let id: String
        let type = "rich_text"
        let richText: [RichTextObject]

        enum CodingKeys: String, CodingKey {
            case id
            case type
            case richText = "rich_text"
        }
    }
}

extension CreatePageBody.Properties.PeopleProperty {

    struct Person: Encodable {
        let object: String = "user"
        let id: String
    }
}

Масив RichTextObject-ів, як вже було згадано, використовується як для створення коментаря, так і для поля Description. Для зручного інтерфейсу я також створив resultBuilder для нього і деякі допоміжні статичні константи.

 struct RichTextObject: Encodable {

    let type: String = "text"
    let text: TextObject
    let annotations: AnnotationsObject
    let plantText: String
    let href: String?

    enum CodingKeys: String, CodingKey {
        case type
        case text
        case annotations
        case plantText = "plain_text"
        case href
    }
}

extension RichTextObject {

    init(text: String) {
        self.text = TextObject(content: text, link: nil)
        self.annotations = AnnotationsObject(
            bold: false,
            italic: false,
            strikethrough: false,
            underline: false,
            code: false
        )
        self.plantText = text
        self.href = nil
    }

    init(text: String, annotations: AnnotationsObject) {
        self.text = TextObject(content: text, link: nil)
        self.annotations = annotations
        self.plantText = text
        self.href = nil
    }
}

extension RichTextObject {

    struct TextObject: Encodable {
        let content: String
        let link: String?
    }

    struct AnnotationsObject: Encodable {
        var bold = false
        var italic = false
        var strikethrough = false
        var underline = false
        var code = false
        var color = "default"
    }
}

@resultBuilder enum RichTextObjectBuilder {

    static func buildPartialBlock(first: RichTextObject) -> [RichTextObject] {
        [first]
    }

    static func buildPartialBlock(first: [RichTextObject]) -> [RichTextObject] {
        first
    }

    static func buildPartialBlock(accumulated: [RichTextObject], next: RichTextObject) -> [RichTextObject] {
        accumulated + CollectionOfOne(next)
    }

    static func buildPartialBlock(accumulated: [RichTextObject], next: [RichTextObject]) -> [RichTextObject] {
        accumulated + next
    }

    static func buildOptional(_ component: [RichTextObject]?) -> [RichTextObject] {
        if let component {
            component
        } else {
            []
        }
    }
}

extension [RichTextObject] {
    static var divider: Self {
        [
            RichTextObject(text: "\n"),
            RichTextObject(
                text: "———————————————————————————————————————————————",
                annotations: RichTextObject.AnnotationsObject(code: true, color: "gray")
            ),
            RichTextObject(text: "\n"),
            RichTextObject(text: "\n"),
        ]
    }
}

Наостанок додамо зручний інтерфейс для створення CreatePageBody:

extension CreatePageBody {

    init(
        name: String,
        description: [RichTextObject],
        priority: Priority
    ) {
        self.parent = Parent(databaseID: "18041f07ef9f80e19ee3fb0e7ddbd28b")
        self.properties = Properties(
            description: description.nilIfEmpty
                .map { Properties.RichTextProperty(id: "%3CD%3D%3C", richText: $0) },
            priority: Properties.SelectProperty(
                id: "HQgX",
                select: Properties.Select(
                    id: priority.id,
                    name: priority.name,
                    color: priority.color
                )
            ),
            taskType: Properties.SelectProperty(
                id: "%5C%3Crq",
                select: Properties.Select(
                    id: "cRVq",
                    name: "Bug",
                    color: "red"
                )
            ),
            team: Properties.SelectProperty(
                id: "ayWU",
                select: Properties.Select(
                    id: "Or<g",
                    name: "iOS",
                    color: "purple"
                )
            ),
            project: Properties.SelectProperty(
                id: "vq%3Bs",
                select: Properties.Select(
                    id: "e:R]",
                    name: "HabitTracker",
                    color: "green"
                )
            ),
            assign: Properties.PeopleProperty(
                id: "x~pV",
                people: [
                    Properties.PeopleProperty.Person(
                        id: "f75d611b-434a-413a-8452-fa8c9908ccc6"
                    )
                ]
            ),
            status: Properties.StatusProperty(
                id: "%7BrEm",
                status: Properties.Select(
                    id: "12d15f23-37f7-4ece-8289-8fcab10a6a80",
                    name: "Not started",
                    color: "default"
                )
            ),
            name: Properties.TitleProperty(
                title: [
                    RichTextObject(
                        text: name
                    )
                ]
            )
        )
    }
}

private extension Collection {

    var nilIfEmpty: Self? {
        isEmpty ? nil : self
    }
}

Як я і згадував, майже всі поля й ідентифікатори тут захардкоджені та взяті з ріспонсів.

Сама реалізація запиту виглядає наступним чином:

private struct CreatePageRequest: Request {

    var host: String { "https://api.notion.com/v1/" }

    var path: String { "pages" }

    var method: HTTPMethod { .post }

    var headers: [String : String]? {
        [
            "Authorization": "Bearer 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",
            "Content-Type": "application/json",
            "Notion-Version": "2022-06-28"
        ]
    }

    let body: (any Encodable)?

    init(
        name: String,
        priority: Priority,
        description: [RichTextObject]
    ) {
        self.body = CreatePageBody(
            name: name,
            description: description,
            priority: priority
        )
    }
}

struct CreatePageResponse: Decodable {
    let id: String
    let url: String
}

extension NetworkManager {

    func createPage(
        name: String,
        priority: Priority,
        @RichTextObjectBuilder description: () -> [RichTextObject]
    ) async throws -> CreatePageResponse {
        try await request(
            CreatePageRequest(
                name: name,
                priority: priority,
                description: description()
            )
        )
    }
}

Нарешті, можна використати цей метод у ViewModel і створити перший тікет:

func createPage() {
    isLoading = true
    Task {
        if issueTitle.isEmpty {
            await changeAlert(to: .error("Title can not be empty"))
            return
        }
        do {
            let page = try await tryCreatePage()
            await changeAlert(to: .success(page.url))
        } catch {
            await changeAlert(to: .error("Something went wrong"))
        }
    }
}

private func tryCreatePage() async throws -> CreatePageResponse {
    try await networkManager.createPage(
        name: issueTitle,
        priority: priority
    ) {
        if !preconditions.isEmpty {
            RichTextObject(text: "🤔 Preconditions:\n", annotations: .init(bold: true))
            preconditions.split(separator: "\n")
                .map {
                    String($0)
                }
                .enumerated()
                .map { idx, string in
                    RichTextObject(text: "\(idx + 1). \(string)\n")
                }

            [RichTextObject].divider
        }

        if !str.isEmpty {
            RichTextObject(text: "🕵️‍♂️ Steps to reproduce:\n", annotations: .init(bold: true))
            str.split(separator: "\n")
                .map {
                    String($0)
                }
                .enumerated()
                .map { idx, string in
                    RichTextObject(text: "\(idx + 1). \(string)\n")
                }

            [RichTextObject].divider
        }

        if !expectedResult.isEmpty {
            RichTextObject(text: "😍 Expected result:\n", annotations: .init(bold: true))
            RichTextObject(text: expectedResult + "\n")
            [RichTextObject].divider
        }

        if !actualResult.isEmpty {
            RichTextObject(text: "😢 Actual result:\n", annotations: .init(bold: true))
            RichTextObject(text: actualResult + "\n")
            [RichTextObject].divider
        }
    }
}

@MainActor
private func changeAlert(to alert: AlertType) async {
    isLoading = false
    self.alert = alert
}

Перейдемо до реалізації ендпоінта для додавання коментарів. Він дещо схожий на попередній, але має сильно спрощену структуру:

struct WriteCommentBody: Encodable {

    let parent: Parent
    let richText: [RichTextObject]

    init(pageID: String, richText: [RichTextObject]) {
        self.parent = Parent(pageID: pageID)
        self.richText = richText
    }

    enum CodingKeys: String, CodingKey {
        case parent
        case richText = "rich_text"
    }
}

extension WriteCommentBody {

    struct Parent: Encodable {
        let pageID: String

        enum CodingKeys: String, CodingKey {
            case pageID = "page_id"
        }
    }
}

.

struct WriteCommentRequest: Request {

    var host: String { "https://api.notion.com/v1/" }

    var path: String { "comments" }

    var method: HTTPMethod { .post }

    var headers: [String : String]? {
        [
            "Authorization": "Bearer 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",
            "Content-Type": "application/json",
            "Notion-Version": "2022-06-28"
        ]
    }

    let body: (any Encodable)?

    init(pageID: String, richText: [RichTextObject]) {
        self.body = WriteCommentBody(pageID: pageID, richText: richText)
    }
}

struct WriteCommentResponse: Decodable {
    let id: String
}

extension NetworkManager {

    func writeComment(pageID: String, richText: [RichTextObject]) async throws -> WriteCommentResponse {
        try await request(WriteCommentRequest(pageID: pageID, richText: richText))
    }
}

Для тестування додамо метод у ViewModel, що буде додавати коментар у створений тікет:

@discardableResult
private func writeComment(pageID: String) async throws -> WriteCommentResponse {
    try await networkManager.writeComment(
        pageID: pageID,
        richText: [RichTextObject(
            text: "Hello World!"
        )]
    )
}

І викличемо його після створення сторінки:

...
do {
    let page = try await tryCreatePage()
    try await writeComment(pageID: page.id)
    ...
}
...

Мережеві запити відпрацьовують коректно. Єдине, що залишається — це прокинути дані із проєкту в фічу баг ріпортнігу, аби вони автоматично додавалися до тікета.

Я планую додавати цю інформацію або як частину поля Description, або у вигляді коментаря. Обидва цих флоу потребують однакової структури: масиву [RichTextObject]. Тому я створю протокол, описуючи потрібний мені інтерфейс:

protocol BugInfo {
    var content: [RichTextObject] { get }
}

Одна із конкретних імплементацій цього інтерфейсу — коментар. Коментар мусить приймати заголовок та сам контент.

struct Comment: BugInfo {

    var content: [RichTextObject] {
        _content()
    }

    private let _content: () -> [RichTextObject]

    init(
        _ title: String,
        logs: @autoclosure @escaping () -> String
    ) {
        _content = {
            [
                RichTextObject(text: title + "\n", annotations: .init(bold: true))
            ] + logs().chunks(maxLength: 2000).map { string in
                RichTextObject(
                    text: string,
                    annotations: .init(code: true)
                )
            }
        }
    }
}

private extension String {

    func chunks(maxLength: Int) -> [String] {
        var result: [String] = []
        var currentIndex = startIndex

        while currentIndex < endIndex {
            let endIndex = index(currentIndex, offsetBy: maxLength, limitedBy: endIndex) ?? endIndex
            let substring = String(self[currentIndex..<endIndex])
            result.append(substring)
            currentIndex = endIndex
        }

        return result
    }
}

Через обмеження Notion API, яке полягає у тому, що обʼєкт RichTextObject не може містити текст довжиною більше ніж 2000 символів, нам необхідно розділити його на шматки.

Інша конкретна імплементація — це секція у полі Description. Виходячи з моїх потреб, секція мусить мати заголовок, а також колекція рядків. Кожен рядок, своєю чергою, теж має заголовок і інформаційний контент. Для зручності використання інтерфейсу, ми також імплементуємо resultBuilder.

struct BugSection: BugInfo {

    private let title: String
    private let rows: [Row]

    init(
        _ title: String,
        @RowResultBuilder rows: () -> [Row]
    ) {
        self.title = title
        self.rows = rows()
    }

    var content: [RichTextObject] {
        CollectionOfOne(RichTextObject(text: title + "\n", annotations: .init(bold: true))) +
        rows.flatMap { row in
            [
                RichTextObject(text: row.title + ": ", annotations: .init(bold: true)),
                RichTextObject(text: row.description + "\n"),
            ]
        }
    }
}

struct Row {
    let title: String
    let description: String
}

@resultBuilder
enum RowResultBuilder {

    static func buildBlock(_ components: Row...) -> [Row] {
        components
    }
}

Тепер початкову функцію createBug можна модифікувати так, щоб вона приймала масив [BugInfo]. І знову для її комфортного використання, ми створимо resultBuilder:

@resultBuilder
enum BugInfoResultBuilder {

    static func buildBlock(_ components: BugInfo...) -> [BugInfo] {
        components
    }
}

func createBug(
    @BugInfoResultBuilder infos: () -> [BugInfo]
) {
 ...
}

Маючи інформацію від застосунку, можна прокинути її у ViewModel і використати для заповнення багу.

private let infos: [BugInfo]

init(infos: [BugInfo]) {
    self.infos = infos
}

private var additionalDescriptionInfo: [RichTextObject] {
    infos
        .compactMap { info in
            info as? BugSection
        }.map { section in
            section.content + [RichTextObject].divider
        }
        .flatMap { $0 }
}

private var comments: [[RichTextObject]] {
    infos.compactMap { $0 as? Comment }.map(\.content)
}

additionalDescriptionInfo буде використано при створенні сторінки, а comments — для написання коментарів:

try await networkManager.createPage(
    name: issueTitle,
    priority: priority
) {
    additionalDescriptionInfo
    ...
}
...

let page = try await tryCreatePage()

for comment in comments {
    try await writeComment(pageID: page.id, richText: comment)
}

Тепер можна викликати фічу з необхідними даними і перевірити працездатність функціоналу:

createBug {
    BugSection("General Info") {
        Row(title: "Device", description: UIDevice.current.modelName)
        Row(title: "iOS", description: UIDevice.current.systemVersion)
        Row(title: "App version", description: Bundle.main.appVersion)
        Row(title: "Build number", description: Bundle.main.buildVersion)
    }

    Comment(
        "Analytics Logs",
        logs: SimpleLogger.app.logs
            .map(\.text)
            .joined(separator: "\n***********\n")
    )

    Comment(
        "Network Logs",
        logs: NetworkLogger.app.logs
            .map(\.debugDescription)
            .joined(separator: "\n***********\n")
    )
}

Чудово, все працює! ✨

Висновки

У своїй команді я нещодавно інтегрував цей функціонал, і перший відгук від колег був позитивний. Надалі ми разом із QA-відділом плануємо вдосконалювати рішення відповідно до наших потреб.

Хочу зазначити, що для демонстрації я реалізовував функціонал баг-ріпортінгу всередині проєкту, але він не має на нього залежностей, що дозволяє легко винести цей функціонал у SPM-пакет для перевикористовування. Винятком є лише код для мережевих запитів, який можна просто продублювати.

У моїй імплементації багато значень було захардкоджено — це було виправдано тим, що мої вимоги не потребували динамічного завантаження даних, тому це б додавало зайвої складності на теперішньому етапі.

Подібний функціонал можна реалізувати й для роботи з Jira. Її API навіть має певні переваги:

  • Підтримка передачі файлів через multipart/form-data.
  • Можливість додавати випадаючі блоки в описі багу для покращення читабельності.

Проте Jira API використовує ADF (Atlassian Document Format) для роботи з текстовими описами, який досить складний у використанні.

Цей функціонал можна використовувати не лише як внутрішнє рішення для оптимізації робочих процесів, але й як інструмент для збору зворотного зв’язку від користувачів. Наприклад, подібний підхід використовують GoogleMaps, Instagram і Jira для збирання звітів про помилки чи відгуків.

Сподіваюся, мандрівнику, ти знайшов щось корисне для себе. Якщо виникли запитання — пиши, я з радістю відповім. А якщо маєш власні приклади покращення робочих процесів — не соромся поділитися, мені буде цікаво дізнатися про твої ідеї!

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Завжди приємно читати таке. Дякую, що ви є, і за те, що ви робите.

Підписатись на коментарі