Compare commits
4
Commits
cd3ca50a22
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6638fc85 | ||
|
|
f02cbaebf7 | ||
|
|
3babed3def | ||
|
|
3a8657e770 |
+30
-6
@@ -1,21 +1,45 @@
|
|||||||
* Worklog
|
#+TITLE: Worklog
|
||||||
|
#+AUTHOR: Riyyi
|
||||||
|
#+LANGUAGE: en
|
||||||
|
#+OPTIONS: toc:nil
|
||||||
|
|
||||||
Register worklog entries to the Jira API.
|
Register worklog entries to the Jira API.
|
||||||
|
|
||||||
* Download
|
** Getting started
|
||||||
|
|
||||||
** Clone
|
*** Clone
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
#+BEGIN_SRC sh
|
||||||
$ git clone https://github.com/riyyi/worklog
|
$ git clone https://github.com/riyyi/worklog
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
* Build instructions
|
*** Build instructions
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
#+BEGIN_SRC sh
|
||||||
$ go build
|
$ go build
|
||||||
#+END_SRC
|
#+END_SRC
|
||||||
|
|
||||||
* Gitignore
|
*** Usage
|
||||||
|
|
||||||
git update-index --assume-unchanged src/secrets.go
|
#+BEGIN_SRC sh
|
||||||
|
$ worklog --help
|
||||||
|
worklog - process a worklog file
|
||||||
|
|
||||||
|
Usage: worklog [--decl MONTH] [--process] [--issues] FILE
|
||||||
|
|
||||||
|
Positional arguments:
|
||||||
|
FILE the file to perform the action on
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--decl MONTH, -d MONTH
|
||||||
|
Generate travel declaration table
|
||||||
|
--process, -p Process specified file and call Jira API
|
||||||
|
--issues, -i Store issues in specified file
|
||||||
|
--help, -h display this help and exit
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Gitignore
|
||||||
|
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
$ git update-index --assume-unchanged src/secrets.go
|
||||||
|
#+END_SRC
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import (
|
|||||||
type Args struct {
|
type Args struct {
|
||||||
Decl string `arg:"-d,--decl" help:"Generate travel declaration table" placeholder:"MONTH"`
|
Decl string `arg:"-d,--decl" help:"Generate travel declaration table" placeholder:"MONTH"`
|
||||||
Process bool `arg:"-p,--process" help:"Process specified file and call Jira API"`
|
Process bool `arg:"-p,--process" help:"Process specified file and call Jira API"`
|
||||||
File string `arg:"positional,required" help:"the worklog file to process"`
|
Issues bool `arg:"-i,--issues" help:"Store issues in specified file"`
|
||||||
|
// -------------------------------------
|
||||||
|
File string `arg:"positional,required" help:"the file to perform the action on"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Args) Description() string {
|
func (Args) Description() string {
|
||||||
@@ -69,4 +71,9 @@ func main() {
|
|||||||
src.File.Parse(args.File, job, false)
|
src.File.Parse(args.File, job, false)
|
||||||
fmt.Println(decl.Result())
|
fmt.Println(decl.Result())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if args.Issues {
|
||||||
|
var issues src.IssueData = src.MakeIssueData()
|
||||||
|
issues.GenerateIssuesFile(args.File)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-44
@@ -7,12 +7,7 @@
|
|||||||
package src
|
package src
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,62 +16,45 @@ var Api api
|
|||||||
|
|
||||||
type api struct {}
|
type api struct {}
|
||||||
|
|
||||||
func (api) CallApi(date string, from_time string, to_time string, item_id string, description string) error {
|
func (api) CallApi(date string, fromTime string, toTime string, itemID string, description string) error {
|
||||||
if item_id == "break" || item_id == "lunch" || item_id == "pauze" { return nil }
|
if itemID == "break" || itemID == "lunch" || itemID == "pauze" || itemID == "T1-break" || itemID == "T1-lunch" || itemID == "T1-pauze" { return nil }
|
||||||
|
|
||||||
if date == "" || from_time == "" || to_time == "" || item_id == "" {
|
if date == "" || fromTime == "" || toTime == "" || itemID == "" {
|
||||||
return fmt.Errorf("incomplete log entry: %s, %s-%s, %s, %s", date, from_time, to_time, item_id, description)
|
return fmt.Errorf("incomplete log entry: %s, %s-%s, %s, %s", date, fromTime, toTime, itemID, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
time1, err := time.Parse("15:04", from_time)
|
const layout = "2006-01-02 15:04:05"
|
||||||
|
timestamp1 := date + " " + fromTime + ":00"
|
||||||
|
timestamp2 := date + " " + toTime + ":00"
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Local")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading location: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time1, err := time.ParseInLocation(layout, timestamp1, location)
|
||||||
if err != nil { return fmt.Errorf("error parsing from_time: %s", err) }
|
if err != nil { return fmt.Errorf("error parsing from_time: %s", err) }
|
||||||
|
|
||||||
time2, err := time.Parse("15:04", to_time)
|
time2, err := time.ParseInLocation(layout, timestamp2, location)
|
||||||
if err != nil { return fmt.Errorf("error parsing to_time: %s", err) }
|
if err != nil { return fmt.Errorf("error parsing to_time: %s", err) }
|
||||||
|
|
||||||
// Convert local timezone to UTC time
|
// Convert local timezone to UTC time
|
||||||
_, offset := time.Now().Zone()
|
time1UTC := time1.UTC()
|
||||||
time1.Add(-time.Duration(offset) * time.Second);
|
|
||||||
time2.Add(-time.Duration(offset) * time.Second);
|
|
||||||
|
|
||||||
duration := time2.Sub(time1)
|
duration := time2.Sub(time1)
|
||||||
seconds := int(duration.Seconds())
|
seconds := int(duration.Seconds())
|
||||||
if seconds < 0 { return fmt.Errorf("from_time is later than to_time: %s > %s", from_time, to_time) }
|
if seconds < 0 { return fmt.Errorf("from_time is later than to_time: %s > %s", fromTime, toTime) }
|
||||||
|
|
||||||
var url string = base_url + "/rest/api/2/issue/" + item_id + "/worklog"
|
var url string = baseUrl + "/rest/api/2/issue/" + itemID + "/worklog"
|
||||||
|
|
||||||
data := map[string]string{
|
data := map[string]string{
|
||||||
"comment": description,
|
"comment": description,
|
||||||
"started": fmt.Sprintf("%sT%s:00.000+0000", date, from_time), // "2021-01-17T12:34:00.000+0000",
|
"started": fmt.Sprintf("%s:00.000+0000",
|
||||||
|
time1UTC.Format("2006-01-02T15:04")), // "2021-01-17T12:34:00.000+0000",
|
||||||
"timeSpentSeconds": strconv.Itoa(seconds),
|
"timeSpentSeconds": strconv.Itoa(seconds),
|
||||||
}
|
}
|
||||||
|
|
||||||
json_data, err := json.Marshal(data)
|
_, err = Request(url, data, 201) // "Created"
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error marshaling JSON: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(json_data))
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating request: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := username + ":" + password
|
|
||||||
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
|
|
||||||
req.Header.Set("Authorization", authHeader)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil { return fmt.Errorf("error making request: %s", err) }
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil { return fmt.Errorf("error reading response body: %s", err) }
|
|
||||||
|
|
||||||
if resp.Status != "201 Created" {
|
|
||||||
return fmt.Errorf("invalid Jira request:\n%s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -38,7 +38,7 @@ func (file) Parse(path string, job func(line string, line_number int) string, ov
|
|||||||
line_number++
|
line_number++
|
||||||
|
|
||||||
// Write line to output_file
|
// Write line to output_file
|
||||||
if writer != nil {
|
if overwrite && writer != nil {
|
||||||
_, err := writer.WriteString(line + "\n")
|
_, err := writer.WriteString(line + "\n")
|
||||||
assert(err)
|
assert(err)
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,8 @@ func (file) Parse(path string, job func(line string, line_number int) string, ov
|
|||||||
err = scanner.Err()
|
err = scanner.Err()
|
||||||
assert(err)
|
assert(err)
|
||||||
|
|
||||||
err = os.Rename(path + ".tmp", path)
|
if overwrite {
|
||||||
assert(err)
|
err = os.Rename(path + ".tmp", path)
|
||||||
|
assert(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package src
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IssueData struct {
|
||||||
|
totalIssues []issueResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor
|
||||||
|
func MakeIssueData() IssueData {
|
||||||
|
return IssueData {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *IssueData) GenerateIssuesFile(path string) {
|
||||||
|
// Safety check
|
||||||
|
verify(filepath.Base(path) != "worklog.org", "protected from overwriting worklog file!")
|
||||||
|
|
||||||
|
// Get all issues of the active sprint, and its subtasks
|
||||||
|
self.fetchIssues(0)
|
||||||
|
|
||||||
|
// Store the issues
|
||||||
|
outputFile, err := os.Create(path)
|
||||||
|
assert(err)
|
||||||
|
defer outputFile.Close()
|
||||||
|
writer := bufio.NewWriter(outputFile)
|
||||||
|
defer writer.Flush()
|
||||||
|
self.writeIssues(writer)
|
||||||
|
|
||||||
|
// Clear the results
|
||||||
|
self.totalIssues = self.totalIssues[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
|
||||||
|
type issuesResponse struct {
|
||||||
|
StartAt int `json:"startAt"`
|
||||||
|
MaxResults int `json:"maxResults"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Issues []issueResponse `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type issueResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Fields fieldsResponse `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fieldsResponse struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
SubTasks []subTaskResponse `json:"subtasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type subTaskResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Fields subTaskFieldsResponse `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type subTaskFieldsResponse struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
|
||||||
|
func (self *IssueData) fetchIssues(startAt int) error {
|
||||||
|
var url string = baseUrl + "/rest/api/2/search"
|
||||||
|
var maxResults int = 100
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"fields": []string{ "key", "summary", "subtasks" },
|
||||||
|
"jql": `project = "` + projectName + `" AND sprint IN openSprints() AND issuetype != "Sub-task" ORDER BY created ASC`,
|
||||||
|
"maxResults": maxResults,
|
||||||
|
"startAt": startAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := Request(url, data, 200) // "OK"
|
||||||
|
if err != nil { return err }
|
||||||
|
|
||||||
|
var result issuesResponse
|
||||||
|
err = json.Unmarshal(body, &result)
|
||||||
|
if err != nil { fmt.Println("NOPE!"); return err }
|
||||||
|
|
||||||
|
// Add fetched issues
|
||||||
|
self.totalIssues = append(self.totalIssues, result.Issues...)
|
||||||
|
|
||||||
|
// Pagination, if more results
|
||||||
|
if startAt + maxResults < result.Total {
|
||||||
|
self.fetchIssues(startAt + maxResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self* IssueData) writeIssues(writer *bufio.Writer) {
|
||||||
|
// Issues
|
||||||
|
writer.WriteString(".\n")
|
||||||
|
var count int = len(self.totalIssues)
|
||||||
|
for i, issue := range(self.totalIssues) {
|
||||||
|
if i == count - 1 {
|
||||||
|
writer.WriteString("└")
|
||||||
|
} else {
|
||||||
|
writer.WriteString("├")
|
||||||
|
}
|
||||||
|
writer.WriteString("── ")
|
||||||
|
writer.WriteString(issue.Key)
|
||||||
|
writer.WriteString(" ")
|
||||||
|
writer.WriteString(issue.Fields.Summary)
|
||||||
|
writer.WriteString("\n")
|
||||||
|
|
||||||
|
// Subtasks
|
||||||
|
var subtaskCount int = len(issue.Fields.SubTasks)
|
||||||
|
for j, subtask := range(issue.Fields.SubTasks) {
|
||||||
|
// Last issue
|
||||||
|
if i == count - 1 {
|
||||||
|
writer.WriteString(" ")
|
||||||
|
} else {
|
||||||
|
writer.WriteString("│ ")
|
||||||
|
}
|
||||||
|
// Last subtask
|
||||||
|
if j == subtaskCount - 1 {
|
||||||
|
writer.WriteString("└")
|
||||||
|
} else {
|
||||||
|
writer.WriteString("├")
|
||||||
|
}
|
||||||
|
writer.WriteString("── ")
|
||||||
|
writer.WriteString(subtask.Key)
|
||||||
|
writer.WriteString(" ")
|
||||||
|
writer.WriteString(subtask.Fields.Summary)
|
||||||
|
writer.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -92,7 +92,7 @@ func (self *Process) parseTask(line string, line_number int) error {
|
|||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
|
|
||||||
// Set last_time, last_item_id, description
|
// Set last_time, last_item_id, description
|
||||||
if data[3] == "X" {
|
if data[3] == "X" || data[3] == "x" {
|
||||||
date.last_time = data[1]
|
date.last_time = data[1]
|
||||||
date.last_item_id = data[2]
|
date.last_item_id = data[2]
|
||||||
date.last_description = data[4]
|
date.last_description = data[4]
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package src
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Request[T ~map[string]string | ~map[string]interface{}](url string, data T, status int) ([]byte, error) {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil { return nil, fmt.Errorf("error marshaling JSON: %s", err) }
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil { return nil, fmt.Errorf("error creating request: %s", err) }
|
||||||
|
|
||||||
|
auth := username + ":" + password
|
||||||
|
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
|
||||||
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil { return nil, fmt.Errorf("error making request: %s", err) }
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil { return nil, fmt.Errorf("error reading response body: %s", err) }
|
||||||
|
|
||||||
|
if resp.StatusCode != status {
|
||||||
|
return nil, fmt.Errorf("invalid Jira request:\n%s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
+2
-1
@@ -9,4 +9,5 @@ package src
|
|||||||
var username string = "" // email
|
var username string = "" // email
|
||||||
var password string = "" // API key
|
var password string = "" // API key
|
||||||
|
|
||||||
var base_url string = "" // base URL
|
var baseUrl string = ""
|
||||||
|
var projectName string = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user