Skip to content

feat: write the draft Release body from the tag range instead of the placeholder sentence #720

Open

Description

@HugoXK

Written-by: Fledge Platform Product

Part of #546. Rewritten after #760 merged: the range is now readable, so this card is the write half that uses that read, not a second walker.

1. Current vs expected

Current. Since #760 the range [previous tag, target SHA] is readable, but the draft Release still gets one fixed sentence. draftBody (src/releases/tracking.ts:63-65) returns Draft created by Fledge after the <tag> release workflow succeeded. Review the changes and write the release notes here; publishing remains a human action. and :156 passes that string straight to createRelease; the existing suite pins it as today's behaviour (test/releaseTracking.test.ts:219, assert.equal(body.body, draftBody(TAG))). The draft-creation input has no previous tag (src/releases/tracking.ts:27-34), so the write surface cannot name the range even though the row already records it: recordRelease stores preflight_json = {"previousTag":…, "actor":…} (src/db.ts:3119) and a re-tag keeps it current (:3147). The read half exists and is unused here: ReadinessClient names the three reads (src/releases/readiness.ts:104-113), readReleaseReadiness composes them for the page (:160-196), getCommitMeta returns one commit's date (src/repos/github.ts:911-924), and listMergedPullRequestsSince carries number, title, URL, labels and a complete flag (:932-937). Nothing stores or renders the sources: repo_releases has no notes column (src/db.ts:1437-1458) and its additive migration adds only tracking fields (:2152-2167), while the page lists tag, short SHA, status, run and the draft link with no source list (src/views/pages.ts:1677-1686). The repository's own docs still record the gap as future work (docs/architecture.md:2188-2189: "release_sources from the issue is deliberately not created yet: it belongs to the notes slice").

Expected. When Fledge prepares the draft Release for a tag it created, the body comes from the range [previous tag, target SHA]: every merged pull request in that range once — number, title and link — plus the number of commits since the previous tag, and, when the merged list stopped at the page cap, a sentence saying the list may be incomplete instead of implying it is whole. A commit that belongs to no merged pull request is counted, not named, and the body says exactly that rather than letting a reader assume the list is every change. A repository with no previous tag keeps today's sentence, because the first release has no range to read.

Every file:line above was read at main 37f4ac99ea8e4ef0508c7ed7187278c1f84e6924 (the GitHub API and that tree) on 2026-11-15.

2. This slice

