Вдохновившись статьей "Добавляем в Go-проект конфигурацию на языке Terraform" захотелось попробовать в каком-нибудь проекте описать конфигурацию на HCL.
И как-то, в очередной раз, заменяя переменные в скрипте на python, чтобы создать задачки в Jira, меня посетила мысль, что можно попробовать написать утилитку на Go, которая будет по описанию в HCL генерировать задачи в Jira. Заодно и с Go познакомлюсь.
Забегая вперед скажу, что поиски примеров и изучение парсера дались мне трудно. Кроме пары банальных примеров найти что-то вменяемое мне не удалось. Были мысли сделать на Python, но для Python парсер оказался совсем убогим, мог только перевести HCL в dict и никакой валидации и обработки выражений. Поэтому пришлось вернуться к затее с Go.
Чего хочу добиться от утилиты:
возможность создания нескольких задач по описанию в HCL с заполнением разных используемых полей, включая custom fields
генерировать сразу несколько задач (бывает нужно сделать 30 похожих задач для разных репозиториев) по шаблону для разных проектов/компонентов
линковать задачи, делать подзадачи
все то же самое для обновления задач
Итак, начнем с самого простого примера. Хочу описывать структуру создаваемой задачи в блоке create, а для обновления использовать блок update. Начнем пока с create
create "Task" {
project = "AA" # required
summary = "My first issue" # required
assignee = "ivanov" # required
description = "issue description" # optional
labels = ["no-qa"] # optional
}
где Task - это тип issue
Для обработки такого шаблона нам подойдет следующий код
package main
import (
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/kr/pretty"
"log"
)
type Root struct {
Create config `hcl:"create,block"`
}
type config struct {
Type string `hcl:"type,label"`
Project string `hcl:"project"`
Summary string `hcl:"summary"`
Assignee string `hcl:"assignee"`
Description string `hcl:"description,optional"`
Labels []string `hcl:"labels,optional"`
}
func main() {
filename := "example.hcl"
parser := hclparse.NewParser()
f, diags := parser.ParseHCLFile(filename)
if diags.HasErrors() {
log.Fatal(diags)
}
var root Root
diags = gohcl.DecodeBody(f.Body, nil, &root)
if diags.HasErrors() {
log.Fatal(diags)
}
_, _ = pretty.Println(root)
}
После выполнения кода получаем следующий ответ
main.Root{
Create: main.config{
Type: "Task",
Project: "AA",
Summary: "My first issue",
Assignee: "ivanov",
Description: "issue description",
Labels: {"no-qa"},
},
}
Пробуем убрать required поле, например, project
2022/11/04 21:55:07 example.hcl:1,15-15: Missing required argument; The argument "project" is required, but no definition was found.
exit status 1
Вроде понятно, но можно сделать симпатичнее вывод ошибки. Перед log.Fatal() добавляем следующий код
wr := hcl.NewDiagnosticTextWriter(
os.Stdout, // writer to send messages to
parser.Files(), // the parser's file cache, for source snippets
78, // wrapping width
true, // generate colored/highlighted output
)
_ = wr.WriteDiagnostics(diags)
Теперь, если убрать обязательное поле, например, summary, то получим такой вывод
Error: Missing required argument
on example.hcl line 1, in create "Task":
1: create "Task" {
The argument "summary" is required, but no definition was found.
2022/11/04 21:59:43 example.hcl:1,15-15: Missing required argument; The argument "summary" is required, but no definition was found.
exit status 1
Мне такой вариант нравится больше.
Добавляем клиент для Jira и пробуем создать задачу (user/pass для доступа к Jira я добавил в переменные окружения)
package main
import (
"fmt"
"github.com/andygrunwald/go-jira"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/kr/pretty"
"log"
"os"
)
type Root struct {
Create config `hcl:"create,block"`
}
type config struct {
Type string `hcl:"type,label"`
Project string `hcl:"project"`
Summary string `hcl:"summary"`
Assignee string `hcl:"assignee"`
Description string `hcl:"description,optional"`
Labels []string `hcl:"labels,optional"`
}
func renderDiags(diags hcl.Diagnostics, files map[string]*hcl.File) {
wr := hcl.NewDiagnosticTextWriter(
os.Stdout, // writer to send messages to
files, // the parser's file cache, for source snippets
78, // wrapping width
true, // generate colored/highlighted output
)
_ = wr.WriteDiagnostics(diags)
}
func main() {
filename := "example.hcl"
parser := hclparse.NewParser()
f, diags := parser.ParseHCLFile(filename)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
log.Fatal(diags)
}
var root Root
diags = gohcl.DecodeBody(f.Body, nil, &root)
if diags.HasErrors() {
renderDiags(diags, parser.Files())
log.Fatal(diags)
}
_, _ = pretty.Println(root)
basicAuth := jira.BasicAuthTransport{
Username: os.Getenv("JIRA_USERNAME"),
Password: os.Getenv("JIRA_PASSWORD"),
}
jiraClient, _ := jira.NewClient(basicAuth.Client(), os.Getenv("JIRA_URL"))
i := jira.Issue{
Fields: &jira.IssueFields{
Description: root.Create.Description,
Type: jira.IssueType{Name: root.Create.Type},
Project: jira.Project{Key: root.Create.Project},
Summary: root.Create.Summary,
Labels: root.Create.Labels,
},
}
issue, _, err := jiraClient.Issue.Create(&i)
if err != nil {
log.Fatal(err)
}
fmt.Println(issue.Key)
}
Отлично, простенькую задачку создать удалось
main.Root{
Create: main.config{
Type: "Task",
Project: "AA",
Summary: "My first issue",
Assignee: "ivanov",
Description: "issue description",
Labels: {"no-qa"},
},
}
AA-1234
А что будет, если в файле указать 2 (например с типом Bug) и более блоков create?
Error: Duplicate create block
on example.hcl line 9, in create "Bug":
9: create "Bug" {
Only one create block is allowed. Another was defined at example.hcl:1,1-14.
2022/11/04 22:20:48 example.hcl:9,1-13: Duplicate create block; Only one create block is allowed. Another was defined at example.hcl:1,1-14.
exit status 1
Ок, вроде несложно поправить
...
// указываем для Root, что create - это массив
type Root struct {
Create []config `hcl:"create,block"`
}
...
// обрабатываем массив из create
for _, create := range root.Create {
i := jira.Issue{
Fields: &jira.IssueFields{
Description: create.Description,
Type: jira.IssueType{Name: create.Type},
Project: jira.Project{Key: create.Project},
Summary: create.Summary,
Labels: create.Labels,
},
}
issue, _, err := jiraClient.Issue.Create(&i)
if err != nil {
log.Fatal(err)
}
fmt.Println(issue.Key)
}
...
Результат
main.Root{
Create: {
{
Type: "Task",
Project: "AA",
Summary: "My first issue",
Assignee: "ivanov",
Description: "issue description",
Labels: {"no-qa"},
},
{
Type: "Bug",
Project: "AA",
Summary: "My first issue",
Assignee: "ivanov",
Description: "issue description",
Labels: {"no-qa"},
},
},
}
AA-1001
AA-1002
Уже лучше, но пока этого не достаточно, чтобы заменить мой скрипт на Python.
Для начала я хочу научиться работать с переменными. С этого момента и начались сложности с реализацией моих хотелок из-за отсутствия толковых примеров и документации (либо я плохо искал и не умею пользоваться доками), а так же отсутствия опыта написания программ на Go.
Про добавление переменных и частичный парсинг, я расскажу в следующей части.
Примеры, по ходу приобретения опыта, буду пополнять на github