From 111e26bce5e6d601e5d2ebf243cf7038b6d69f83 Mon Sep 17 00:00:00 2001 From: Glenn Lewis Date: Fri, 18 Mar 2016 11:26:02 -0700 Subject: [PATCH] Add support for Migrations API The Migrations API is the GitHub equivalent of Google TakeOut. It allows you to create an archive of one or more repositories that you can download and later use for import purposes. See: https://help.github.com/enterprise/2.4/admin/guides/migrations/exporting-the-github-com-organization-s-repositories/ for more information. Fixes #292. Change-Id: Id20624acb4f16a2aade9143e723c8eb85ec75402 --- github/github.go | 5 + github/migrations.go | 225 ++++++++++++++++++++++++++++++++++++++ github/migrations_test.go | 177 ++++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 github/migrations.go create mode 100644 github/migrations_test.go diff --git a/github/github.go b/github/github.go index 9eb99b7..66add82 100644 --- a/github/github.go +++ b/github/github.go @@ -62,6 +62,9 @@ const ( // https://developer.github.com/changes/2016-02-24-commit-reference-sha-api/ mediaTypeCommitReferenceSHAPreview = "application/vnd.github.chitauri-preview+sha" + + // https://help.github.com/enterprise/2.4/admin/guides/migrations/exporting-the-github-com-organization-s-repositories/ + mediaTypeMigrationsPreview = "application/vnd.github.wyandotte-preview+json" ) // A Client manages communication with the GitHub API. @@ -97,6 +100,7 @@ type Client struct { Search *SearchService Users *UsersService Licenses *LicensesService + Migrations *MigrationService } // ListOptions specifies the optional parameters to various List methods that @@ -159,6 +163,7 @@ func NewClient(httpClient *http.Client) *Client { c.Search = &SearchService{client: c} c.Users = &UsersService{client: c} c.Licenses = &LicensesService{client: c} + c.Migrations = &MigrationService{client: c} return c } diff --git a/github/migrations.go b/github/migrations.go new file mode 100644 index 0000000..8a7bc5f --- /dev/null +++ b/github/migrations.go @@ -0,0 +1,225 @@ +// Copyright 2016 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +// MigrationService provides access to the migration related functions +// in the GitHub API. +// +// GitHub API docs: https://developer.github.com/v3/migration/ +type MigrationService struct { + client *Client +} + +// Migration represents a GitHub migration (archival). +type Migration struct { + ID *int `json:"id,omitempty"` + GUID *string `json:"guid,omitempty"` + // State is the current state of a migration. + // Possible values are: + // "pending" which means the migration hasn't started yet, + // "exporting" which means the migration is in progress, + // "exported" which means the migration finished successfully, or + // "failed" which means the migration failed. + State *string `json:"state,omitempty"` + // LockRepositories indicates whether repositories are locked (to prevent + // manipulation) while migrating data. + LockRepositories *bool `json:"lock_repositories,omitempty"` + // ExcludeAttachments indicates whether attachments should be excluded from + // the migration (to reduce migration archive file size). + ExcludeAttachments *bool `json:"exclude_attachments,omitempty"` + URL *string `json:"url,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + Repositories []*Repository `json:"repositories,omitempty"` +} + +func (m Migration) String() string { + return Stringify(m) +} + +// MigrationOptions specifies the optional parameters to Migration methods. +type MigrationOptions struct { + // LockRepositories indicates whether repositories should be locked (to prevent + // manipulation) while migrating data. + LockRepositories bool + + // ExcludeAttachments indicates whether attachments should be excluded from + // the migration (to reduce migration archive file size). + ExcludeAttachments bool +} + +// startMigration represents the body of a StartMigration request. +type startMigration struct { + // Repositories is a slice of repository names to migrate. + Repositories []string `json:"repositories,omitempty"` + + // LockRepositories indicates whether repositories should be locked (to prevent + // manipulation) while migrating data. + LockRepositories *bool `json:"lock_repositories,omitempty"` + + // ExcludeAttachments indicates whether attachments should be excluded from + // the migration (to reduce migration archive file size). + ExcludeAttachments *bool `json:"exclude_attachments,omitempty"` +} + +// StartMigration starts the generation of a migration archive. +// repos is a slice of repository names to migrate. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#start-a-migration +func (s *MigrationService) StartMigration(org string, repos []string, opt *MigrationOptions) (*Migration, *Response, error) { + u := fmt.Sprintf("orgs/%v/migrations", org) + + body := &startMigration{Repositories: repos} + if opt != nil { + body.LockRepositories = Bool(opt.LockRepositories) + body.ExcludeAttachments = Bool(opt.ExcludeAttachments) + } + + req, err := s.client.NewRequest("POST", u, body) + if err != nil { + return nil, nil, err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + m := &Migration{} + resp, err := s.client.Do(req, m) + if err != nil { + return nil, resp, err + } + + return m, resp, nil +} + +// ListMigrations lists the most recent migrations. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#get-a-list-of-migrations +func (s *MigrationService) ListMigrations(org string) ([]*Migration, *Response, error) { + u := fmt.Sprintf("orgs/%v/migrations", org) + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + var m []*Migration + resp, err := s.client.Do(req, &m) + if err != nil { + return nil, resp, err + } + + return m, resp, nil +} + +// MigrationStatus gets the status of a specific migration archive. +// id is the migration ID. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#get-the-status-of-a-migration +func (s *MigrationService) MigrationStatus(org string, id int) (*Migration, *Response, error) { + u := fmt.Sprintf("orgs/%v/migrations/%v", org, id) + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + m := &Migration{} + resp, err := s.client.Do(req, m) + if err != nil { + return nil, resp, err + } + + return m, resp, nil +} + +// MigrationArchiveURL fetches a migration archive URL. +// id is the migration ID. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#download-a-migration-archive +func (s *MigrationService) MigrationArchiveURL(org string, id int) (url string, err error) { + u := fmt.Sprintf("orgs/%v/migrations/%v/archive", org, id) + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return "", err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + s.client.clientMu.Lock() + defer s.client.clientMu.Unlock() + + // Disable the redirect mechanism because AWS fails if the GitHub auth token is provided. + var loc string + saveRedirect := s.client.client.CheckRedirect + s.client.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + loc = req.URL.String() + return errors.New("disable redirect") + } + defer func() { s.client.client.CheckRedirect = saveRedirect }() + + _, err = s.client.Do(req, nil) // expect error from disable redirect + if err == nil { + return "", errors.New("expected redirect, none provided") + } + if !strings.Contains(err.Error(), "disable redirect") { + return "", err + } + return loc, nil +} + +// DeleteMigration deletes a previous migration archive. +// id is the migration ID. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#delete-a-migration-archive +func (s *MigrationService) DeleteMigration(org string, id int) (*Response, error) { + u := fmt.Sprintf("orgs/%v/migrations/%v/archive", org, id) + + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + return s.client.Do(req, nil) +} + +// UnlockRepo unlocks a repository that was locked for migration. +// id is the migration ID. +// You should unlock each migrated repository and delete them when the migration +// is complete and you no longer need the source data. +// +// GitHub API docs: https://developer.github.com/v3/migration/migrations/#unlock-a-repository +func (s *MigrationService) UnlockRepo(org string, id int, repo string) (*Response, error) { + u := fmt.Sprintf("orgs/%v/migrations/%v/repos/%v/lock", org, id, repo) + + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + // TODO: remove custom Accept header when this API fully launches. + req.Header.Set("Accept", mediaTypeMigrationsPreview) + + return s.client.Do(req, nil) +} diff --git a/github/migrations_test.go b/github/migrations_test.go new file mode 100644 index 0000000..9a902e4 --- /dev/null +++ b/github/migrations_test.go @@ -0,0 +1,177 @@ +// Copyright 2016 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "net/http" + "reflect" + "strings" + "testing" +) + +func TestMigrationService_StartMigration(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + w.WriteHeader(http.StatusCreated) + w.Write(migrationJSON) + }) + + opt := &MigrationOptions{ + LockRepositories: true, + ExcludeAttachments: false, + } + got, _, err := client.Migrations.StartMigration("o", []string{"r"}, opt) + if err != nil { + t.Errorf("StartMigration returned error: %v", err) + } + if want := wantMigration; !reflect.DeepEqual(got, want) { + t.Errorf("StartMigration = %+v, want %+v", got, want) + } +} + +func TestMigrationService_ListMigrations(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("[%s]", migrationJSON))) + }) + + got, _, err := client.Migrations.ListMigrations("o") + if err != nil { + t.Errorf("ListMigrations returned error: %v", err) + } + if want := []*Migration{wantMigration}; !reflect.DeepEqual(got, want) { + t.Errorf("ListMigrations = %+v, want %+v", got, want) + } +} + +func TestMigrationService_MigrationStatus(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + w.WriteHeader(http.StatusOK) + w.Write(migrationJSON) + }) + + got, _, err := client.Migrations.MigrationStatus("o", 1) + if err != nil { + t.Errorf("MigrationStatus returned error: %v", err) + } + if want := wantMigration; !reflect.DeepEqual(got, want) { + t.Errorf("MigrationStatus = %+v, want %+v", got, want) + } +} + +func TestMigrationService_MigrationArchiveURL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations/1/archive", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + http.Redirect(w, r, "/yo", http.StatusFound) + }) + mux.HandleFunc("/yo", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("0123456789abcdef")) + }) + + got, err := client.Migrations.MigrationArchiveURL("o", 1) + if err != nil { + t.Errorf("MigrationStatus returned error: %v", err) + } + if want := "/yo"; !strings.HasSuffix(got, want) { + t.Errorf("MigrationArchiveURL = %+v, want %+v", got, want) + } +} + +func TestMigrationService_DeleteMigration(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations/1/archive", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + w.WriteHeader(http.StatusNoContent) + }) + + if _, err := client.Migrations.DeleteMigration("o", 1); err != nil { + t.Errorf("DeleteMigration returned error: %v", err) + } +} + +func TestMigrationService_UnlockRepo(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/orgs/o/migrations/1/repos/r/lock", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + testHeader(t, r, "Accept", mediaTypeMigrationsPreview) + + w.WriteHeader(http.StatusNoContent) + }) + + if _, err := client.Migrations.UnlockRepo("o", 1, "r"); err != nil { + t.Errorf("UnlockRepo returned error: %v", err) + } +} + +var migrationJSON = []byte(`{ + "id": 79, + "guid": "0b989ba4-242f-11e5-81e1-c7b6966d2516", + "state": "pending", + "lock_repositories": true, + "exclude_attachments": false, + "url": "https://api.github.com/orgs/octo-org/migrations/79", + "created_at": "2015-07-06T15:33:38-07:00", + "updated_at": "2015-07-06T15:33:38-07:00", + "repositories": [ + { + "id": 1296269, + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "description": "This your first repo!" + } + ] +}`) + +var wantMigration = &Migration{ + ID: Int(79), + GUID: String("0b989ba4-242f-11e5-81e1-c7b6966d2516"), + State: String("pending"), + LockRepositories: Bool(true), + ExcludeAttachments: Bool(false), + URL: String("https://api.github.com/orgs/octo-org/migrations/79"), + CreatedAt: String("2015-07-06T15:33:38-07:00"), + UpdatedAt: String("2015-07-06T15:33:38-07:00"), + Repositories: []*Repository{ + { + ID: Int(1296269), + Name: String("Hello-World"), + FullName: String("octocat/Hello-World"), + Description: String("This your first repo!"), + }, + }, +}