Skip to content

Commit 72add0d

Browse files
authored
Merge pull request #101 from WeTransfer/feature/fix-release-notes
Make sure GH release notes are used correctly with previous tag
2 parents 9bd34e0 + 8d820fa commit 72add0d

File tree

11 files changed

+211
-17
lines changed

11 files changed

+211
-17
lines changed

Diff for: Package.resolved

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"repositoryURL": "https://github.com/WeTransfer/octokit.swift",
1616
"state": {
1717
"branch": "main",
18-
"revision": "3d0fea9587af530cb13ef5801a3cb90186fce43e",
18+
"revision": "d3706890a06d2f9afc1de4665191167058438153",
1919
"version": null
2020
}
2121
},

Diff for: Sources/GitBuddyCore/Changelog/ChangelogProducer.swift

+50-1
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,44 @@ final class ChangelogProducer: URLSessionInjectable {
3333
}
3434

3535
private lazy var octoKit: Octokit = .init()
36+
37+
/// The base branch to compare with. Defaults to master.
3638
let baseBranch: Branch
39+
40+
/// "The tag to use as a base. Defaults to the latest tag.
3741
let since: Since
42+
3843
let from: Date
3944
let to: Date
45+
46+
/// The GIT Project to create a changelog for.
4047
let project: GITProject
4148

