diff --git a/Octokit.Reactive/Clients/IObservableIssueTimelineClient.cs b/Octokit.Reactive/Clients/IObservableIssueTimelineClient.cs new file mode 100644 index 0000000000..251fa7c851 --- /dev/null +++ b/Octokit.Reactive/Clients/IObservableIssueTimelineClient.cs @@ -0,0 +1,57 @@ +using System; + +namespace Octokit.Reactive +{ + /// + /// A client for GitHub's Issue Timeline API. + /// + /// + /// See the Issue Timeline API documentation for more information. + /// + public interface IObservableIssueTimelineClient + { + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + IObservable GetAllForIssue(string owner, string repo, int number); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + /// Options for changing the API response + IObservable GetAllForIssue(string owner, string repo, int number, ApiOptions options); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + IObservable GetAllForIssue(int repositoryId, int number); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + /// Options for changing the API response + IObservable GetAllForIssue(int repositoryId, int number, ApiOptions options); + } +} diff --git a/Octokit.Reactive/Clients/IObservableIssuesClient.cs b/Octokit.Reactive/Clients/IObservableIssuesClient.cs index 9637d9b68e..0e9bd4478d 100644 --- a/Octokit.Reactive/Clients/IObservableIssuesClient.cs +++ b/Octokit.Reactive/Clients/IObservableIssuesClient.cs @@ -39,6 +39,11 @@ public interface IObservableIssuesClient /// IObservableIssueCommentsClient Comment { get; } + /// + /// Client for reading the timeline of events for an issue + /// + IObservableIssueTimelineClient Timeline { get; } + /// /// Gets a single Issue by number. /// diff --git a/Octokit.Reactive/Clients/ObservableIssueTimelineClient.cs b/Octokit.Reactive/Clients/ObservableIssueTimelineClient.cs new file mode 100644 index 0000000000..9e53b48678 --- /dev/null +++ b/Octokit.Reactive/Clients/ObservableIssueTimelineClient.cs @@ -0,0 +1,88 @@ +using System; +using Octokit.Reactive.Internal; + +namespace Octokit.Reactive +{ + /// + /// A client for GitHub's Issue Timeline API. + /// + /// + /// See the Issue Timeline API documentation for more information. + /// + public class ObservableIssueTimelineClient : IObservableIssueTimelineClient + { + readonly IConnection _connection; + + public ObservableIssueTimelineClient(IGitHubClient client) + { + Ensure.ArgumentNotNull(client, "client"); + + _connection = client.Connection; + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + public IObservable GetAllForIssue(string owner, string repo, int number) + { + Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); + Ensure.ArgumentNotNullOrEmptyString(repo, "repo"); + + return GetAllForIssue(owner, repo, number, ApiOptions.None); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + /// Options for changing the API response + public IObservable GetAllForIssue(string owner, string repo, int number, ApiOptions options) + { + Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); + Ensure.ArgumentNotNullOrEmptyString(repo, "repo"); + Ensure.ArgumentNotNull(options, "options"); + + return _connection.GetAndFlattenAllPages(ApiUrls.IssueTimeline(owner, repo, number), null, AcceptHeaders.IssueTimelineApiPreview, options); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + public IObservable GetAllForIssue(int repositoryId, int number) + { + return GetAllForIssue(repositoryId, number, ApiOptions.None); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + /// Options for changing the API response + public IObservable GetAllForIssue(int repositoryId, int number, ApiOptions options) + { + Ensure.ArgumentNotNull(options, "options"); + + return _connection.GetAndFlattenAllPages(ApiUrls.IssueTimeline(repositoryId, number), null, AcceptHeaders.IssueTimelineApiPreview, options); + } + } +} diff --git a/Octokit.Reactive/Clients/ObservableIssuesClient.cs b/Octokit.Reactive/Clients/ObservableIssuesClient.cs index 5156fe68b7..4e715ad53c 100644 --- a/Octokit.Reactive/Clients/ObservableIssuesClient.cs +++ b/Octokit.Reactive/Clients/ObservableIssuesClient.cs @@ -43,6 +43,11 @@ public class ObservableIssuesClient : IObservableIssuesClient /// public IObservableMilestonesClient Milestone { get; private set; } + /// + /// Client for reading the timeline of events for an issue + /// + public IObservableIssueTimelineClient Timeline { get; private set; } + public ObservableIssuesClient(IGitHubClient client) { Ensure.ArgumentNotNull(client, "client"); @@ -54,6 +59,7 @@ public ObservableIssuesClient(IGitHubClient client) Labels = new ObservableIssuesLabelsClient(client); Milestone = new ObservableMilestonesClient(client); Comment = new ObservableIssueCommentsClient(client); + Timeline = new ObservableIssueTimelineClient(client); } /// diff --git a/Octokit.Reactive/Octokit.Reactive-Mono.csproj b/Octokit.Reactive/Octokit.Reactive-Mono.csproj index 0964b1ffa7..c96ce61bfb 100644 --- a/Octokit.Reactive/Octokit.Reactive-Mono.csproj +++ b/Octokit.Reactive/Octokit.Reactive-Mono.csproj @@ -192,6 +192,8 @@ + + diff --git a/Octokit.Reactive/Octokit.Reactive-MonoAndroid.csproj b/Octokit.Reactive/Octokit.Reactive-MonoAndroid.csproj index 6a5e5193d4..2b555cc715 100644 --- a/Octokit.Reactive/Octokit.Reactive-MonoAndroid.csproj +++ b/Octokit.Reactive/Octokit.Reactive-MonoAndroid.csproj @@ -208,6 +208,8 @@ + + diff --git a/Octokit.Reactive/Octokit.Reactive-Monotouch.csproj b/Octokit.Reactive/Octokit.Reactive-Monotouch.csproj index dcef4ea5f3..0431a56579 100644 --- a/Octokit.Reactive/Octokit.Reactive-Monotouch.csproj +++ b/Octokit.Reactive/Octokit.Reactive-Monotouch.csproj @@ -204,6 +204,8 @@ + + diff --git a/Octokit.Reactive/Octokit.Reactive.csproj b/Octokit.Reactive/Octokit.Reactive.csproj index cf8b2786ac..ae1cdaf97e 100644 --- a/Octokit.Reactive/Octokit.Reactive.csproj +++ b/Octokit.Reactive/Octokit.Reactive.csproj @@ -93,6 +93,7 @@ + @@ -109,6 +110,7 @@ + diff --git a/Octokit.Tests.Integration/Clients/IssueTimelineClientTests.cs b/Octokit.Tests.Integration/Clients/IssueTimelineClientTests.cs new file mode 100644 index 0000000000..af18d72f67 --- /dev/null +++ b/Octokit.Tests.Integration/Clients/IssueTimelineClientTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading.Tasks; +using Octokit.Tests.Integration.Helpers; +using Xunit; + +namespace Octokit.Tests.Integration.Clients +{ + public class IssueTimelineClientTests :IDisposable + { + private readonly IIssueTimelineClient _issueTimelineClient; + private readonly IIssuesClient _issuesClient; + private readonly RepositoryContext _context; + + public IssueTimelineClientTests() + { + var github = Helper.GetAuthenticatedClient(); + + _issueTimelineClient = github.Issue.Timeline; + _issuesClient = github.Issue; + + var repoName = Helper.MakeNameWithTimestamp("public-repo"); + + _context = github.CreateRepositoryContext(new NewRepository(repoName)).Result; + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssue() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.RepositoryOwner, _context.RepositoryName, newIssue); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + Assert.Empty(timelineEventInfos); + + var closed = await _issuesClient.Update(_context.RepositoryOwner, _context.RepositoryName, issue.Number, new IssueUpdate() { State = ItemState.Closed }); + Assert.NotNull(closed); + + timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(EventInfoState.Closed, timelineEventInfos[0].Event); + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssueWithApiOptions() + { + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue("octokit", "octokit.net", 1115); + Assert.NotEmpty(timelineEventInfos); + Assert.NotEqual(1, timelineEventInfos.Count); + + var pageOptions = new ApiOptions + { + PageSize = 1, + PageCount = 1, + StartPage = 1 + }; + + timelineEventInfos = await _issueTimelineClient.GetAllForIssue("octokit", "octokit.net", 1115, pageOptions); + Assert.NotEmpty(timelineEventInfos); + Assert.Equal(1, timelineEventInfos.Count); + } + + [IntegrationTest] + public async Task CanDeserializeRenameEvent() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.RepositoryOwner, _context.RepositoryName, newIssue); + + var renamed = await _issuesClient.Update(_context.Repository.Id, issue.Number, new IssueUpdate { Title = "A test issue" }); + Assert.NotNull(renamed); + Assert.Equal("A test issue", renamed.Title); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal("a test issue", timelineEventInfos[0].Rename.From); + Assert.Equal("A test issue", timelineEventInfos[0].Rename.To); + } + + [IntegrationTest] + public async Task CanDeserializeCrossReferenceEvent() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.RepositoryOwner, _context.RepositoryName, newIssue); + + newIssue = new NewIssue("another test issue") { Body = "Another new unassigned issue referencing the first new issue in #" + issue.Number }; + var anotherNewIssue = await _issuesClient.Create(_context.Repository.Id, newIssue); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(anotherNewIssue.Id, timelineEventInfos[0].Source.Id); + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssueByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.Repository.Id, newIssue); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.Repository.Id, issue.Number); + Assert.Empty(timelineEventInfos); + + var closed = await _issuesClient.Update(_context.Repository.Id, issue.Number, new IssueUpdate() { State = ItemState.Closed }); + Assert.NotNull(closed); + + timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.Repository.Id, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(EventInfoState.Closed, timelineEventInfos[0].Event); + } + + [IntegrationTest] + public async Task CanDeserializeRenameEventByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.Repository.Id, newIssue); + + var renamed = await _issuesClient.Update(_context.Repository.Id, issue.Number, new IssueUpdate { Title = "A test issue" }); + Assert.NotNull(renamed); + Assert.Equal("A test issue", renamed.Title); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.Repository.Id, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal("a test issue", timelineEventInfos[0].Rename.From); + Assert.Equal("A test issue", timelineEventInfos[0].Rename.To); + } + + [IntegrationTest] + public async Task CanDeserializeCrossReferenceEventByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var issue = await _issuesClient.Create(_context.Repository.Id, newIssue); + + newIssue = new NewIssue("another test issue") { Body = "Another new unassigned issue referencing the first new issue in #" + issue.Number }; + var anotherNewIssue = await _issuesClient.Create(_context.Repository.Id, newIssue); + + var timelineEventInfos = await _issueTimelineClient.GetAllForIssue(_context.Repository.Id, issue.Number); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(anotherNewIssue.Id, timelineEventInfos[0].Source.Id); + } + + public void Dispose() + { + _context.Dispose(); + } + } +} diff --git a/Octokit.Tests.Integration/Octokit.Tests.Integration.csproj b/Octokit.Tests.Integration/Octokit.Tests.Integration.csproj index 4681be2473..62a1cd4090 100644 --- a/Octokit.Tests.Integration/Octokit.Tests.Integration.csproj +++ b/Octokit.Tests.Integration/Octokit.Tests.Integration.csproj @@ -87,6 +87,7 @@ + @@ -153,6 +154,7 @@ + diff --git a/Octokit.Tests.Integration/Reactive/ObservableIssueTimelineClientTests.cs b/Octokit.Tests.Integration/Reactive/ObservableIssueTimelineClientTests.cs new file mode 100644 index 0000000000..7cfb66a887 --- /dev/null +++ b/Octokit.Tests.Integration/Reactive/ObservableIssueTimelineClientTests.cs @@ -0,0 +1,158 @@ +using Octokit.Tests.Integration.Helpers; +using System.Reactive.Linq; +using System.Threading.Tasks; +using Octokit.Reactive; +using Xunit; + +namespace Octokit.Tests.Integration.Reactive +{ + public class ObservableIssueTimelineClientTests + { + private readonly RepositoryContext _context; + private readonly ObservableGitHubClient _client; + + public ObservableIssueTimelineClientTests() + { + var github = Helper.GetAuthenticatedClient(); + + _client = new ObservableGitHubClient(github); + + var reponame = Helper.MakeNameWithTimestamp("public-repo"); + _context = github.CreateRepositoryContext(new NewRepository(reponame)).Result; + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssue() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Empty(timelineEventInfos); + + observable = _client.Issue.Update(_context.Repository.Id, issue.Number, new IssueUpdate { State = ItemState.Closed }); + var closed = await observable; + Assert.NotNull(closed); + + observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(EventInfoState.Closed, timelineEventInfos[0].Event); + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssueWithApiOptions() + { + var observableTimeline = _client.Issue.Timeline.GetAllForIssue("octokit", "octokit.net", 1115); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.NotEmpty(timelineEventInfos); + Assert.NotEqual(1, timelineEventInfos.Count); + + var pageOptions = new ApiOptions + { + PageSize = 1, + PageCount = 1, + StartPage = 1 + }; + observableTimeline = _client.Issue.Timeline.GetAllForIssue("octokit", "octokit.net", 1115, pageOptions); + timelineEventInfos = await observableTimeline.ToList(); + Assert.NotEmpty(timelineEventInfos); + Assert.Equal(1, timelineEventInfos.Count); + } + + [IntegrationTest] + public async Task CanDeserializeRenameEvent() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + observable = _client.Issue.Update(_context.Repository.Id, issue.Number, new IssueUpdate { Title = "A test issue" }); + var renamed = await observable; + Assert.NotNull(renamed); + Assert.Equal("A test issue", renamed.Title); + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal("a test issue", timelineEventInfos[0].Rename.From); + Assert.Equal("A test issue", timelineEventInfos[0].Rename.To); + } + + [IntegrationTest] + public async Task CanDeserializeCrossReferenceEvent() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + newIssue = new NewIssue("another test issue") { Body = "Another new unassigned issue referencing the first new issue in #" + issue.Number }; + observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var anotherNewIssue = await observable; + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.RepositoryOwner, _context.RepositoryName, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(anotherNewIssue.Id, timelineEventInfos[0].Source.Id); + } + + [IntegrationTest] + public async Task CanRetrieveTimelineForIssueByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.Repository.Id, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Empty(timelineEventInfos); + + observable = _client.Issue.Update(_context.Repository.Id, issue.Number, new IssueUpdate { State = ItemState.Closed }); + var closed = await observable; + Assert.NotNull(closed); + + observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.Repository.Id, issue.Number); + timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(EventInfoState.Closed, timelineEventInfos[0].Event); + } + + [IntegrationTest] + public async Task CanDeserializeRenameEventByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + observable = _client.Issue.Update(_context.Repository.Id, issue.Number, new IssueUpdate { Title = "A test issue" }); + var renamed = await observable; + Assert.NotNull(renamed); + Assert.Equal("A test issue", renamed.Title); + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.Repository.Id, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal("a test issue", timelineEventInfos[0].Rename.From); + Assert.Equal("A test issue", timelineEventInfos[0].Rename.To); + } + + [IntegrationTest] + public async Task CanDeserializeCrossReferenceEventByRepositoryId() + { + var newIssue = new NewIssue("a test issue") { Body = "A new unassigned issue" }; + var observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var issue = await observable; + + newIssue = new NewIssue("another test issue") { Body = "Another new unassigned issue referencing the first new issue in #" + issue.Number }; + observable = _client.Issue.Create(_context.Repository.Id, newIssue); + var anotherNewIssue = await observable; + + var observableTimeline = _client.Issue.Timeline.GetAllForIssue(_context.Repository.Id, issue.Number); + var timelineEventInfos = await observableTimeline.ToList(); + Assert.Equal(1, timelineEventInfos.Count); + Assert.Equal(anotherNewIssue.Id, timelineEventInfos[0].Source.Id); + } + } +} diff --git a/Octokit.Tests/Clients/IssueTimelineClientTests.cs b/Octokit.Tests/Clients/IssueTimelineClientTests.cs new file mode 100644 index 0000000000..694cedd7f2 --- /dev/null +++ b/Octokit.Tests/Clients/IssueTimelineClientTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NSubstitute; +using Xunit; + +namespace Octokit.Tests.Clients +{ + public class IssueTimelineClientTests + { + public class TheCtor + { + [Fact] + public void EnsuresNonNullArguments() + { + Assert.Throws( + () => new IssueTimelineClient(null)); + } + } + + public class TheGetAllForIssueMethod + { + [Fact] + public async Task RequestsTheCorrectUrl() + { + var connection = Substitute.For(); + var client = new IssueTimelineClient(connection); + + await client.GetAllForIssue("fake", "repo", 42); + + connection.Received().GetAll( + Arg.Is(u => u.ToString() == "repos/fake/repo/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview", + Arg.Any()); + } + + [Fact] + public async Task RequestsTheCorrectUrlWithApiOptions() + { + var connection = Substitute.For(); + var client = new IssueTimelineClient(connection); + + await client.GetAllForIssue("fake", "repo", 42, new ApiOptions {PageSize = 30}); + + connection.Received().GetAll( + Arg.Is(u => u.ToString() == "repos/fake/repo/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview", + Arg.Is(ao => ao.PageSize == 30)); + } + + [Fact] + public async Task RequestsTheCorrectUrlWithRepositoryId() + { + var connection = Substitute.For(); + var client = new IssueTimelineClient(connection); + + await client.GetAllForIssue(1, 42); + + connection.Received().GetAll( + Arg.Is(u => u.ToString() == "repositories/1/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview", + Arg.Any()); + } + + [Fact] + public async Task RequestsTheCorrectUrlWithRepositoryIdAndApiOptions() + { + var connection = Substitute.For(); + var client = new IssueTimelineClient(connection); + + await client.GetAllForIssue(1, 42, new ApiOptions {PageSize = 30}); + + connection.Received().GetAll( + Arg.Is(u => u.ToString() == "repositories/1/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview", + Arg.Is(ao => ao.PageSize == 30)); + } + + [Fact] + public async Task EnsuresNonNullArguments() + { + var client = new IssueTimelineClient(Substitute.For()); + + await Assert.ThrowsAsync(() => client.GetAllForIssue(null, "repo", 42)); + await Assert.ThrowsAsync(() => client.GetAllForIssue("owner", null, 42)); + await Assert.ThrowsAsync(() => client.GetAllForIssue("owner", "repo", 42, null)); + await Assert.ThrowsAsync(() => client.GetAllForIssue(1, 42, null)); + + await Assert.ThrowsAsync(() => client.GetAllForIssue("", "repo", 42)); + await Assert.ThrowsAsync(() => client.GetAllForIssue("owner", "", 42)); + + } + } + } +} diff --git a/Octokit.Tests/Octokit.Tests.csproj b/Octokit.Tests/Octokit.Tests.csproj index a05b3f8092..673961abfb 100644 --- a/Octokit.Tests/Octokit.Tests.csproj +++ b/Octokit.Tests/Octokit.Tests.csproj @@ -88,6 +88,7 @@ + @@ -208,6 +209,7 @@ + diff --git a/Octokit.Tests/Reactive/ObservableIssueTimelineClientTests.cs b/Octokit.Tests/Reactive/ObservableIssueTimelineClientTests.cs new file mode 100644 index 0000000000..776f84352b --- /dev/null +++ b/Octokit.Tests/Reactive/ObservableIssueTimelineClientTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using NSubstitute; +using Octokit.Internal; +using Octokit.Reactive; +using Xunit; + +namespace Octokit.Tests.Reactive +{ + public class ObservableIssueTimelineClientTests + { + public class TheCtor + { + [Fact] + public void EnsuresNonNullArguments() + { + Assert.Throws( + () => new ObservableIssueTimelineClient(null)); + } + } + + public class TheGetAllForIssueMethod + { + [Fact] + public async Task RequestsCorrectUrl() + { + var result = new List { new TimelineEventInfo() }; + + var connection = Substitute.For(); + var gitHubClient = new GitHubClient(connection); + var client = new ObservableIssueTimelineClient(gitHubClient); + + IApiResponse> response = new ApiResponse>( + new Response + { + ApiInfo = new ApiInfo(new Dictionary(), new List(), new List(), "etag", new RateLimit()), + }, result); + gitHubClient.Connection.Get>(Args.Uri, Args.EmptyDictionary, "application/vnd.github.mockingbird-preview") + .Returns(Task.FromResult(response)); + + var timelineEvents = await client.GetAllForIssue("fake", "repo", 42).ToList(); + + connection.Received().Get>( + Arg.Is(u => u.ToString() == "repos/fake/repo/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview"); + Assert.Equal(1, timelineEvents.Count); + } + + [Fact] + public async Task RequestsCorrectUrlWithApiOptions() + { + var result = new List { new TimelineEventInfo() }; + + var connection = Substitute.For(); + var gitHubClient = new GitHubClient(connection); + var client = new ObservableIssueTimelineClient(gitHubClient); + + IApiResponse> response = new ApiResponse>( + new Response + { + ApiInfo = new ApiInfo(new Dictionary(), new List(), new List(), "etag", new RateLimit()), + }, result); + gitHubClient.Connection.Get>(Args.Uri, Arg.Is>(d => d.Count == 1), "application/vnd.github.mockingbird-preview") + .Returns(Task.FromResult(response)); + + var timelineEvents = await client.GetAllForIssue("fake", "repo", 42, new ApiOptions {PageSize = 30}).ToList(); + + connection.Received().Get>( + Arg.Is(u => u.ToString() == "repos/fake/repo/issues/42/timeline"), + Arg.Is>(d => d.Count == 1 && d["per_page"] == "30"), + "application/vnd.github.mockingbird-preview"); + Assert.Equal(1, timelineEvents.Count); + } + + [Fact] + public async Task RequestCorrectUrlWithRepositoryId() + { + var result = new List { new TimelineEventInfo() }; + var connection = Substitute.For(); + var githubClient = new GitHubClient(connection); + var client = new ObservableIssueTimelineClient(githubClient); + + IApiResponse> response = new ApiResponse>( + new Response + { + ApiInfo = new ApiInfo(new Dictionary(), new List(), new List(), "etag", new RateLimit()), + }, result); + githubClient.Connection.Get>(Args.Uri, Args.EmptyDictionary, "application/vnd.github.mockingbird-preview") + .Returns(Task.FromResult(response)); + + var timelineEvents = await client.GetAllForIssue(1, 42).ToList(); + + connection.Received().Get>( + Arg.Is(u => u.ToString() == "repositories/1/issues/42/timeline"), + Arg.Any>(), + "application/vnd.github.mockingbird-preview"); + Assert.Equal(1, timelineEvents.Count); + } + + [Fact] + public async Task RequestCorrectUrlWithRepositoryIdAndApiOptions() + { + var result = new List { new TimelineEventInfo() }; + var connection = Substitute.For(); + var githubClient = new GitHubClient(connection); + var client = new ObservableIssueTimelineClient(githubClient); + + IApiResponse> response = new ApiResponse>( + new Response + { + ApiInfo = new ApiInfo(new Dictionary(), new List(), new List(), "etag", new RateLimit()), + }, result); + githubClient.Connection.Get>(Args.Uri, Arg.Is>(d => d.Count == 1), "application/vnd.github.mockingbird-preview") + .Returns(Task.FromResult(response)); + + var timelineEvents = await client.GetAllForIssue(1, 42, new ApiOptions {PageSize = 30}).ToList(); + + connection.Received().Get>( + Arg.Is(u => u.ToString() == "repositories/1/issues/42/timeline"), + Arg.Is>(d => d.Count == 1 && d["per_page"] == "30"), + "application/vnd.github.mockingbird-preview"); + Assert.Equal(1, timelineEvents.Count); + } + + [Fact] + public async Task EnsuresNonNullArguments() + { + var client = new ObservableIssueTimelineClient(Substitute.For()); + + Assert.Throws(() => client.GetAllForIssue(null, "repo", 42)); + Assert.Throws(() => client.GetAllForIssue("owner", null, 42)); + Assert.Throws(() => client.GetAllForIssue("owner", "repo", 42, null)); + Assert.Throws(() => client.GetAllForIssue(1, 42, null)); + + Assert.Throws(() => client.GetAllForIssue("", "repo", 42)); + Assert.Throws(() => client.GetAllForIssue("owner", "", 42)); + + } + } + } +} diff --git a/Octokit/Clients/IIssueTimelineClient.cs b/Octokit/Clients/IIssueTimelineClient.cs new file mode 100644 index 0000000000..e6321ea638 --- /dev/null +++ b/Octokit/Clients/IIssueTimelineClient.cs @@ -0,0 +1,60 @@ +#if NET_45 +using System.Collections.Generic; +using System.Threading.Tasks; +#endif + +namespace Octokit +{ + /// + /// A client for GitHub's Issue Timeline API. + /// + /// + /// See the Issue Timeline API documentation for more information. + /// + public interface IIssueTimelineClient + { + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + Task> GetAllForIssue(string owner, string repo, int number); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + /// Options for changing the API repsonse + Task> GetAllForIssue(string owner, string repo, int number, ApiOptions options); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + Task> GetAllForIssue(int repositoryId, int number); + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + /// Options for changing the API response + Task> GetAllForIssue(int repositoryId, int number, ApiOptions options); + } +} diff --git a/Octokit/Clients/IIssuesClient.cs b/Octokit/Clients/IIssuesClient.cs index 5dbfb1d948..81f8843b09 100644 --- a/Octokit/Clients/IIssuesClient.cs +++ b/Octokit/Clients/IIssuesClient.cs @@ -39,6 +39,8 @@ public interface IIssuesClient /// IIssueCommentsClient Comment { get; } + IIssueTimelineClient Timeline { get; } + /// /// Gets a single Issue by number. /// diff --git a/Octokit/Clients/IssueTimelineClient.cs b/Octokit/Clients/IssueTimelineClient.cs new file mode 100644 index 0000000000..ec71d3f3d0 --- /dev/null +++ b/Octokit/Clients/IssueTimelineClient.cs @@ -0,0 +1,85 @@ +#if NET_45 +using System.Collections.Generic; +using System.Threading.Tasks; +#endif + +namespace Octokit +{ + /// + /// A client for GitHub's Issue Timeline API. + /// + /// + /// See the Issue Timeline API documentation for more information. + /// + public class IssueTimelineClient: ApiClient, IIssueTimelineClient + { + public IssueTimelineClient(IApiConnection apiConnection) : base(apiConnection) + { + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + public Task> GetAllForIssue(string owner, string repo, int number) + { + Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); + Ensure.ArgumentNotNullOrEmptyString(repo, "repo"); + + return GetAllForIssue(owner, repo, number, ApiOptions.None); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + /// Options for changing the API repsonse + public Task> GetAllForIssue(string owner, string repo, int number, ApiOptions options) + { + Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); + Ensure.ArgumentNotNullOrEmptyString(repo, "repo"); + Ensure.ArgumentNotNull(options, "options"); + + return ApiConnection.GetAll(ApiUrls.IssueTimeline(owner, repo, number), null, AcceptHeaders.IssueTimelineApiPreview, options); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + public Task> GetAllForIssue(int repositoryId, int number) + { + return GetAllForIssue(repositoryId, number, ApiOptions.None); + } + + /// + /// Gets all the various events that have occurred around an issue or pull request. + /// + /// + /// https://developer.github.com/v3/issues/timeline/#list-events-for-an-issue + /// + /// The Id of the repository + /// The issue number + /// Options for changing the API response + public Task> GetAllForIssue(int repositoryId, int number, ApiOptions options) + { + Ensure.ArgumentNotNull(options, "options"); + + return ApiConnection.GetAll(ApiUrls.IssueTimeline(repositoryId, number), null, AcceptHeaders.IssueTimelineApiPreview, options); + } + } +} diff --git a/Octokit/Clients/IssuesClient.cs b/Octokit/Clients/IssuesClient.cs index 7387527892..3542da4e4b 100644 --- a/Octokit/Clients/IssuesClient.cs +++ b/Octokit/Clients/IssuesClient.cs @@ -22,6 +22,7 @@ public IssuesClient(IApiConnection apiConnection) : base(apiConnection) Labels = new IssuesLabelsClient(apiConnection); Milestone = new MilestonesClient(apiConnection); Comment = new IssueCommentsClient(apiConnection); + Timeline = new IssueTimelineClient(apiConnection); } /// @@ -51,6 +52,11 @@ public IssuesClient(IApiConnection apiConnection) : base(apiConnection) /// public IIssueCommentsClient Comment { get; private set; } + /// + /// Client for reading the timeline of events for an issue + /// + public IIssueTimelineClient Timeline { get; private set; } + /// /// Gets a single Issue by number. /// diff --git a/Octokit/Helpers/AcceptHeaders.cs b/Octokit/Helpers/AcceptHeaders.cs index 6082aa3f21..f85ebc322f 100644 --- a/Octokit/Helpers/AcceptHeaders.cs +++ b/Octokit/Helpers/AcceptHeaders.cs @@ -38,5 +38,7 @@ public static class AcceptHeaders public const string InvitationsApiPreview = "application/vnd.github.swamp-thing-preview+json"; public const string PagesApiPreview = "application/vnd.github.mister-fantastic-preview+json"; + + public const string IssueTimelineApiPreview = "application/vnd.github.mockingbird-preview"; } } diff --git a/Octokit/Helpers/ApiUrls.cs b/Octokit/Helpers/ApiUrls.cs index 5dbfe3d313..c4d964e849 100644 --- a/Octokit/Helpers/ApiUrls.cs +++ b/Octokit/Helpers/ApiUrls.cs @@ -341,6 +341,29 @@ public static Uri IssueReactions(int repositoryId, int number) return "repositories/{0}/issues/{1}/reactions".FormatUri(repositoryId, number); } + /// + /// Returns the for the timeline of a specified issue. + /// + /// The owner of the repository + /// The name of the repository + /// The issue number + /// + public static Uri IssueTimeline(string owner, string repo, int number) + { + return "repos/{0}/{1}/issues/{2}/timeline".FormatUri(owner, repo, number); + } + + /// + /// Returns the for the timeline of a specified issue. + /// + /// The Id of the repository + /// The issue number + /// + public static Uri IssueTimeline(int repositoryId, int number) + { + return "repositories/{0}/issues/{1}/timeline".FormatUri(repositoryId, number); + } + /// /// Returns the for the comments for all issues in a specific repo. /// diff --git a/Octokit/Models/Response/EventInfo.cs b/Octokit/Models/Response/EventInfo.cs index a659994104..cd4529570e 100644 --- a/Octokit/Models/Response/EventInfo.cs +++ b/Octokit/Models/Response/EventInfo.cs @@ -165,6 +165,25 @@ public enum EventInfoState /// /// The actor unsubscribed from notifications for an issue. /// - Unsubscribed + Unsubscribed, + + /// + /// A comment was added to the issue. + /// + Commented, + + /// + /// A commit was added to the pull request's HEAD branch. + /// Only provided for pull requests. + /// + Committed, + + /// + /// The issue was referenced from another issue. + /// The source attribute contains the id, actor, and + /// url of the reference's source. + /// + [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Crossreferenced")] + Crossreferenced } } \ No newline at end of file diff --git a/Octokit/Models/Response/RenameInfo.cs b/Octokit/Models/Response/RenameInfo.cs new file mode 100644 index 0000000000..d21f0f3234 --- /dev/null +++ b/Octokit/Models/Response/RenameInfo.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; +using System.Globalization; + +namespace Octokit +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class RenameInfo + { + public string From { get; protected set; } + public string To { get; protected set; } + + internal string DebuggerDisplay + { + get { return string.Format(CultureInfo.InvariantCulture, "From: {0} To: {1}", From, To); } + } + } +} diff --git a/Octokit/Models/Response/SourceInfo.cs b/Octokit/Models/Response/SourceInfo.cs new file mode 100644 index 0000000000..ff91bdfec5 --- /dev/null +++ b/Octokit/Models/Response/SourceInfo.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Globalization; + +namespace Octokit +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class SourceInfo + { + public User Actor { get; protected set; } + public int Id { get; protected set; } + public string Url { get; protected set; } + + internal string DebuggerDisplay + { + get { return string.Format(CultureInfo.InvariantCulture, "Id: {0} Url: {1}", Id, Url); } + } + } +} diff --git a/Octokit/Models/Response/TimelineEventInfo.cs b/Octokit/Models/Response/TimelineEventInfo.cs new file mode 100644 index 0000000000..f67c6fc7dd --- /dev/null +++ b/Octokit/Models/Response/TimelineEventInfo.cs @@ -0,0 +1,44 @@ +using System; +using System.Diagnostics; +using System.Globalization; + +namespace Octokit +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class TimelineEventInfo + { + public TimelineEventInfo() { } + + public TimelineEventInfo(int id, string url, User actor, string commitId, EventInfoState @event, DateTimeOffset createdAt, Label label, User assignee, Milestone milestone, SourceInfo source, RenameInfo rename) + { + Id = id; + Url = url; + Actor = actor; + CommitId = commitId; + Event = @event; + CreatedAt = createdAt; + Label = label; + Assignee = assignee; + Milestone = milestone; + Source = source; + Rename = rename; + } + + public int Id { get; protected set; } + public string Url { get; protected set; } + public User Actor { get; protected set; } + public string CommitId { get; protected set; } + public EventInfoState Event { get; protected set; } + public DateTimeOffset CreatedAt { get; protected set; } + public Label Label { get; protected set; } + public User Assignee { get; protected set; } + public Milestone Milestone { get; protected set; } + public SourceInfo Source { get; protected set; } + public RenameInfo Rename { get; protected set; } + + internal string DebuggerDisplay + { + get { return string.Format(CultureInfo.InvariantCulture, "Id: {0} CreatedAt: {1} Event: {2}", Id, CreatedAt, Event); } + } + } +} diff --git a/Octokit/Octokit-Mono.csproj b/Octokit/Octokit-Mono.csproj index dda54a6619..6671612640 100644 --- a/Octokit/Octokit-Mono.csproj +++ b/Octokit/Octokit-Mono.csproj @@ -484,6 +484,11 @@ + + + + + \ No newline at end of file diff --git a/Octokit/Octokit-MonoAndroid.csproj b/Octokit/Octokit-MonoAndroid.csproj index 2c472d67d9..3b494a38e5 100644 --- a/Octokit/Octokit-MonoAndroid.csproj +++ b/Octokit/Octokit-MonoAndroid.csproj @@ -495,6 +495,11 @@ + + + + + \ No newline at end of file diff --git a/Octokit/Octokit-Monotouch.csproj b/Octokit/Octokit-Monotouch.csproj index 16c0e17c21..40f8ba1911 100644 --- a/Octokit/Octokit-Monotouch.csproj +++ b/Octokit/Octokit-Monotouch.csproj @@ -491,6 +491,11 @@ + + + + + diff --git a/Octokit/Octokit-Portable.csproj b/Octokit/Octokit-Portable.csproj index f28e8a25ac..3270a8efa6 100644 --- a/Octokit/Octokit-Portable.csproj +++ b/Octokit/Octokit-Portable.csproj @@ -481,6 +481,11 @@ + + + + + diff --git a/Octokit/Octokit-netcore45.csproj b/Octokit/Octokit-netcore45.csproj index d807f7c109..c0b3f04f29 100644 --- a/Octokit/Octokit-netcore45.csproj +++ b/Octokit/Octokit-netcore45.csproj @@ -488,6 +488,11 @@ + + + + + diff --git a/Octokit/Octokit.csproj b/Octokit/Octokit.csproj index 4adbffc62a..0e0f5347c4 100644 --- a/Octokit/Octokit.csproj +++ b/Octokit/Octokit.csproj @@ -61,10 +61,12 @@ + + @@ -207,7 +209,10 @@ + + +