Initial commit.

This commit is contained in:
2021-09-26 10:30:51 +05:00
commit 1b1c218bb6
13 changed files with 838 additions and 0 deletions

53
internal/config/config.go Normal file
View File

@@ -0,0 +1,53 @@
package config
import (
"os"
"path/filepath"
"strings"
"go.dev.pztrn.name/periodicator/internal/gitlab"
"go.dev.pztrn.name/periodicator/internal/tasks"
"gopkg.in/yaml.v2"
)
// Config is a global configuration structure.
type Config struct {
Gitlab gitlab.Config `yaml:"gitlab"`
Tasks []tasks.Config `yaml:"tasks"`
}
// Parse tries to parse configuration and returns filled structure.
func Parse() *Config {
configPath, found := os.LookupEnv("GPT_CONFIG")
if !found {
panic("No configuration file path provided in 'GPT_CONFIG' environment variable!")
}
if strings.HasPrefix(configPath, "~") {
homeDir, err := os.UserHomeDir()
if err != nil {
panic("Failed to get user's home directory: " + err.Error())
}
configPath = strings.Replace(configPath, "~", homeDir, 1)
}
configPath, absErr := filepath.Abs(configPath)
if absErr != nil {
panic("Failed to get absolute path for '" + configPath + "': " + absErr.Error())
}
data, readErr := os.ReadFile(configPath)
if readErr != nil {
panic("Failed to read configuration file data: " + readErr.Error())
}
// nolint:exhaustivestruct
c := &Config{}
if err := yaml.Unmarshal(data, c); err != nil {
panic("Failed to unmarshal YAML data: " + err.Error())
}
return c
}

11
internal/gitlab/config.go Normal file
View File

@@ -0,0 +1,11 @@
package gitlab
// Config is a Gitlab client configuration structure that embedded in common
// Config struct.
// nolint:tagliatelle
type Config struct {
// BaseURL is a base part of URL for Gitlab.
BaseURL string `yaml:"base_url"`
// Token is a personal token to use for interacting with Gitlab API.
Token string `yaml:"token"`
}

83
internal/gitlab/gitlab.go Normal file
View File

@@ -0,0 +1,83 @@
package gitlab
import (
"errors"
"log"
"net/http"
"github.com/xanzy/go-gitlab"
)
// Client is a Gitlab's client controlling structure.
type Client struct {
client *gitlab.Client
config *Config
}
// NewGitlabClient creates new Gitlab's client controlling structure.
func NewGitlabClient(cfg *Config) *Client {
// nolint:exhaustivestruct
c := &Client{
config: cfg,
}
c.initialize()
return c
}
// CreateIssue creates issue in designated project (by ID) using passed options.
// Returns error if something went wrong.
func (c *Client) CreateIssue(projectID int, options *gitlab.CreateIssueOptions) error {
_, _, err := c.client.Issues.CreateIssue(projectID, options)
// ToDo: fix it!
// nolint:wrapcheck
return err
}
// GetClient returns underlying Gitlab's client.
func (c *Client) GetClient() *gitlab.Client {
return c.client
}
// GetIssuesByTitle returns list of issues that matches passed title in specific
// project.
func (c *Client) GetIssuesByTitle(projectID int, title string) ([]*gitlab.Issue, error) {
// nolint:exhaustivestruct
issues, resp, err := c.client.Issues.ListProjectIssues(projectID, &gitlab.ListProjectIssuesOptions{
ListOptions: gitlab.ListOptions{
PerPage: 1000,
Page: 1,
},
Search: &title,
})
if err != nil {
log.Println("Failed to execute HTTP request to Gitlab: " + err.Error())
// ToDo: fix it!
// nolint:wrapcheck
return nil, err
}
if resp.StatusCode != http.StatusOK {
log.Println("Got status: " + resp.Status)
// ToDo: fix it!
// nolint:goerr113
return nil, errors.New("not HTTP 200 from Gitlab")
}
return issues, nil
}
func (c *Client) initialize() {
client, err := gitlab.NewClient(
c.config.Token,
gitlab.WithBaseURL(c.config.BaseURL),
)
if err != nil {
panic("Failed to create Gitlab Client: " + err.Error())
}
c.client = client
}

127
internal/tasks/base.go Normal file
View File

