@@ -705,57 +705,66 @@ class GitHubRepository(
705705 .getOrNull()
706706
707707 /* *
708- * The canary builds, newest first.
709- *
710- * These are **prereleases**, not Actions artifacts, and that is the whole point. GitHub gates an
711- * artifact download behind an account even for a public repository — `actions/artifacts/<id>/zip`
712- * answers 401 to an anonymous caller, while a release asset answers 206 — so sourcing canaries
713- * from artifacts would mean asking every would-be tester for an OAuth grant to work around a
714- * storage decision. CI attaches the same zips to a rolling `canary-<versionCode>` prerelease,
715- * and this reads that, so nobody signs in to anything.
716- *
717- * Filtered to the canary tag rather than taking every prerelease: a hand-cut release candidate
718- * is also a prerelease, and it is not a nightly.
708+ * The issues that have been closed as done, newest first.
709+ *
710+ * **Asked of the issue tracker rather than derived from the commits, and that is not a
711+ * shortcut.** An issue linked through GitHub's *Development* panel — the usual way here — is
712+ * closed by the merge itself and leaves no trace in any commit message, so reading the history
713+ * would report only the minority that happened to be written up as `Fixes #816`. The link that
714+ * did the closing lives in `PullRequest.closingIssuesReferences`, which exists only in GraphQL,
715+ * and GraphQL answers 403 without an account. This endpoint answers the neighbouring question
716+ * anonymously, and the answer is the one worth showing: not which commit closed what, but what
717+ * has been fixed since the reader's build.
718+ *
719+ * Closed is not fixed: `not_planned` and `duplicate` are also closures, and were a fifth of one
720+ * page here. Only `completed` is counted.
721+ *
722+ * One page, unpaginated. A hundred items reaches back several weeks on this repository, which
723+ * covers the span between any two builds a reader could be choosing between; older than that
724+ * and the number stops mattering because the reader is being told to update, not to test.
719725 */
720- suspend fun canaryBuilds (freshness : Freshness = Freshness .Revalidate ): List <CanaryBuild > =
726+ suspend fun closedIssues (freshness : Freshness = Freshness .Revalidate ): List <ClosedIssue > =
721727 withContext(Dispatchers .IO ) {
722- val body = releaseListJson(freshness) ? : return @withContext emptyList()
723-
724- runCatching { json.decodeFromString<List <GhRelease >>(body) }
725- .onFailure { e -> logE(" update: canary release list unreadable" , e) }
728+ val url = " $API /$REPO /issues?state=closed&per_page=100&sort=updated&direction=desc"
729+ val body =
730+ runCatching { get(url, freshness) }
731+ .onFailure { e -> logW(" canary: closed issue list unavailable" , e) }
732+ .getOrNull() ? : return @withContext emptyList()
733+
734+ runCatching { json.decodeFromString<List <GhIssue >>(body) }
735+ .onFailure { e -> logE(" canary: closed issue list unreadable" , e) }
726736 .getOrDefault(emptyList())
727- .filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX ) }
728- .take(CANARY_KEEP )
729- .map { release ->
730- CanaryBuild (
731- id = release.id,
732- versionCode = release.versionCode() ? : 0 ,
733- title = release.name ? : release.tagName,
734- branch = release.tagName,
735- shortSha = release.targetCommitish.take(7 ),
736- epochSeconds = parseIso8601(release.publishedAt.orEmpty()),
737- htmlUrl = release.htmlUrl,
738- artifacts =
739- release.assets.map {
740- CanaryArtifact (
741- id = it.id,
742- name = it.name,
743- sizeInBytes = it.size,
744- expired = false ,
745- downloadUrl = it.downloadUrl,
746- )
747- },
737+ .filter { ! it.isPullRequest && it.stateReason == " completed" }
738+ .mapNotNull { issue ->
739+ val closed = parseIso8601(issue.closedAt ? : return @mapNotNull null )
740+ ClosedIssue (
741+ number = issue.number,
742+ title = issue.title,
743+ closedAtEpoch = closed.takeIf { it > 0 } ? : return @mapNotNull null ,
744+ htmlUrl = issue.htmlUrl,
748745 )
749746 }
747+ .sortedByDescending { it.closedAtEpoch }
750748 }
751749
752750 /* *
753751 * Every published build, both channels, newest first.
754752 *
755- * One fetch for both because they come from the same endpoint, and because deciding which
756- * channel a reader is on needs to see both: a canary that has aged out of the rolling five is
757- * still recognisable as a canary by being *newer than the newest stable release*, and that
758- * comparison is impossible with only one of the two lists in hand.
753+ * The canaries here are **prereleases**, not Actions artifacts, and that is the whole point.
754+ * GitHub gates an artifact download behind an account even for a public repository —
755+ * `actions/artifacts/<id>/zip` answers 401 to an anonymous caller, while a release asset answers
756+ * 206 — so sourcing canaries from artifacts would mean asking every would-be tester for an OAuth
757+ * grant to work around a storage decision. CI attaches the same zips to a rolling
758+ * `canary-<versionCode>` prerelease, and this reads those, so nobody signs in to anything.
759+ *
760+ * A canary is recognised by its tag rather than by being a prerelease: a hand-cut release
761+ * candidate is also a prerelease, and it is not a nightly.
762+ *
763+ * One fetch for both channels because they come from the same endpoint, because deciding which
764+ * channel a reader is on needs to see both — a canary that has aged out of the rolling five is
765+ * still recognisable by being *newer than the newest stable release*, and that comparison is
766+ * impossible with only one of the two lists in hand — and because the canary list is this same
767+ * answer filtered, not a second question.
759768 */
760769 suspend fun frameworkReleases (freshness : Freshness = Freshness .Revalidate ):
761770 List <FrameworkRelease > =
@@ -788,7 +797,6 @@ class GitHubRepository(
788797 id = it.id,
789798 name = it.name,
790799 sizeInBytes = it.size,
791- expired = false ,
792800 downloadUrl = it.downloadUrl,
793801 )
794802 },
@@ -945,7 +953,14 @@ class GitHubRepository(
945953
946954 /* * CI keeps five; a few extra are fetched so a stable release among them costs nothing. */
947955 private const val CANARY_FETCH = 12
948- private const val CANARY_KEEP = 5
956+
957+ /* *
958+ * How many canaries CI keeps, which the canary screen states as reassurance.
959+ *
960+ * Read from here rather than written into the sentence, so the promise the screen makes
961+ * and the number the workflow prunes to cannot drift apart silently.
962+ */
963+ const val CANARY_KEEP = 5
949964
950965 private const val API = " https://api.github.com/repos"
951966 private const val API_ROOT = " https://api.github.com"
@@ -982,6 +997,7 @@ class GitHubRepository(
982997
983998 private val PR_SUFFIX = Regex (""" \(#(\d+)\)\s*$""" )
984999
1000+
9851001 private val LAST_PAGE = Regex (""" [?&]page=(\d+)>;\s*rel="last"""" )
9861002
9871003 private val CO_AUTHOR =
0 commit comments