@@ -3,68 +3,140 @@ namespace Scripts
3
3
open System.Collections .Generic
4
4
open System.Linq
5
5
open System.IO
6
+ open System.Text .RegularExpressions
7
+ open System.Text ;
6
8
open Octokit
7
9
open Versioning
8
10
9
11
module ReleaseNotes =
10
-
11
- let private generateNotes newVersion oldVersion =
12
- let label = sprintf " v%O " newVersion.Full
13
- let releaseNotes = sprintf " ReleaseNotes-%O .md" newVersion.Full |> Paths.Output
14
- let client = new GitHubClient( new ProductHeaderValue( " ReleaseNotesGenerator" ))
15
- client.Credentials <- Credentials.Anonymous
16
-
17
- let filter = new RepositoryIssueRequest()
18
- filter.Labels.Add label
19
- filter.State <- ItemStateFilter.Closed
12
+ let issueNumberRegex ( url : string ) =
13
+ let pattern = sprintf " \s (?:#|%s issues/)(?<num>\d +)" url
14
+ Regex( pattern, RegexOptions.Multiline ||| RegexOptions.IgnoreCase ||| RegexOptions.CultureInvariant ||| RegexOptions.ExplicitCapture ||| RegexOptions.Compiled)
15
+
16
+ type GitHubItem ( issue : Issue , relatedIssues : int list ) =
17
+ member val Issue = issue
18
+ member val RelatedIssues = relatedIssues
19
+ member this.Title =
20
+ let builder = StringBuilder( " #" )
21
+ .Append( issue.Number)
22
+ .Append( " " )
23
+ if issue.PullRequest = null then
24
+ builder.AppendFormat( " [ISSUE] {0}" , issue.Title)
25
+ else
26
+ builder.Append( issue.Title) |> ignore
27
+ if relatedIssues.Length > 0 then
28
+ relatedIssues
29
+ |> List.map( fun i -> sprintf " #%i " i)
30
+ |> String.concat " , "
31
+ |> sprintf " (%s : %s )" ( if relatedIssues.Length = 1 then " issue" else " issues" )
32
+ |> builder.Append
33
+ else builder
34
+ |> ignore
35
+ builder.ToString()
36
+
37
+ member this.Labels = issue.Labels
38
+ member this.Number = issue.Number
20
39
21
- let labelHeaders =
22
- [( " Feature" , " Features & Enhancements" );
40
+ type Config =
41
+ { labels: Map < string , string >
42
+ uncategorized: string }
43
+
44
+ let config = {
45
+ labels = Map.ofList <| [
46
+ ( " Feature" , " Features & Enhancements" );
23
47
( " Bug" , " Bug Fixes" );
24
48
( " Deprecation" , " Deprecations" );
25
- ( " Uncategorized" , " Uncategorized" );]
26
- |> Map.ofList
27
-
28
- let groupByLabel ( issues : IReadOnlyList < Issue >) =
29
- let dict = new Dictionary< string, Issue list>()
30
- for issue in issues do
31
- let mutable categorized = false
32
- for labelHeader in labelHeaders do
33
- if issue.Labels.Any( fun l -> l.Name = labelHeader.Key) then
34
- let exists , list = dict.TryGetValue( labelHeader.Key)
35
- match exists with
36
- | true -> dict.[ labelHeader.Key] <- issue :: list
37
- | false -> dict.Add( labelHeader.Key, [ issue])
38
- categorized <- true
39
-
40
- if ( categorized = false ) then
41
- let label = " Uncategorized"
42
- let exists , list = dict.TryGetValue( label)
49
+ ( " Uncategorized" , " Uncategorized" )
50
+ ]
51
+ uncategorized = " Uncategorized"
52
+ };
53
+
54
+ let groupByLabel ( config : Config ) ( items : List < GitHubItem >) =
55
+ let dict = Dictionary< string, GitHubItem list>()
56
+ for item in items do
57
+ let mutable categorized = false
58
+ // if an item is categorized with multiple config labels, it'll appear multiple times, once under each label
59
+ for label in config.labels do
60
+ if item.Labels.Any( fun l -> l.Name = label.Key) then
61
+ let exists , list = dict.TryGetValue( label.Key)
43
62
match exists with
44
- | true ->
45
- match List.tryFind( fun ( i : Issue ) -> i.Number = issue.Number) list with
46
- | Some _ -> ()
47
- | None -> dict.[ label] <- issue :: list
48
- | false -> dict.Add( label, [ issue])
49
- dict
63
+ | true -> dict.[ label.Key] <- item :: list
64
+ | false -> dict.Add( label.Key, [ item])
65
+ categorized <- true
66
+
67
+ if categorized = false then
68
+ let exists , list = dict.TryGetValue( config.uncategorized)
69
+ match exists with
70
+ | true ->
71
+ match List.tryFind( fun ( i : GitHubItem ) -> i.Number = item.Number) list with
72
+ | Some _ -> ()
73
+ | None -> dict.[ config.uncategorized] <- item :: list
74
+ | false -> dict.Add( config.uncategorized, [ item])
75
+ dict
76
+
77
+ let filterByPullRequests ( issueNumberRegex : Regex ) ( issues : IReadOnlyList < Issue >): List < GitHubItem > =
78
+ let extractRelatedIssues ( issue : Issue ) =
79
+ let matches = issueNumberRegex.Matches( issue.Body)
80
+ if matches.Count = 0 then list.Empty
81
+ else
82
+ matches
83
+ |> Seq.cast< Match>
84
+ |> Seq.filter( fun m -> m.Success)
85
+ |> Seq.map( fun m -> m.Groups.[ " num" ]. Value |> int)
86
+ |> Seq.toList
87
+
88
+ let collectedIssues = List< GitHubItem>()
89
+ let items = List< GitHubItem>()
90
+
91
+ for issue in issues do
92
+ if issue.PullRequest <> null then
93
+ let relatedIssues = extractRelatedIssues issue
94
+ items.Add( GitHubItem( issue, relatedIssues))
95
+ else
96
+ collectedIssues.Add( GitHubItem( issue, list.Empty))
97
+
98
+ // remove all issues that are referenced by pull requests
99
+ for pullRequest in items do
100
+ for relatedIssue in pullRequest.RelatedIssues do
101
+ collectedIssues.RemoveAll( fun i -> i.Issue.Number = relatedIssue) |> ignore
102
+
103
+ // any remaining issues do not have an associated pull request, so add them
104
+ items.AddRange( collectedIssues)
105
+ items
106
+
107
+ let getClosedIssues ( label : string , config : Config ) =
108
+ let issueNumberRegex = issueNumberRegex Paths.Repository
109
+ let filter = RepositoryIssueRequest()
110
+ filter.Labels.Add label
111
+ filter.State <- ItemStateFilter.Closed
112
+
113
+ let client = GitHubClient( ProductHeaderValue( " ReleaseNotesGenerator" ))
114
+ client.Credentials <- Credentials.Anonymous
115
+
116
+ client.Issue.GetAllForRepository( Paths.OwnerName, Paths.RepositoryName, filter)
117
+ |> Async.AwaitTask
118
+ |> Async.RunSynchronously
119
+ |> filterByPullRequests issueNumberRegex
120
+ |> groupByLabel config
121
+
122
+ let private generateNotes newVersion oldVersion =
123
+ let label = sprintf " v%O " newVersion.Full
124
+ let releaseNotes = sprintf " ReleaseNotes-%O .md" newVersion.Full |> Paths.Output
50
125
51
- let closedIssues = client.Issue.GetAllForRepository( Paths.OwnerName, Paths.RepositoryName, filter)
52
- |> Async.AwaitTask
53
- |> Async.RunSynchronously
54
- |> groupByLabel
126
+ let closedIssues = getClosedIssues( label, config)
55
127
56
128
use file = File.OpenWrite <| releaseNotes
57
129
use writer = new StreamWriter( file)
58
- writer.WriteLine( sprintf " %s /compare /%O ...%O " Paths.Repository oldVersion.Full newVersion.Full)
130
+ writer.WriteLine( sprintf " %s compare /%O ...%O " Paths.Repository oldVersion.Full newVersion.Full)
59
131
writer.WriteLine()
60
132
for closedIssue in closedIssues do
61
- labelHeaders .[ closedIssue.Key] |> sprintf " ## %s " |> writer.WriteLine
133
+ config.labels .[ closedIssue.Key] |> sprintf " ## %s " |> writer.WriteLine
62
134
writer.WriteLine()
63
135
for issue in closedIssue.Value do
64
- sprintf " - # %i %s " issue.Number issue.Title |> writer.WriteLine
136
+ sprintf " - %s " issue.Title |> writer.WriteLine
65
137
writer.WriteLine()
66
138
67
- sprintf " ### [View the full list of issues and PRs](%s /issues ?utf8=%%E 2%%9C%%93&q=label%%3A%s )" Paths.Repository label
139
+ sprintf " ### [View the full list of issues and PRs](%s issues ?utf8=%%E 2%%9C%%93&q=label%%3A%s )" Paths.Repository label
68
140
|> writer.WriteLine
69
141
70
142
let GenerateNotes version =
0 commit comments