@@ -0,0 +1,127 @@
package tasks
import (
"log"
"time"
"github.com/robfig/cron"
g "github.com/xanzy/go-gitlab"
"go.dev.pztrn.name/periodicator/internal/gitlab"
)
// BaseTask is a base task structure.
type BaseTask struct {
client *gitlab.Client
projectID int
title string
body string
tags []string
executionStartTimestamp time.Time
cron string
dueIn time.Duration
}
func (b *BaseTask) checkIfOpenedTaskExists(issues []*g.Issue) bool {
b.log("Checking if opened task already exists...")
var foundAndNotClosed bool
for _, issue := range issues {
if issue.Title == b.title && issue.ClosedAt == nil {
foundAndNotClosed = true
break
}
}
return foundAndNotClosed
}
func (b *BaseTask) log(message string) {
log.Println("Task '" + b.title + "': " + message)
}
// Run executes task procedure.
func (b *BaseTask) Run() {
// Get similar tasks.
issues, err := b.client.GetIssuesByTitle(b.projectID, b.title)
if err != nil {
b.log("Error while getting issues from Gitlab: " + err.Error())
return
}
// Check if we have opened task. We should not create another task until already
// created task is closed.
if b.checkIfOpenedTaskExists(issues) {
b.log("Found already existing task that isn't closed, won't create new task")
return
}
b.log("No still opened tasks found, checking if we should create new one...")
// Get latest task creation timestamp from Gitlab.
lastTaskCreationTS := b.executionStartTimestamp
for _, issue := range issues {
if issue.ClosedAt != nil && issue.CreatedAt.After(lastTaskCreationTS) {
lastTaskCreationTS = *issue.CreatedAt
}
}
b.log("Last task creation timestamp: " + lastTaskCreationTS.String())
// Set up cron job parser.
cp := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := cp.Parse(b.cron)
if err != nil {
b.log("Failed to parse cron string: " + err.Error())
return
}
// Figure out next task creation and deadline timestamps.
nextCreationTS := schedule.Next(lastTaskCreationTS)
// Check if task should be created and create if so.
if nextCreationTS.Before(time.Now()) {
// Deadlines should be calculated until first one will appear AFTER today.
nextDeadlineTSAssumed := nextCreationTS.Add(b.dueIn)
var nextDeadlineTS g.ISOTime
for {
if nextDeadlineTSAssumed.After(time.Now()) {
nextDeadlineTS = g.ISOTime(nextDeadlineTSAssumed)
break
}
nextDeadlineTSAssumed = nextDeadlineTSAssumed.Add(b.dueIn)
}
b.log("Found no opened tasks and task should be created, doing so. Task deadline: " + nextDeadlineTS.String())
// nolint:exhaustivestruct
err := b.client.CreateIssue(b.projectID, &g.CreateIssueOptions{
Title: &b.title,
Description: &b.body,
Labels: b.tags,
DueDate: &nextDeadlineTS,
})
if err != nil {
b.log("Failed to create task: " + err.Error())
return
}
} else {
b.log("Next task creation in future (" + nextCreationTS.String() + "), skipping for now")
return
}
}

43
internal/tasks/config.go Normal file
View File

@@ -0,0 +1,43 @@
package tasks
import "time"
// Config is a task's configuration as should be defined in configuration file.
// nolint:tagliatelle
type Config struct {
ProjectID int `yaml:"project_id"`
Title string `yaml:"title"`
Body string `yaml:"body"`
Tags []string `yaml:"tags"`
ExecutionStart TaskStartTime `yaml:"execution_start"`
Cron string `yaml:"cron"`
DueIn time.Duration `yaml:"due_in"`
}
// TaskStartTime holds task's start time for next creation timestamp calculation.
type TaskStartTime struct {
ts time.Time
}
func (tts *TaskStartTime) GetTimestamp() time.Time {
return tts.ts
}
func (tts *TaskStartTime) UnmarshalYAML(unmarshal func(interface{}) error) error {
var timeData string
if err := unmarshal(&timeData); err != nil {
return err
}
t, err := time.Parse("2006-01-02 15:04:05", timeData)
if err != nil {
// ToDo: fix it!
// nolint:wrapcheck
return err
}
tts.ts = t
return nil
}

23
internal/tasks/tasks.go Normal file
View File

@@ -0,0 +1,23 @@
package tasks
import (
"go.dev.pztrn.name/periodicator/internal/gitlab"
)
// Process processes passed tasks.
func Process(client *gitlab.Client, tasks []Config) {
for _, task := range tasks {
t := &BaseTask{
client: client,
projectID: task.ProjectID,
title: task.Title,
body: task.Body,
tags: task.Tags,
executionStartTimestamp: task.ExecutionStart.GetTimestamp(),
cron: task.Cron,
dueIn: task.DueIn,
}
t.Run()
}
}