@@ -3,68 +3,140 @@ namespace Scripts
33open System.Collections .Generic
44open System.Linq
55open System.IO
6+ open System.Text .RegularExpressions
7+ open System.Text ;
68open Octokit
79open Versioning
810
911module 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
2039
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" );
2347 ( " Bug" , " Bug Fixes" );
2448 ( " 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)
4362 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
50125
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)
55127
56128 use file = File.OpenWrite <| releaseNotes
57129 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)
59131 writer.WriteLine()
60132 for closedIssue in closedIssues do
61- labelHeaders .[ closedIssue.Key] |> sprintf " ## %s " |> writer.WriteLine
133+ config.labels .[ closedIssue.Key] |> sprintf " ## %s " |> writer.WriteLine
62134 writer.WriteLine()
63135 for issue in closedIssue.Value do
64- sprintf " - # %i %s " issue.Number issue.Title |> writer.WriteLine
136+ sprintf " - %s " issue.Title |> writer.WriteLine
65137 writer.WriteLine()
66138
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
68140 |> writer.WriteLine
69141
70142 let GenerateNotes version =
0 commit comments