Repository navigation
Description
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:#760is 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.ReconcileReleaseInputgainspreviousTag: string | null; at draft creation (:151-158) the body is the rendered notes when a previous tag exists anddraftBody()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()readspreviousTagout of the stored preflight JSON with anullanswer 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.previousTagwhen the tag is created (:2143-2150) and the stored value on a re-check (:2180-2187).- Docs and changelog.
docs/architecture.md:2183-2189is rewritten to say what the draft body is built from and which read it reuses;docs/api.md:731-733gains one sentence that the draft body is written from the range; one fragmentchangelog.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:
#7appears once with its title and its link, the second pull once, and the body namesv1.3.0and3 commits since v1.3.0. Assertion:notes.match(/#7\b/g)has length 1. - Cut list and empty range. With
complete: falsethe 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 nothttps://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…andGET /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 attest/releaseTracking.test.ts:57-110: thePOST /repos/o/r/releasesbody is the rendered notes and is notdraftBody(TAG). - The first release keeps the sentence.
previousTag: nullstill postsdraftBody(TAG), pinning the deliberate non-goal. - The preflight parser.
previousTagFromPreflight('{"previousTag":"v1.3.0","actor":"x"}')is'v1.3.0';nullfornull,'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 at37f4ac99is 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.ymlsucceeds, withv1.2.2two commits behindHEAD; createv1.2.3atHEADfrom/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 checkgreen.
5. Not in this slice
- No per-commit naming.
compareCommitskeeps its{ status, aheadBy }shape (src/repos/github.ts:887-893), so a commit that belongs to no merged pull request is counted, not listed, and the body says so. Extending that read and mapping commits to pulls is its own card inside feat: manage a connected repository's release, tag and draft notes from Fledge #546. - No
release_sources. No column, no table, no per-source list on the repository page;docs/architecture.md:2188keeps saying it is not created yet. This is the third remaining slice of feat: manage a connected repository's release, tag and draft notes from Fledge #546 named in section 2. - No model and no release-notes skill (feat: organise release notes with the repository's release-notes skill at the fixed commit #722), and no Actions log analysis (feat: analyse a release run's logs on demand with a read-only Actions token revoked afterwards #721).
- No publishing and no editing. Fledge never publishes or rewrites a draft a human has already touched, and no notes are written for tags Fledge did not create.
- No change to the preflight or the tag write. Refusal sentences, the five-GET preflight and the annotated-tag write stay exactly as they are.
- No whole-history crawl: a first release keeps the placeholder sentence rather than summarizing the entire repository.
- No UI change: the draft body is the only user-visible surface this slice touches.
Activity
HugoXK-bot commentedon Nov 15, 2026
Issue-review: CHANGES at issue #720 body da4132ac4a74
Written-by: HugoXK-bot
Posted-by: HugoXK-bot
Ran:
da4132ac4a74, trimmed74f8e2052b0a, 6665 bytes; both match Product's pair.node scripts/issue-gate.mjs --repo HugoXK/fledge --issue 720in a worktree whoseHEADis live mainffa010ed3bd1036ea07c706c31b28f1e7a0496e5→issue-gate: no-pass #720, exit 2.ffa010ed; the compare behaviour measured withcurlagainst the live API; the dedupe claim checked withcurlover 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'scommitsarray at 250 and reports the true size astotal_commits— measured on this repo just now:v0.1.0...v0.3.6→total_commits: 401,commitslength 250. Paging does work (per_page=100&page=2returns 101–200,page=5returns 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_commitsis 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}/pullsreturns 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
mainataa83dc0b; the footer says every anchor was re-read atffa010ed. 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
mainffa010ed:draftBodysrc/releases/tracking.ts:63–:65and its use at:156;compareCommitssrc/repos/github.ts:822–:828dropscommits;listMergedPullRequests:700keeps its three-updated-pages cap;repo_releasessrc/db.ts:1349–:1370has no notes or sources column and the migration:2064adds only tracking fields; the release cardsrc/views/pages.ts:1629–:1638lists tag, short SHA, status, run and draft, with only "Check run" (:1637);docs/architecture.md:2001says the sources are deliberately not created yet; the release surface is the tag writesrc/app.ts:1969and the check:2026;preflight_json(the previous tag) is written atsrc/db.ts:2951–:2963;previousTag: nullis allowed atsrc/releases/release.ts:143; the app harness istest/releaseTag.test.ts:296; and no open PR file list (all nine) contains asrc/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.
HugoXK-bot commentedon Nov 15, 2026
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 156b9f4sed -n '58,70p;78,90p;150,160p;200,212p' src/releases/tracking.ts—draftBody:63-65is the placeholder the card replaces,:156passes it tocreateRelease,reconcileRelease:82re-reads by tag so a second reconcile is idempotent,trackingUpdate:205clears every field it did not observesed -n '876,890p' src/repos/github.ts—compareCommits:880-888dropscommits; the card's 14-day question needs that field, so the widening is real worksed -n '754,766p' src/repos/github.ts—listMergedPullRequests:758caps atmaxPages=3, sortedupdated:763, the same shape the card's merged-without-check read reusesrg -n 'repo_releases|migrateReleaseColumns' src/db.ts— table:1437, additive column migration:2153-2166sed -n '3115,3125p' src/db.ts—recordReleasewritespreflight_json {"previousTag","actor"}:3117-3123sed -n '140,146p' src/releases/release.ts—previousTag:143sed -n '1654,1662p' src/views/pages.ts— the release list renders no source todaysed -n '2143,2149p' docs/architecture.md—release_sources … deliberately not created yet, so the doc half is one sentence flipsed -n '2004,2014p;2060,2066p' src/app.ts— tag POST:2006, check route:2063sed -n '292,300p' test/releaseTag.test.ts—buildApp+ fakeapiBaseharness the new suite reusesNot run: the slice (no branch yet), a real Actions run producing a draft Release, deploy.
Conclusion: PASS. One PR: widen
compareCommitsto returncommits, reconcile the tag's run insrc/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 thedocs/architecture.mdsentence. The three answers (no run yet / run not finished / draft ready) are one state machine on one row, not three cards.release_sourcesstays out, as the body says. Nothing to cut.[-]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[/+]HugoXK-bot commentedon Nov 15, 2026
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
#760merged.#546stays 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>— rawacb12d54b92b, trimmed84c31e4f5c42, 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.main37f4ac99:src/releases/tracking.ts:63isdraftBody,:156isbody: draftBody(input.tagName)insidecreateRelease,:27-34isReconcileReleaseInputwith no previous tag;src/releases/readiness.ts:104-113names the client reads;src/repos/github.ts:911isgetCommitMeta,:932-937islistMergedPullRequestsSincewithmaxPages = 3andcomplete;docs/architecture.md:2188still saysrelease_sourcesis deliberately not created;ls src/releases/shows nonotes.ts, so the new module is new and the red case (ERR_MODULE_NOT_FOUND) is real.test/releaseTracking.test.ts:219still assertsbody.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 nullablepreviousTagthreaded throughtracking.ts, one stored-JSON parser inrelease.ts, two call sites inapp.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 isupdated_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 (thex](https://evil.example)case) rather than letting a title become a link.Conclusion: PASS at
acb12d54b92b.agent-readyis the Lead's to add;review:approvedis on the card, and the assignee staysHugoXK.