42-
init(since: Since = .latestTag, to: Date = Date(), baseBranch: Branch?) throws {
49+
/// If `true`, release notes will be generated by GitHub.
50+
/// Defaults to false, which will lead to a changelog based on PR and issue titles matching the tag changes.
51+
let useGitHubReleaseNotes: Bool
52+
53+
/// The name of the tag to use as base for the changelog comparison.
54+
let tagName: String?
55+
56+
/// Specifies the commitish value that will be the target for the release's tag.
57+
/// Required if the supplied tagName does not reference an existing tag. Ignored if the tagName already exists.
58+
/// While we're talking about creating tags, the changelog producer will only use these values for GitHub release
59+
/// rotes generation.
60+
let targetCommitish: String?
61+
62+
/// The previous tag to compare against. Will only be used for GitHub release notes generation.
63+
let previousTagName: String?
64+
65+
init(
66+
since: Since = .latestTag,
67+
to: Date = Date(),
68+
baseBranch: Branch?,
69+
useGitHubReleaseNotes: Bool = false,
70+
tagName: String? = nil,
71+
targetCommitish: String? = nil,
72+
previousTagName: String? = nil
73+
) throws {
4374
try Octokit.authenticate()
4475

4576
self.to = to
@@ -57,9 +88,27 @@ final class ChangelogProducer: URLSessionInjectable {
5788
Log.debug("Getting all changes between \(self.from) and \(self.to)")
5889
self.baseBranch = baseBranch ?? "master"
5990
project = GITProject.current()
91+
self.useGitHubReleaseNotes = useGitHubReleaseNotes
92+
self.tagName = tagName
93+
self.targetCommitish = targetCommitish
94+
self.previousTagName = previousTagName
6095
}
6196

6297
@discardableResult public func run(isSectioned: Bool) throws -> Changelog {
98+
if useGitHubReleaseNotes, let tagName, let targetCommitish, let previousTagName {
99+
return try GitHubReleaseNotesGenerator(
100+
octoKit: octoKit,
101+
project: project,
102+
tagName: tagName,
103+
targetCommitish: targetCommitish,
104+
previousTagName: previousTagName
105+
).generate(using: urlSession)
106+
} else {
107+
return try generateChangelogUsingPRsAndIssues(isSectioned: isSectioned)
108+
}
109+
}
110+
111+
private func generateChangelogUsingPRsAndIssues(isSectioned: Bool) throws -> Changelog {
63112
let pullRequestsFetcher = PullRequestFetcher(octoKit: octoKit, baseBranch: baseBranch, project: project)
64113
let pullRequests = try pullRequestsFetcher.fetchAllBetween(from, and: to, using: urlSession)
65114

Diff for: Sources/GitBuddyCore/Commands/ReleaseCommand.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ struct ReleaseCommand: ParsableCommand {
7272
@Flag(name: .customLong("sections"), help: "Whether the changelog should be split into sections. Defaults to false.")
7373
private var isSectioned: Bool = false
7474

75-
@Flag(name: .customLong("useGitHubReleaseNotes"), help: "If `true`, release notes will be generated by GitHub. Defaults to false, which will lead to a changelog based on PR and issue titles matching the tag changes.")
75+
@Flag(name: .customLong("use-github-release-notes"), help: "If `true`, release notes will be generated by GitHub. Defaults to false, which will lead to a changelog based on PR and issue titles matching the tag changes.")
7676
private var useGitHubReleaseNotes: Bool = false
7777

7878
@Flag(name: .customLong("json"), help: "Whether the release output should be in JSON, containing more details. Defaults to false.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// GitHubReleaseNotesGenerator.swift
3+
//
4+
//
5+
// Created by Antoine van der Lee on 13/06/2023.
6+
// Copyright © 2023 WeTransfer. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import OctoKit
11+
12+
struct GitHubReleaseNotesGenerator {
13+
let octoKit: Octokit
14+
let project: GITProject
15+
let tagName: String
16+
let targetCommitish: String
17+
let previousTagName: String
18+
19+
func generate(using session: URLSession = URLSession.shared) throws -> ReleaseNotes {
20+
let group = DispatchGroup()
21+
group.enter()
22+
23+
var result: Result<ReleaseNotes, Swift.Error>!
24+
25+
octoKit.generateReleaseNotes(
26+
session,
27+
owner: project.organisation,
28+
repository: project.repository,
29+
tagName: tagName,
30+
targetCommitish: targetCommitish,
31+
previousTagName: previousTagName) { response in
32+
switch response {
33+
case .success(let releaseNotes):
34+
result = .success(releaseNotes)
35+
case .failure(let error):
36+
result = .failure(OctoKitError(error: error))
37+
}
38+
group.leave()
39+
}
40+
41+
group.wait()
42+
43+
return try result.get()
44+
}
45+
}

Diff for: Sources/GitBuddyCore/Models/Changelog.swift

+8
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ protocol Changelog: CustomStringConvertible {
2020
var itemIdentifiers: [PullRequestID: [IssueID]] { get }
2121
}
2222

23+
extension ReleaseNotes: Changelog {
24+
var itemIdentifiers: [PullRequestID : [IssueID]] {
25+
[:]
26+
}
27+
28+
public var description: String { body }
29+
}
30+
2331
/// Represents a changelog with a single section of changelog items.
2432
struct SingleSectionChangelog: Changelog {
2533
let description: String

Diff for: Sources/GitBuddyCore/Release/ReleaseProducer.swift

+43-3
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,50 @@ final class ReleaseProducer: URLSessionInjectable, ShellInjectable {
2323
}
2424

2525
private lazy var octoKit: Octokit = .init()
26+
27+
/// The path to the Changelog to update it with the latest changes.
2628
let changelogURL: Foundation.URL?
29+
30+
/// Disable commenting on issues and PRs about the new release.
2731
let skipComments: Bool
32+
33+
/// Create the release as a pre-release.
2834
let isPrerelease: Bool
35+
36+
/*
37+
Specifies the commitish value that determines where the Git tag is created from. Can be any branch or commit SHA.
38+
Unused if the Git tag already exists.
39+
40+
Default: the repository's default branch (usually main).
41+
*/
2942
let targetCommitish: String?
43+
44+
/*
45+
The name of the tag. If set, `changelogToTag` is required too.
46+
47+
Default: takes the last created tag to publish as a GitHub release.
48+
*/
3049
let tagName: String?
50+
51+
/// The title of the release. Default: uses the tag name.
3152
let releaseTitle: String?
53+
54+
/// The last release tag to use as a base for the changelog creation. Default: previous tag.
3255
let lastReleaseTag: String?
56+
57+
/*
58+
If set, the date of this tag will be used as the limit for the changelog creation.
59+
This variable should be passed when `tagName` is set.
60+
61+
Default: latest tag.
62+
*/
3363
let changelogToTag: String?
64+
65+
/// The base branch to compare with for generating the changelog. Defaults to master.
3466
let baseBranch: String
67+
68+
/// If `true`, release notes will be generated by GitHub.
69+
/// Defaults to false, which will lead to a changelog based on PR and issue titles matching the tag changes.
3570
let useGitHubReleaseNotes: Bool
3671

3772
init(
@@ -69,17 +104,21 @@ final class ReleaseProducer: URLSessionInjectable, ShellInjectable {
69104

70105
/// We're adding 60 seconds to make sure the tag commit itself is included in the changelog as well.
71106
let adjustedChangelogToDate = changelogToDate.addingTimeInterval(60)
107+
let tagName = try tagName ?? Tag.latest().name
72108

73109
let changelogSinceTag = lastReleaseTag ?? Self.shell.execute(.previousTag)
74110
let changelogProducer = try ChangelogProducer(
75111
since: .tag(tag: changelogSinceTag),
76112
to: adjustedChangelogToDate,
77-
baseBranch: baseBranch
113+
baseBranch: baseBranch,
114+
useGitHubReleaseNotes: useGitHubReleaseNotes,
115+
tagName: tagName,
116+
targetCommitish: targetCommitish,
117+
previousTagName: changelogSinceTag
78118
)
79119
let changelog = try changelogProducer.run(isSectioned: isSectioned)
80120
Log.debug("\(changelog)\n")
81121

82-
let tagName = try tagName ?? Tag.latest().name
83122
try updateChangelogFile(adding: changelog.description, for: tagName)
84123

85124
let repositoryName = Self.shell.execute(.repositoryName)
@@ -206,7 +245,8 @@ final class ReleaseProducer: URLSessionInjectable, ShellInjectable {
206245
body: body,
207246
prerelease: isPrerelease,
208247
draft: false,
209-
generateReleaseNotes: useGitHubReleaseNotes
248+
/// Since GitHub's API does not support setting `previous_tag_name`, we manually call the API to generate automated GH changelogs.
249+
generateReleaseNotes: false
210250
) { response in
211251
switch response {
212252
case .success(let release):

Diff for: Tests/GitBuddyTests/Changelog/ChangelogItemsFactoryTests.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ final class ChangelogItemsFactoryTests: XCTestCase {
3131

3232
/// It should return the pull request only if no referencing issues are found.
3333
func testCreatingItems() {
34-
let pullRequest = PullRequestsJSON.data(using: .utf8)!.mapJSON(to: [PullRequest].self).first!
34+
let pullRequest = Data(PullRequestsJSON.utf8).mapJSON(to: [PullRequest].self).first!
3535
let factory = ChangelogItemsFactory(octoKit: octoKit, pullRequests: [pullRequest], project: project)
3636
let items = factory.items(using: urlSession)
3737
XCTAssertEqual(items.count, 1)
@@ -41,8 +41,8 @@ final class ChangelogItemsFactoryTests: XCTestCase {
4141

4242
/// It should return the referencing issue with the pull request.
4343
func testReferencingIssue() {
44-
let pullRequest = PullRequestsJSON.data(using: .utf8)!.mapJSON(to: [PullRequest].self).last!
45-
let issue = IssueJSON.data(using: .utf8)!.mapJSON(to: Issue.self)
44+
let pullRequest = Data(PullRequestsJSON.utf8).mapJSON(to: [PullRequest].self).last!
45+
let issue = Data(IssueJSON.utf8).mapJSON(to: Issue.self)
4646
let factory = ChangelogItemsFactory(octoKit: octoKit, pullRequests: [pullRequest], project: project)
4747
Mocker.mockForIssueNumber(39)
4848
let items = factory.items(using: urlSession)

Diff for: Tests/GitBuddyTests/Models/ChangelogItemTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ final class ChangelogItemTests: XCTestCase {
3838

3939
/// It should show the user if possible.
4040
func testUser() {
41-
let input = PullRequestsJSON.data(using: .utf8)!.mapJSON(to: [PullRequest].self).first!
41+
let input = Data(PullRequestsJSON.utf8).mapJSON(to: [PullRequest].self).first!
4242
input.htmlURL = nil
4343
let item = ChangelogItem(input: input, closedBy: input)
4444
XCTAssertEqual(item.title, "Add charset utf-8 to html head via [@AvdLee](https://github.com/AvdLee)")
4545
}
4646

4747
/// It should fallback to the assignee if the user is nil for Pull Requests.
4848
func testAssigneeFallback() {
49-
let input = PullRequestsJSON.data(using: .utf8)!.mapJSON(to: [PullRequest].self).first!
49+
let input = Data(PullRequestsJSON.utf8).mapJSON(to: [PullRequest].self).first!
5050
input.user = nil
5151
input.htmlURL = nil
5252
let item = ChangelogItem(input: input, closedBy: input)

Diff for: Tests/GitBuddyTests/Release/ReleaseProducerTests.swift

+22
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ final class ReleaseProducerTests: XCTestCase {
7878
wait(for: [mockExpectation], timeout: 0.3)
7979
}
8080

81+
/// It should set the parameters correctly.
82+
func testPostBodyArgumentsGitHubReleaseNotes() throws {
83+
let mockExpectation = expectation(description: "Mocks should be called")
84+
Mocker.mockReleaseNotes()
85+
var mock = Mocker.mockRelease()
86+
mock.onRequest = { _, parameters in
87+
guard let parameters = try? XCTUnwrap(parameters) else { return }
88+
XCTAssertEqual(parameters["prerelease"] as? Bool, false)
89+
XCTAssertEqual(parameters["draft"] as? Bool, false)
90+
XCTAssertEqual(parameters["tag_name"] as? String, "1.0.1")
91+
XCTAssertEqual(parameters["name"] as? String, "1.0.1")
92+
XCTAssertEqual(parameters["body"] as? String, """
93+
##Changes in Release v1.0.0 ... ##Contributors @monalisa
94+
""")
95+
mockExpectation.fulfill()
96+
}
97+
mock.register()
98+
99+
try executeCommand("gitbuddy release -s --use-github-release-notes -t develop")
100+
wait(for: [mockExpectation], timeout: 0.3)
101+
}
102+
81103
/// It should update the changelog file if the argument is set.
82104
func testChangelogUpdating() throws {
83105
let existingChangelog = """

Diff for: Tests/GitBuddyTests/TestHelpers/Mocks.swift

+18-6
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ extension Mocker {
8080
URLQueryItem(name: "state", value: "closed")
8181
]
8282

83-
let pullRequestJSONData = PullRequestsJSON.data(using: .utf8)!
83+
let pullRequestJSONData = Data(PullRequestsJSON.utf8)
8484
let mock = Mock(url: urlComponents.url!, dataType: .json, statusCode: 200, data: [.get: pullRequestJSONData])
8585
mock.register()
8686
}
@@ -101,19 +101,19 @@ extension Mocker {
101101
URLQueryItem(name: "state", value: "closed")
102102
]
103103

104-
let data = IssuesJSON.data(using: .utf8)!
104+
let data = Data(IssuesJSON.utf8)
105105
let mock = Mock(url: urlComponents.url!, dataType: .json, statusCode: 200, data: [.get: data])
106106
mock.register()
107107
}
108108

109109
static func mockForIssueNumber(_ issueNumber: Int) {
110110
let urlComponents = URLComponents(string: "https://api.github.com/repos/WeTransfer/Diagnostics/issues/\(issueNumber)")!
111-
let issueJSONData = IssueJSON.data(using: .utf8)!
111+
let issueJSONData = Data(IssueJSON.utf8)
112112
Mock(url: urlComponents.url!, dataType: .json, statusCode: 200, data: [.get: issueJSONData]).register()
113113
}
114114

115115
@discardableResult static func mockRelease() -> Mock {
116-
let releaseJSONData = ReleaseJSON.data(using: .utf8)!
116+
let releaseJSONData = Data(ReleaseJSON.utf8)
117117
let mock = Mock(
118118
url: URL(string: "https://api.github.com/repos/WeTransfer/Diagnostics/releases")!,
119119
dataType: .json,
@@ -124,8 +124,20 @@ extension Mocker {
124124
return mock
125125
}
126126

127+
@discardableResult static func mockReleaseNotes() -> Mock {
128+
let releaseNotesJSONData = Data(ReleaseNotesJSON.utf8)
129+
let mock = Mock(
130+
url: URL(string: "https://api.github.com/repos/WeTransfer/Diagnostics/releases/generate-notes")!,
131+
dataType: .json,
132+
statusCode: 200,
133+
data: [.post: releaseNotesJSONData]
134+
)
135+
mock.register()
136+
return mock
137+
}
138+
127139
@discardableResult static func mockListReleases() -> Mock {
128-
let releaseJSONData = ListReleasesJSON.data(using: .utf8)!
140+
let releaseJSONData = Data(ListReleasesJSON.utf8)
129141
let mock = Mock(
130142
url: URL(string: "https://api.github.com/repos/WeTransfer/Diagnostics/releases?per_page=100")!,
131143
dataType: .json,
@@ -160,7 +172,7 @@ extension Mocker {
160172

161173
static func mockForCommentingOn(issueNumber: Int) -> Mock {
162174
let urlComponents = URLComponents(string: "https://api.github.com/repos/WeTransfer/Diagnostics/issues/\(issueNumber)/comments")!
163-
let commentJSONData = CommentJSON.data(using: .utf8)!
175+
let commentJSONData = Data(CommentJSON.utf8)
164176
return Mock(url: urlComponents.url!, dataType: .json, statusCode: 201, data: [.post: commentJSONData])
165177
}
166178
}
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// ReleaseNotesJSON.swift
3+
//
4+
//
5+
// Created by Antoine van der Lee on 13/06/2023.
6+
// Copyright © 2023 WeTransfer. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
// swiftlint:disable line_length
12+
let ReleaseNotesJSON = """
13+
14+
{
15+
"name": "Release v1.0.0 is now available!",
16+
"body": "##Changes in Release v1.0.0 ... ##Contributors @monalisa"
17+
}
18+
"""

0 commit comments

Comments
 (0)