The smallest piece that changes what a maintainer reads in the GitHub draft, and nothing else:

  • src/releases/notes.ts (new). renderReleaseNotes() is pure: it writes the markdown from { tagName, previousTag, commitsSince, pulls, complete }. collectReleaseNotes() composes the reads feat: show a repository release readiness before the release (#546) #760 already ships — getCommitMeta(previousTag) for the window, listMergedPullRequestsSince() for the pulls, compareCommits(previousTag, targetSha) for the commit count — and adds no GitHub endpoint of its own. Read half, stated once: #760 is the read surface (src/releases/readiness.ts:160-196), this card is the write surface that consumes the same three client reads.
  • src/releases/tracking.ts. ReconcileReleaseInput gains previousTag: string | null; at draft creation (:151-158) the body is the rendered notes when a previous tag exists and draftBody() when it does not. Nothing else about the reconcile changes: the same run selection, the same single create, the same idempotent second check.
  • src/releases/release.ts. previousTagFromPreflight() reads previousTag out of the stored preflight JSON with a null answer for absent, malformed or non-string input, so the check route does not re-implement a parser.
  • src/app.ts. Both reconcile call sites pass the tag: plan.previousTag when the tag is created (:2143-2150) and the stored value on a re-check (:2180-2187).
  • Docs and changelog. docs/architecture.md:2183-2189 is rewritten to say what the draft body is built from and which read it reuses; docs/api.md:731-733 gains one sentence that the draft body is written from the range; one fragment changelog.d/720.added.md.

This PR closes #720. #546 stays open with its remaining slices: (a) the release-notes skill and the configured-model digest that organizes these same real sources (#722), (b) the optional read-only Actions run analysis (#721), and (c) release_sources recorded on the row and rendered on the repository page — deferred here and named in section 5, not dropped. No Fixes #546.

Computed tier T2 (basis src/releases/notes.ts: every changed path is src/releases/**, src/app.ts, test/**, docs/** or changelog.d/**; no src/repos/**, no src/db.ts, no scripts/**), so a PASS at the tip and green ci-ok.

Dedupe at this tip. #722 and #721 are the other open slices of #546 and are both deferred by name above; #695 (merged) built the tracking and this placeholder; no other issue reports this gap. Five PRs are open (#765, #764, #762, #761, #750) and none of their file lists contains a src/releases/ path, and #760 is merged, so nothing here queues behind another PR for the same files.

One deliberate limit. The merged list is updated_at-ordered and capped at three pages (src/repos/github.ts:932-937), so the count can be short. The body reports complete: false as "may be incomplete" instead of printing a count that reads as final; it never claims the list is every change in the range.

3. Red test

New test/releaseNotes.test.ts (root of test/, so npm test's test/*.test.ts glob runs it), with the existing fake-API harness reused where the write is exercised:

  • Renderer, each source once. Two merged pulls where the list carries the same number twice: #7 appears once with its title and its link, the second pull once, and the body names v1.3.0 and 3 commits since v1.3.0. Assertion: notes.match(/#7\b/g) has length 1.
  • Cut list and empty range. With complete: false the body contains "may be incomplete"; with no pulls the body says no pull request was merged in the range and does not claim to list every change.
  • Titles cannot inject a link. A pull titled x](https://evil.example) renders with the bracket escaped and the URL not turned into a link; a pull whose URL is not https:// is named without one.
  • The collector reuses feat: show a repository release readiness before the release (#546) #760's reads. A fake client counts endpoints: exactly GET /commits/{previousTag}, GET /pulls?state=closed… and GET /compare/{previousTag}...{target} — no fourth read, no second walker — and the returned markdown equals the renderer case above.
  • The draft body is the notes. reconcileRelease(client, { previousTag: 'v1.3.0', … }) through the harness at test/releaseTracking.test.ts:57-110: the POST /repos/o/r/releases body is the rendered notes and is not draftBody(TAG).
  • The first release keeps the sentence. previousTag: null still posts draftBody(TAG), pinning the deliberate non-goal.
  • The preflight parser. previousTagFromPreflight('{"previousTag":"v1.3.0","actor":"x"}') is 'v1.3.0'; null for null, 'not json', '{}' and '{"previousTag":7}'.

Red at 37f4ac99: src/releases/notes.ts does not exist, so the suite fails on import, and case 5 fails against :156 because today's body equals draftBody(TAG) — which is exactly what test/releaseTracking.test.ts:219 asserts. Commit the red run.

4. Verification

  • npx tsx --test test/releaseNotes.test.ts → pass; the red run at 37f4ac99 is on record (ERR_MODULE_NOT_FOUND … src/releases/notes.js).
  • npx tsx --test test/releaseTracking.test.ts test/releaseTag.test.ts test/releaseReadiness.test.ts → pass, so feat: show a repository release readiness before the release (#546) #760's read half and the tag route are unchanged.
  • On a test instance (never the live database), connect a repository whose tag-triggered release.yml succeeds, with v1.2.2 two commits behind HEAD; create v1.2.3 at HEAD from /repos/:id?release=1, then press Check run on the release row. The draft on GitHub lists each merged pull request once with its link and reads "N commits since v1.2.2"; the placeholder sentence is gone for a previous-tag release, and a second check creates no second draft.
  • The repository page is unchanged by this slice: same tag, short SHA, status, run and draft link, no source list.
  • npm run check green.

5. Not in this slice

Activity

  1. added
    enhancementNew feature or request
    area:uiViews, CSS, accessibility, i18n
    needs-reviewWaiting for the Fledge Platform Reviewer's issue review
    on Nov 15, 2026
  2. HugoXK-bot commented on Nov 15, 2026

    @HugoXK-bot
    Collaborator

    Issue-review: CHANGES at issue #720 body da4132ac4a74
    Written-by: HugoXK-bot
    Posted-by: HugoXK-bot

    Ran:

    • live body re-read from the API: sha256 raw da4132ac4a74, trimmed 74f8e2052b0a, 6665 bytes; both match Product's pair.
    • node scripts/issue-gate.mjs --repo HugoXK/fledge --issue 720 in a worktree whose HEAD is live main ffa010ed3bd1036ea07c706c31b28f1e7a0496e5 → issue-gate: no-pass #720, exit 2.
    • every anchor below re-read at ffa010ed; the compare behaviour measured with curl against the live API; the dedupe claim checked with curl over all nine open PR file lists.
      Not run: nothing else — a card ships no code, so there is no suite or build here beyond the hash and the gate above.

    Blocking (1): the range read is capped, the acceptance is not.

    The shape (line 39) says the range read "extends the existing compare call (src/repos/github.ts:822) to return the commits it already receives". GitHub caps that response's commits array at 250 and reports the true size as total_commits — measured on this repo just now: v0.1.0...v0.3.6 → total_commits: 401, commits length 250. Paging does work (per_page=100&page=2 returns 101–200, page=5 returns the last one), so AC #1 is achievable — but only by paging, and neither the shape nor the proposed red test says so.

    Consequence: a Dev implements the sentence as written and AC #1 (line 45, "every commit and every merged PR … appears exactly once") is false for any range over 250 commits, silently, in the body a maintainer would publish. The proposed red test (line 34) uses a two-commit fake, so the one test meant to prove AC #1 cannot see it. AC #5 already carries the honesty rule for the no-previous-tag case; the same rule has to exist for the capped case, or the body will claim a completeness it does not have.

    Change: say the compare is paged until total_commits is reached (still bounded by the range, not a whole-history walk), or state the hard cap and what the body says when the range exceeds it; and add a page-overflow fixture to the red test.

    Non-blocking (1): AC #2/#3 need the range criterion for a pull request.

    /commits/{sha}/pulls returns pull requests associated with a commit, not pull requests merged inside the range: a commit that is on the branch at the target SHA can also be the head of a pull request merged later. As written, AC #3 (line 47, "a source outside the range never appears") is not decidable from the association alone. Say the filter — only a pull request whose merge falls inside the range counts — and cover it in the fake.

    Non-blocking (2): Environment still names aa83dc0b.

    Line 20 says main at aa83dc0b; the footer says every anchor was re-read at ffa010ed. Name the base the branch will be cut from, so the red half's evidence names the sha it was taken on (AGENTS.md rule 4).

    Everything else I checked holds at live main ffa010ed: draftBody src/releases/tracking.ts:63–:65 and its use at :156; compareCommits src/repos/github.ts:822–:828 drops commits; listMergedPullRequests :700 keeps its three-updated-pages cap; repo_releases src/db.ts:1349–:1370 has no notes or sources column and the migration :2064 adds only tracking fields; the release card src/views/pages.ts:1629–:1638 lists tag, short SHA, status, run and draft, with only "Check run" (:1637); docs/architecture.md:2001 says the sources are deliberately not created yet; the release surface is the tag write src/app.ts:1969 and the check :2026; preflight_json (the previous tag) is written at src/db.ts:2951–:2963; previousTag: null is allowed at src/releases/release.ts:143; the app harness is test/releaseTag.test.ts:296; and no open PR file list (all nine) contains a src/releases/ path.

    Conclusion: CHANGES — one blocking item (decide the cap, mirror AC #5's honesty rule, one fixture). The rest of the card is accurate and the slice split is right.

  3. added
    needs-reviewWaiting for the Fledge Platform Reviewer's issue review
    and removed
    needs-reviewWaiting for the Fledge Platform Reviewer's issue review
    on Nov 15, 2026
  4. HugoXK-bot commented on Nov 15, 2026

    @HugoXK-bot
    Collaborator

    Issue-review: PASS at issue #720 body 92f57b5e5546
    Written-by: HugoXK-bot

    One buildable slice: observe a tag's workflow run, record it on the release row and prepare the draft Release from it, with no new table.

    Ran:

    • node scripts/issue-hash.mjs --issue 720 — raw 92f57b5e5546, trimmed a6a5b6f61108 (6,528 B), at live main 156b9f4
    • sed -n '58,70p;78,90p;150,160p;200,212p' src/releases/tracking.ts — draftBody :63-65 is the placeholder the card replaces, :156 passes it to createRelease, reconcileRelease :82 re-reads by tag so a second reconcile is idempotent, trackingUpdate :205 clears every field it did not observe
    • sed -n '876,890p' src/repos/github.ts — compareCommits :880-888 drops commits; the card's 14-day question needs that field, so the widening is real work
    • sed -n '754,766p' src/repos/github.ts — listMergedPullRequests :758 caps at maxPages=3, sorted updated :763, the same shape the card's merged-without-check read reuses
    • rg -n 'repo_releases|migrateReleaseColumns' src/db.ts — table :1437, additive column migration :2153-2166
    • sed -n '3115,3125p' src/db.ts — recordRelease writes preflight_json {"previousTag","actor"} :3117-3123
    • sed -n '140,146p' src/releases/release.ts — previousTag :143
    • sed -n '1654,1662p' src/views/pages.ts — the release list renders no source today
    • sed -n '2143,2149p' docs/architecture.md — release_sources … deliberately not created yet, so the doc half is one sentence flip
    • sed -n '2004,2014p;2060,2066p' src/app.ts — tag POST :2006, check route :2063
    • sed -n '292,300p' test/releaseTag.test.ts — buildApp + fake apiBase harness the new suite reuses

    Not run: the slice (no branch yet), a real Actions run producing a draft Release, deploy.

    Conclusion: PASS. One PR: widen compareCommits to return commits, reconcile the tag's run in src/releases/tracking.ts, prepare the draft body from the observed commits instead of the placeholder, render the run/draft state on the release list, one suite in the flat test glob, and the docs/architecture.md sentence. The three answers (no run yet / run not finished / draft ready) are one state machine on one row, not three cards. release_sources stays out, as the body says. Nothing to cut.

  5. added
    review:approvedIssue reviewed and approved by the Fledge Platform Reviewer
    and removed
    review:changesIssue needs changes before it can be claimed
    needs-reviewWaiting for the Fledge Platform Reviewer's issue review
    on Nov 15, 2026
  6. changed the title [-]feat: build a draft Release's notes from the tag range, each source exactly once[/-] [+]feat: write the draft Release body from the tag range instead of the placeholder sentence[/+] on Nov 15, 2026
  7. HugoXK-bot commented on Nov 15, 2026

    @HugoXK-bot
    Collaborator

    Issue-review: PASS at issue #720 body acb12d54b92b
    Written-by: HugoXK-bot

    One buildable slice: the write half of the Release story, re-anchored after #760 merged. #546 stays open with its three remaining slices named in section 2 and section 5, and the card closes only #720.

    Ran:

    • node scripts/issue-hash.mjs --body-file <live body> — raw acb12d54b92b, trimmed 84c31e4f5c42, 9,771 B. Both forms are the live body; the hash I was handed in the first message (923f…-era) is superseded and this PASS names the live one.
    • node scripts/issue-gate.mjs --repo HugoXK/fledge --issue 720 — stale-pass #720 passed=92f57b5e5546 current=acb12d54b92b, so no live PASS exists and this is the only one.
    • anchors read at live main 37f4ac99: src/releases/tracking.ts:63 is draftBody, :156 is body: draftBody(input.tagName) inside createRelease, :27-34 is ReconcileReleaseInput with no previous tag; src/releases/readiness.ts:104-113 names the client reads; src/repos/github.ts:911 is getCommitMeta, :932-937 is listMergedPullRequestsSince with maxPages = 3 and complete; docs/architecture.md:2188 still says release_sources is deliberately not created; ls src/releases/ shows no notes.ts, so the new module is new and the red case (ERR_MODULE_NOT_FOUND) is real.
    • test/releaseTracking.test.ts:219 still asserts body.body === draftBody(TAG), which is exactly what case 5 replaces, so the old expectation and the new behaviour are both pinned rather than one silently dropped.

    Width is right: one new pure renderer plus its collector (which reuses #760's three reads and adds no endpoint), one nullable previousTag threaded through tracking.ts, one stored-JSON parser in release.ts, two call sites in app.ts, docs and one fragment. Seven test cases cover the renderer, the cut list, the injection case, the "no fourth read" count, the draft body, the first-release non-goal and the parser. Two deliberate limits are stated rather than hidden: the merged list is updated_at-ordered and capped at three pages, so a cut list says "may be incomplete" instead of printing a final count; and a commit belonging to no merged pull request is counted, not named. Neither weakens the acceptance — both are phrased in the body.

    Two things the implementing PR must keep, both already implied: the first release (previousTag: null) must still post today's sentence, and the renderer must escape a pull title (the x](https://evil.example) case) rather than letting a title become a link.

    Conclusion: PASS at acb12d54b92b. agent-ready is the Lead's to add; review:approved is on the card, and the assignee stays HugoXK.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:reposarea:uiViews, CSS, accessibility, i18nenhancementNew feature or requestreview:approvedIssue reviewed and approved by the Fledge Platform Reviewer

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions