diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6ceefb0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +# All files +[*] +indent_style = space +trim_trailing_whitespace = true + +# Xml files +[*.xml] +indent_size = 2 + +[.fs] +indent_size = 2 \ No newline at end of file diff --git a/src/Ionide.KeepAChangelog.Tasks/Library.fs b/src/Ionide.KeepAChangelog.Tasks/Library.fs index 4fc8095..9089de0 100644 --- a/src/Ionide.KeepAChangelog.Tasks/Library.fs +++ b/src/Ionide.KeepAChangelog.Tasks/Library.fs @@ -4,19 +4,16 @@ open Microsoft.Build.Utilities open Microsoft.Build.Framework open System.IO open Ionide.KeepAChangelog -open Ionide.KeepAChangelog.Domain open System.Linq +open SemVersion +open System module Util = - let mapReleaseInfo (version: SemVersion.SemanticVersion) (date: System.DateTime) (item: ITaskItem) : ITaskItem = + let mapReleaseInfo (version: SemanticVersion) (date: DateTime) (item: ITaskItem) : ITaskItem = item.ItemSpec <- string version item.SetMetadata("Date", date.ToString("yyyy-MM-dd")) item - let mapUnreleasedInfo (item: ITaskItem) : ITaskItem = - item.ItemSpec <- "Unreleased" - item - let allReleaseNotesFor (data: ChangelogData) = let section name items = match items with @@ -24,18 +21,17 @@ module Util = | items -> $"### {name}" :: items @ [ "" ] String.concat - System.Environment.NewLine + Environment.NewLine ([ yield! section "Added" data.Added yield! section "Changed" data.Changed yield! section "Deprecated" data.Deprecated yield! section "Removed" data.Removed yield! section "Fixed" data.Fixed yield! section "Security" data.Security - for KeyValue(heading, lines) in data.Custom do - yield! section heading lines ]) + for KeyValue (heading, lines) in data.Custom do + yield! section heading lines ]) - let stitch items = - String.concat System.Environment.NewLine items + let stitch items = String.concat Environment.NewLine items let mapChangelogData (data: ChangelogData) (item: ITaskItem) : ITaskItem = item.SetMetadata("Added", stitch data.Added) @@ -44,10 +40,27 @@ module Util = item.SetMetadata("Removed", stitch data.Removed) item.SetMetadata("Fixed", stitch data.Fixed) item.SetMetadata("Security", stitch data.Security) - for (KeyValue(heading, lines)) in data.Custom do + + for (KeyValue (heading, lines)) in data.Custom do item.SetMetadata(heading, stitch lines) + item + let mapUnreleasedInfo changelogs (item: ITaskItem) : ITaskItem = + match Promote.fromUnreleased changelogs with + | None -> + item.ItemSpec <- "Unreleased" + + changelogs.Unreleased + |> Option.map (fun d -> mapChangelogData d item) + |> Option.defaultValue item + | Some (unreleasedVersion, releaseDate, data) -> + let item = mapReleaseInfo unreleasedVersion releaseDate item + + data + |> Option.map (fun d -> mapChangelogData d item) + |> Option.defaultValue item + type ParseChangelogs() = inherit Task() @@ -57,6 +70,9 @@ type ParseChangelogs() = [] member val UnreleasedChangelog: ITaskItem = null with get, set + [] + member val UnreleasedReleaseNotes: string = null with get, set + [] member val CurrentReleaseChangelog: ITaskItem = null with get, set @@ -66,6 +82,7 @@ type ParseChangelogs() = [] member val LatestReleaseNotes: string = null with get, set + override this.Execute() : bool = let file = this.ChangelogFile |> FileInfo @@ -77,10 +94,8 @@ type ParseChangelogs() = | Ok changelogs -> changelogs.Unreleased |> Option.iter (fun unreleased -> - this.UnreleasedChangelog <- - TaskItem() - |> Util.mapChangelogData unreleased - |> Util.mapUnreleasedInfo) + this.UnreleasedChangelog <- TaskItem() |> Util.mapUnreleasedInfo changelogs + this.UnreleasedReleaseNotes <- Util.allReleaseNotesFor unreleased) let sortedReleases = // have to use LINQ here because List.sortBy* require IComparable, which @@ -92,8 +107,10 @@ type ParseChangelogs() = |> Seq.map (fun (version, date, data) -> TaskItem() |> Util.mapReleaseInfo version date - |> fun d -> match data with Some data -> Util.mapChangelogData data d | None -> d - ) + |> fun d -> + match data with + | Some data -> Util.mapChangelogData data d + | None -> d) |> Seq.toArray this.AllReleasedChangelogs <- items @@ -103,9 +120,7 @@ type ParseChangelogs() = |> Seq.tryHead |> Option.iter (fun (version, date, data) -> data - |> Option.iter (fun data -> - this.LatestReleaseNotes <- Util.allReleaseNotesFor data) - ) + |> Option.iter (fun data -> this.LatestReleaseNotes <- Util.allReleaseNotesFor data)) true | Error (formatted, msg) -> diff --git a/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.props b/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.props index 4f5fed2..14176f8 100644 --- a/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.props +++ b/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.props @@ -1,5 +1,10 @@ + CHANGELOG.md + + true + + true \ No newline at end of file diff --git a/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.targets b/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.targets index e63d812..92ef8c9 100644 --- a/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.targets +++ b/src/Ionide.KeepAChangelog.Tasks/build/Ionide.KeepAChangelog.Tasks.targets @@ -7,14 +7,15 @@ $(PrepareForBuildDependsOn) - + - + @@ -24,15 +25,24 @@ + %(CurrentReleaseChangelog.Identity) %(CurrentReleaseChangelog.Identity) @(LatestReleaseNotes) + <_ReleaseDate>%(CurrentReleaseChangelog.Date) + + + + %(UnreleasedChangelog.Identity) + %(UnreleasedChangelog.Identity) + @(UnreleasedReleaseNotes) + <_ReleaseDate>%(UnreleasedChangelog.Date) - - + + <_Parameter1>BuildDate - <_Parameter2>%(CurrentReleaseChangelog.Date) + <_Parameter2>@(_ReleaseDate) diff --git a/src/Ionide.KeepAChangelog/Library.fs b/src/Ionide.KeepAChangelog/Library.fs index ae144b3..2c9b881 100644 --- a/src/Ionide.KeepAChangelog/Library.fs +++ b/src/Ionide.KeepAChangelog/Library.fs @@ -1,40 +1,94 @@ namespace Ionide.KeepAChangelog -module Domain = +open SemVersion +open System + +type ChangelogData = + { Added: string list + Changed: string list + Deprecated: string list + Removed: string list + Fixed: string list + Security: string list + Custom: Map } + static member Default = + { Added = [] + Changed = [] + Deprecated = [] + Removed = [] + Fixed = [] + Security = [] + Custom = Map.empty } + +type Changelogs = + { Unreleased: ChangelogData option + Releases: (SemanticVersion * DateTime * ChangelogData option) list } + +module Promote = open SemVersion - open System - - // TODO: a changelog entry may have a description? - type ChangelogData = - { Added: string list - Changed: string list - Deprecated: string list - Removed: string list - Fixed: string list - Security: string list - Custom: Map} - static member Default = - { Added = [] - Changed = [] - Deprecated = [] - Removed = [] - Fixed = [] - Security = [] - Custom = Map.empty } - - type Changelogs = - { Unreleased: ChangelogData option - Releases: (SemanticVersion * DateTime * ChangelogData option) list } -module Parser = + type private SemVerBump = + | Major + | Minor + | Patch + + let inline (|NonEmpty|_|) xs = + match xs with + | [] -> None + | _ -> Some() + + /// the prerelease segment is just a count of the individual changes + let revisionNumber (c: ChangelogData) = + c.Added.Length + + c.Changed.Length + + c.Deprecated.Length + + c.Fixed.Length + + c.Removed.Length + + c.Security.Length + + (c.Custom.Values |> Seq.sumBy (fun l -> l.Length)) + + // TODO: expand this logic later to allow for customization of the bump types based on change content? + let private determineBump (c: ChangelogData) : SemVerBump = + match c.Removed, c.Added with + | NonEmpty, _ -> Major + | _, NonEmpty -> Minor + | _, _ -> Patch + + /// for unreleased changes, bump the version an assign a prerelease part based on the number of changes + let private bumpVersion (ver: SemanticVersion) bumpType revisions = + let prereleasePart = $"beta.{revisions}" + + match bumpType with + | Major -> SemanticVersion(ver.Major.Value + 1, Nullable(), Nullable(), prerelease = prereleasePart) + | Minor -> SemanticVersion(ver.Major, ver.Minor.Value + 1, Nullable(), prerelease = prereleasePart) + | Patch -> SemanticVersion(ver.Major, ver.Minor, ver.Patch.Value + 1, prerelease = prereleasePart) + + /// given a changelog, determine the version to promote to from the unreleased changes, if any. + /// + /// The version bump algoritm is as follows: + /// * removals require a major bump + /// * additions require a minor bump + /// * all other changes require a patch bump + /// + let fromUnreleased (c: Changelogs) = + c.Unreleased + |> Option.bind (fun unreleased -> + match c.Releases |> List.tryHead with + | None -> None + | Some (lastVersion, _, releaseData) -> + let bumpType = + releaseData + |> Option.map determineBump + |> Option.defaultValue Patch + + let numberOfRevisions = revisionNumber unreleased + let newVersion = bumpVersion lastVersion bumpType numberOfRevisions + Some(newVersion, DateTime.Today, Some unreleased)) - open Domain +module Parser = open FParsec - open FParsec.CharParsers - open FParsec.Primitives open System.IO - open System.Collections.Generic type Parser<'t> = Parser<'t, unit> @@ -65,7 +119,7 @@ module Parser = fun stream -> let mutable found = false - stream.SkipCharsOrNewlinesUntilString(str, System.Int32.MaxValue, &found) + stream.SkipCharsOrNewlinesUntilString(str, Int32.MaxValue, &found) |> ignore Reply(()) @@ -80,18 +134,18 @@ module Parser = // but we also need to keep parsing next lines until // * we find a bullet, or // * we get an empty line - let firstLine = FParsec.CharParsers.restOfLine true + let firstLine = restOfLine true let followingLine = nextCharSatisfiesNot (fun c -> c = '\n' || c = '-' || c = '*') >>. spaces1 - >>. FParsec.CharParsers.restOfLine true + >>. restOfLine true let rest = opt (many1 (attempt followingLine)) - pipe2 firstLine rest (fun f rest -> + pipe2 firstLine rest (fun f rest -> match rest with - | None -> f + | None -> f | Some parts -> String.concat " " (f :: parts)) "line item" @@ -99,10 +153,9 @@ module Parser = let pCustomSection: Parser = let sectionName = - skipString "###" - >>. spaces1 - >>. restOfLine true // TODO: maybe not the whole line? - $"custom section header" + skipString "###" >>. spaces1 >>. restOfLine true // TODO: maybe not the whole line? + $"custom section header" + sectionName .>>. (many pEntry $"{sectionName} entries") .>> attempt (opt newline) @@ -125,15 +178,28 @@ module Parser = let pOrEmptyList p = opt (attempt p) let pSections: Parser ChangelogData> = - choice [ - attempt (pAdded |>> fun x data -> { data with Added = x }) - attempt (pChanged |>> fun x data -> { data with Changed = x }) - attempt (pRemoved |>> fun x data -> { data with Removed = x }) - attempt (pDeprecated |>> fun x data -> { data with Deprecated = x }) - attempt (pFixed |>> fun x data -> { data with Fixed = x }) - attempt (pSecurity |>> fun x data -> { data with Security = x }) - attempt (many1 pCustomSection |>> fun x data -> { data with Custom = Map.ofList x }) - ] + choice [ attempt (pAdded |>> fun x data -> { data with Added = x }) + attempt ( + pChanged + |>> fun x data -> { data with Changed = x } + ) + attempt ( + pRemoved + |>> fun x data -> { data with Removed = x } + ) + attempt ( + pDeprecated + |>> fun x data -> { data with Deprecated = x } + ) + attempt (pFixed |>> fun x data -> { data with Fixed = x }) + attempt ( + pSecurity + |>> fun x data -> { data with Security = x } + ) + attempt ( + many1 pCustomSection + |>> fun x data -> { data with Custom = Map.ofList x } + ) ] let pData: Parser = many1 pSections @@ -153,13 +219,15 @@ module Parser = let pUnreleased: Parser = let unreleased = skipString "Unreleased" - let name = attempt ( - skipString "##" - >>. spaces1 - >>. (mdUrl unreleased <|> unreleased) - .>> skipRestOfLine true - "Unreleased label" - ) + + let name = + attempt ( + skipString "##" + >>. spaces1 + >>. (mdUrl unreleased <|> unreleased) + .>> skipRestOfLine true + "Unreleased label" + ) name >>. opt (many newline) >>. opt pData "Unreleased version section" @@ -178,30 +246,24 @@ module Parser = |>> fun text -> SemVersion.SemanticVersion.Parse text let pDate: Parser<_> = - let pYear = - pint32 - |> attempt + let pYear = pint32 |> attempt - let pMonth = - pint32 - |> attempt + let pMonth = pint32 |> attempt - let pDay = - pint32 - |> attempt + let pDay = pint32 |> attempt - let ymdDashes = + let ymdDashes = let dash = pchar '-' pipe5 pYear dash pMonth dash pDay (fun y _ m _ d -> System.DateTime(y, m, d)) - let dmyDots = + let dmyDots = let dot = pchar '.' pipe5 pDay dot pMonth dot pYear (fun d _ m _ y -> System.DateTime(y, m, d)) attempt dmyDots <|> ymdDashes - + let pVersion = mdUrl pSemver <|> pSemver @@ -215,18 +277,15 @@ module Parser = let pChangeLogs: Parser = let unreleased = pUnreleased - |>> fun unreleased -> - match unreleased with - | None -> None - | Some u when u = ChangelogData.Default -> None - | Some unreleased -> Some unreleased - pipe3 - pHeader - (attempt (opt unreleased)) - (attempt (many pRelease)) - (fun header unreleased releases -> - { Unreleased = defaultArg unreleased None - Releases = releases }) + |>> fun unreleased -> + match unreleased with + | None -> None + | Some u when u = ChangelogData.Default -> None + | Some unreleased -> Some unreleased + + pipe3 pHeader (attempt (opt unreleased)) (attempt (many pRelease)) (fun header unreleased releases -> + { Unreleased = defaultArg unreleased None + Releases = releases }) let parseChangeLog (file: FileInfo) = match