github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/assignments/tasks.go (about)

     1  package assignments
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  
     8  	"github.com/quickfeed/quickfeed/database"
     9  	"github.com/quickfeed/quickfeed/qf"
    10  	"github.com/quickfeed/quickfeed/scm"
    11  )
    12  
    13  // taskName returns the task name based on the filename
    14  // excluding the task- prefix and the .md suffix.
    15  func taskName(filename string) string {
    16  	taskName := filename[len("task-"):]
    17  	return taskName[:len(taskName)-len(".md")]
    18  }
    19  
    20  // newTask returns a task from markdown contents and associates it with the given assignment.
    21  // The provided markdown contents must contain a title specified on the first line,
    22  // starting with the "# " character sequence, followed by two new line characters.
    23  func newTask(contents []byte, assignmentOrder uint32, name string) (*qf.Task, error) {
    24  	if !bytes.HasPrefix(contents, []byte("# ")) {
    25  		return nil, fmt.Errorf("task with name: %s, does not start with a # title marker", name)
    26  	}
    27  	bodyIndex := bytes.Index(contents, []byte("\n\n"))
    28  	if bodyIndex == -1 {
    29  		return nil, fmt.Errorf("failed to find task body in task: %s", name)
    30  	}
    31  
    32  	return &qf.Task{
    33  		AssignmentOrder: assignmentOrder,
    34  		Title:           string(contents[2:bodyIndex]),
    35  		Body:            string(contents[bodyIndex+2:]),
    36  		Name:            name,
    37  	}, nil
    38  }
    39  
    40  // tasksFromAssignments returns a map, mapping each assignment-order to a map of tasks.
    41  func tasksFromAssignments(assignments []*qf.Assignment) map[uint32]map[string]*qf.Task {
    42  	taskMap := make(map[uint32]map[string]*qf.Task)
    43  	for _, assignment := range assignments {
    44  		temp := make(map[string]*qf.Task)
    45  		for _, task := range assignment.Tasks {
    46  			temp[task.Name] = task
    47  		}
    48  		taskMap[assignment.Order] = temp
    49  	}
    50  	return taskMap
    51  }
    52  
    53  // mapTasksByID transforms the given tasks to a map from taskID to task.
    54  func mapTasksByID(tasks []*qf.Task) map[uint64]*qf.Task {
    55  	taskMap := make(map[uint64]*qf.Task)
    56  	for _, task := range tasks {
    57  		taskMap[task.ID] = task
    58  	}
    59  	return taskMap
    60  }
    61  
    62  // synchronizeTasksWithIssues synchronizes tasks with issues on SCM's group repositories.
    63  func synchronizeTasksWithIssues(ctx context.Context, db database.Database, sc scm.SCM, course *qf.Course, assignments []*qf.Assignment) error {
    64  	tasksFromTestsRepo := tasksFromAssignments(assignments)
    65  	createdTasks, updatedTasks, err := db.SynchronizeAssignmentTasks(course, tasksFromTestsRepo)
    66  	if err != nil {
    67  		return err
    68  	}
    69  
    70  	repos, err := db.GetRepositoriesWithIssues(&qf.Repository{
    71  		ScmOrganizationID: course.GetScmOrganizationID(),
    72  	})
    73  	if err != nil {
    74  		return err
    75  	}
    76  
    77  	// Creates, updates and deletes issues on all group repositories, based on how tasks differ from last push.
    78  	// The created issues will be created by the QuickFeed user (the App owner).
    79  	var createdIssues []*qf.Issue
    80  	for _, repo := range repos {
    81  		if !repo.IsGroupRepo() {
    82  			continue
    83  		}
    84  		repoCreatedIssues, err := createIssues(ctx, sc, course, repo, createdTasks)
    85  		if err != nil {
    86  			return err
    87  		}
    88  		createdIssues = append(createdIssues, repoCreatedIssues...)
    89  		if err = updateIssues(ctx, sc, course, repo, updatedTasks); err != nil {
    90  			return err
    91  		}
    92  	}
    93  	// Create issues in the database based on issues created on the scm.
    94  	return db.CreateIssues(createdIssues)
    95  }
    96  
    97  // createIssues creates issues on scm based on repository, course and tasks. Returns created issues.
    98  func createIssues(ctx context.Context, sc scm.SCM, course *qf.Course, repo *qf.Repository, tasks []*qf.Task) ([]*qf.Issue, error) {
    99  	var createdIssues []*qf.Issue
   100  	for _, task := range tasks {
   101  		issueOptions := &scm.IssueOptions{
   102  			Organization: course.GetScmOrganizationName(),
   103  			Repository:   repo.Name(),
   104  			Title:        task.Title,
   105  			Body:         task.Body,
   106  		}
   107  		scmIssue, err := sc.CreateIssue(ctx, issueOptions)
   108  		if err != nil {
   109  			return nil, err
   110  		}
   111  		createdIssues = append(createdIssues, &qf.Issue{
   112  			RepositoryID:   repo.ID,
   113  			TaskID:         task.ID,
   114  			ScmIssueNumber: uint64(scmIssue.Number),
   115  		})
   116  	}
   117  	return createdIssues, nil
   118  }
   119  
   120  // updateIssues updates issues based on repository, course and tasks. It handles deleted tasks by closing them and inserting a statement into the body.
   121  func updateIssues(ctx context.Context, sc scm.SCM, course *qf.Course, repo *qf.Repository, tasks []*qf.Task) error {
   122  	taskMap := mapTasksByID(tasks)
   123  	for _, issue := range repo.Issues {
   124  		task, ok := taskMap[issue.TaskID]
   125  		if !ok {
   126  			// Issue does not need to be updated
   127  			continue
   128  		}
   129  		issueOptions := &scm.IssueOptions{
   130  			Organization: course.GetScmOrganizationName(),
   131  			Repository:   repo.Name(),
   132  			Title:        task.Title,
   133  			Body:         task.Body,
   134  			Number:       int(issue.ScmIssueNumber),
   135  		}
   136  		if task.IsDeleted() {
   137  			issueOptions.State = "closed"
   138  		}
   139  
   140  		if _, err := sc.UpdateIssue(ctx, issueOptions); err != nil {
   141  			return err
   142  		}
   143  	}
   144  	return nil
   145  }