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

     1  package scm
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"strconv"
     8  
     9  	"go.uber.org/zap"
    10  
    11  	"github.com/google/go-github/v45/github"
    12  	"github.com/gosimple/slug"
    13  	"github.com/quickfeed/quickfeed/qf"
    14  	"github.com/shurcooL/githubv4"
    15  	"golang.org/x/oauth2"
    16  )
    17  
    18  // GithubSCM implements the SCM interface.
    19  type GithubSCM struct {
    20  	logger      *zap.SugaredLogger
    21  	client      *github.Client
    22  	clientV4    *githubv4.Client
    23  	config      *Config
    24  	token       string
    25  	providerURL string
    26  	tokenURL    string
    27  }
    28  
    29  // NewGithubSCMClient returns a new Github client implementing the SCM interface.
    30  func NewGithubSCMClient(logger *zap.SugaredLogger, token string) *GithubSCM {
    31  	src := oauth2.StaticTokenSource(
    32  		&oauth2.Token{AccessToken: token},
    33  	)
    34  	httpClient := oauth2.NewClient(context.Background(), src)
    35  	return &GithubSCM{
    36  		logger:      logger,
    37  		client:      github.NewClient(httpClient),
    38  		clientV4:    githubv4.NewClient(httpClient),
    39  		token:       token,
    40  		providerURL: "github.com",
    41  	}
    42  }
    43  
    44  // GetOrganization implements the SCM interface.
    45  func (s *GithubSCM) GetOrganization(ctx context.Context, opt *OrganizationOptions) (*qf.Organization, error) {
    46  	if !opt.valid() {
    47  		return nil, fmt.Errorf("missing fields: %+v", opt)
    48  	}
    49  	var orgNameOrID string
    50  	var gitOrg *github.Organization
    51  	var err error
    52  	// priority is getting the organization by ID
    53  	if opt.ID > 0 {
    54  		orgNameOrID = strconv.Itoa(int(opt.ID))
    55  		gitOrg, _, err = s.client.Organizations.GetByID(ctx, int64(opt.ID))
    56  	} else {
    57  		// if ID not provided, get by name
    58  		orgNameOrID = slug.Make(opt.Name)
    59  		gitOrg, _, err = s.client.Organizations.Get(ctx, slug.Make(opt.Name))
    60  	}
    61  	if err != nil || gitOrg == nil {
    62  		return nil, ErrFailedSCM{
    63  			Method:   "GetOrganization",
    64  			Message:  fmt.Sprintf("could not find github organization %s. Make sure it allows third party access.", orgNameOrID), // this message is logged, never sent to user
    65  			GitError: err,
    66  		}
    67  	}
    68  
    69  	org := &qf.Organization{
    70  		ScmOrganizationID:   uint64(gitOrg.GetID()),
    71  		ScmOrganizationName: gitOrg.GetLogin(),
    72  	}
    73  
    74  	// If getting organization for the purpose of creating a new course,
    75  	// ensure that the organization does not already contain any course repositories.
    76  	if opt.NewCourse {
    77  		repos, err := s.GetRepositories(ctx, org)
    78  		if err != nil {
    79  			return nil, err
    80  		}
    81  		if isDirty(repos) {
    82  			return nil, ErrAlreadyExists
    83  		}
    84  	}
    85  
    86  	// If user name is provided, return the organization only if the user is one of its owners.
    87  	if opt.Username != "" {
    88  		// fetch user membership in that organization, if exists
    89  		membership, _, err := s.client.Organizations.GetOrgMembership(ctx, opt.Username, slug.Make(opt.Name))
    90  		if err != nil {
    91  			return nil, ErrFailedSCM{
    92  				Method:   "GetOrganization",
    93  				Message:  fmt.Sprintf("Failed to GetOrganization for (%q, %q)", opt.Username, slug.Make(opt.Name)),
    94  				GitError: fmt.Errorf("failed to GetOrgMembership(%q, %q): %w", opt.Username, slug.Make(opt.Name), err),
    95  			}
    96  		}
    97  		// membership role must be "admin", if not, return error (possibly to show user)
    98  		if membership.GetRole() != OrgOwner {
    99  			return nil, ErrNotOwner
   100  		}
   101  	}
   102  	return org, nil
   103  }
   104  
   105  // GetRepositories implements the SCM interface.
   106  func (s *GithubSCM) GetRepositories(ctx context.Context, org *qf.Organization) ([]*Repository, error) {
   107  	path := org.GetScmOrganizationName()
   108  	if path == "" {
   109  		return nil, errors.New("organization name must be provided")
   110  	}
   111  	repos, _, err := s.client.Repositories.ListByOrg(ctx, path, nil)
   112  	if err != nil {
   113  		return nil, ErrFailedSCM{
   114  			GitError: err,
   115  			Method:   "GetRepositories",
   116  			Message:  fmt.Sprintf("failed to access repositories for organization %s", path),
   117  		}
   118  	}
   119  	repositories := make([]*Repository, 0, len(repos))
   120  	for _, repo := range repos {
   121  		repositories = append(repositories, toRepository(repo))
   122  	}
   123  	return repositories, nil
   124  }
   125  
   126  // RepositoryIsEmpty implements the SCM interface
   127  func (s *GithubSCM) RepositoryIsEmpty(ctx context.Context, opt *RepositoryOptions) bool {
   128  	_, contents, resp, err := s.client.Repositories.GetContents(
   129  		ctx,
   130  		opt.Owner,
   131  		opt.Path,
   132  		"",
   133  		&github.RepositoryContentGetOptions{},
   134  	)
   135  	// GitHub returns 404 both when repository does not exist and when it is empty with no commits.
   136  	// If there are commits but no contents, GitHub returns no error and an empty slice for directory contents.
   137  	// We want to return true if error is 404 or there is no error and no contents, otherwise false.
   138  	return (err != nil && resp.StatusCode == 404) || (err == nil && len(contents) == 0)
   139  }
   140  
   141  // UpdateTeamMembers implements the SCM interface
   142  func (s *GithubSCM) UpdateTeamMembers(ctx context.Context, opt *UpdateTeamOptions) error {
   143  	if !opt.valid() {
   144  		return fmt.Errorf("missing fields: %+v", opt)
   145  	}
   146  
   147  	// find current team members
   148  	oldUsers, _, err := s.client.Teams.ListTeamMembersByID(ctx, int64(opt.OrganizationID), int64(opt.TeamID), nil)
   149  	if err != nil {
   150  		return ErrFailedSCM{
   151  			GitError: err,
   152  			Method:   "UpdateTeamMember",
   153  			Message:  fmt.Sprintf("failed to get members for team ID %d", opt.TeamID),
   154  		}
   155  	}
   156  
   157  	// check whether group members are already in team; add missing members
   158  	for _, member := range opt.Users {
   159  		_, _, err = s.client.Teams.AddTeamMembershipByID(ctx, int64(opt.OrganizationID), int64(opt.TeamID), member, nil)
   160  		if err != nil {
   161  			return ErrFailedSCM{
   162  				GitError: err,
   163  				Method:   "UpdateTeamMember",
   164  				Message:  fmt.Sprintf("failed to add user %s to team ID %d", member, opt.TeamID),
   165  			}
   166  		}
   167  	}
   168  
   169  	// check if all the team members are in the new group;
   170  	for _, teamMember := range oldUsers {
   171  		toRemove := true
   172  		for _, groupMember := range opt.Users {
   173  			if teamMember.GetLogin() == groupMember {
   174  				toRemove = false
   175  			}
   176  		}
   177  		if toRemove {
   178  			_, err = s.client.Teams.RemoveTeamMembershipByID(ctx, int64(opt.OrganizationID), int64(opt.TeamID), teamMember.GetLogin())
   179  			if err != nil {
   180  				return ErrFailedSCM{
   181  					GitError: err,
   182  					Method:   "UpdateTeamMember",
   183  					Message:  fmt.Sprintf("failed to remove user %s from team ID %d", teamMember.GetLogin(), opt.TeamID),
   184  				}
   185  			}
   186  		}
   187  	}
   188  	return nil
   189  }
   190  
   191  // CreateIssue implements the SCM interface
   192  func (s *GithubSCM) CreateIssue(ctx context.Context, opt *IssueOptions) (*Issue, error) {
   193  	if !opt.valid() {
   194  		return nil, fmt.Errorf("missing fields: %+v", opt)
   195  	}
   196  	newIssue := &github.IssueRequest{
   197  		Title:     &opt.Title,
   198  		Body:      &opt.Body,
   199  		Assignee:  opt.Assignee,
   200  		Assignees: opt.Assignees,
   201  	}
   202  
   203  	s.logger.Debugf("Creating issue %q on %s", opt.Title, opt.Repository)
   204  	issue, _, err := s.client.Issues.Create(ctx, opt.Organization, opt.Repository, newIssue)
   205  	if err != nil {
   206  		return nil, ErrFailedSCM{
   207  			Method:   "CreateIssue",
   208  			Message:  fmt.Sprintf("failed to create issue %q", opt.Title),
   209  			GitError: err,
   210  		}
   211  	}
   212  	s.logger.Debugf("Created issue %q", opt.Title)
   213  
   214  	return toIssue(issue), nil
   215  }
   216  
   217  // UpdateIssue implements the SCM interface
   218  func (s *GithubSCM) UpdateIssue(ctx context.Context, opt *IssueOptions) (*Issue, error) {
   219  	if !opt.valid() {
   220  		return nil, fmt.Errorf("missing fields: %+v", opt)
   221  	}
   222  
   223  	issueReq := &github.IssueRequest{
   224  		Title:     &opt.Title,
   225  		Body:      &opt.Body,
   226  		State:     &opt.State,
   227  		Assignee:  opt.Assignee,
   228  		Assignees: opt.Assignees,
   229  	}
   230  	s.logger.Debugf("Updating issue %d on %s", opt.Number, opt.Repository)
   231  	issue, _, err := s.client.Issues.Edit(ctx, opt.Organization, opt.Repository, opt.Number, issueReq)
   232  	if err != nil {
   233  		return nil, ErrFailedSCM{
   234  			Method:   "UpdateIssue",
   235  			Message:  fmt.Sprintf("failed to update issue %d on %s/%s", opt.Number, opt.Organization, opt.Repository),
   236  			GitError: err,
   237  		}
   238  	}
   239  	s.logger.Debugf("Updated issue number %d", opt.Number)
   240  	return toIssue(issue), nil
   241  }
   242  
   243  // GetIssue implements the SCM interface
   244  func (s *GithubSCM) GetIssue(ctx context.Context, opt *RepositoryOptions, number int) (*Issue, error) {
   245  	if !opt.valid() {
   246  		return nil, fmt.Errorf("missing fields: %+v", opt)
   247  	}
   248  	issue, _, err := s.client.Issues.Get(ctx, opt.Owner, opt.Path, number)
   249  	if err != nil {
   250  		return nil, ErrFailedSCM{
   251  			Method:   "GetIssue",
   252  			Message:  fmt.Sprintf("failed to get issue %d", number),
   253  			GitError: err,
   254  		}
   255  	}
   256  	return toIssue(issue), nil
   257  }
   258  
   259  // GetIssues implements the SCM interface
   260  func (s *GithubSCM) GetIssues(ctx context.Context, opt *RepositoryOptions) ([]*Issue, error) {
   261  	if !opt.valid() {
   262  		return nil, fmt.Errorf("missing fields: %+v", opt)
   263  	}
   264  	issueList, _, err := s.client.Issues.ListByRepo(ctx, opt.Owner, opt.Path, &github.IssueListByRepoOptions{})
   265  	if err != nil {
   266  		return nil, ErrFailedSCM{
   267  			Method:   "GetIssues",
   268  			Message:  fmt.Sprintf("failed to get issues for %s", opt.Path),
   269  			GitError: err,
   270  		}
   271  	}
   272  	var issues []*Issue
   273  	for _, issue := range issueList {
   274  		issues = append(issues, toIssue(issue))
   275  	}
   276  
   277  	return issues, nil
   278  }
   279  
   280  // RequestReviewers implements the SCM interface
   281  func (s *GithubSCM) RequestReviewers(ctx context.Context, opt *RequestReviewersOptions) error {
   282  	if !opt.valid() {
   283  		return fmt.Errorf("missing fields: %+v", opt)
   284  	}
   285  	reviewersRequest := github.ReviewersRequest{
   286  		Reviewers: opt.Reviewers,
   287  	}
   288  	if _, _, err := s.client.PullRequests.RequestReviewers(ctx, opt.Organization, opt.Repository, opt.Number, reviewersRequest); err != nil {
   289  		return ErrFailedSCM{
   290  			Method:   "RequestReviewers",
   291  			Message:  fmt.Sprintf("failed to request reviewers for pull request #%d on %s/%s", opt.Number, opt.Organization, opt.Repository),
   292  			GitError: err,
   293  		}
   294  	}
   295  	return nil
   296  }
   297  
   298  // CreateIssueComment implements the SCM interface
   299  func (s *GithubSCM) CreateIssueComment(ctx context.Context, opt *IssueCommentOptions) (int64, error) {
   300  	if !opt.valid() {
   301  		return 0, fmt.Errorf("missing fields: %+v", opt)
   302  	}
   303  	createdComment, _, err := s.client.Issues.CreateComment(ctx, opt.Organization, opt.Repository, opt.Number, &github.IssueComment{Body: &opt.Body})
   304  	if err != nil {
   305  		return 0, ErrFailedSCM{
   306  			Method:   "CreateIssueComment",
   307  			Message:  fmt.Sprintf("failed to create comment for issue #%d, in repository: %s, for organization: %s", opt.Number, opt.Repository, opt.Organization),
   308  			GitError: err,
   309  		}
   310  	}
   311  	return createdComment.GetID(), nil
   312  }
   313  
   314  // UpdateIssueComment implements the SCM interface
   315  func (s *GithubSCM) UpdateIssueComment(ctx context.Context, opt *IssueCommentOptions) error {
   316  	if !opt.valid() {
   317  		return fmt.Errorf("missing fields: %+v", opt)
   318  	}
   319  	if _, _, err := s.client.Issues.EditComment(ctx, opt.Organization, opt.Repository, opt.CommentID, &github.IssueComment{Body: &opt.Body}); err != nil {
   320  		return ErrFailedSCM{
   321  			Method:   "UpdateIssueComment",
   322  			Message:  fmt.Sprintf("failed to edit comment in repository: %s, for organization: %s", opt.Repository, opt.Organization),
   323  			GitError: err,
   324  		}
   325  	}
   326  	return nil
   327  }
   328  
   329  // CreateCourse creates repositories and teams for a new course.
   330  func (s *GithubSCM) CreateCourse(ctx context.Context, opt *CourseOptions) ([]*Repository, error) {
   331  	if !opt.valid() {
   332  		return nil, fmt.Errorf("missing fields: %+v", opt)
   333  	}
   334  	// Get and check the organization's suitability for the course
   335  	org, err := s.GetOrganization(ctx, &OrganizationOptions{ID: opt.OrganizationID, NewCourse: true})
   336  	if err != nil {
   337  		return nil, err
   338  	}
   339  
   340  	// Set restrictions to prevent students from creating new repositories and prevent access
   341  	// to organization repositories. This will not affect organization owners (teachers).
   342  	defaultPermissions := OrgNone
   343  	createRepoPermissions := false
   344  	if _, _, err = s.client.Organizations.Edit(ctx, org.ScmOrganizationName, &github.Organization{
   345  		DefaultRepoPermission: &defaultPermissions,
   346  		MembersCanCreateRepos: &createRepoPermissions,
   347  	}); err != nil {
   348  		return nil, fmt.Errorf("failed to update permissions for GitHub organization %s: %w", org.ScmOrganizationName, err)
   349  	}
   350  
   351  	// Create course repositories
   352  	repositories := make([]*Repository, 0, len(RepoPaths)+1)
   353  	for path, private := range RepoPaths {
   354  		repoOptions := &CreateRepositoryOptions{
   355  			Path:         path,
   356  			Organization: org.ScmOrganizationName,
   357  			Private:      private,
   358  		}
   359  		repo, err := s.createRepository(ctx, repoOptions)
   360  		if err != nil {
   361  			return nil, err
   362  		}
   363  		repositories = append(repositories, repo)
   364  	}
   365  
   366  	// Create teacher team with course creator
   367  	teamOpt := &TeamOptions{
   368  		Organization: org.ScmOrganizationName,
   369  		TeamName:     TeachersTeam,
   370  		Users:        []string{opt.CourseCreator},
   371  	}
   372  	if _, err = s.createTeam(ctx, teamOpt); err != nil {
   373  		return nil, err
   374  	}
   375  
   376  	// Create student repository for the course creator
   377  	repo, err := s.createStudentRepo(ctx, org.ScmOrganizationName, opt.CourseCreator)
   378  	if err != nil {
   379  		return nil, err
   380  	}
   381  	repositories = append(repositories, repo)
   382  	return repositories, nil
   383  }
   384  
   385  // UpdateEnrollment updates organization and team membership and creates user repositories.
   386  func (s *GithubSCM) UpdateEnrollment(ctx context.Context, opt *UpdateEnrollmentOptions) (*Repository, error) {
   387  	if !opt.valid() {
   388  		return nil, fmt.Errorf("missing fields: %+v", opt)
   389  	}
   390  	org, err := s.GetOrganization(ctx, &OrganizationOptions{
   391  		Name: opt.Organization,
   392  	})
   393  	if err != nil {
   394  		return nil, err
   395  	}
   396  	switch opt.Status {
   397  	case qf.Enrollment_STUDENT:
   398  		// Give access to the course's info and assignments repositories
   399  		if err := s.grantPullAccessToCourseRepos(ctx, org.ScmOrganizationName, opt.User); err != nil {
   400  			return nil, err
   401  		}
   402  		repo, err := s.createStudentRepo(ctx, org.ScmOrganizationName, opt.User)
   403  		if err != nil {
   404  			return nil, err
   405  		}
   406  		// Promote user to organization member
   407  		role := OrgMember
   408  		if _, _, err := s.client.Organizations.EditOrgMembership(ctx, opt.User, org.ScmOrganizationName, &github.Membership{Role: &role}); err != nil {
   409  			return nil, err
   410  		}
   411  		return repo, nil
   412  
   413  	case qf.Enrollment_TEACHER:
   414  		// Promote user to organization owner
   415  		role := OrgOwner
   416  		if _, _, err := s.client.Organizations.EditOrgMembership(ctx, opt.User, org.ScmOrganizationName, &github.Membership{Role: &role}); err != nil {
   417  			return nil, err
   418  		}
   419  		err = s.promoteToTeacher(ctx, org.ScmOrganizationName, opt.User)
   420  	}
   421  	return nil, err
   422  }
   423  
   424  // RejectEnrollment removes user's repository and revokes user's membership in the course organization.
   425  func (s *GithubSCM) RejectEnrollment(ctx context.Context, opt *RejectEnrollmentOptions) error {
   426  	if !opt.valid() {
   427  		return fmt.Errorf("missing fields: %+v", opt)
   428  	}
   429  	org, err := s.GetOrganization(ctx, &OrganizationOptions{ID: opt.OrganizationID})
   430  	if err != nil {
   431  		return err
   432  	}
   433  	if _, err := s.client.Organizations.RemoveMember(ctx, org.ScmOrganizationName, opt.User); err != nil {
   434  		return err
   435  	}
   436  	return s.deleteRepository(ctx, &RepositoryOptions{ID: opt.RepositoryID})
   437  }
   438  
   439  // DemoteTeacherToStudent removes user from teachers team, revokes owner status in the organization.
   440  func (s *GithubSCM) DemoteTeacherToStudent(ctx context.Context, opt *UpdateEnrollmentOptions) error {
   441  	if !opt.valid() {
   442  		return fmt.Errorf("missing fields: %+v", opt)
   443  	}
   444  	if _, err := s.client.Teams.RemoveTeamMembershipBySlug(ctx, opt.Organization, TeachersTeam, opt.User); err != nil {
   445  		return err
   446  	}
   447  	role := OrgMember
   448  	_, _, err := s.client.Organizations.EditOrgMembership(ctx, opt.User, opt.Organization, &github.Membership{Role: &role})
   449  	return err
   450  }
   451  
   452  // CreateGroup creates team and repository for a new group.
   453  func (s *GithubSCM) CreateGroup(ctx context.Context, opt *TeamOptions) (*Repository, *Team, error) {
   454  	if !opt.valid() {
   455  		return nil, nil, fmt.Errorf("missing fields: %+v", opt)
   456  	}
   457  	orgOptions := &OrganizationOptions{Name: opt.Organization}
   458  	org, err := s.GetOrganization(ctx, orgOptions)
   459  	if err != nil {
   460  		return nil, nil, err
   461  	}
   462  	repoOptions := &CreateRepositoryOptions{
   463  		Organization: opt.Organization,
   464  		Path:         opt.TeamName,
   465  		Private:      true,
   466  	}
   467  	repo, err := s.createRepository(ctx, repoOptions)
   468  	if err != nil {
   469  		return nil, nil, err
   470  	}
   471  
   472  	team, err := s.createTeam(ctx, opt)
   473  	if err != nil {
   474  		return nil, nil, err
   475  	}
   476  	permissions := &github.TeamAddTeamRepoOptions{
   477  		Permission: RepoPush, // make sure users can pull and push
   478  	}
   479  	if _, err := s.client.Teams.AddTeamRepoByID(ctx, int64(org.ScmOrganizationID), int64(team.ID), org.ScmOrganizationName, repo.Path, permissions); err != nil {
   480  		return nil, nil, err
   481  	}
   482  	return repo, team, nil
   483  }
   484  
   485  // DeleteGroup deletes group's repository and team.
   486  func (s *GithubSCM) DeleteGroup(ctx context.Context, opt *GroupOptions) error {
   487  	if !opt.valid() {
   488  		return fmt.Errorf("missing fields: %+v", opt)
   489  	}
   490  	if err := s.deleteRepository(ctx, &RepositoryOptions{ID: opt.RepositoryID}); err != nil {
   491  		return err
   492  	}
   493  	_, err := s.client.Teams.DeleteTeamByID(ctx, int64(opt.OrganizationID), int64(opt.TeamID))
   494  	return err
   495  }
   496  
   497  // createRepository creates a new repository or returns an existing repository with the given name.
   498  func (s *GithubSCM) createRepository(ctx context.Context, opt *CreateRepositoryOptions) (*Repository, error) {
   499  	if !opt.valid() {
   500  		return nil, fmt.Errorf("missing fields: %+v", opt)
   501  	}
   502  
   503  	// check that repo does not already exist for this user or group
   504  	repo, _, err := s.client.Repositories.Get(ctx, opt.Organization, slug.Make(opt.Path))
   505  	if repo != nil {
   506  		s.logger.Debugf("CreateRepository: found existing repository (skipping creation): %s: %v", opt.Path, repo)
   507  		return toRepository(repo), nil
   508  	}
   509  	// error expected to be 404 Not Found; logging here in case it's a different error
   510  	s.logger.Debugf("CreateRepository: check for repository %s: %s", opt.Path, err)
   511  
   512  	// repo does not exist, create it
   513  	s.logger.Debugf("CreateRepository: creating %s", opt.Path)
   514  	repo, _, err = s.client.Repositories.Create(ctx, opt.Organization, &github.Repository{
   515  		Name:    &opt.Path,
   516  		Private: &opt.Private,
   517  	})
   518  	if err != nil {
   519  		return nil, ErrFailedSCM{
   520  			Method:   "CreateRepository",
   521  			Message:  fmt.Sprintf("failed to create repository %s, make sure it does not already exist", opt.Path),
   522  			GitError: err,
   523  		}
   524  	}
   525  	s.logger.Debugf("CreateRepository: done creating %s", opt.Path)
   526  	return toRepository(repo), nil
   527  }
   528  
   529  // deleteRepository deletes repository by name or ID.
   530  func (s *GithubSCM) deleteRepository(ctx context.Context, opt *RepositoryOptions) error {
   531  	if !opt.valid() {
   532  		return fmt.Errorf("missing fields: %+v", opt)
   533  	}
   534  
   535  	// if ID provided, get path and owner from github
   536  	if opt.ID > 0 {
   537  		repo, _, err := s.client.Repositories.GetByID(ctx, int64(opt.ID))
   538  		if err != nil {
   539  			return ErrFailedSCM{
   540  				GitError: err,
   541  				Method:   "DeleteRepository",
   542  				Message:  fmt.Sprintf("failed to fetch repository %d: may not exists in the course organization", opt.ID),
   543  			}
   544  		}
   545  		opt.Path = repo.GetName()
   546  		opt.Owner = repo.Owner.GetLogin()
   547  	}
   548  
   549  	if _, err := s.client.Repositories.Delete(ctx, opt.Owner, opt.Path); err != nil {
   550  		return ErrFailedSCM{
   551  			GitError: err,
   552  			Method:   "DeleteRepository",
   553  			Message:  fmt.Sprintf("failed to delete repository %s", opt.Path),
   554  		}
   555  	}
   556  	return nil
   557  }
   558  
   559  // createTeam creates a new GitHub team.
   560  func (s *GithubSCM) createTeam(ctx context.Context, opt *TeamOptions) (*Team, error) {
   561  	if !opt.valid() {
   562  		return nil, fmt.Errorf("missing fields: %+v", opt)
   563  	}
   564  	teamName := slug.Make(opt.TeamName)
   565  	// check that the team name does not already exist for this organization
   566  	team, _, err := s.client.Teams.GetTeamBySlug(ctx, opt.Organization, teamName)
   567  	if err != nil {
   568  		// error expected to be 404 Not Found; logging here in case it's a different error
   569  		s.logger.Debugf("CreateTeam: check for team %s: %s", teamName, err)
   570  	}
   571  
   572  	if team == nil {
   573  		s.logger.Debugf("CreateTeam: creating %s", teamName)
   574  		team, _, err = s.client.Teams.CreateTeam(ctx, opt.Organization, github.NewTeam{
   575  			Name: teamName,
   576  		})
   577  		if err != nil {
   578  			return nil, ErrFailedSCM{
   579  				Method:   "CreateTeam",
   580  				Message:  fmt.Sprintf("failed to create GitHub team %s, make sure it does not already exist", opt.TeamName),
   581  				GitError: fmt.Errorf("failed to create GitHub team %s: %w", opt.TeamName, err),
   582  			}
   583  		}
   584  		s.logger.Debugf("CreateTeam: done creating %s", teamName)
   585  	}
   586  	for _, user := range opt.Users {
   587  		s.logger.Debugf("CreateTeam: adding user %s to %s", user, teamName)
   588  		_, _, err = s.client.Teams.AddTeamMembershipByID(ctx, team.GetOrganization().GetID(), team.GetID(), user, nil)
   589  		if err != nil {
   590  			return nil, ErrFailedSCM{
   591  				Method:   "CreateTeam",
   592  				Message:  fmt.Sprintf("failed to add user '%s' to GitHub team '%s'", user, team.GetName()),
   593  				GitError: fmt.Errorf("failed to add '%s' to GitHub team '%s': %w", user, team.GetName(), err),
   594  			}
   595  		}
   596  	}
   597  	return &Team{
   598  		ID:           uint64(team.GetID()),
   599  		Name:         team.GetName(),
   600  		Organization: team.GetOrganization().GetLogin(),
   601  	}, nil
   602  }
   603  
   604  // createStudentRepo creates {username}-labs repository and provides pull/push access to it for the given student.
   605  func (s *GithubSCM) createStudentRepo(ctx context.Context, organization string, login string) (*Repository, error) {
   606  	// create repo, or return existing repo if it already exists
   607  	// if repo is found, it is safe to reuse it
   608  	repo, err := s.createRepository(ctx, &CreateRepositoryOptions{
   609  		Organization: organization,
   610  		Path:         qf.StudentRepoName(login),
   611  		Private:      true,
   612  	})
   613  	if err != nil {
   614  		return nil, fmt.Errorf("failed to create repo: %w", err)
   615  	}
   616  
   617  	// add push access to student repo
   618  	opt := &github.RepositoryAddCollaboratorOptions{
   619  		Permission: RepoPush,
   620  	}
   621  	if _, _, err := s.client.Repositories.AddCollaborator(ctx, repo.Owner, repo.Path, login, opt); err != nil {
   622  		return nil, fmt.Errorf("failed to grant push access to %s/%s for user %s: %w", repo.Owner, repo.Path, login, err)
   623  	}
   624  	return repo, nil
   625  }
   626  
   627  // grantPullAccessToCourseRepos gives pull access to the course's info and assignments repositories.
   628  func (s *GithubSCM) grantPullAccessToCourseRepos(ctx context.Context, org, login string) error {
   629  	commonRepos := []string{qf.InfoRepo, qf.AssignmentsRepo}
   630  	for _, repoType := range commonRepos {
   631  		opt := &github.RepositoryAddCollaboratorOptions{
   632  			Permission: RepoPull,
   633  		}
   634  		if _, _, err := s.client.Repositories.AddCollaborator(ctx, org, repoType, login, opt); err != nil {
   635  			return fmt.Errorf("failed to grant pull access to %s/%s for user %s: %w", org, repoType, login, err)
   636  		}
   637  	}
   638  	return nil
   639  }
   640  
   641  // promoteToTeacher adds user to the organization's "teachers" team.
   642  func (s *GithubSCM) promoteToTeacher(ctx context.Context, org, login string) error {
   643  	teamMaintainer := &github.TeamAddTeamMembershipOptions{Role: TeamMaintainer}
   644  	_, _, err := s.client.Teams.AddTeamMembershipBySlug(ctx, org, TeachersTeam, login, teamMaintainer)
   645  	return err
   646  }
   647  
   648  // Client returns GitHub client.
   649  func (s *GithubSCM) Client() *github.Client {
   650  	return s.client
   651  }
   652  
   653  func toRepository(repo *github.Repository) *Repository {
   654  	return &Repository{
   655  		ID:      uint64(repo.GetID()),
   656  		Path:    repo.GetName(),
   657  		Owner:   repo.Owner.GetLogin(),
   658  		HTMLURL: repo.GetHTMLURL(),
   659  		OrgID:   uint64(repo.Organization.GetID()),
   660  		Size:    uint64(repo.GetSize()),
   661  	}
   662  }
   663  
   664  func toIssue(issue *github.Issue) *Issue {
   665  	return &Issue{
   666  		ID:         uint64(issue.GetID()),
   667  		Title:      issue.GetTitle(),
   668  		Body:       issue.GetBody(),
   669  		Repository: issue.Repository.GetName(),
   670  		Assignee:   issue.Assignee.GetName(),
   671  		Number:     issue.GetNumber(),
   672  		Status:     issue.GetState(),
   673  	}
   674  }