diff --git a/Octokit.Reactive/Clients/IObservableRepositoriesClient.cs b/Octokit.Reactive/Clients/IObservableRepositoriesClient.cs index ff3c79ff43..d9da62cbc0 100644 --- a/Octokit.Reactive/Clients/IObservableRepositoriesClient.cs +++ b/Octokit.Reactive/Clients/IObservableRepositoriesClient.cs @@ -23,6 +23,15 @@ public interface IObservableRepositoriesClient /// An instance for the created repository IObservable Create(string organizationLogin, NewRepository newRepository); + /// + /// Creates a new repository using a repository template. + /// + /// The owner of the template + /// The name of the template + /// A instance describing the new repository to create from a template + /// An instance for the created repository + IObservable Generate(string templateOwner, string templateRepo, NewRepositoryFromTemplate newRepository); + /// /// Deletes a repository for the specified owner and name. /// diff --git a/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs b/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs index 6b835c29e1..9fbb2157b1 100644 --- a/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs +++ b/Octokit.Reactive/Clients/ObservableRepositoriesClient.cs @@ -72,6 +72,24 @@ public IObservable Create(string organizationLogin, NewRepository ne return _client.Create(organizationLogin, newRepository).ToObservable(); } + /// + /// Creates a new repository from a template + /// + /// The organization or person who will owns the template + /// The name of template repository to work from + /// A instance describing the new repository to create from a template + /// + public IObservable Generate(string templateOwner, string templateRepo, NewRepositoryFromTemplate newRepository) + { + Ensure.ArgumentNotNull(templateOwner, nameof(templateOwner)); + Ensure.ArgumentNotNull(templateRepo, nameof(templateRepo)); + Ensure.ArgumentNotNull(newRepository, nameof(newRepository)); + if (string.IsNullOrEmpty(newRepository.Name)) + throw new ArgumentException("The new repository's name must not be null."); + + return _client.Generate(templateOwner, templateRepo, newRepository).ToObservable(); + } + /// /// Deletes a repository for the specified owner and name. /// diff --git a/Octokit.Tests.Integration/Clients/RepositoriesClientTests.cs b/Octokit.Tests.Integration/Clients/RepositoriesClientTests.cs index 432deb99c3..cd1fffea94 100644 --- a/Octokit.Tests.Integration/Clients/RepositoriesClientTests.cs +++ b/Octokit.Tests.Integration/Clients/RepositoriesClientTests.cs @@ -219,6 +219,51 @@ public async Task CreatesARepositoryWithALicenseTemplate() } } + [IntegrationTest] + public async Task CreatesARepositoryAsTemplate() + { + var github = Helper.GetAuthenticatedClient(); + var repoName = Helper.MakeNameWithTimestamp("repo-as-template"); + + var newRepository = new NewRepository(repoName) + { + IsTemplate = true + }; + + using (var context = await github.CreateRepositoryContext(newRepository)) + { + var createdRepository = context.Repository; + + var repository = await github.Repository.Get(Helper.UserName, repoName); + + Assert.True(repository.IsTemplate); + } + } + + [IntegrationTest] + public async Task CreatesARepositoryFromTemplate() + { + var github = Helper.GetAuthenticatedClient(); + var repoTemplateName = Helper.MakeNameWithTimestamp("repo-template"); + var repoFromTemplateName = Helper.MakeNameWithTimestamp("repo-from-template"); + var owner = github.User.Current().Result.Login; + + var newTemplate = new NewRepository(repoTemplateName) + { + IsTemplate = true + }; + + var newRepo = new NewRepositoryFromTemplate(repoFromTemplateName); + + using (var templateContext = await github.CreateRepositoryContext(newTemplate)) + using (var context = await github.Generate(owner, repoFromTemplateName, newRepo)) + { + var repository = await github.Repository.Get(Helper.UserName, repoFromTemplateName); + + Assert.Equal(repoFromTemplateName, repository.Name); + } + } + [IntegrationTest] public async Task CreatesARepositoryWithDeleteBranchOnMergeEnabled() { diff --git a/Octokit.Tests.Integration/Helpers/GithubClientExtensions.cs b/Octokit.Tests.Integration/Helpers/GithubClientExtensions.cs index 329b6360ea..3c6b0f3fa9 100644 --- a/Octokit.Tests.Integration/Helpers/GithubClientExtensions.cs +++ b/Octokit.Tests.Integration/Helpers/GithubClientExtensions.cs @@ -26,6 +26,13 @@ internal static async Task CreateRepositoryContext(this IGitH return new RepositoryContext(client.Connection, repo); } + internal static async Task Generate(this IGitHubClient client, string owner, string repoName, NewRepositoryFromTemplate newRepository) + { + var repo = await client.Repository.Generate(owner, repoName, newRepository); + + return new RepositoryContext(client.Connection, repo); + } + internal static async Task CreateTeamContext(this IGitHubClient client, string organization, NewTeam newTeam) { newTeam.Privacy = TeamPrivacy.Closed; diff --git a/Octokit.Tests/Clients/RepositoriesClientTests.cs b/Octokit.Tests/Clients/RepositoriesClientTests.cs index c518b7152c..00b3b520bf 100644 --- a/Octokit.Tests/Clients/RepositoriesClientTests.cs +++ b/Octokit.Tests/Clients/RepositoriesClientTests.cs @@ -42,7 +42,7 @@ public void UsesTheUserReposUrl() connection.Received().Post(Arg.Is(u => u.ToString() == "user/repos"), Arg.Any(), - "application/vnd.github.nebula-preview+json"); + "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json"); } [Fact] @@ -54,7 +54,7 @@ public void TheNewRepositoryDescription() client.Create(newRepository); - connection.Received().Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json"); + connection.Received().Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json"); } [Fact] @@ -70,7 +70,7 @@ public async Task ThrowsRepositoryExistsExceptionWhenRepositoryExistsForCurrentU var connection = Substitute.For(); connection.Connection.BaseAddress.Returns(GitHubClient.GitHubApiUrl); connection.Connection.Credentials.Returns(credentials); - connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json") + connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json") .Returns>(_ => { throw new ApiValidationException(response); }); var client = new RepositoriesClient(connection); @@ -97,7 +97,7 @@ public async Task ThrowsExceptionWhenPrivateRepositoryQuotaExceeded() var connection = Substitute.For(); connection.Connection.BaseAddress.Returns(GitHubClient.GitHubApiUrl); connection.Connection.Credentials.Returns(credentials); - connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json") + connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json") .Returns>(_ => { throw new ApiValidationException(response); }); var client = new RepositoriesClient(connection); @@ -130,7 +130,7 @@ public async Task UsesTheOrganizationsReposUrl() connection.Received().Post( Arg.Is(u => u.ToString() == "orgs/theLogin/repos"), Args.NewRepository, - "application/vnd.github.nebula-preview+json"); + "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json"); } [Fact] @@ -142,7 +142,7 @@ public async Task TheNewRepositoryDescription() await client.Create("aLogin", newRepository); - connection.Received().Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json"); + connection.Received().Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json"); } [Fact] @@ -156,7 +156,7 @@ public async Task ThrowsRepositoryExistsExceptionWhenRepositoryExistsForSpecifie + @"""code"":""custom"",""field"":""name"",""message"":""name already exists on this account""}]}"); var connection = Substitute.For(); connection.Connection.BaseAddress.Returns(GitHubClient.GitHubApiUrl); - connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json") + connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json") .Returns>(_ => { throw new ApiValidationException(response); }); var client = new RepositoriesClient(connection); @@ -181,7 +181,7 @@ public async Task ThrowsValidationException() + @"""http://developer.github.com/v3/repos/#create"",""errors"":[]}"); var connection = Substitute.For(); connection.Connection.BaseAddress.Returns(GitHubClient.GitHubApiUrl); - connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json") + connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json") .Returns>(_ => { throw new ApiValidationException(response); }); var client = new RepositoriesClient(connection); @@ -202,7 +202,7 @@ public async Task ThrowsRepositoryExistsExceptionForEnterpriseInstance() + @"""code"":""custom"",""field"":""name"",""message"":""name already exists on this account""}]}"); var connection = Substitute.For(); connection.Connection.BaseAddress.Returns(new Uri("https://example.com")); - connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json") + connection.Post(Args.Uri, newRepository, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json") .Returns>(_ => { throw new ApiValidationException(response); }); var client = new RepositoriesClient(connection); @@ -214,6 +214,44 @@ public async Task ThrowsRepositoryExistsExceptionForEnterpriseInstance() } } + public class TheGenerateMethod + { + [Fact] + public async Task EnsuresNonNullArguments() + { + var client = new RepositoriesClient(Substitute.For()); + + await Assert.ThrowsAsync(() => client.Generate(null, null, null)); + await Assert.ThrowsAsync(() => client.Generate("asd", null, null)); + await Assert.ThrowsAsync(() => client.Generate("asd", "asd", null)); + } + + [Fact] + public void UsesTheUserReposUrl() + { + var connection = Substitute.For(); + var client = new RepositoriesClient(connection); + + client.Generate("asd", "asd", new NewRepositoryFromTemplate("aName")); + + connection.Received().Post(Arg.Is(u => u.ToString() == "repos/asd/asd/generate"), + Arg.Any(), + "application/vnd.github.baptiste-preview+json"); + } + + [Fact] + public void TheNewRepositoryDescription() + { + var connection = Substitute.For(); + var client = new RepositoriesClient(connection); + var newRepository = new NewRepositoryFromTemplate("aName"); + + client.Generate("anOwner", "aRepo", newRepository); + + connection.Received().Post(Args.Uri, newRepository, "application/vnd.github.baptiste-preview+json"); + } + } + public class TheTransferMethod { [Fact] @@ -490,7 +528,7 @@ public async Task RequestsTheCorrectUrlAndReturnsRepositories() connection.Received() .GetAll(Arg.Is(u => u.ToString() == "user/repos"), null, - "application/vnd.github.nebula-preview+json", + "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json", Args.ApiOptions); } @@ -644,7 +682,7 @@ public async Task RequestsTheCorrectUrl() await client.GetAllForOrg("orgname"); connection.Received() - .GetAll(Arg.Is(u => u.ToString() == "orgs/orgname/repos"), null, "application/vnd.github.nebula-preview+json", Args.ApiOptions); + .GetAll(Arg.Is(u => u.ToString() == "orgs/orgname/repos"), null, "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json", Args.ApiOptions); } [Fact] @@ -1078,7 +1116,7 @@ public void PatchesCorrectUrl() connection.Received() .Patch(Arg.Is(u => u.ToString() == "repos/owner/repo"), Arg.Any(), - "application/vnd.github.nebula-preview+json"); + "application/vnd.github.nebula-preview+json,application/vnd.github.baptiste-preview+json"); } [Fact] @@ -1332,7 +1370,7 @@ public async Task RequestsTheCorrectUrlForOwnerAndRepoWithEmptyTopics() await _client.ReplaceAllTopics("owner", "name", _emptyTopics); _connection.Received() - .Put(Arg.Is(u => u.ToString() == "repos/owner/name/topics"), _emptyTopics, null,"application/vnd.github.mercy-preview+json"); + .Put(Arg.Is(u => u.ToString() == "repos/owner/name/topics"), _emptyTopics, null, "application/vnd.github.mercy-preview+json"); } [Fact] @@ -1341,7 +1379,7 @@ public async Task RequestsTheCorrectUrlForOwnerAndRepoWithListOfTopics() await _client.ReplaceAllTopics("owner", "name", _listOfTopics); _connection.Received() - .Put(Arg.Is(u => u.ToString() == "repos/owner/name/topics"), _listOfTopics,null, "application/vnd.github.mercy-preview+json"); + .Put(Arg.Is(u => u.ToString() == "repos/owner/name/topics"), _listOfTopics, null, "application/vnd.github.mercy-preview+json"); } [Fact] @@ -1356,10 +1394,10 @@ public async Task RequestsTheCorrectUrlForRepoIdWithEmptyTopics() [Fact] public async Task RequestsTheCorrectUrlForRepoIdWithListOfTopics() { - await _client.ReplaceAllTopics(1234,_listOfTopics); + await _client.ReplaceAllTopics(1234, _listOfTopics); _connection.Received() - .Put(Arg.Is(u => u.ToString() == "repositories/1234/topics"), _listOfTopics,null, "application/vnd.github.mercy-preview+json"); + .Put(Arg.Is(u => u.ToString() == "repositories/1234/topics"), _listOfTopics, null, "application/vnd.github.mercy-preview+json"); } } } diff --git a/Octokit/Clients/IRepositoriesClient.cs b/Octokit/Clients/IRepositoriesClient.cs index dd291cfd1e..976766f96c 100644 --- a/Octokit/Clients/IRepositoriesClient.cs +++ b/Octokit/Clients/IRepositoriesClient.cs @@ -77,6 +77,16 @@ public interface IRepositoriesClient /// A instance for the created repository Task Create(string organizationLogin, NewRepository newRepository); + /// + /// Creates a new repository from a template + /// + /// The organization or person who will owns the template + /// The name of template repository to work from + /// + /// + Task Generate(string templateOwner, string templateRepo, NewRepositoryFromTemplate newRepository); + + /// /// Deletes the specified repository. /// diff --git a/Octokit/Clients/RepositoriesClient.cs b/Octokit/Clients/RepositoriesClient.cs index 256b24fbd9..5a279837c0 100644 --- a/Octokit/Clients/RepositoriesClient.cs +++ b/Octokit/Clients/RepositoriesClient.cs @@ -70,6 +70,7 @@ public Task Create(NewRepository newRepository) /// Thrown when a general API error occurs. /// A instance for the created repository [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("POST", "/orgs/{org}/repos")] public Task Create(string organizationLogin, NewRepository newRepository) { @@ -81,11 +82,31 @@ public Task Create(string organizationLogin, NewRepository newReposi return Create(ApiUrls.OrganizationRepositories(organizationLogin), organizationLogin, newRepository); } + /// + /// Creates a new repository from a template + /// + /// The organization or person who will owns the template + /// The name of template repository to work from + /// + /// + [Preview("baptiste")] + [ManualRoute("POST", "/repos/{owner}/{repo}/generate")] + public Task Generate(string templateOwner, string templateRepo, NewRepositoryFromTemplate newRepository) + { + Ensure.ArgumentNotNull(templateOwner, nameof(templateOwner)); + Ensure.ArgumentNotNull(templateRepo, nameof(templateRepo)); + Ensure.ArgumentNotNull(newRepository, nameof(newRepository)); + if (string.IsNullOrEmpty(newRepository.Name)) + throw new ArgumentException("The new repository's name must not be null."); + + return ApiConnection.Post(ApiUrls.Repositories(templateOwner, templateRepo), newRepository, AcceptHeaders.TemplatePreview); + } + async Task Create(Uri url, string organizationLogin, NewRepository newRepository) { try { - return await ApiConnection.Post(url, newRepository, AcceptHeaders.VisibilityPreview).ConfigureAwait(false); + return await ApiConnection.Post(url, newRepository, AcceptHeaders.Concat(AcceptHeaders.VisibilityPreview, AcceptHeaders.TemplatePreview)).ConfigureAwait(false); } catch (ApiValidationException e) { @@ -216,6 +237,7 @@ public Task Transfer(long repositoryId, RepositoryTransfer repositor /// New values to update the repository with /// The updated [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("PATCH", "/repos/{owner}/{repo}")] public Task Edit(string owner, string name, RepositoryUpdate update) { @@ -224,7 +246,7 @@ public Task Edit(string owner, string name, RepositoryUpdate update) Ensure.ArgumentNotNull(update, nameof(update)); Ensure.ArgumentNotNull(update.Name, nameof(update.Name)); - return ApiConnection.Patch(ApiUrls.Repository(owner, name), update, AcceptHeaders.VisibilityPreview); + return ApiConnection.Patch(ApiUrls.Repository(owner, name), update, AcceptHeaders.Concat(AcceptHeaders.VisibilityPreview, AcceptHeaders.TemplatePreview)); } /// @@ -326,6 +348,7 @@ public Task> GetAllPublic(PublicRepositoryRequest requ /// Thrown when a general API error occurs. /// A of . [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("GET", "/user/repos")] public Task> GetAllForCurrent() { @@ -343,12 +366,13 @@ public Task> GetAllForCurrent() /// Thrown when a general API error occurs. /// A of . [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("GET", "/user/repos")] public Task> GetAllForCurrent(ApiOptions options) { Ensure.ArgumentNotNull(options, nameof(options)); - return ApiConnection.GetAll(ApiUrls.Repositories(), null, AcceptHeaders.VisibilityPreview, options); + return ApiConnection.GetAll(ApiUrls.Repositories(), null, AcceptHeaders.Concat(AcceptHeaders.VisibilityPreview, AcceptHeaders.TemplatePreview), options); } /// @@ -440,6 +464,7 @@ public Task> GetAllForUser(string login, ApiOptions op /// Thrown when a general API error occurs. /// A of . [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("GET", "/orgs/{org}/repos")] public Task> GetAllForOrg(string organization) { @@ -459,13 +484,14 @@ public Task> GetAllForOrg(string organization) /// Thrown when a general API error occurs. /// A of . [Preview("nebula")] + [Preview("baptiste")] [ManualRoute("GET", "/orgs/{org}/repos")] public Task> GetAllForOrg(string organization, ApiOptions options) { Ensure.ArgumentNotNullOrEmptyString(organization, nameof(organization)); Ensure.ArgumentNotNull(options, nameof(options)); - return ApiConnection.GetAll(ApiUrls.OrganizationRepositories(organization), null, AcceptHeaders.VisibilityPreview, options); + return ApiConnection.GetAll(ApiUrls.OrganizationRepositories(organization), null, AcceptHeaders.Concat(AcceptHeaders.VisibilityPreview, AcceptHeaders.TemplatePreview), options); } /// @@ -745,7 +771,7 @@ public async Task GetAllTopics(long repositoryId, ApiOptions o { Ensure.ArgumentNotNull(options, nameof(options)); var endpoint = ApiUrls.RepositoryTopics(repositoryId); - var data = await ApiConnection.Get(endpoint,null,AcceptHeaders.RepositoryTopicsPreview).ConfigureAwait(false); + var data = await ApiConnection.Get(endpoint, null, AcceptHeaders.RepositoryTopicsPreview).ConfigureAwait(false); return data ?? new RepositoryTopics(); } @@ -824,7 +850,7 @@ public async Task ReplaceAllTopics(string owner, string name, Ensure.ArgumentNotNull(topics, nameof(topics)); var endpoint = ApiUrls.RepositoryTopics(owner, name); - var data = await ApiConnection.Put(endpoint, topics,null, AcceptHeaders.RepositoryTopicsPreview).ConfigureAwait(false); + var data = await ApiConnection.Put(endpoint, topics, null, AcceptHeaders.RepositoryTopicsPreview).ConfigureAwait(false); return data ?? new RepositoryTopics(); } diff --git a/Octokit/Helpers/AcceptHeaders.cs b/Octokit/Helpers/AcceptHeaders.cs index 09826011e3..a2723a8e27 100644 --- a/Octokit/Helpers/AcceptHeaders.cs +++ b/Octokit/Helpers/AcceptHeaders.cs @@ -54,6 +54,8 @@ public static class AcceptHeaders public const string VisibilityPreview = "application/vnd.github.nebula-preview+json"; + public const string TemplatePreview = "application/vnd.github.baptiste-preview+json"; + /// /// Combines multiple preview headers. GitHub API supports Accept header with multiple /// values separated by comma. diff --git a/Octokit/Helpers/ApiUrls.cs b/Octokit/Helpers/ApiUrls.cs index 48b5660ac5..1637866a51 100644 --- a/Octokit/Helpers/ApiUrls.cs +++ b/Octokit/Helpers/ApiUrls.cs @@ -58,6 +58,15 @@ public static Uri Repositories(string login) return "users/{0}/repos".FormatUri(login); } + /// + /// Returns the that create a repository using a template. + /// + /// + public static Uri Repositories(string owner, string repo) + { + return "repos/{0}/{1}/generate".FormatUri(owner, repo); + } + /// /// Returns the that returns all of the repositories for the specified organization in /// response to a GET request. A POST to this URL creates a new repository for the organization. diff --git a/Octokit/Models/Request/NewRepository.cs b/Octokit/Models/Request/NewRepository.cs index 3942d53ed6..aa5152e618 100644 --- a/Octokit/Models/Request/NewRepository.cs +++ b/Octokit/Models/Request/NewRepository.cs @@ -47,6 +47,11 @@ public NewRepository(string name) /// public bool? HasWiki { get; set; } + /// + /// Either true to make this repo available as a template repository or false to prevent it. Default: false. + /// + public bool? IsTemplate { get; set; } + /// /// Optional. Gets or sets the new repository's optional website. /// diff --git a/Octokit/Models/Request/NewRepositoryFromTemplate.cs b/Octokit/Models/Request/NewRepositoryFromTemplate.cs new file mode 100644 index 0000000000..5385ef3cf0 --- /dev/null +++ b/Octokit/Models/Request/NewRepositoryFromTemplate.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Globalization; + +namespace Octokit +{ + /// + /// Describes a new repository to create via the method. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class NewRepositoryFromTemplate + { + /// + /// Creates an object that describes the repository to create on GitHub. + /// + /// The name of the repository. This is the only required parameter. + public NewRepositoryFromTemplate(string name) + { + Ensure.ArgumentNotNullOrEmptyString(name, nameof(name)); + + Name = name; + } + + /// + /// Optional. The organization or person who will own the new repository. + /// To create a new repository in an organization, the authenticated user must be a member of the specified organization. + /// + public string Owner { get; set; } + + /// + /// Required. The name of the new repository. + /// + public string Name { get; set; } + + /// + /// Optional. A short description of the new repository. + /// + public string Description { get; set; } + + /// + /// Optional. Either true to create a new private repository or false to create a new public one. Default: false + /// + public bool Private { get; set; } + + internal string DebuggerDisplay => string.Format(CultureInfo.InvariantCulture, "Name: {0} Description: {1}", Name, Description); + } +}