package gitw import ( "errors" "fmt" "strconv" "strings" "time" "gitea.yves-biener.de/yves-biener/gitwarrior/internal/diff" "gitea.yves-biener.de/yves-biener/gitwarrior/internal/gitea" "gitea.yves-biener.de/yves-biener/gitwarrior/internal/taskwarrior" ) type Project struct { server gitea.Gitea repository gitea.Repository issues []Issue milestones []gitea.Milestone } func NewProject(server gitea.Gitea, repository gitea.Repository) Project { return Project{ server: server, repository: repository, } } func (project *Project) Pull(dry_run bool) error { if err := project.fetch(); err != nil { return err } if err := project.merge(dry_run); err != nil { return err } return nil } func (project *Project) fetch() error { comments, err := project.server.GetComments(project.repository) if err != nil { return err } issues, err := project.server.GetIssues(project.repository) if err != nil { return err } project.milestones, err = project.server.GetMilestones(project.repository) if err != nil { return err } for _, issue := range issues { var issue_comments []gitea.Comment issue_comments = append(issue_comments, gitea.Comment{ Body: strings.ReplaceAll(issue.Body, "\r\n", "\n"), Created_at: issue.Created_at, Updated_at: issue.Created_at, }) for _, comment := range comments { if comment.Issue_url == issue.Html_url { issue_comments = append(issue_comments, comment) } } project.issues = append(project.issues, Issue{ git_issue: issue, comments: issue_comments, }) } return nil } func (project *Project) Push(dry_run bool) error { if err := project.fetch(); err != nil { return err } // TODO: check if a pull is required before pushing changes var filter taskwarrior.Filter filter.IncludeProjects(project.repository.Name) tasks, err := taskwarrior.GetTasks(filter) if err != nil { return err } for x, task := range tasks { if task.Git_type == string(taskwarrior.ISSUE) { var issue gitea.Issue // TODO: add new issue if necessary issue.Number = uint(task.Git_number) issue.Title = task.Description issue.Body = task.Annotations[0].Description if len(task.Due) > 0 { issue.Due_date = taskwarrior.TaskTimeToGoTime(task.Due) } else { issue.Due_date = time.Unix(0, 0) } filter.Reset() filter.IncludeGitType(taskwarrior.MILESTONE) filter.IncludeProjects(project.repository.Name) milestones, err := taskwarrior.GetTasks(filter) if err != nil { return err } found := false for _, milestone := range milestones { for _, dep := range milestone.Depends { if task.Uuid == dep { issue.Milestone.Id = uint(milestone.Git_number) found = true break } } if found { break } } if task.Status == "completed" { issue.State = string(gitea.CLOSED) } else { issue.State = string(gitea.OPEN) } if !dry_run { if err = project.server.UpdateIssue(project.repository, issue); err != nil { return err } } else { fmt.Printf("\tUpdate Issue: '%s'\n", issue.Title) } } else { var milestone gitea.Milestone milestone.Id = uint(task.Git_number) milestone.Title = task.Description milestone.Description = task.Annotations[0].Description milestone.Due_on = taskwarrior.TaskTimeToGoTime(task.Due) // TODO: add new milestone if necessary if task.Status == "completed" { milestone.State = string(gitea.CLOSED) } else { milestone.State = string(gitea.OPEN) } if !dry_run { if err = project.server.UpdateMilestone(project.repository, milestone); err != nil { return err } } else { fmt.Printf("\tUpdating Milestone: '%s'\n", milestone.Title) } } 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, 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 !dry_run { 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 !dry_run { if err = project.server.UpdateComment(project.repository, comment); err != nil { return err } } else { fmt.Printf("\tUpdating comment: '%d'\n", comment.Id) } } } 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 !dry_run { 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)) } if !dry_run { return taskwarrior.UpdateTasks(tasks) } else { return nil } } func (project *Project) merge(dry_run bool) error { var tasks []taskwarrior.Task var task taskwarrior.Task var filter taskwarrior.Filter // NOTE: merge tasks for _, issue := range project.issues { filter.Reset() filter.IncludeGitNumber(issue.git_issue.Number) filter.IncludeGitType(taskwarrior.ISSUE) git_tasks, err := taskwarrior.GetTasks(filter) if err != nil { return err } if len(git_tasks) > 1 { return errors.New("Git issue id was at least used twice in taskwarrior tasks.") } else if len(git_tasks) == 0 { // NOTE: ignore closed issues which do not have a taskwarrior task if issue.git_issue.State == string(gitea.CLOSED) { continue } // NOTE: this task does not yet exist task, err = issue.IntoTask(project.repository) if err != nil { return err } fmt.Printf("\tCreated task: '%s'\n", task.Description) tasks = append(tasks, task) } else { // NOTE: there is excactly one git_task task = issue.MergeTask(git_tasks[0]) fmt.Printf("\tUpdated task: '%s'\n", task.Description) tasks = append(tasks, task) } } if !dry_run { if err := taskwarrior.UpdateTasks(tasks); err != nil { return err } } else { for _, task := range tasks { fmt.Printf("\t%#v\n\n", task) } } tasks = nil // NOTE: reset tasks after successfully updating the issues // TODO: merge milestones for _, milestone := range project.milestones { filter.Reset() filter.IncludeGitNumber(milestone.Id) filter.IncludeGitType(taskwarrior.MILESTONE) git_tasks, err := taskwarrior.GetTasks(filter) if err != nil { return err } if len(git_tasks) > 1 { return errors.New("Git milestone id was at least used twice in taskwarrior tasks.") } else if len(git_tasks) == 0 { // NOTE: ignore closed milestones which do not have a taskwarrior task if milestone.State == string(gitea.CLOSED) { continue } // NOTE: this milestone does not yet exist task = milestone.IntoTask(project.repository) for _, issue := range project.issues { if issue.git_issue.State == string(gitea.CLOSED) || issue.git_issue.Milestone.Id != milestone.Id { continue } // link to the corresponding task filter.Reset() filter.IncludeGitNumber(issue.git_issue.Number) filter.IncludeGitType(taskwarrior.ISSUE) tasks, err := taskwarrior.GetTasks(filter) if err != nil { return err } if len(tasks) != 1 { return errors.New("Git issue id used for this milestone does not exist") } task.Depends = append(task.Depends, tasks[0].Uuid) } fmt.Printf("\tCreated milestone: '%s'\n", task.Description) tasks = append(tasks, task) } else { // NOTE: there is exactly one git_task task = milestone.MergeTask(git_tasks[0]) fmt.Printf("\tUpdated milestone: '%s'\n", task.Description) tasks = append(tasks, task) } } if !dry_run { return taskwarrior.UpdateTasks(tasks) } else { for _, task := range tasks { fmt.Printf("\t%#v\n\n", task) } } return nil } type Issue struct { git_issue gitea.Issue comments []gitea.Comment } func (issue *Issue) IntoTask(repository gitea.Repository) (task taskwarrior.Task, err error) { // transform current issue into new taskwarrior.Task task = taskwarrior.NewTask( issue.git_issue.Title, repository.Name, 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(int(comment.Id), comment.Body, comment.Updated_at) } return } // TODO: implement merging of git issue or milestone into a taskwarrior task func (issue *Issue) MergeTask(task taskwarrior.Task) taskwarrior.Task { last_update := taskwarrior.TaskTimeToGoTime(task.Last_gitw_update) if issue.git_issue.Updated_at.After(last_update) { // there are changes we need to merge if taskwarrior.TaskTimeToGoTime(task.Modified).After(last_update) { // NOTE: this means that there are local modifications which are not yet pushed 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 == 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 for _, annotation := range task.Annotations { annotations = append(annotations, annotation.Description) } var comments []string for _, comment := range issue.comments { comments = append(comments, comment.Body) } // 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 { continue } if i < len(task.Annotations) { task.Annotations[i].Description = description } else { 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 == string(gitea.CLOSED) { // otherwise do not update the value task.Status = "completed" task.End = taskwarrior.GoTimeToTaskTime(issue.git_issue.Closed_at) } annotations := len(task.Annotations) for i, comment := range issue.comments { if comment.Updated_at.After(last_update) { if i < annotations { task.Annotations[i].Description = comment.Body task.Annotations[i].Entry = taskwarrior.GoTimeToTaskTime(comment.Updated_at) } else { task.AppendComment(-1, comment.Body, comment.Updated_at) } } } } task.Last_gitw_update = taskwarrior.GoTimeToTaskTime(time.Now().In(time.Local)) } return task }