From 854dafde01ea321c571796b73e5c2e8f4ce1a7c4 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Wed, 25 Oct 2023 17:14:54 +0200 Subject: [PATCH] mod(taskwarrior): complete push implementation Currently the merging process is not working entirely as I would expect. There are no checks for actual changes between the local version and the server version. WIP for #3. Configuration file created with corresponding values like, user name and access code, which are - for obvious reasons - not commited. --- cmd/gitw/main.go | 2 +- internal/gitea/comment.go | 42 ++++++++++++--- internal/gitea/gitea.go | 25 +++++++-- internal/gitea/issue.go | 4 +- internal/gitea/milestone.go | 12 ++--- internal/gitw/project.go | 98 ++++++++++++++++++++++++++-------- internal/taskwarrior/filter.go | 4 +- internal/taskwarrior/task.go | 20 +++++-- 8 files changed, 160 insertions(+), 47 deletions(-) diff --git a/cmd/gitw/main.go b/cmd/gitw/main.go index c58c356..f23bf57 100644 --- a/cmd/gitw/main.go +++ b/cmd/gitw/main.go @@ -10,7 +10,7 @@ import ( func main() { // TODO: server url may be also be derived from the git configuration? - server := gitea.NewGitea("https://gitea.yves-biener.de") + server := gitea.NewGitea() repository, err := gitw.Discover() if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/gitea/comment.go b/internal/gitea/comment.go index e040899..e60ddd3 100644 --- a/internal/gitea/comment.go +++ b/internal/gitea/comment.go @@ -62,7 +62,6 @@ func (gitea *Gitea) GetComment(repo Repository, id uint) (Comment, error) { return comment, err } -// FIXME: how to do basic authorization? func (gitea *Gitea) UpdateComment(repo Repository, comment Comment) error { url := fmt.Sprintf("%s/repos/%s/issues/comments/%d", gitea.Url(), repo.Full_name, comment.Id) json, err := json.Marshal(&map[string]interface{}{ @@ -73,6 +72,7 @@ func (gitea *Gitea) UpdateComment(repo Repository, comment Comment) error { } client := &http.Client{} request, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(json)) + request.SetBasicAuth(gitea.User_name, gitea.Access_code) request.Header.Set("content-type", "application/json") if err != nil { return err @@ -82,14 +82,14 @@ func (gitea *Gitea) UpdateComment(repo Repository, comment Comment) error { return err } defer result.Body.Close() - if result.StatusCode != 201 { + if result.StatusCode != 200 && result.StatusCode != 204 { return errors.New(fmt.Sprintf("\tRequest returned status: %s\n", result.Status)) } return nil } func (gitea *Gitea) NewComment(repo Repository, issue Issue, comment Comment) (Comment, error) { - url := fmt.Sprintf("%s/repos/%s/issues/%d/comments", gitea.Url(), repo.Full_name, issue.Id) + url := fmt.Sprintf("%s/repos/%s/issues/%d/comments", gitea.Url(), repo.Full_name, issue.Number) payload, err := json.Marshal(&map[string]interface{}{ "body": comment.Body, }) @@ -97,12 +97,22 @@ func (gitea *Gitea) NewComment(repo Repository, issue Issue, comment Comment) (C if err != nil { return res, err } - response, err := http.Post(url, "application/json", bytes.NewBuffer(payload)) + client := &http.Client{} + request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload)) + request.SetBasicAuth(gitea.User_name, gitea.Access_code) + request.Header.Set("content-type", "application/json") if err != nil { return res, nil } - defer response.Body.Close() - decoder := json.NewDecoder(response.Body) + result, err := client.Do(request) + if err != nil { + return res, err + } + defer result.Body.Close() + if result.StatusCode != 201 { + return res, errors.New(fmt.Sprintf("\tRequest returned status: %s\n", result.Status)) + } + decoder := json.NewDecoder(result.Body) // NOTE: remove this if I do not want to store everything from the json result in the struct decoder.DisallowUnknownFields() // remain if every field shall be extracted if err = decoder.Decode(&res); err != nil { @@ -110,3 +120,23 @@ func (gitea *Gitea) NewComment(repo Repository, issue Issue, comment Comment) (C } return res, nil } + +func (gitea *Gitea) DeleteComment(repo Repository, comment_id uint) error { + url := fmt.Sprintf("%s/repos/%s/issues/comments/%d", gitea.Url(), repo.Full_name, comment_id) + client := &http.Client{} + request, err := http.NewRequest(http.MethodDelete, url, nil) + request.SetBasicAuth(gitea.User_name, gitea.Access_code) + request.Header.Set("content-type", "application/json") + if err != nil { + return nil + } + result, err := client.Do(request) + if err != nil { + return err + } + defer result.Body.Close() + if result.StatusCode != 204 { + return errors.New(fmt.Sprintf("\tRequest returned status: %s\n", result.Status)) + } + return nil +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go index 5d44c39..baff140 100644 --- a/internal/gitea/gitea.go +++ b/internal/gitea/gitea.go @@ -1,16 +1,33 @@ package gitea +import ( + "encoding/json" + "fmt" + "os" +) + var API_PATH = "/api/v1" type Gitea struct { - base_url string + Base_url string `json:"base_url"` + User_name string `json:"user_name"` + Access_code string `json:"access_code"` } -func NewGitea(base_url string) (gitea Gitea) { - gitea.base_url = base_url +func NewGitea() (gitea Gitea) { + configJson, err := os.ReadFile("./configs/gitw.json") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(-1) + } + err = json.Unmarshal(configJson, &gitea) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(-1) + } return } func (gitea *Gitea) Url() string { - return gitea.base_url + API_PATH + return gitea.Base_url + API_PATH } diff --git a/internal/gitea/issue.go b/internal/gitea/issue.go index 18a9b2f..c721e9f 100644 --- a/internal/gitea/issue.go +++ b/internal/gitea/issue.go @@ -81,9 +81,8 @@ func (gitea *Gitea) GetIssue(repo Repository, id uint) (Issue, error) { return issue, nil } -// FIXME: how to do basic authorization? func (gitea *Gitea) UpdateIssue(repo Repository, issue Issue) error { - url := fmt.Sprintf("%s/repos/%s/issues/%d", gitea.Url(), repo.Full_name, issue.Id) + url := fmt.Sprintf("%s/repos/%s/issues/%d", gitea.Url(), repo.Full_name, issue.Number) payload := make(map[string]interface{}) // payload["assignee"] = issue.Assignee // payload["assignees"] = issue.Assignees @@ -100,6 +99,7 @@ func (gitea *Gitea) UpdateIssue(repo Repository, issue Issue) error { } client := &http.Client{} request, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(json)) + request.SetBasicAuth(gitea.User_name, gitea.Access_code) request.Header.Set("content-type", "application/json") if err != nil { return err diff --git a/internal/gitea/milestone.go b/internal/gitea/milestone.go index d8db417..5f92376 100644 --- a/internal/gitea/milestone.go +++ b/internal/gitea/milestone.go @@ -35,7 +35,7 @@ func (milestone *Milestone) IntoTask(repository Repository) (task taskwarrior.Ta task.Due = taskwarrior.GoTimeToTaskTime(milestone.Due_on) task.Entry = taskwarrior.GoTimeToTaskTime(milestone.Created_at) task.Modified = taskwarrior.GoTimeToTaskTime(milestone.Updated_at) - task.AppendComment(milestone.Description, milestone.Updated_at) + task.AppendComment(0, milestone.Description, milestone.Updated_at) return } @@ -55,7 +55,7 @@ func (milestone *Milestone) MergeTask(task taskwarrior.Task) taskwarrior.Task { } } if len(task.Annotations) < 1 { - task.AppendComment(milestone.Description, milestone.Created_at) + task.AppendComment(0, milestone.Description, milestone.Created_at) } else { task.Annotations[0].Description = diff.Prompt(task.Annotations[0].Description, milestone.Description, task.Annotations[0].Description) task.Annotations[0].Entry = taskwarrior.GoTimeToTaskTime(time.Now().In(time.Local)) @@ -63,7 +63,7 @@ func (milestone *Milestone) MergeTask(task taskwarrior.Task) taskwarrior.Task { } else { // NOTE: there are no modifications between the last received update and the current version, hence we just accept theirs fully task.Description = milestone.Title - if milestone.State == "closed" { + if milestone.State == string(CLOSED) { // otherwise do not update the value task.Status = "completed" task.End = taskwarrior.GoTimeToTaskTime(milestone.Closed_at) @@ -72,7 +72,7 @@ func (milestone *Milestone) MergeTask(task taskwarrior.Task) taskwarrior.Task { task.Annotations[0].Description = milestone.Description task.Annotations[0].Entry = taskwarrior.GoTimeToTaskTime(milestone.Updated_at) } else { - task.AppendComment(milestone.Description, milestone.Updated_at) + task.AppendComment(0, milestone.Description, milestone.Updated_at) } } task.Last_gitw_update = taskwarrior.GoTimeToTaskTime(time.Now().In(time.Local)) @@ -114,7 +114,6 @@ func (gitea *Gitea) GetMilestone(repo Repository, id uint) (Milestone, error) { return milestone, nil } -// FIXME: how to do basic authorization? func (gitea *Gitea) UpdateMilestone(repo Repository, milestone Milestone) error { url := fmt.Sprintf("%s/repos/%s/milestones/%d", gitea.Url(), repo.Full_name, milestone.Id) payload := make(map[string]interface{}) @@ -128,6 +127,7 @@ func (gitea *Gitea) UpdateMilestone(repo Repository, milestone Milestone) error } client := &http.Client{} request, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(json)) + request.SetBasicAuth(gitea.User_name, gitea.Access_code) request.Header.Set("content-type", "application/json") if err != nil { return err @@ -137,7 +137,7 @@ func (gitea *Gitea) UpdateMilestone(repo Repository, milestone Milestone) error return err } defer result.Body.Close() - if result.StatusCode != 201 { + if result.StatusCode != 200 { return errors.New(fmt.Sprintf("\tRequest returned status: %s\n", result.Status)) } return nil diff --git a/internal/gitw/project.go b/internal/gitw/project.go index 31b9cb5..6dac9a4 100644 --- a/internal/gitw/project.go +++ b/internal/gitw/project.go @@ -3,6 +3,7 @@ package gitw import ( "errors" "fmt" + "strconv" "strings" "time" @@ -76,10 +77,10 @@ func (project *Project) Push() error { if err != nil { return err } - for _, task := range tasks { + for x, task := range tasks { if task.Git_type == string(taskwarrior.ISSUE) { var issue gitea.Issue - issue.Id = uint(task.Git_id) + issue.Number = uint(task.Git_number) issue.Title = task.Description issue.Body = task.Annotations[0].Description if len(task.Due) > 0 { @@ -98,7 +99,7 @@ func (project *Project) Push() error { for _, milestone := range milestones { for _, dep := range milestone.Depends { if task.Uuid == dep { - issue.Milestone.Id = uint(milestone.Git_id) + issue.Milestone.Id = uint(milestone.Git_number) found = true break } @@ -117,7 +118,7 @@ func (project *Project) Push() error { } } else { var milestone gitea.Milestone - milestone.Id = uint(task.Git_id) + milestone.Id = uint(task.Git_number) milestone.Title = task.Description milestone.Description = task.Annotations[0].Description milestone.Due_on = taskwarrior.TaskTimeToGoTime(task.Due) @@ -130,19 +131,73 @@ func (project *Project) Push() error { return err } } + comment_ids := strings.Split(task.Git_comment_ids, ",") for i, annotation := range task.Annotations { if i == 0 { // that the body of the issue / milestone continue } var comment gitea.Comment - comment.Id = uint(task.Git_id) - comment.Body = annotation.Description - if err = project.server.UpdateComment(project.repository, comment); err != nil { + comment_id, err := strconv.Atoi(comment_ids[i]) + if err != nil { return err } + if comment_id == 0 { + continue + } + if comment_id < 0 { + // create new comment + var issue gitea.Issue + found_issue := false + for _, git_issue := range project.issues { + if git_issue.git_issue.Number == uint(task.Git_number) { + issue = git_issue.git_issue + found_issue = true + break + } + } + if !found_issue { + return errors.New("Could not find corresponding issue for annotation to create new comment on gitea.") + } + comment.Body = annotation.Description + comment.Created_at = taskwarrior.TaskTimeToGoTime(annotation.Entry) + fmt.Printf("\tAdd comment to '%s'#'%d'\n", issue.Title, issue.Number) + if comment, err = project.server.NewComment(project.repository, issue, comment); err != nil { + return err + } + // update the corresponding id from -1 to the one assigned by git + comment_ids[i] = fmt.Sprintf("%d", comment.Id) + } else { + // update existing comment + comment.Id = uint(comment_id) + comment.Body = annotation.Description + if err = project.server.UpdateComment(project.repository, comment); err != nil { + return err + } + } } + if len(task.Annotations) < len(comment_ids) { + // there have been annotations removed, hence the corresponding comments need to be removed as well + for i := len(task.Annotations); i < len(comment_ids); i += 1 { + comment_id, err := strconv.Atoi(comment_ids[i]) + if err != nil { + return err + } + if comment_id < 0 { + return errors.New("Tried to delete comment with id < 0") + } + fmt.Printf("\tRemoving comment\n") + if err = project.server.DeleteComment(project.repository, uint(comment_id)); err != nil { + return err + } + } + comment_ids = comment_ids[:len(task.Annotations)] + } + // update task + tasks[x].Git_comment_ids = strings.Join(comment_ids, ",") + // as we update the task we also update the last update time as well + tasks[x].Last_gitw_update = taskwarrior.GoTimeToTaskTime(time.Now().In(time.Local)) } - return nil + return taskwarrior.UpdateTasks(tasks) } // TODO: tasks should include the corresponding time's of the git related issues and milestones @@ -155,7 +210,7 @@ func (project *Project) merge() error { // NOTE: merge tasks for _, issue := range project.issues { filter.Reset() - filter.IncludeGitId(issue.git_issue.Id) + filter.IncludeGitNumber(issue.git_issue.Number) filter.IncludeGitType(taskwarrior.ISSUE) git_tasks, err := taskwarrior.GetTasks(filter) if err != nil { @@ -197,7 +252,7 @@ func (project *Project) merge() error { // TODO: merge milestones for _, milestone := range project.milestones { filter.Reset() - filter.IncludeGitId(milestone.Id) + filter.IncludeGitNumber(milestone.Id) filter.IncludeGitType(taskwarrior.MILESTONE) git_tasks, err := taskwarrior.GetTasks(filter) if err != nil { @@ -213,12 +268,12 @@ func (project *Project) merge() error { // NOTE: this milestone does not yet exist task = milestone.IntoTask(project.repository) for _, issue := range project.issues { - if issue.git_issue.State == "closed" || issue.git_issue.Milestone.Id != milestone.Id { + if issue.git_issue.State == string(gitea.CLOSED) || issue.git_issue.Milestone.Id != milestone.Id { continue } // link to the corresponding task filter.Reset() - filter.IncludeGitId(issue.git_issue.Id) + filter.IncludeGitNumber(issue.git_issue.Number) filter.IncludeGitType(taskwarrior.ISSUE) tasks, err := taskwarrior.GetTasks(filter) if err != nil { @@ -258,14 +313,14 @@ func (issue *Issue) IntoTask(repository gitea.Repository) (task taskwarrior.Task task = taskwarrior.NewTask( issue.git_issue.Title, repository.Name, - issue.git_issue.Id, + issue.git_issue.Number, taskwarrior.ISSUE, issue.git_issue.Labels..., ) task.Entry = taskwarrior.GoTimeToTaskTime(issue.git_issue.Created_at) task.Modified = taskwarrior.GoTimeToTaskTime(issue.git_issue.Updated_at) for _, comment := range issue.comments { - task.AppendComment(comment.Body, comment.Updated_at) + task.AppendComment(int(comment.Id), comment.Body, comment.Updated_at) } return } @@ -280,14 +335,13 @@ func (issue *Issue) MergeTask(task taskwarrior.Task) taskwarrior.Task { if task.Description != issue.git_issue.Title { task.Description = diff.Prompt(task.Description, issue.git_issue.Title, task.Description) } - if task.Status == "completed" || issue.git_issue.State == "closed" { - if !(task.Status == "completed" && issue.git_issue.State == "closed" || task.Status != "completed" && issue.git_issue.State != "closed") { + if task.Status == "completed" || issue.git_issue.State == string(gitea.CLOSED) { + if !(task.Status == "completed" && issue.git_issue.State == string(gitea.CLOSED) || task.Status != "completed" && issue.git_issue.State != string(gitea.CLOSED)) { task.Status = diff.Prompt(task.Status, issue.git_issue.State, task.Status) } } // TODO: try to automatically derive the correct required changes var annotations []string - annotations = append(annotations, issue.git_issue.Body) for _, annotation := range task.Annotations { annotations = append(annotations, annotation.Description) } @@ -295,7 +349,9 @@ func (issue *Issue) MergeTask(task taskwarrior.Task) taskwarrior.Task { for _, comment := range issue.comments { comments = append(comments, comment.Body) } - // TODO: provide options for theirs, mine and manual edit of the task annotations + // FIXME: provide options for theirs, mine and manual edit of the task annotations + // FIXME: prompted for no right reason + // FIXME: changes the task incorrectly annotation_joined := diff.Prompt(strings.Join(annotations, "\n\n"), strings.Join(comments, "\n\n"), strings.Join(annotations, "\n\n")) for i, description := range strings.Split(annotation_joined, "\n\n") { if len(description) == 0 { @@ -304,13 +360,13 @@ func (issue *Issue) MergeTask(task taskwarrior.Task) taskwarrior.Task { if i < len(task.Annotations) { task.Annotations[i].Description = description } else { - task.AppendComment(description, time.Now().In(time.Local)) + task.AppendComment(-1, description, time.Now().In(time.Local)) } } } else { // NOTE: there are no modifications between the last received update and the current version, hence we just accept theirs fully task.Description = issue.git_issue.Title - if issue.git_issue.State == "closed" { + if issue.git_issue.State == string(gitea.CLOSED) { // otherwise do not update the value task.Status = "completed" task.End = taskwarrior.GoTimeToTaskTime(issue.git_issue.Closed_at) @@ -322,7 +378,7 @@ func (issue *Issue) MergeTask(task taskwarrior.Task) taskwarrior.Task { task.Annotations[i].Description = comment.Body task.Annotations[i].Entry = taskwarrior.GoTimeToTaskTime(comment.Updated_at) } else { - task.AppendComment(comment.Body, comment.Updated_at) + task.AppendComment(-1, comment.Body, comment.Updated_at) } } } diff --git a/internal/taskwarrior/filter.go b/internal/taskwarrior/filter.go index d6d8f92..c04c80f 100644 --- a/internal/taskwarrior/filter.go +++ b/internal/taskwarrior/filter.go @@ -39,8 +39,8 @@ func (f *Filter) IncludeIds(ids ...uint) { } } -func (f *Filter) IncludeGitId(id uint) { - f.filter = append(f.filter, fmt.Sprintf("git_id=%d", id)) +func (f *Filter) IncludeGitNumber(id uint) { + f.filter = append(f.filter, fmt.Sprintf("git_number=%d", id)) } func (f *Filter) IncludeGitType(value Type) { diff --git a/internal/taskwarrior/task.go b/internal/taskwarrior/task.go index b613e47..6d53ce1 100644 --- a/internal/taskwarrior/task.go +++ b/internal/taskwarrior/task.go @@ -15,12 +15,13 @@ import ( type Task struct { Id uint `json:"id,omitempty"` - Git_id float32 `json:"git_id,omitempty"` // uda - Git_type string `json:"git_type,omitempty"` // uda + Git_number float32 `json:"git_number,omitempty"` // uda + Git_type string `json:"git_type,omitempty"` // uda Project string `json:"project,omitempty"` Tags []string `json:"tags,omitempty"` Description string `json:"description,omitempty"` Annotations []Annotation `json:"annotations,omitempty"` + Git_comment_ids string `json:"git_comment_ids,omitempty"` // uda Status string `json:"status,omitempty"` Depends []string `json:"depends,omitempty"` Due string `json:"due,omitempty"` @@ -33,25 +34,34 @@ type Task struct { } type Annotation struct { + Id int `json:"-"` Description string `json:"description,omitempty"` Entry string `json:"entry,omitempty"` } -func (task *Task) AppendComment(description string, time time.Time) { +func (task *Task) AppendComment(comment_id int, description string, time time.Time) { annotation := Annotation{ + Id: comment_id, Description: description, Entry: GoTimeToTaskTime(time), } + var comment_ids []string + comment_ids = nil + if len(task.Git_comment_ids) > 0 { + comment_ids = strings.Split(task.Git_comment_ids, ",") + } + comment_ids = append(comment_ids, fmt.Sprintf("%d", comment_id)) + task.Git_comment_ids = strings.Join(comment_ids, ",") task.Annotations = append(task.Annotations, annotation) } -func NewTask(description string, project string, git_id uint, git_type Type, tags ...string) Task { +func NewTask(description string, project string, git_number uint, git_type Type, tags ...string) Task { // TODO: update task struct to include the new user defined value, which shall // also be provided as an argument return Task{ Project: project, Description: description, - Git_id: float32(git_id), + Git_number: float32(git_number), Git_type: string(git_type), Last_gitw_update: GoTimeToTaskTime(time.Now()), Tags: tags,