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 }