From 4aa847091ac37830fe04f81295fd7499219b1bed Mon Sep 17 00:00:00 2001 From: Sandesh Date: Mon, 6 Jul 2026 19:15:20 +0530 Subject: [PATCH 01/34] Add VertexAiClient interface and FakeVertexAiClient for changelog generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a narrow VertexAiClient interface abstracting Vertex AI text generation calls, and a FakeVertexAiClient for unit testing. The fake records all received prompts, supports a configurable shouldFail flag (auto-resets after each throw), and exposes a reset() helper — matching the same pattern as FakePlayConsoleClient and FakeCloudSigner. Part of fix #6106 (M2 PR 2.1 — GenerateChangelogs script). --- .../scripts/release/FakeVertexAiClient.kt | 37 +++++++++++++++++++ .../android/scripts/release/VertexAiClient.kt | 25 +++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt create mode 100644 scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt diff --git a/scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt new file mode 100644 index 00000000000..f25fb3f2a4b --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt @@ -0,0 +1,37 @@ +package org.oppia.android.scripts.release + +/** + * In-memory fake implementation of [VertexAiClient] for use in unit tests. + * + * By default, [generateText] returns [defaultResponse]. Tests can simulate an LLM failure by + * setting [shouldFail] to `true`, which causes the next call to throw an [IllegalStateException]. + * The flag resets to `false` after each thrown exception so that subsequent calls succeed again + * (unless re-set). All prompts received are recorded in [receivedPrompts] for assertion. + * + * @property defaultResponse the text returned by [generateText] when [shouldFail] is false + */ +class FakeVertexAiClient( + private val defaultResponse: String = "Fake generated changelog summary." +) : VertexAiClient { + + /** Whether the next call to [generateText] should throw to simulate an LLM failure. */ + var shouldFail = false + + /** All prompts passed to [generateText], in call order. */ + val receivedPrompts = mutableListOf() + + override fun generateText(prompt: String): String { + receivedPrompts += prompt + if (shouldFail) { + shouldFail = false + error("FakeVertexAiClient: simulated Vertex AI failure") + } + return defaultResponse + } + + /** Resets all recorded state and configuration to defaults. */ + fun reset() { + shouldFail = false + receivedPrompts.clear() + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt new file mode 100644 index 00000000000..0f871054465 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt @@ -0,0 +1,25 @@ +package org.oppia.android.scripts.release + +/** + * Client for invoking Vertex AI Generative Language models (e.g., Gemini) to produce + * user-facing text summaries. + * + * Implementations are expected to handle HTTP communication and authentication + * (typically via a GCP access token obtained through Workload Identity Federation). + * The interface is intentionally narrow so that tests can inject a [FakeVertexAiClient] + * without needing a real GCP project. + */ +interface VertexAiClient { + /** + * Sends [prompt] to the configured Vertex AI model and returns the generated text. + * + * The returned string is the raw text part of the first candidate response. Callers are + * responsible for trimming or post-processing the output as needed. + * + * @param prompt the full prompt to send to the model + * @return the generated text from the model's first candidate response + * @throws Exception if the HTTP call fails, the model returns a non-OK status, or the + * response cannot be parsed + */ + fun generateText(prompt: String): String +} From 14bee6f527686c63270249e70c1e1cd06d394d9d Mon Sep 17 00:00:00 2001 From: Sandesh Date: Mon, 6 Jul 2026 19:15:32 +0530 Subject: [PATCH 02/34] Add GoogleVertexAiClient: production Vertex AI REST implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements VertexAiClient using OkHttp + Moshi to call the Vertex AI generateContent REST endpoint. Auth is via a GCP Bearer token obtained through Workload Identity Federation in CI. Key details: - Sends synchronous HTTP POST to the generateContent endpoint - Parses candidates[0].content.parts[0].text from the JSON response - Fails with a descriptive message on non-2xx status or empty response - apiBaseUrl is a companion object var, overridable in tests to point at a local MockWebServer without subclassing Part of fix #6106 (M2 PR 2.1 — GenerateChangelogs script). --- .../scripts/release/GoogleVertexAiClient.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt diff --git a/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt new file mode 100644 index 00000000000..b92d13f4811 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt @@ -0,0 +1,119 @@ +package org.oppia.android.scripts.release + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Production implementation of [VertexAiClient] that calls the Vertex AI REST API. + * + * Authentication is performed via a GCP Bearer token, typically obtained via Workload Identity + * Federation inside a GitHub Actions workflow: + * ``` + * gcloud auth print-access-token + * ``` + * + * Requests are sent synchronously to the Vertex AI `generateContent` endpoint for the configured + * [gcpProject], [location], and [modelId]. + * + * @property gcpProject GCP project ID that has Vertex AI enabled (e.g. "sandesh-oppia-release-dev") + * @property location Vertex AI region (e.g. "us-central1") + * @property modelId Vertex AI model identifier (e.g. "gemini-1.5-flash") + * @property gcpAccessToken GCP Bearer token for authenticating with the Vertex AI API + */ +class GoogleVertexAiClient( + private val gcpProject: String, + private val location: String, + private val modelId: String, + private val gcpAccessToken: String +) : VertexAiClient { + + private val httpClient by lazy { OkHttpClient.Builder().build() } + private val moshi by lazy { Moshi.Builder().build() } + private val requestAdapter by lazy { + moshi.adapter(GenerateContentRequest::class.java) + } + private val responseAdapter by lazy { + moshi.adapter(GenerateContentResponse::class.java) + } + + override fun generateText(prompt: String): String { + val endpoint = buildEndpointUrl() + val requestBody = GenerateContentRequest( + contents = listOf( + Content(parts = listOf(Part(text = prompt))) + ) + ) + val jsonBody = checkNotNull(requestAdapter.toJson(requestBody)) { + "Failed to serialize Vertex AI request body." + } + + val request = Request.Builder() + .url(endpoint) + .addHeader("Authorization", "Bearer $gcpAccessToken") + .addHeader("Content-Type", "application/json") + .post(jsonBody.toRequestBody(JSON_MEDIA_TYPE)) + .build() + + val responseBody = httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { + "Vertex AI API call failed with HTTP ${response.code}: ${response.body?.string()}" + } + checkNotNull(response.body?.string()) { + "Vertex AI returned an empty response body." + } + } + + val parsed = checkNotNull(responseAdapter.fromJson(responseBody)) { + "Failed to parse Vertex AI response: $responseBody" + } + return checkNotNull( + parsed.candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text + ) { + "Vertex AI response contained no text candidates: $responseBody" + }.trim() + } + + private fun buildEndpointUrl(): String { + return "$apiBaseUrl/v1/projects/$gcpProject/locations/$location" + + "/publishers/google/models/$modelId:generateContent" + } + + // --- Moshi model classes for JSON serialization --- + + @JsonClass(generateAdapter = true) + data class GenerateContentRequest( + @Json(name = "contents") val contents: List + ) + + @JsonClass(generateAdapter = true) + data class Content( + @Json(name = "parts") val parts: List + ) + + @JsonClass(generateAdapter = true) + data class Part( + @Json(name = "text") val text: String + ) + + @JsonClass(generateAdapter = true) + data class GenerateContentResponse( + @Json(name = "candidates") val candidates: List? + ) + + @JsonClass(generateAdapter = true) + data class Candidate( + @Json(name = "content") val content: Content? + ) + + companion object { + /** The Vertex AI REST API base URL. Exposed as a `var` so tests can override it. */ + var apiBaseUrl = "https://us-central1-aiplatform.googleapis.com" + + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + } +} From 8e13e9237c09c7cb97fdffdceb59507985fc1bba Mon Sep 17 00:00:00 2001 From: Sandesh Date: Mon, 6 Jul 2026 19:15:48 +0530 Subject: [PATCH 03/34] Add GenerateChangelogs script and Bazel targets for automated changelog PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the GenerateChangelogs.kt script that: 1. Reads MAJOR/MINOR_VERSION from version.bzl to derive the previous version whose changelog needs to be generated (e.g. bump 0.17→0.18 generates 0.17) 2. Finds the commit range between the two most recent release branch merge-bases 3. Collects merged PRs (via 'git log --oneline') and parses Fixes/Closes #NNNN issue references from commit subjects 4. Invokes Vertex AI (Gemini) with a structured prompt to produce a 2-3 sentence user-facing summary of the release 5. Falls back to a raw commit list with a marker if the Vertex AI call throws, so the PR is still created for human review 6. Creates/updates a PR on 'automated/changelog-' branch via 'gh pr create' with links to all reference material in the PR body Also adds Bazel library and binary targets for all new source files, following the same visibility pattern as the existing release automation targets. Part of fix #6106 (M2 PR 2.1 — GenerateChangelogs script). --- scripts/BUILD.bazel | 8 + .../oppia/android/scripts/release/BUILD.bazel | 39 +- .../scripts/release/GenerateChangelogs.kt | 582 ++++++++++++++++++ 3 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index 1f3029c3099..d81f34278f9 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -367,6 +367,14 @@ kt_jvm_binary( ) # Release automation binaries — invoked by GitHub Actions release workflows. +kt_jvm_binary( + name = "generate_changelogs", + main_class = "org.oppia.android.scripts.release.GenerateChangelogsKt", + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/release:generate_changelogs_lib", + ], +) + kt_jvm_binary( name = "sign_release_binary", main_class = "org.oppia.android.scripts.release.SignReleaseBinaryKt", diff --git a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel index e7b49de1ae2..10c667d8ae5 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel @@ -2,7 +2,7 @@ Libraries for release automation scripts. """ -load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_binary", "kt_jvm_library") kt_jvm_library( name = "app_flavor", @@ -106,6 +106,43 @@ kt_jvm_library( ], ) +kt_jvm_library( + name = "vertex_ai_client", + srcs = ["VertexAiClient.kt"], + visibility = ["//scripts:oppia_script_library_visibility"], +) + +kt_jvm_library( + name = "fake_vertex_ai_client", + testonly = True, + srcs = ["FakeVertexAiClient.kt"], + visibility = ["//scripts:oppia_script_test_visibility"], + deps = [":vertex_ai_client"], +) + +kt_jvm_library( + name = "google_vertex_ai_client", + srcs = ["GoogleVertexAiClient.kt"], + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [ + ":vertex_ai_client", + "//third_party:moshi", + "//third_party:com_squareup_okhttp3_okhttp", + ], +) + +kt_jvm_library( + name = "generate_changelogs_lib", + srcs = ["GenerateChangelogs.kt"], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + ":google_vertex_ai_client", + ":vertex_ai_client", + "//scripts/src/java/org/oppia/android/scripts/common:command_executor", + "//scripts/src/java/org/oppia/android/scripts/common:script_background_coroutine_dispatcher", + ], +) + kt_jvm_library( name = "sign_release_binary_lib", srcs = ["SignReleaseBinary.kt"], diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt new file mode 100644 index 00000000000..ceca13e11f9 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -0,0 +1,582 @@ +package org.oppia.android.scripts.release + +import org.oppia.android.scripts.common.CommandExecutor +import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.common.ScriptBackgroundCoroutineDispatcher +import java.io.File + +/** + * Script that automatically generates a changelog for the previous app version whenever the minor + * version is bumped in `version.bzl`, and proposes it as a pull request on `develop`. + * + * ### How it works + * 1. Reads the current `MINOR_VERSION` from `version.bzl` (e.g. `18` after a `0.17 → 0.18` bump). + * 2. Derives the *previous* version (`0.17`) — the version whose changelog must be generated + * (since `0.18` is the new unreleased developer build and `0.17` is soon to be released). + * 3. Collects all PRs merged into `develop` between the two most recent release branch merge-bases. + * 4. Parses `Fixes #NNNN` / `Closes #NNNN` references from commit messages to include issue titles. + * 5. Invokes Vertex AI (Gemini) with the above material to produce a 2–3 sentence user-facing + * summary of the release changes. + * 6. If the Vertex AI call fails, falls back to a raw commit-list changelog marked with + * `` so a human can fill in the summary later. + * 7. Writes `config/changelogs/..md` and creates (or updates) a PR targeting + * `develop` on the **upstream** repository branch `automated/changelog-.`. + * The PR description links all reference material so reviewers can adjust the LLM context. + * + * ### Usage (called by `generate_changelog.yml` via Bazel) + * ``` + * bazel run //scripts:generate_changelogs -- \ + * + * ``` + * + * ### Arguments (positional) + * 0. `workspace_root` — absolute path to the local repository root + * 1. `gcp_project` — GCP project ID that has Vertex AI enabled + * 2. `gcp_location` — Vertex AI region (e.g. "us-central1") + * 3. `vertex_model` — Vertex AI model ID (e.g. "gemini-1.5-flash") + * 4. `gcp_access_token`— GCP Bearer token for authenticating with Vertex AI + * + * An optional 6th argument overrides the Vertex AI API base URL; this is used in integration + * tests to route HTTP calls through a local mock server. + */ +fun main(args: Array) { + require(args.size in 5..6) { + "Usage: generate_changelogs " + + " \nGot ${args.size} argument(s): ${args.toList()}" + } + + val workspaceRoot = args[0] + val gcpProject = args[1] + val gcpLocation = args[2] + val vertexModel = args[3] + val gcpAccessToken = args[4] + + if (args.size == 6) GoogleVertexAiClient.apiBaseUrl = args[5] + + ScriptBackgroundCoroutineDispatcher().use { scriptBgDispatcher -> + val commandExecutor = CommandExecutorImpl(scriptBgDispatcher) + val vertexAiClient = GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken) + generateChangelogs( + workspaceRoot = File(workspaceRoot), + commandExecutor = commandExecutor, + vertexAiClient = vertexAiClient + ) + } +} + +/** + * Orchestrates the full changelog generation workflow. + * + * This is separated from [main] so that tests can inject a [FakeVertexAiClient] and a + * [FakeCommandExecutor] without executing the real `main` entry point. + * + * @param workspaceRoot the root directory of the local repository + * @param commandExecutor the executor for shell commands (`git`, `gh`) + * @param vertexAiClient the Vertex AI client for generating the changelog summary + */ +fun generateChangelogs( + workspaceRoot: File, + commandExecutor: CommandExecutor, + vertexAiClient: VertexAiClient +) { + // Step 1 — Parse version.bzl to determine which version's changelog to generate. + val (majorVersion, minorVersion) = parseVersionBzl(workspaceRoot) + val prevMinor = minorVersion - 1 + check(prevMinor >= 0) { + "Cannot generate changelog: MINOR_VERSION in version.bzl is $minorVersion. " + + "Expected a value ≥ 1 (need a previous version to generate a changelog for)." + } + val changelogVersion = "$majorVersion.$prevMinor" + val changelogFileName = "$changelogVersion.md" + val changelogFile = File(workspaceRoot, "$CHANGELOGS_DIR/$changelogFileName") + + println("=== Generate Changelog ===") + println(" Current version : $majorVersion.$minorVersion") + println(" Changelog for : $changelogVersion") + println() + + // Step 2 — Bail early if changelog already exists (idempotency guard). + if (changelogFile.exists()) { + println( + "Changelog $changelogFileName already exists at ${changelogFile.path}. Nothing to do." + ) + return + } + + // Step 3 — Find the commit range: commits merged since the previous release branch diverged. + val releaseBranch = "release-$changelogVersion" + val prevReleaseBranch = "release-$majorVersion.${prevMinor - 1}" + + println("Finding commit range between $prevReleaseBranch and $releaseBranch on develop...") + val (fromSha, toSha) = findCommitRange( + workspaceRoot, commandExecutor, releaseBranch, prevReleaseBranch, prevMinor + ) + println(" From SHA : $fromSha") + println(" To SHA : $toSha") + println() + + // Step 4 — Collect merged PRs and linked issues in that range. + println("Collecting PRs and issues in commit range...") + val commits = collectCommitsBetween(workspaceRoot, commandExecutor, fromSha, toSha) + val prEntries = parsePrEntries(commits) + val issueNumbers = parseFixedIssueNumbers(commits) + println(" Found ${prEntries.size} PR(s), ${issueNumbers.size} referenced issue(s).") + println() + + // Step 5 — Build the LLM prompt and invoke Vertex AI. Fall back on failure. + val prListText = prEntries.joinToString("\n") { "- ${it.title} (#${it.number})" } + val issueListText = if (issueNumbers.isEmpty()) { + "(none)" + } else { + issueNumbers.joinToString("\n") { "- #$it" } + } + val prompt = buildPrompt(changelogVersion, prListText, issueListText) + + val (summary, llmFailed) = invokeLlmWithFallback(vertexAiClient, prompt) + + // Step 6 — Write the changelog file. + val changelogContent = buildChangelogContent( + summary = summary, + prEntries = prEntries, + issueNumbers = issueNumbers, + llmFailed = llmFailed + ) + changelogFile.parentFile.mkdirs() + changelogFile.writeText(changelogContent) + println("Wrote changelog to: ${changelogFile.path}") + println() + + // Step 7 — Push branch and create (or update) the PR. + val branchName = "automated/changelog-$changelogVersion" + val prBody = buildPrBody( + version = changelogVersion, + fromSha = fromSha, + toSha = toSha, + prEntries = prEntries, + issueNumbers = issueNumbers, + llmFailed = llmFailed + ) + createOrUpdateChangelogPr( + workspaceRoot = workspaceRoot, + commandExecutor = commandExecutor, + branchName = branchName, + changelogFile = changelogFile, + changelogVersion = changelogVersion, + prBody = prBody + ) +} + +// --------------------------------------------------------------------------- +// version.bzl parsing +// --------------------------------------------------------------------------- + +/** + * Reads `version.bzl` from [workspaceRoot] and extracts the `MAJOR_VERSION` and `MINOR_VERSION` + * values. + * + * @return a pair of (majorVersion, minorVersion) integers + * @throws IllegalStateException if either value cannot be found or parsed + */ +fun parseVersionBzl(workspaceRoot: File): Pair { + val versionBzl = File(workspaceRoot, "version.bzl") + check(versionBzl.exists()) { "version.bzl not found at: ${versionBzl.absolutePath}" } + val content = versionBzl.readText() + val major = MAJOR_VERSION_REGEX.find(content)?.groupValues?.get(1)?.toIntOrNull() + ?: error("Could not parse MAJOR_VERSION from version.bzl") + val minor = MINOR_VERSION_REGEX.find(content)?.groupValues?.get(1)?.toIntOrNull() + ?: error("Could not parse MINOR_VERSION from version.bzl") + return major to minor +} + +// --------------------------------------------------------------------------- +// Commit-range computation +// --------------------------------------------------------------------------- + +/** + * Computes the `fromSha..toSha` range for the changelog commit collection. + * + * The **toSha** is the merge-base of [releaseBranch] and `develop` — the point where the current + * release branched off (i.e. all commits up to and including the version bump commit). + * + * The **fromSha** is the merge-base of [prevReleaseBranch] and `develop` — the point where the + * *previous* release branched off. If the previous release branch doesn't exist (first release), + * falls back to the very first commit on `develop`. + * + * @param prevMinor the previous minor version number, used to detect the first-release edge case + * @return a (fromSha, toSha) pair of full commit SHAs + */ +fun findCommitRange( + workspaceRoot: File, + commandExecutor: CommandExecutor, + releaseBranch: String, + prevReleaseBranch: String, + prevMinor: Int +): Pair { + val toSha = gitMergeBase(workspaceRoot, commandExecutor, releaseBranch, "origin/develop") + val fromSha = if (prevMinor <= 0) { + // First-ever release: include all commits from the beginning of develop. + gitFirstCommit(workspaceRoot, commandExecutor) + } else { + try { + gitMergeBase(workspaceRoot, commandExecutor, prevReleaseBranch, "origin/develop") + } catch (e: IllegalStateException) { + // Previous release branch doesn't exist on the remote — fall back to first commit. + println( + "WARNING: Previous release branch '$prevReleaseBranch' not found on remote. " + + "Collecting from the beginning of develop." + ) + gitFirstCommit(workspaceRoot, commandExecutor) + } + } + return fromSha to toSha +} + +private fun gitMergeBase( + workspaceRoot: File, + commandExecutor: CommandExecutor, + ref1: String, + ref2: String +): String { + val result = commandExecutor.executeCommand(workspaceRoot, "git", "merge-base", ref1, ref2) + check(result.exitCode == 0) { + "git merge-base $ref1 $ref2 failed (exit ${result.exitCode}):\n" + + result.output.joinToString("\n") + } + return result.output.first().trim() +} + +private fun gitFirstCommit(workspaceRoot: File, commandExecutor: CommandExecutor): String { + val result = commandExecutor.executeCommand( + workspaceRoot, "git", "rev-list", "--max-parents=0", "HEAD" + ) + check(result.exitCode == 0) { + "git rev-list --max-parents=0 HEAD failed (exit ${result.exitCode}):\n" + + result.output.joinToString("\n") + } + return result.output.first().trim() +} + +// --------------------------------------------------------------------------- +// Commit collection and PR / issue parsing +// --------------------------------------------------------------------------- + +/** + * Returns all commit subject lines between [fromSha] (exclusive) and [toSha] (inclusive) on + * `develop`, using `git log`. + */ +fun collectCommitsBetween( + workspaceRoot: File, + commandExecutor: CommandExecutor, + fromSha: String, + toSha: String +): List { + val result = commandExecutor.executeCommand( + workspaceRoot, + "git", "log", "--oneline", "--no-merges", "$fromSha..$toSha" + ) + check(result.exitCode == 0) { + "git log $fromSha..$toSha failed (exit ${result.exitCode}):\n" + + result.output.joinToString("\n") + } + return result.output.filter { it.isNotBlank() } +} + +/** + * Represents a merged pull request extracted from a commit message. + * + * @property number the PR number (e.g. 6277) + * @property title the one-line PR title taken from the commit subject + */ +data class PrEntry(val number: Int, val title: String) + +/** + * Parses PR numbers and titles from [commitLines] by matching GitHub's squash-merge format: + * ` (#<number>)`. + * + * @return list of [PrEntry] objects in the order they appear in [commitLines] + */ +fun parsePrEntries(commitLines: List<String>): List<PrEntry> { + return commitLines.mapNotNull { line -> + // git log --oneline format: "<short_sha> <subject>" + val subject = line.substringAfter(" ") + val match = PR_REFERENCE_REGEX.find(subject) ?: return@mapNotNull null + val number = match.groupValues[1].toIntOrNull() ?: return@mapNotNull null + val title = subject.substringBefore(" (#").trim() + PrEntry(number = number, title = title) + } +} + +/** + * Extracts GitHub issue numbers from `Fixes #NNNN` / `Closes #NNNN` / `Fix #NNNN` patterns in + * [commitLines]. + * + * @return deduplicated, sorted list of issue numbers + */ +fun parseFixedIssueNumbers(commitLines: List<String>): List<Int> { + return commitLines + .flatMap { FIXES_ISSUE_REGEX.findAll(it).map { m -> m.groupValues[1].toInt() } } + .toSortedSet() + .toList() +} + +// --------------------------------------------------------------------------- +// LLM invocation and fallback +// --------------------------------------------------------------------------- + +/** + * Builds the prompt sent to the Vertex AI model for changelog summary generation. + * + * @param version the version string being released (e.g. "0.17") + * @param prListText formatted bullet list of PR titles and numbers + * @param issueListText formatted bullet list of fixed issue numbers, or "(none)" + * @return the complete prompt string + */ +fun buildPrompt(version: String, prListText: String, issueListText: String): String { + return """ + You are writing the changelog for Oppia Android app version $version. + Oppia is a free educational app helping underserved learners around the world. + + Below are the pull requests merged since the previous release: + $prListText + + Referenced issues fixed in this release: + $issueListText + + Write a brief 2-3 sentence summary of this release for end users. + Focus on user-visible improvements and bug fixes. + Do not mention pull request numbers, issue numbers, or developer jargon. + Keep it simple, positive, and friendly. + """.trimIndent() +} + +/** + * Calls [vertexAiClient] with [prompt] and returns the summary plus a failure flag. + * + * If the call throws any exception, the failure is logged and a fallback raw-list marker is + * returned instead so the PR can still be created with a placeholder for human review. + * + * @return a pair of (summaryText, llmFailed) where [llmFailed] is `true` if the LLM call failed + */ +fun invokeLlmWithFallback( + vertexAiClient: VertexAiClient, + prompt: String +): Pair<String, Boolean> { + return try { + val summary = vertexAiClient.generateText(prompt) + println("Vertex AI summary generated successfully.") + summary to false + } catch (e: Exception) { + println("WARNING: Vertex AI call failed — using fallback raw commit list.") + println(" Reason: ${e.message}") + LLM_FALLBACK_MARKER to true + } +} + +// --------------------------------------------------------------------------- +// Changelog file content +// --------------------------------------------------------------------------- + +/** + * Builds the markdown content for `config/changelogs/<version>.md`. + * + * If [llmFailed] is `true`, includes [LLM_FALLBACK_MARKER] and a raw list so that a human + * reviewer can easily replace the placeholder with the actual summary. + */ +fun buildChangelogContent( + summary: String, + prEntries: List<PrEntry>, + issueNumbers: List<Int>, + llmFailed: Boolean +): String { + val sb = StringBuilder() + if (llmFailed) { + sb.appendLine(LLM_FALLBACK_MARKER) + sb.appendLine( + "<!-- Replace the marker above with a 2-3 sentence user-facing summary before release -->" + ) + sb.appendLine() + } + sb.appendLine(summary) + if (llmFailed && prEntries.isNotEmpty()) { + sb.appendLine() + sb.appendLine("### Changes in this release") + prEntries.forEach { sb.appendLine("- ${it.title}") } + } + if (llmFailed && issueNumbers.isNotEmpty()) { + sb.appendLine() + sb.appendLine("### Issues addressed") + issueNumbers.forEach { sb.appendLine("- #$it") } + } + return sb.toString().trimEnd() + "\n" +} + +// --------------------------------------------------------------------------- +// PR creation +// --------------------------------------------------------------------------- + +/** + * Builds the PR description body with links to all reference material. + * + * Includes: commit range link, PR list, issue list, and a note on LLM failure if applicable. + */ +fun buildPrBody( + version: String, + fromSha: String, + toSha: String, + prEntries: List<PrEntry>, + issueNumbers: List<Int>, + llmFailed: Boolean +): String { + val sb = StringBuilder() + sb.appendLine("## Auto-generated changelog for version $version") + sb.appendLine() + if (llmFailed) { + sb.appendLine( + "> ⚠️ **LLM generation failed.** The changelog contains a raw commit list. " + + "Please replace the `$LLM_FALLBACK_MARKER` placeholder with a user-facing summary." + ) + sb.appendLine() + } + sb.appendLine("### Reference material") + sb.appendLine() + sb.appendLine( + "**Commit range:** [`${fromSha.take(7)}`..`${toSha.take(7)}`]" + + "(https://github.com/$REPO_OWNER/$REPO_NAME/compare/$fromSha...$toSha)" + ) + sb.appendLine() + if (prEntries.isNotEmpty()) { + sb.appendLine("**Pull requests included:**") + prEntries.forEach { pr -> + sb.appendLine( + "- [#${pr.number} — ${pr.title}]" + + "(https://github.com/$REPO_OWNER/$REPO_NAME/pull/${pr.number})" + ) + } + sb.appendLine() + } + if (issueNumbers.isNotEmpty()) { + sb.appendLine("**Issues addressed:**") + issueNumbers.forEach { n -> + sb.appendLine( + "- [#$n](https://github.com/$REPO_OWNER/$REPO_NAME/issues/$n)" + ) + } + sb.appendLine() + } + sb.appendLine("---") + sb.appendLine( + "*This PR was automatically created by `generate_changelogs.yml`. " + + "Review and merge after verifying the changelog content.*" + ) + return sb.toString().trimEnd() +} + +/** + * Commits [changelogFile], force-pushes to [branchName], and creates or updates the PR on GitHub. + * + * Uses the `gh` CLI (authenticated via the workflow's `GITHUB_TOKEN`) to create the PR. If a PR + * for [branchName] already exists, it is updated automatically by the force-push. + */ +fun createOrUpdateChangelogPr( + workspaceRoot: File, + commandExecutor: CommandExecutor, + branchName: String, + changelogFile: File, + changelogVersion: String, + prBody: String +) { + println("Setting up git config for automated commit...") + runGit(workspaceRoot, commandExecutor, "config", "user.email", GIT_AUTHOR_EMAIL) + runGit(workspaceRoot, commandExecutor, "config", "user.name", GIT_AUTHOR_NAME) + + println("Checking out branch $branchName...") + // Create or reset the branch to HEAD of develop. + runGitAllowFailure(workspaceRoot, commandExecutor, "branch", "-D", branchName) + runGit(workspaceRoot, commandExecutor, "checkout", "-b", branchName) + + println("Staging changelog file...") + runGit(workspaceRoot, commandExecutor, "add", changelogFile.absolutePath) + + println("Committing changelog...") + runGit( + workspaceRoot, commandExecutor, + "commit", "-m", "Add changelog for version $changelogVersion [automated]" + ) + + println("Force-pushing to origin/$branchName...") + runGit(workspaceRoot, commandExecutor, "push", "--force", "origin", branchName) + + println("Creating or updating PR via gh CLI...") + val prTitle = "Add changelog for version $changelogVersion" + val result = commandExecutor.executeCommand( + workspaceRoot, + "gh", "pr", "create", + "--base", "develop", + "--head", branchName, + "--title", prTitle, + "--body", prBody, + "--label", "automated-changelog" + ) + if (result.exitCode == 0) { + val prUrl = result.output.lastOrNull { it.startsWith("https://") } ?: "(URL not found)" + println("PR created: $prUrl") + } else { + // PR already exists — force-push already updated it. Log but don't fail. + println( + "gh pr create exited with ${result.exitCode} (PR likely already exists). " + + "Force-push already updated the branch.\n" + + result.output.joinToString("\n") + ) + } +} + +private fun runGit( + workspaceRoot: File, + commandExecutor: CommandExecutor, + vararg args: String +) { + val result = commandExecutor.executeCommand(workspaceRoot, "git", *args) + check(result.exitCode == 0) { + "git ${args.toList()} failed (exit ${result.exitCode}):\n" + + result.output.joinToString("\n") + } +} + +private fun runGitAllowFailure( + workspaceRoot: File, + commandExecutor: CommandExecutor, + vararg args: String +) { + commandExecutor.executeCommand(workspaceRoot, "git", *args) +} + +// --------------------------------------------------------------------------- +// Constants and regex +// --------------------------------------------------------------------------- + +private const val CHANGELOGS_DIR = "config/changelogs" +private const val REPO_OWNER = "oppia" +private const val REPO_NAME = "oppia-android" +private const val GIT_AUTHOR_EMAIL = "actions@github.com" +private const val GIT_AUTHOR_NAME = "github-actions[bot]" + +/** Marker inserted into changelogs when LLM generation fails. */ +const val LLM_FALLBACK_MARKER = "<!-- LLM generation failed -->" + +/** Matches `MAJOR_VERSION = <n>` in version.bzl. */ +private val MAJOR_VERSION_REGEX = Regex("""MAJOR_VERSION\s*=\s*(\d+)""") + +/** Matches `MINOR_VERSION = <n>` in version.bzl. */ +private val MINOR_VERSION_REGEX = Regex("""MINOR_VERSION\s*=\s*(\d+)""") + +/** + * Matches the `(#<number>)` PR reference at the end of a GitHub squash-merge commit subject. + * Example: `Fix part of #6106: Add deploy workflow (#6270)` → group 1 = `6270` + */ +private val PR_REFERENCE_REGEX = Regex("""\(#(\d+)\)\s*$""") + +/** + * Matches `Fixes #NNNN`, `Fix #NNNN`, `Closes #NNNN`, `Close #NNNN` (case-insensitive). + * Example: `Fix part of #6106: ...` → group 1 = `6106` + */ +private val FIXES_ISSUE_REGEX = Regex("""(?i)(?:fix(?:es)?|clos(?:es?)) #(\d+)""") From 0bf63b88030aa0c862ac4b954954f5e31cc8b315 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 6 Jul 2026 19:19:28 +0530 Subject: [PATCH 04/34] Fix: remove unused kt_jvm_binary from release/BUILD.bazel load statement --- .../src/java/org/oppia/android/scripts/release/BUILD.bazel | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel index 10c667d8ae5..e999a1f7add 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel @@ -2,7 +2,7 @@ Libraries for release automation scripts. """ -load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_binary", "kt_jvm_library") +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") kt_jvm_library( name = "app_flavor", @@ -126,8 +126,8 @@ kt_jvm_library( visibility = ["//scripts:oppia_script_library_visibility"], deps = [ ":vertex_ai_client", - "//third_party:moshi", "//third_party:com_squareup_okhttp3_okhttp", + "//third_party:moshi", ], ) From 9814e601582f3340b7648e74ddab74a150ade4cb Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 13 Jul 2026 02:04:41 +0530 Subject: [PATCH 05/34] minor fix --- .../scripts/release/GoogleVertexAiClient.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt index b92d13f4811..be1a6566f14 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt @@ -56,7 +56,7 @@ class GoogleVertexAiClient( .url(endpoint) .addHeader("Authorization", "Bearer $gcpAccessToken") .addHeader("Content-Type", "application/json") - .post(jsonBody.toRequestBody(JSON_MEDIA_TYPE)) + .method("POST", jsonBody.toRequestBody(JSON_MEDIA_TYPE)) .build() val responseBody = httpClient.newCall(request).execute().use { response -> @@ -85,26 +85,51 @@ class GoogleVertexAiClient( // --- Moshi model classes for JSON serialization --- + /** + * Top-level request body sent to the Vertex AI generateContent endpoint. + * + * @property contents the list of content turns to send to the model + */ @JsonClass(generateAdapter = true) data class GenerateContentRequest( @Json(name = "contents") val contents: List<Content> ) + /** + * Represents a single content turn containing one or more parts. + * + * @property parts the list of content parts in this turn + */ @JsonClass(generateAdapter = true) data class Content( @Json(name = "parts") val parts: List<Part> ) + /** + * A single text part within a [Content] turn. + * + * @property text the text content of this part + */ @JsonClass(generateAdapter = true) data class Part( @Json(name = "text") val text: String ) + /** + * Top-level response from the Vertex AI generateContent endpoint. + * + * @property candidates the list of generated response candidates, or null if none were returned + */ @JsonClass(generateAdapter = true) data class GenerateContentResponse( @Json(name = "candidates") val candidates: List<Candidate>? ) + /** + * A single candidate response from the model. + * + * @property content the content of this candidate, or null if the model returned no content + */ @JsonClass(generateAdapter = true) data class Candidate( @Json(name = "content") val content: Content? From a07aac82d19a43bb8b39fce96d446458ae48edfb Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 00:23:45 +0530 Subject: [PATCH 06/34] Add unit tests for GenerateChangelogs script --- .../oppia/android/scripts/release/BUILD.bazel | 13 + .../scripts/release/GenerateChangelogsTest.kt | 682 ++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index 63feb1d4dc3..7f8228368aa 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -140,3 +140,16 @@ kt_jvm_test( "//third_party:com_google_truth_truth", ], ) + +kt_jvm_test( + name = "GenerateChangelogsTest", + srcs = ["GenerateChangelogsTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common/testing:fake_command_executor", + "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", + "//scripts/src/java/org/oppia/android/scripts/release:generate_changelogs_lib", + "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt new file mode 100644 index 00000000000..dbf6337547c --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt @@ -0,0 +1,682 @@ +package org.oppia.android.scripts.release + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.common.testing.FakeCommandExecutor +import org.oppia.android.testing.assertThrows +import java.io.File + +/** + * Tests for [GenerateChangelogs.kt]. + * + * Pure-logic functions ([parseVersionBzl], [parsePrEntries], [parseFixedIssueNumbers], + * [buildPrompt], [buildChangelogContent], [buildPrBody], [invokeLlmWithFallback]) are tested + * directly with no I/O. The integrated [generateChangelogs] function is tested via + * [FakeCommandExecutor] and [FakeVertexAiClient]. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class GenerateChangelogsTest { + @field:[Rule JvmField] val tempFolder = TemporaryFolder() + + private lateinit var fakeExecutor: FakeCommandExecutor + private lateinit var fakeVertexAiClient: FakeVertexAiClient + + @Before + fun setUp() { + fakeExecutor = FakeCommandExecutor() + fakeVertexAiClient = FakeVertexAiClient() + fakeExecutor.registerHandler("gh") { _, _, _, _ -> 0 } + } + + // --------------------------------------------------------------------------- + // main() -- argument validation + // --------------------------------------------------------------------------- + + @Test + fun testMain_noArguments_throwsWithUsageMessage() { + val exception = assertThrows<IllegalArgumentException> { main(emptyArray()) } + + assertThat(exception).hasMessageThat().contains("Usage:") + assertThat(exception).hasMessageThat().contains("generate_changelogs") + } + + @Test + fun testMain_fourArguments_throwsWithUsageMessage() { + val exception = + assertThrows<IllegalArgumentException> { main(arrayOf("a", "b", "c", "d")) } + + assertThat(exception).hasMessageThat().contains("Usage:") + } + + @Test + fun testMain_sevenArguments_throwsWithUsageMessage() { + val exception = + assertThrows<IllegalArgumentException> { + main(arrayOf("a", "b", "c", "d", "e", "f", "g")) + } + + assertThat(exception).hasMessageThat().contains("Usage:") + } + + // --------------------------------------------------------------------------- + // parseVersionBzl() + // --------------------------------------------------------------------------- + + @Test + fun testParseVersionBzl_missingFile_throwsWithPath() { + val exception = + assertThrows<IllegalStateException> { parseVersionBzl(tempFolder.root) } + + assertThat(exception).hasMessageThat().contains("version.bzl not found") + assertThat(exception).hasMessageThat().contains(tempFolder.root.absolutePath) + } + + @Test + fun testParseVersionBzl_validContent_returnsMajorAndMinor() { + writeVersionBzl(major = 0, minor = 18) + + val (major, minor) = parseVersionBzl(tempFolder.root) + + assertThat(major).isEqualTo(0) + assertThat(minor).isEqualTo(18) + } + + @Test + fun testParseVersionBzl_nonZeroMajor_returnsMajorAndMinor() { + writeVersionBzl(major = 1, minor = 3) + + val (major, minor) = parseVersionBzl(tempFolder.root) + + assertThat(major).isEqualTo(1) + assertThat(minor).isEqualTo(3) + } + + @Test + fun testParseVersionBzl_missingMajorVersion_throwsWithMessage() { + tempFolder.newFile("version.bzl").writeText("MINOR_VERSION = 18\n") + + val exception = + assertThrows<IllegalStateException> { parseVersionBzl(tempFolder.root) } + + assertThat(exception).hasMessageThat().contains("MAJOR_VERSION") + } + + @Test + fun testParseVersionBzl_missingMinorVersion_throwsWithMessage() { + tempFolder.newFile("version.bzl").writeText("MAJOR_VERSION = 0\n") + + val exception = + assertThrows<IllegalStateException> { parseVersionBzl(tempFolder.root) } + + assertThat(exception).hasMessageThat().contains("MINOR_VERSION") + } + + @Test + fun testParseVersionBzl_nonNumericMinorValue_throwsWithMessage() { + tempFolder.newFile("version.bzl").writeText( + "MAJOR_VERSION = 0\nMINOR_VERSION = \"eighteen\"\n" + ) + + val exception = + assertThrows<IllegalStateException> { parseVersionBzl(tempFolder.root) } + + assertThat(exception).hasMessageThat().contains("MINOR_VERSION") + } + + @Test + fun testParseVersionBzl_extraWhitespaceAroundAssignment_parsesCorrectly() { + tempFolder.newFile("version.bzl").writeText( + "MAJOR_VERSION = 0\nMINOR_VERSION = 17\n" + ) + + val (major, minor) = parseVersionBzl(tempFolder.root) + + assertThat(major).isEqualTo(0) + assertThat(minor).isEqualTo(17) + } + + // --------------------------------------------------------------------------- + // parsePrEntries() + // --------------------------------------------------------------------------- + + @Test + fun testParsePrEntries_emptyList_returnsEmpty() { + val result = parsePrEntries(emptyList()) + + assertThat(result).isEmpty() + } + + @Test + fun testParsePrEntries_standardSquashMergeLine_returnsPrEntry() { + val result = parsePrEntries(listOf("abc1234 Fix flaky test in release flow (#6270)")) + + assertThat(result).hasSize(1) + assertThat(result[0].number).isEqualTo(6270) + assertThat(result[0].title).isEqualTo("Fix flaky test in release flow") + } + + @Test + fun testParsePrEntries_lineWithNoPrReference_isSkipped() { + val result = parsePrEntries(listOf("abc1234 Update README with setup instructions")) + + assertThat(result).isEmpty() + } + + @Test + fun testParsePrEntries_multipleLines_returnsAllMatchingEntries() { + val lines = listOf( + "aaa1111 Add deploy workflow (#6200)", + "bbb2222 No PR reference here -- skip me", + "ccc3333 Fix crash on startup (#6215)" + ) + + val result = parsePrEntries(lines) + + assertThat(result).hasSize(2) + assertThat(result[0].number).isEqualTo(6200) + assertThat(result[1].number).isEqualTo(6215) + } + + @Test + fun testParsePrEntries_prReferenceInMiddleOfTitle_isNotMatched() { + // PR reference must be at the end of the subject line to match. + val result = parsePrEntries(listOf("abc1234 Fix (#6200) something after")) + + assertThat(result).isEmpty() + } + + @Test + fun testParsePrEntries_titlePreservesHashInBody_returnsCorrectTitle() { + val result = + parsePrEntries(listOf("abc1234 Fix part of #6106: Add rollout script (#6270)")) + + assertThat(result).hasSize(1) + assertThat(result[0].title).isEqualTo("Fix part of #6106: Add rollout script") + assertThat(result[0].number).isEqualTo(6270) + } + + // --------------------------------------------------------------------------- + // parseFixedIssueNumbers() + // --------------------------------------------------------------------------- + + @Test + fun testParseFixedIssueNumbers_emptyList_returnsEmpty() { + val result = parseFixedIssueNumbers(emptyList()) + + assertThat(result).isEmpty() + } + + @Test + fun testParseFixedIssueNumbers_fixesKeyword_returnsIssueNumber() { + val result = parseFixedIssueNumbers(listOf("abc1234 Fixes #6100 in crash path (#6270)")) + + assertThat(result).containsExactly(6100) + } + + @Test + fun testParseFixedIssueNumbers_fixKeyword_returnsIssueNumber() { + val result = parseFixedIssueNumbers(listOf("abc1234 Fix #5999 (#6200)")) + + assertThat(result).containsExactly(5999) + } + + @Test + fun testParseFixedIssueNumbers_closesKeyword_returnsIssueNumber() { + val result = parseFixedIssueNumbers(listOf("abc1234 Closes #6050 (#6210)")) + + assertThat(result).containsExactly(6050) + } + + @Test + fun testParseFixedIssueNumbers_closeKeyword_returnsIssueNumber() { + val result = parseFixedIssueNumbers(listOf("abc1234 Close #6051 (#6211)")) + + assertThat(result).containsExactly(6051) + } + + @Test + fun testParseFixedIssueNumbers_caseInsensitiveFixesKeyword_returnsIssueNumber() { + val result = parseFixedIssueNumbers(listOf("abc1234 FIXES #6100 (#6270)")) + + assertThat(result).containsExactly(6100) + } + + @Test + fun testParseFixedIssueNumbers_duplicateIssueAcrossCommits_returnsDeduplicated() { + val lines = listOf( + "aaa Fixes #6100 (#6270)", + "bbb Fixes #6100 also (#6271)" + ) + + val result = parseFixedIssueNumbers(lines) + + assertThat(result).containsExactly(6100) + } + + @Test + fun testParseFixedIssueNumbers_multipleIssuesInOneCommit_returnsAllSorted() { + val result = parseFixedIssueNumbers( + listOf("abc Fixes #6200, Closes #6100 (#6300)") + ) + + assertThat(result).containsExactly(6100, 6200).inOrder() + } + + @Test + fun testParseFixedIssueNumbers_noFixesPattern_returnsEmpty() { + val result = + parseFixedIssueNumbers(listOf("abc1234 Add changelog for version 0.17 (#6270)")) + + assertThat(result).isEmpty() + } + + // --------------------------------------------------------------------------- + // buildPrompt() + // --------------------------------------------------------------------------- + + @Test + fun testBuildPrompt_containsVersionInContent() { + val prompt = buildPrompt("0.17", "- PR title (#100)", "(none)") + + assertThat(prompt).contains("0.17") + } + + @Test + fun testBuildPrompt_containsPrListText() { + val prompt = buildPrompt("0.17", "- My feature PR (#6200)", "(none)") + + assertThat(prompt).contains("- My feature PR (#6200)") + } + + @Test + fun testBuildPrompt_containsIssueListText() { + val prompt = buildPrompt("0.17", "- PR (#100)", "- #6100\n- #6200") + + assertThat(prompt).contains("- #6100") + assertThat(prompt).contains("- #6200") + } + + @Test + fun testBuildPrompt_noIssues_containsNoneMarker() { + val prompt = buildPrompt("0.17", "- PR (#100)", "(none)") + + assertThat(prompt).contains("(none)") + } + + // --------------------------------------------------------------------------- + // buildChangelogContent() + // --------------------------------------------------------------------------- + + @Test + fun testBuildChangelogContent_llmSucceeded_containsSummaryOnly() { + val content = buildChangelogContent( + summary = "Great release summary.", + prEntries = listOf(PrEntry(6200, "My feature")), + issueNumbers = listOf(6100), + llmFailed = false + ) + + assertThat(content).contains("Great release summary.") + assertThat(content).doesNotContain(LLM_FALLBACK_MARKER) + assertThat(content).doesNotContain("Changes in this release") + } + + @Test + fun testBuildChangelogContent_llmFailed_containsFallbackMarker() { + val content = buildChangelogContent( + summary = LLM_FALLBACK_MARKER, + prEntries = emptyList(), + issueNumbers = emptyList(), + llmFailed = true + ) + + assertThat(content).contains(LLM_FALLBACK_MARKER) + } + + @Test + fun testBuildChangelogContent_llmFailed_withPrEntries_containsPrList() { + val content = buildChangelogContent( + summary = LLM_FALLBACK_MARKER, + prEntries = listOf(PrEntry(6200, "Add deploy workflow")), + issueNumbers = emptyList(), + llmFailed = true + ) + + assertThat(content).contains("Changes in this release") + assertThat(content).contains("Add deploy workflow") + } + + @Test + fun testBuildChangelogContent_llmFailed_withIssueNumbers_containsIssueSection() { + val content = buildChangelogContent( + summary = LLM_FALLBACK_MARKER, + prEntries = emptyList(), + issueNumbers = listOf(6100, 6200), + llmFailed = true + ) + + assertThat(content).contains("Issues addressed") + assertThat(content).contains("#6100") + assertThat(content).contains("#6200") + } + + @Test + fun testBuildChangelogContent_llmFailed_noPrsOrIssues_noExtraSections() { + val content = buildChangelogContent( + summary = LLM_FALLBACK_MARKER, + prEntries = emptyList(), + issueNumbers = emptyList(), + llmFailed = true + ) + + assertThat(content).doesNotContain("Changes in this release") + assertThat(content).doesNotContain("Issues addressed") + } + + @Test + fun testBuildChangelogContent_endsWithSingleNewline() { + val content = buildChangelogContent( + summary = "Summary.", + prEntries = emptyList(), + issueNumbers = emptyList(), + llmFailed = false + ) + + assertThat(content).endsWith("\n") + assertThat(content).doesNotMatch(".*\\n\\n$") + } + + // --------------------------------------------------------------------------- + // buildPrBody() + // --------------------------------------------------------------------------- + + @Test + fun testBuildPrBody_containsVersionInHeading() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa1111", toSha = "bbb2222", + prEntries = emptyList(), issueNumbers = emptyList(), llmFailed = false + ) + + assertThat(body).contains("0.17") + } + + @Test + fun testBuildPrBody_containsCommitRangeLink() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa111122223333", toSha = "bbb444455556666", + prEntries = emptyList(), issueNumbers = emptyList(), llmFailed = false + ) + + assertThat(body).contains("aaa1111") + assertThat(body).contains("bbb4444") + assertThat(body).contains("github.com/oppia/oppia-android/compare/") + } + + @Test + fun testBuildPrBody_withPrEntries_containsPrLinks() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa", toSha = "bbb", + prEntries = listOf(PrEntry(6200, "Add deploy workflow")), + issueNumbers = emptyList(), llmFailed = false + ) + + assertThat(body).contains("#6200") + assertThat(body).contains("Add deploy workflow") + assertThat(body).contains("github.com/oppia/oppia-android/pull/6200") + } + + @Test + fun testBuildPrBody_withIssueNumbers_containsIssueLinks() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa", toSha = "bbb", + prEntries = emptyList(), issueNumbers = listOf(6100), llmFailed = false + ) + + assertThat(body).contains("#6100") + assertThat(body).contains("github.com/oppia/oppia-android/issues/6100") + } + + @Test + fun testBuildPrBody_llmFailed_containsWarningBlock() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa", toSha = "bbb", + prEntries = emptyList(), issueNumbers = emptyList(), llmFailed = true + ) + + assertThat(body).contains("LLM generation failed") + assertThat(body).contains(LLM_FALLBACK_MARKER) + } + + @Test + fun testBuildPrBody_llmSucceeded_noWarningBlock() { + val body = buildPrBody( + version = "0.17", fromSha = "aaa", toSha = "bbb", + prEntries = emptyList(), issueNumbers = emptyList(), llmFailed = false + ) + + assertThat(body).doesNotContain("LLM generation failed") + } + + // --------------------------------------------------------------------------- + // invokeLlmWithFallback() + // --------------------------------------------------------------------------- + + @Test + fun testInvokeLlmWithFallback_successfulCall_returnsSummaryAndFalse() { + fakeVertexAiClient = FakeVertexAiClient(defaultResponse = "Great release summary.") + + val (summary, failed) = invokeLlmWithFallback(fakeVertexAiClient, "prompt text") + + assertThat(summary).isEqualTo("Great release summary.") + assertThat(failed).isFalse() + } + + @Test + fun testInvokeLlmWithFallback_clientThrows_returnsFallbackMarkerAndTrue() { + fakeVertexAiClient.shouldFail = true + + val (summary, failed) = invokeLlmWithFallback(fakeVertexAiClient, "prompt text") + + assertThat(summary).isEqualTo(LLM_FALLBACK_MARKER) + assertThat(failed).isTrue() + } + + @Test + fun testInvokeLlmWithFallback_clientThrows_promptIsStillRecorded() { + fakeVertexAiClient.shouldFail = true + + invokeLlmWithFallback(fakeVertexAiClient, "my prompt") + + assertThat(fakeVertexAiClient.receivedPrompts).containsExactly("my prompt") + } + + // --------------------------------------------------------------------------- + // generateChangelogs() -- integrated tests + // --------------------------------------------------------------------------- + + @Test + fun testGenerateChangelogs_changelogAlreadyExists_doesNotCallLlm() { + writeVersionBzl(major = 0, minor = 18) + val changelogsDir = tempFolder.newFolder("config", "changelogs") + File(changelogsDir, "0.17.md").writeText("Existing changelog.") + + generateChangelogs( + workspaceRoot = tempFolder.root, + commandExecutor = fakeExecutor, + vertexAiClient = fakeVertexAiClient + ) + + assertThat(fakeVertexAiClient.receivedPrompts).isEmpty() + } + + @Test + fun testGenerateChangelogs_minorVersionIsZero_throwsWithMessage() { + writeVersionBzl(major = 0, minor = 0) + + val exception = assertThrows<IllegalStateException> { + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + } + + assertThat(exception).hasMessageThat().contains("MINOR_VERSION") + assertThat(exception).hasMessageThat().contains("0") + } + + @Test + fun testGenerateChangelogs_llmSucceeds_writesChangelogWithSummary() { + writeVersionBzl(major = 0, minor = 18) + fakeVertexAiClient = FakeVertexAiClient(defaultResponse = "LLM-generated summary.") + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + val changelogFile = File(tempFolder.root, "config/changelogs/0.17.md") + assertThat(changelogFile.exists()).isTrue() + assertThat(changelogFile.readText()).contains("LLM-generated summary.") + assertThat(changelogFile.readText()).doesNotContain(LLM_FALLBACK_MARKER) + } + + @Test + fun testGenerateChangelogs_llmFails_writesChangelogWithFallbackMarker() { + writeVersionBzl(major = 0, minor = 18) + fakeVertexAiClient.shouldFail = true + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + val changelogFile = File(tempFolder.root, "config/changelogs/0.17.md") + assertThat(changelogFile.exists()).isTrue() + assertThat(changelogFile.readText()).contains(LLM_FALLBACK_MARKER) + } + + @Test + fun testGenerateChangelogs_llmSucceeds_promptContainsVersion() { + writeVersionBzl(major = 0, minor = 18) + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + assertThat(fakeVertexAiClient.receivedPrompts).hasSize(1) + assertThat(fakeVertexAiClient.receivedPrompts[0]).contains("0.17") + } + + @Test + fun testGenerateChangelogs_withPrsInLog_promptContainsPrTitles() { + writeVersionBzl(major = 0, minor = 18) + setupStandardGitHandlers( + mergeBaseSha = "deadbeef", + logLines = listOf("abc1234 Add cool feature (#6300)") + ) + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + assertThat(fakeVertexAiClient.receivedPrompts[0]).contains("Add cool feature (#6300)") + } + + @Test + fun testGenerateChangelogs_prevBranchNotFound_fallsBackAndStillCallsLlm() { + writeVersionBzl(major = 0, minor = 18) + val firstCommit = "firstcommitsha" + val toSha = "toshasha123" + var mergeBaseCallCount = 0 + fakeExecutor.registerHandler("git") { _, args, out, _ -> + when { + args.contains("merge-base") -> { + mergeBaseCallCount++ + if (mergeBaseCallCount == 1) { + out.println(toSha); 0 + } else { + // Previous release branch not found -- simulate failure. + 1 + } + } + args.contains("--max-parents=0") -> { out.println(firstCommit); 0 } + args.contains("log") -> { out.println(""); 0 } + else -> { out.println(""); 0 } + } + } + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + assertThat(fakeVertexAiClient.receivedPrompts).hasSize(1) + } + + @Test + fun testGenerateChangelogs_firstEverRelease_prevMinorIsZero_usesFirstCommit() { + // minor=1 means prevMinor=0, which triggers the first-release path. + writeVersionBzl(major = 0, minor = 1) + val firstCommit = "firstcommitsha" + val toSha = "toshasha123" + fakeExecutor.registerHandler("git") { _, args, out, _ -> + when { + args.contains("merge-base") -> { out.println(toSha); 0 } + args.contains("--max-parents=0") -> { out.println(firstCommit); 0 } + args.contains("log") -> { out.println(""); 0 } + else -> { out.println(""); 0 } + } + } + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + assertThat(fakeVertexAiClient.receivedPrompts).hasSize(1) + } + + @Test + fun testGenerateChangelogs_changelogFileWrittenToCorrectPath() { + writeVersionBzl(major = 0, minor = 18) + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + val expectedPath = File(tempFolder.root, "config/changelogs/0.17.md") + assertThat(expectedPath.exists()).isTrue() + } + + @Test + fun testGenerateChangelogs_nonZeroMajorVersion_writesCorrectChangelogFileName() { + writeVersionBzl(major = 1, minor = 5) + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + val expectedPath = File(tempFolder.root, "config/changelogs/1.4.md") + assertThat(expectedPath.exists()).isTrue() + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private fun writeVersionBzl(major: Int, minor: Int) { + tempFolder.newFile("version.bzl").writeText( + "MAJOR_VERSION = $major\nMINOR_VERSION = $minor\n" + ) + } + + /** + * Registers git and gh handlers on [fakeExecutor] that model the happy-path flow: + * - `git merge-base` returns [mergeBaseSha] + * - `git log` returns [logLines] + * - All other git sub-commands (config, checkout, add, commit, push) succeed silently + * - `gh pr create` succeeds silently + */ + private fun setupStandardGitHandlers( + mergeBaseSha: String, + logLines: List<String> = emptyList() + ) { + fakeExecutor.registerHandler("git") { _, args, out, _ -> + when { + args.contains("merge-base") -> { out.println(mergeBaseSha); 0 } + args.contains("log") -> { logLines.forEach { out.println(it) }; 0 } + else -> { out.println(""); 0 } + } + } + fakeExecutor.registerHandler("gh") { _, _, out, _ -> + out.println("https://github.com/oppia/oppia-android/pull/9999") + 0 + } + } +} From 368ffc3a81136f587f2da3aff9a917e7946691d6 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 00:30:34 +0530 Subject: [PATCH 07/34] Fix mutable companion apiBaseUrl; add GoogleVertexAiClientTest --- .../scripts/release/GenerateChangelogs.kt | 8 +- .../scripts/release/GoogleVertexAiClient.kt | 7 +- .../oppia/android/scripts/release/BUILD.bazel | 11 + .../release/GoogleVertexAiClientTest.kt | 259 ++++++++++++++++++ 4 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 scripts/src/javatests/org/oppia/android/scripts/release/GoogleVertexAiClientTest.kt diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index ceca13e11f9..4b2577e4e92 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -51,11 +51,15 @@ fun main(args: Array<String>) { val vertexModel = args[3] val gcpAccessToken = args[4] - if (args.size == 6) GoogleVertexAiClient.apiBaseUrl = args[5] + val overrideApiBaseUrl = if (args.size == 6) args[5] else null ScriptBackgroundCoroutineDispatcher().use { scriptBgDispatcher -> val commandExecutor = CommandExecutorImpl(scriptBgDispatcher) - val vertexAiClient = GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken) + val vertexAiClient = if (overrideApiBaseUrl != null) { + GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken, overrideApiBaseUrl) + } else { + GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken) + } generateChangelogs( workspaceRoot = File(workspaceRoot), commandExecutor = commandExecutor, diff --git a/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt index be1a6566f14..14fee8390f1 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GoogleVertexAiClient.kt @@ -29,7 +29,8 @@ class GoogleVertexAiClient( private val gcpProject: String, private val location: String, private val modelId: String, - private val gcpAccessToken: String + private val gcpAccessToken: String, + private val apiBaseUrl: String = DEFAULT_API_BASE_URL ) : VertexAiClient { private val httpClient by lazy { OkHttpClient.Builder().build() } @@ -136,8 +137,8 @@ class GoogleVertexAiClient( ) companion object { - /** The Vertex AI REST API base URL. Exposed as a `var` so tests can override it. */ - var apiBaseUrl = "https://us-central1-aiplatform.googleapis.com" + /** Default Vertex AI REST API base URL used in production. */ + const val DEFAULT_API_BASE_URL = "https://us-central1-aiplatform.googleapis.com" private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() } diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index 7f8228368aa..d1e354cb3b6 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -153,3 +153,14 @@ kt_jvm_test( "//third_party:com_google_truth_truth", ], ) + +kt_jvm_test( + name = "GoogleVertexAiClientTest", + srcs = ["GoogleVertexAiClientTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/release:google_vertex_ai_client", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:com_squareup_okhttp3_mockwebserver", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GoogleVertexAiClientTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GoogleVertexAiClientTest.kt new file mode 100644 index 00000000000..902ef049c3e --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GoogleVertexAiClientTest.kt @@ -0,0 +1,259 @@ +package org.oppia.android.scripts.release + +import com.google.common.truth.Truth.assertThat +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.oppia.android.testing.assertThrows + +/** + * Tests for [GoogleVertexAiClient]. + * + * Uses [MockWebServer] to intercept HTTP calls and verify request structure and response handling + * without making real network calls to the Vertex AI API. + */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class GoogleVertexAiClientTest { + private lateinit var server: MockWebServer + private lateinit var client: GoogleVertexAiClient + + @Before + fun setUp() { + server = MockWebServer() + server.start() + client = GoogleVertexAiClient( + gcpProject = "test-project", + location = "us-central1", + modelId = "gemini-flash", + gcpAccessToken = "test-token", + apiBaseUrl = server.url("/").toString().trimEnd('/') + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + // --------------------------------------------------------------------------- + // Request structure + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_sendsPostRequest() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my prompt") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + } + + @Test + fun testGenerateText_requestPathContainsProjectAndModel() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my prompt") + + val request = server.takeRequest() + assertThat(request.path).contains("test-project") + assertThat(request.path).contains("gemini-flash") + assertThat(request.path).contains("generateContent") + } + + @Test + fun testGenerateText_requestPathContainsLocation() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my prompt") + + val request = server.takeRequest() + assertThat(request.path).contains("us-central1") + } + + @Test + fun testGenerateText_sendsAuthorizationHeader() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my prompt") + + val request = server.takeRequest() + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer test-token") + } + + @Test + fun testGenerateText_sendsJsonContentTypeHeader() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my prompt") + + val request = server.takeRequest() + assertThat(request.getHeader("Content-Type")).contains("application/json") + } + + @Test + fun testGenerateText_requestBodyContainsPrompt() { + server.enqueue(successResponse("Generated text.")) + + client.generateText("my specific prompt text") + + val request = server.takeRequest() + assertThat(request.body.readUtf8()).contains("my specific prompt text") + } + + // --------------------------------------------------------------------------- + // Successful response handling + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_successResponse_returnsGeneratedText() { + server.enqueue(successResponse("This is the generated changelog summary.")) + + val result = client.generateText("some prompt") + + assertThat(result).isEqualTo("This is the generated changelog summary.") + } + + @Test + fun testGenerateText_responseTextWithLeadingAndTrailingWhitespace_isTrimmed() { + server.enqueue(successResponse(" Summary with whitespace. ")) + + val result = client.generateText("some prompt") + + assertThat(result).isEqualTo("Summary with whitespace.") + } + + @Test + fun testGenerateText_multipleCandidates_returnsFirstCandidateText() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "candidates": [ + {"content": {"parts": [{"text": "First candidate."}]}}, + {"content": {"parts": [{"text": "Second candidate."}]}} + ] + } + """.trimIndent() + ) + ) + + val result = client.generateText("some prompt") + + assertThat(result).isEqualTo("First candidate.") + } + + // --------------------------------------------------------------------------- + // Error response handling + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_nonSuccessResponse_throwsWithStatusCode() { + server.enqueue(MockResponse().setResponseCode(403).setBody("Forbidden")) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("403") + } + + @Test + fun testGenerateText_serverError_throwsWithStatusCode() { + server.enqueue(MockResponse().setResponseCode(500).setBody("Internal Server Error")) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("500") + } + + // --------------------------------------------------------------------------- + // Malformed / empty response handling + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_nullCandidatesField_throwsWithMessage() { + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{"candidates": null}""") + ) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("no text candidates") + } + + @Test + fun testGenerateText_emptyCandidatesList_throwsWithMessage() { + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{"candidates": []}""") + ) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("no text candidates") + } + + @Test + fun testGenerateText_nullContentInCandidate_throwsWithMessage() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"candidates": [{"content": null}]}""" + ) + ) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("no text candidates") + } + + @Test + fun testGenerateText_emptyPartsInContent_throwsWithMessage() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"candidates": [{"content": {"parts": []}}]}""" + ) + ) + + val exception = assertThrows<IllegalStateException> { + client.generateText("some prompt") + } + + assertThat(exception).hasMessageThat().contains("no text candidates") + } + + // --------------------------------------------------------------------------- + // Default API base URL + // --------------------------------------------------------------------------- + + @Test + fun testDefaultApiBaseUrl_pointsToProductionEndpoint() { + assertThat(GoogleVertexAiClient.DEFAULT_API_BASE_URL) + .isEqualTo("https://us-central1-aiplatform.googleapis.com") + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Returns a [MockResponse] with a 200 status and a valid Vertex AI generateContent response + * body containing [text] as the first candidate's text part. + */ + private fun successResponse(text: String): MockResponse { + val escapedText = text.replace("\"", "\\\"") + return MockResponse().setResponseCode(200).setBody( + """{"candidates": [{"content": {"parts": [{"text": "$escapedText"}]}}]}""" + ) + } +} From c6af6371c7031cdc5b21d0fc9b5d3cd276d6104b Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 00:35:00 +0530 Subject: [PATCH 08/34] Add gh pr create arg assertions to GenerateChangelogsTest --- .../scripts/release/GenerateChangelogsTest.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt index dbf6337547c..297e1460ef8 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt @@ -646,10 +646,50 @@ class GenerateChangelogsTest { assertThat(expectedPath.exists()).isTrue() } + + @Test + fun testGenerateChangelogs_ghPrCreate_usesCorrectBranchName() { + writeVersionBzl(major = 0, minor = 18) + var capturedGhArgs: List<String> = emptyList() + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + fakeExecutor.registerHandler("gh") { _, args, out, _ -> + capturedGhArgs = args + out.println("https://github.com/oppia/oppia-android/pull/9999") + 0 + } + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + assertThat(capturedGhArgs).contains("--head") + val headIndex = capturedGhArgs.indexOf("--head") + assertThat(capturedGhArgs[headIndex + 1]).isEqualTo("automated/changelog-0.17") + } + + @Test + fun testGenerateChangelogs_ghPrCreate_bodyContainsVersionAndCommitRange() { + writeVersionBzl(major = 0, minor = 18) + var capturedGhArgs: List<String> = emptyList() + setupStandardGitHandlers(mergeBaseSha = "deadbeef") + fakeExecutor.registerHandler("gh") { _, args, out, _ -> + capturedGhArgs = args + out.println("https://github.com/oppia/oppia-android/pull/9999") + 0 + } + + generateChangelogs(tempFolder.root, fakeExecutor, fakeVertexAiClient) + + val bodyIndex = capturedGhArgs.indexOf("--body") + assertThat(bodyIndex).isGreaterThan(-1) + val prBody = capturedGhArgs[bodyIndex + 1] + assertThat(prBody).contains("0.17") + assertThat(prBody).contains("deadbeef") + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- + private fun writeVersionBzl(major: Int, minor: Int) { tempFolder.newFile("version.bzl").writeText( "MAJOR_VERSION = $major\nMINOR_VERSION = $minor\n" From 510cb3e469b65eb12654e3da7f73c57b845b9020 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 00:36:02 +0530 Subject: [PATCH 09/34] Fix ktlint blank line violations in GenerateChangelogsTest --- .../oppia/android/scripts/release/GenerateChangelogsTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt index 297e1460ef8..b3f657ba7ee 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt @@ -646,7 +646,6 @@ class GenerateChangelogsTest { assertThat(expectedPath.exists()).isTrue() } - @Test fun testGenerateChangelogs_ghPrCreate_usesCorrectBranchName() { writeVersionBzl(major = 0, minor = 18) @@ -688,8 +687,6 @@ class GenerateChangelogsTest { // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- - - private fun writeVersionBzl(major: Int, minor: Int) { tempFolder.newFile("version.bzl").writeText( "MAJOR_VERSION = $major\nMINOR_VERSION = $minor\n" From 3161be3d0a7e763b611b05d2edd7a6028b626bae Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 00:53:36 +0530 Subject: [PATCH 10/34] Exempt VertexAiClient and FakeVertexAiClient from test file check --- scripts/assets/test_file_exemptions.textproto | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index dc9d9ad58cb..50127f36ed4 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -4939,3 +4939,11 @@ test_file_exemption { exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/CloudSigner.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt" + test_file_not_required: true +} +test_file_exemption { + exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt" + test_file_not_required: true +} From e6bac62cde3ca724cfd76879800384fc3efc2584 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 19:38:25 +0530 Subject: [PATCH 11/34] Move FakeVertexAiClient to src/javatests; exempt VertexAiClient interface --- scripts/assets/test_file_exemptions.textproto | 4 ---- .../org/oppia/android/scripts/release/BUILD.bazel | 8 +------- .../org/oppia/android/scripts/release/BUILD.bazel | 14 ++++++++++++-- .../android/scripts/release/FakeVertexAiClient.kt | 0 4 files changed, 13 insertions(+), 13 deletions(-) rename scripts/src/{java => javatests}/org/oppia/android/scripts/release/FakeVertexAiClient.kt (100%) diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index 50127f36ed4..f1b2710b4ef 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -4939,10 +4939,6 @@ test_file_exemption { exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/CloudSigner.kt" test_file_not_required: true } -test_file_exemption { - exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt" - test_file_not_required: true -} test_file_exemption { exempted_file_path: "scripts/src/java/org/oppia/android/scripts/release/VertexAiClient.kt" test_file_not_required: true diff --git a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel index e999a1f7add..1c75387967d 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel @@ -112,13 +112,7 @@ kt_jvm_library( visibility = ["//scripts:oppia_script_library_visibility"], ) -kt_jvm_library( - name = "fake_vertex_ai_client", - testonly = True, - srcs = ["FakeVertexAiClient.kt"], - visibility = ["//scripts:oppia_script_test_visibility"], - deps = [":vertex_ai_client"], -) + kt_jvm_library( name = "google_vertex_ai_client", diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index d1e354cb3b6..624ac3a2d5d 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -2,7 +2,17 @@ Tests for release automation scripts. """ -load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test") +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") + +kt_jvm_library( + name = "fake_vertex_ai_client", + testonly = True, + srcs = ["FakeVertexAiClient.kt"], + visibility = ["//scripts:oppia_script_test_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", + ], +) kt_jvm_test( name = "AppFlavorTest", @@ -145,8 +155,8 @@ kt_jvm_test( name = "GenerateChangelogsTest", srcs = ["GenerateChangelogsTest.kt"], deps = [ + ":fake_vertex_ai_client", "//scripts/src/java/org/oppia/android/scripts/common/testing:fake_command_executor", - "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", "//scripts/src/java/org/oppia/android/scripts/release:generate_changelogs_lib", "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", "//testing:assertion_helpers", diff --git a/scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt b/scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClient.kt similarity index 100% rename from scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt rename to scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClient.kt From 6ee58599b796a52263ccecb1c61581888789a6a5 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 19:39:04 +0530 Subject: [PATCH 12/34] Fix buildifier blank lines in release BUILD.bazel --- scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel index 1c75387967d..5484e59bc15 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel @@ -112,8 +112,6 @@ kt_jvm_library( visibility = ["//scripts:oppia_script_library_visibility"], ) - - kt_jvm_library( name = "google_vertex_ai_client", srcs = ["GoogleVertexAiClient.kt"], From f583f284efb15ee2c74f28cf06266c96922df61d Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 21 Jul 2026 21:13:16 +0530 Subject: [PATCH 13/34] Add FakeVertexAiClientTest to satisfy test file presence check --- .../oppia/android/scripts/release/BUILD.bazel | 10 ++ .../scripts/release/FakeVertexAiClientTest.kt | 146 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClientTest.kt diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index 624ac3a2d5d..59d1d964b32 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -14,6 +14,16 @@ kt_jvm_library( ], ) +kt_jvm_test( + name = "FakeVertexAiClientTest", + srcs = ["FakeVertexAiClientTest.kt"], + deps = [ + ":fake_vertex_ai_client", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + ], +) + kt_jvm_test( name = "AppFlavorTest", srcs = ["AppFlavorTest.kt"], diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClientTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClientTest.kt new file mode 100644 index 00000000000..b3ba2a4f7f9 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClientTest.kt @@ -0,0 +1,146 @@ +package org.oppia.android.scripts.release + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.oppia.android.testing.assertThrows + +/** Tests for [FakeVertexAiClient]. */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class FakeVertexAiClientTest { + private lateinit var fake: FakeVertexAiClient + + @Before + fun setUp() { + fake = FakeVertexAiClient() + } + + // --------------------------------------------------------------------------- + // generateText -- default response + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_defaultResponse_returnsDefaultText() { + val result = fake.generateText("any prompt") + + assertThat(result).isEqualTo("Fake generated changelog summary.") + } + + @Test + fun testGenerateText_customDefaultResponse_returnsCustomText() { + fake = FakeVertexAiClient(defaultResponse = "Custom summary.") + + val result = fake.generateText("any prompt") + + assertThat(result).isEqualTo("Custom summary.") + } + + // --------------------------------------------------------------------------- + // generateText -- prompt recording + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_singleCall_recordsPrompt() { + fake.generateText("my prompt") + + assertThat(fake.receivedPrompts).containsExactly("my prompt") + } + + @Test + fun testGenerateText_multipleCalls_recordsAllPromptsInOrder() { + fake.generateText("first prompt") + fake.generateText("second prompt") + + assertThat(fake.receivedPrompts).containsExactly("first prompt", "second prompt").inOrder() + } + + @Test + fun testGenerateText_noCalls_receivedPromptsIsEmpty() { + assertThat(fake.receivedPrompts).isEmpty() + } + + // --------------------------------------------------------------------------- + // generateText -- failure simulation + // --------------------------------------------------------------------------- + + @Test + fun testGenerateText_shouldFailTrue_throwsIllegalStateException() { + fake.shouldFail = true + + val exception = assertThrows<IllegalStateException> { fake.generateText("prompt") } + + assertThat(exception).hasMessageThat().contains("simulated Vertex AI failure") + } + + @Test + fun testGenerateText_shouldFailTrue_promptIsStillRecorded() { + fake.shouldFail = true + + try { + fake.generateText("my prompt") + } catch (e: IllegalStateException) { + // Expected. + } + + assertThat(fake.receivedPrompts).containsExactly("my prompt") + } + + @Test + fun testGenerateText_shouldFailTrue_flagResetsAfterThrow() { + fake.shouldFail = true + + try { + fake.generateText("first") + } catch (e: IllegalStateException) { + // Expected. + } + + // Second call should succeed since shouldFail auto-resets to false. + val result = fake.generateText("second") + + assertThat(fake.shouldFail).isFalse() + assertThat(result).isEqualTo("Fake generated changelog summary.") + } + + @Test + fun testGenerateText_shouldFailFalse_doesNotThrow() { + fake.shouldFail = false + + // Should not throw. + fake.generateText("prompt") + } + + // --------------------------------------------------------------------------- + // reset() + // --------------------------------------------------------------------------- + + @Test + fun testReset_afterPrompts_clearsReceivedPrompts() { + fake.generateText("prompt one") + fake.generateText("prompt two") + + fake.reset() + + assertThat(fake.receivedPrompts).isEmpty() + } + + @Test + fun testReset_whenShouldFailIsTrue_resetsFlagToFalse() { + fake.shouldFail = true + + fake.reset() + + assertThat(fake.shouldFail).isFalse() + } + + @Test + fun testReset_afterReset_generatesTextNormally() { + fake.shouldFail = true + fake.reset() + + val result = fake.generateText("prompt after reset") + + assertThat(result).isEqualTo("Fake generated changelog summary.") + } +} From 8985c0e0f70f670287db366ffbb4b2072b9e4caa Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 26 Jul 2026 16:18:19 +0530 Subject: [PATCH 14/34] move FakeVertexAiClient to prod source set to fix test file presence check --- .../org/oppia/android/scripts/release/BUILD.bazel | 8 ++++++++ .../android/scripts/release/FakeVertexAiClient.kt | 0 .../org/oppia/android/scripts/release/BUILD.bazel | 14 ++------------ 3 files changed, 10 insertions(+), 12 deletions(-) rename scripts/src/{javatests => java}/org/oppia/android/scripts/release/FakeVertexAiClient.kt (100%) diff --git a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel index 5484e59bc15..cee8b5468bc 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/release/BUILD.bazel @@ -61,6 +61,14 @@ kt_jvm_library( deps = [":cloud_signer"], ) +kt_jvm_library( + name = "fake_vertex_ai_client", + testonly = True, + srcs = ["FakeVertexAiClient.kt"], + visibility = ["//scripts:oppia_script_test_visibility"], + deps = [":vertex_ai_client"], +) + kt_jvm_library( name = "version_inversion_checker", srcs = ["VersionInversionChecker.kt"], diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClient.kt b/scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt similarity index 100% rename from scripts/src/javatests/org/oppia/android/scripts/release/FakeVertexAiClient.kt rename to scripts/src/java/org/oppia/android/scripts/release/FakeVertexAiClient.kt diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index 59d1d964b32..ea109f9de03 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -4,21 +4,11 @@ Tests for release automation scripts. load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") -kt_jvm_library( - name = "fake_vertex_ai_client", - testonly = True, - srcs = ["FakeVertexAiClient.kt"], - visibility = ["//scripts:oppia_script_test_visibility"], - deps = [ - "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", - ], -) - kt_jvm_test( name = "FakeVertexAiClientTest", srcs = ["FakeVertexAiClientTest.kt"], deps = [ - ":fake_vertex_ai_client", + "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", "//testing:assertion_helpers", "//third_party:com_google_truth_truth", ], @@ -165,7 +155,7 @@ kt_jvm_test( name = "GenerateChangelogsTest", srcs = ["GenerateChangelogsTest.kt"], deps = [ - ":fake_vertex_ai_client", + "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", "//scripts/src/java/org/oppia/android/scripts/common/testing:fake_command_executor", "//scripts/src/java/org/oppia/android/scripts/release:generate_changelogs_lib", "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", From f95b6d3f8a61bfc1a8e10f56104831fec390c5a0 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 26 Jul 2026 16:19:11 +0530 Subject: [PATCH 15/34] remove unused kt_jvm_library from javatests BUILD load statement --- .../src/javatests/org/oppia/android/scripts/release/BUILD.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index ea109f9de03..c7a5e3a608c 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -2,7 +2,7 @@ Tests for release automation scripts. """ -load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test") kt_jvm_test( name = "FakeVertexAiClientTest", From 66120f53fffdf4cddfecd7fd756dae8f584e4533 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 26 Jul 2026 16:19:49 +0530 Subject: [PATCH 16/34] apply buildifier reformat to javatests BUILD --- .../src/javatests/org/oppia/android/scripts/release/BUILD.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel index c7a5e3a608c..cf4acbb1f30 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/release/BUILD.bazel @@ -155,8 +155,8 @@ kt_jvm_test( name = "GenerateChangelogsTest", srcs = ["GenerateChangelogsTest.kt"], deps = [ - "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", "//scripts/src/java/org/oppia/android/scripts/common/testing:fake_command_executor", + "//scripts/src/java/org/oppia/android/scripts/release:fake_vertex_ai_client", "//scripts/src/java/org/oppia/android/scripts/release:generate_changelogs_lib", "//scripts/src/java/org/oppia/android/scripts/release:vertex_ai_client", "//testing:assertion_helpers", From aa6d3e377a563fb261d3a1cb9289471eef2c215f Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 26 Jul 2026 16:25:48 +0530 Subject: [PATCH 17/34] add generate_changelog.yml workflow triggered on version.bzl push --- .github/workflows/generate_changelog.yml | 115 +++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/generate_changelog.yml diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml new file mode 100644 index 00000000000..40efb983605 --- /dev/null +++ b/.github/workflows/generate_changelog.yml @@ -0,0 +1,115 @@ +# Triggered when a version-bump PR is merged to develop (path filter: version.bzl). +# Runs the GenerateChangelogs Kotlin script, which: +# 1. Reads the new MINOR_VERSION from version.bzl and derives the *previous* version +# (e.g. 0.17->0.18 bump generates the changelog for 0.17). +# 2. Collects all PRs merged into develop between the two most recent release branches. +# 3. Parses "Fixes #NNNN" / "Closes #NNNN" references to surface issue titles. +# 4. Calls Vertex AI (Gemini) to produce a 2-3 sentence user-facing changelog summary. +# 5. Writes config/changelogs/<major>.<minor>.md and opens a PR on develop for review. +# +# If the Vertex AI call fails (timeout, quota, API error), the workflow still creates the +# PR with a raw commit list and an <!-- LLM generation failed --> marker. The release +# coordinator then writes the summary manually. +# +# Can also be triggered manually via workflow_dispatch to test or regenerate a changelog. +# +# Note: this workflow authenticates to GCP to call the Vertex AI API. The environment +# (oppia-android-release-env) must NOT have required reviewers configured, since the +# workflow is triggered automatically on every version.bzl push. If required reviewers +# are needed for other workflows in that environment, consider creating a separate +# non-gated environment for changelog generation (e.g. oppia-android-automation-env). + +name: Generate Changelog + +on: + push: + branches: + - develop + paths: + - 'version.bzl' + workflow_dispatch: + +# Only one changelog generation may run at a time. An in-flight run is never cancelled +# (cancel-in-progress: false) so that a rapid double-push to version.bzl does not leave +# a half-committed changelog branch in an inconsistent state. +concurrency: + group: generate-changelog + cancel-in-progress: false + +jobs: + generate_changelog: + name: Generate Changelog + runs-on: ubuntu-24.04 + environment: oppia-android-release-env + permissions: + id-token: write # Required for Workload Identity Federation (Vertex AI auth). + contents: write # Required so the script can push the changelog branch. + pull-requests: write # Required so the script can open the PR via gh. + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + # Full history is required: the script uses `git log` to collect commits between + # release branches and needs the complete DAG to find merge bases correctly. + fetch-depth: 0 + + # Configure a bot identity so that the commit the script creates on the changelog + # branch is attributed to github-actions[bot] rather than an arbitrary user. + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Allow the script to `git push` the changelog branch and `gh pr create` without a + # personal access token. The built-in GITHUB_TOKEN is sufficient here because: + # - contents: write allows pushing a new branch. + # - pull-requests: write allows opening the PR. + - name: Configure git remote credentials + run: | + git remote set-url origin \ + https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + + - name: Authenticate to GCP via Workload Identity Federation + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.GCP_RELEASE_SERVICE_ACCOUNT }} + + - name: Set up Google Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + + # Obtain a short-lived OAuth2 access token for the WIF-impersonated service account. + # GenerateChangelogs passes this as the Bearer token when calling the Vertex AI API. + - name: Get and mask GCP access token + run: | + ACCESS_TOKEN="$(gcloud auth print-access-token)" + echo "::add-mask::$ACCESS_TOKEN" + echo "GCP_ACCESS_TOKEN=$ACCESS_TOKEN" >> "$GITHUB_ENV" + + # The GenerateChangelogs script is a pure Kotlin script with no Android resources. + # The Android build environment (NDK, SDK, etc.) is not needed here. + - name: Set up Bazel + uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 + with: + bazelisk-cache: true + + # Run the GenerateChangelogs script. It will: + # - Determine the version to generate for from version.bzl. + # - Collect commits between the two newest release branches. + # - Call Vertex AI and write config/changelogs/<version>.md. + # - Push the automated/changelog-<version> branch and open a PR. + # + # The GITHUB_TOKEN env var is picked up by the `gh` CLI (used internally by the + # script for `gh pr create`). GCP_PROJECT, GCP_LOCATION, and VERTEX_MODEL are + # non-sensitive configuration values stored as repository variables + # (Settings -> Variables -> Actions). + - name: Generate changelog and open PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bazel run //scripts:generate_changelogs -- \ + "$(pwd)" \ + "${{ vars.GCP_PROJECT }}" \ + "${{ vars.GCP_LOCATION }}" \ + "${{ vars.VERTEX_MODEL }}" \ + "$GCP_ACCESS_TOKEN" From 1136d88719ea0453aa9537278082089005ab2924 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sat, 1 Aug 2026 00:19:54 +0530 Subject: [PATCH 18/34] =?UTF-8?q?Address=20reviewer=20comments:=20fix=20ac?= =?UTF-8?q?tion=20pins,=20Closes=E2=86=92Fix/Fixes,=20cleanup=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/generate_changelog.yml | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 40efb983605..08777ed9781 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -3,7 +3,7 @@ # 1. Reads the new MINOR_VERSION from version.bzl and derives the *previous* version # (e.g. 0.17->0.18 bump generates the changelog for 0.17). # 2. Collects all PRs merged into develop between the two most recent release branches. -# 3. Parses "Fixes #NNNN" / "Closes #NNNN" references to surface issue titles. +# 3. Parses "Fix #NNNN" / "Fixes #NNNN" references to surface issue titles. # 4. Calls Vertex AI (Gemini) to produce a 2-3 sentence user-facing changelog summary. # 5. Writes config/changelogs/<major>.<minor>.md and opens a PR on develop for review. # @@ -12,12 +12,6 @@ # coordinator then writes the summary manually. # # Can also be triggered manually via workflow_dispatch to test or regenerate a changelog. -# -# Note: this workflow authenticates to GCP to call the Vertex AI API. The environment -# (oppia-android-release-env) must NOT have required reviewers configured, since the -# workflow is triggered automatically on every version.bzl push. If required reviewers -# are needed for other workflows in that environment, consider creating a separate -# non-gated environment for changelog generation (e.g. oppia-android-automation-env). name: Generate Changelog @@ -47,7 +41,7 @@ jobs: pull-requests: write # Required so the script can open the PR via gh. steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: # Full history is required: the script uses `git log` to collect commits between # release branches and needs the complete DAG to find merge bases correctly. @@ -70,13 +64,13 @@ jobs: https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} - name: Authenticate to GCP via Workload Identity Federation - uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed + uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.GCP_RELEASE_SERVICE_ACCOUNT }} - name: Set up Google Cloud SDK - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + uses: google-github-actions/setup-gcloud@v2 # Obtain a short-lived OAuth2 access token for the WIF-impersonated service account. # GenerateChangelogs passes this as the Bearer token when calling the Vertex AI API. @@ -86,12 +80,8 @@ jobs: echo "::add-mask::$ACCESS_TOKEN" echo "GCP_ACCESS_TOKEN=$ACCESS_TOKEN" >> "$GITHUB_ENV" - # The GenerateChangelogs script is a pure Kotlin script with no Android resources. - # The Android build environment (NDK, SDK, etc.) is not needed here. - name: Set up Bazel - uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 - with: - bazelisk-cache: true + uses: abhinavsingh/setup-bazel@v3 # Run the GenerateChangelogs script. It will: # - Determine the version to generate for from version.bzl. From ab14f99b7e950c693e15f3d2d0ad4ab224d7898f Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sat, 1 Aug 2026 00:56:02 +0530 Subject: [PATCH 19/34] Add target_version input to workflow_dispatch --- .github/workflows/generate_changelog.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 08777ed9781..4e5f16ef46a 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -22,6 +22,14 @@ on: paths: - 'version.bzl' workflow_dispatch: + inputs: + target_version: + description: >- + Version to generate changelog for (e.g. "0.17"). If empty, the version + is read from version.bzl at HEAD. Use this to regenerate a changelog + for an older release without modifying version.bzl. + required: false + default: '' # Only one changelog generation may run at a time. An in-flight run is never cancelled # (cancel-in-progress: false) so that a rapid double-push to version.bzl does not leave From cdd68bc4b923ead35d11eae021375589338fc39d Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sat, 1 Aug 2026 01:06:42 +0530 Subject: [PATCH 20/34] Wire target_version input to TARGET_VERSION env var in script step --- .github/workflows/generate_changelog.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 4e5f16ef46a..ce5cb330b1a 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -104,6 +104,10 @@ jobs: - name: Generate changelog and open PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Passed through to the GenerateChangelogs script. When set (manual workflow_dispatch + # with a specific version), the script skips reading version.bzl and uses this value + # directly. Empty on automatic push triggers — script falls back to version.bzl. + TARGET_VERSION: ${{ inputs.target_version }} run: | bazel run //scripts:generate_changelogs -- \ "$(pwd)" \ From 53c0ee2ba8e0be83347f9c07aef2bf57a956cc9b Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 2 Aug 2026 16:01:30 +0530 Subject: [PATCH 21/34] Add set-up-android-bazel-build-environment and Bazel 6.5.0 version pin --- .github/workflows/generate_changelog.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index ce5cb330b1a..fd6b84a54a4 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -90,6 +90,11 @@ jobs: - name: Set up Bazel uses: abhinavsingh/setup-bazel@v3 + with: + version: 6.5.0 + + - name: Set up Android + Bazel build environment + uses: ./.github/actions/set-up-android-bazel-build-environment # Run the GenerateChangelogs script. It will: # - Determine the version to generate for from version.bzl. From 6ece40006b5a69143babc47cba9e90851b66ae37 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 2 Aug 2026 17:10:33 +0530 Subject: [PATCH 22/34] add git fetch command --- .github/workflows/generate_changelog.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index fd6b84a54a4..d75a2de5cac 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -55,6 +55,13 @@ jobs: # release branches and needs the complete DAG to find merge bases correctly. fetch-depth: 0 + # actions/checkout only creates a local branch for the ref it checks out. The + # GenerateChangelogs script calls `git merge-base release-X.Y origin/develop` using + # local branch names — without this step the script fails with + # "fatal: Not a valid object name release-X.Y". + - name: Fetch all release branches as local branches + run: git fetch origin '+refs/heads/release-*:refs/heads/release-*' + # Configure a bot identity so that the commit the script creates on the changelog # branch is attributed to github-actions[bot] rather than an arbitrary user. - name: Configure git identity From 302b027aa313d01335cf0679bb2a7421569d8a1a Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 3 Aug 2026 23:33:56 +0530 Subject: [PATCH 23/34] Use oppia-android-automation-env (no approval gate) for changelog workflow --- .github/workflows/generate_changelog.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index d75a2de5cac..66b477c0326 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -42,7 +42,12 @@ jobs: generate_changelog: name: Generate Changelog runs-on: ubuntu-24.04 - environment: oppia-android-release-env + # oppia-android-automation-env holds the WIF provider + service-account secrets needed + # for Vertex AI but has NO required-reviewer gate. This is intentional: this workflow + # only opens a PR that still needs human review before merging — it never deploys + # anything directly. The approval gate on oppia-android-release-env is reserved for + # build / sign / deploy workflows where a human check before execution is critical. + environment: oppia-android-automation-env permissions: id-token: write # Required for Workload Identity Federation (Vertex AI auth). contents: write # Required so the script can push the changelog branch. From bf29ef0cb3630dd2a0fc8e725ed41b5d38d9ddbc Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sat, 8 Aug 2026 16:06:44 +0530 Subject: [PATCH 24/34] Clarify secrets setup; drop automated-changelog label; delete 0.18.md placeholder --- .github/workflows/generate_changelog.yml | 12 +++++++----- config/changelogs/0.18.md | 1 - .../android/scripts/release/GenerateChangelogs.kt | 3 +-- 3 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 config/changelogs/0.18.md diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 66b477c0326..ff19d3d7a95 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -42,11 +42,13 @@ jobs: generate_changelog: name: Generate Changelog runs-on: ubuntu-24.04 - # oppia-android-automation-env holds the WIF provider + service-account secrets needed - # for Vertex AI but has NO required-reviewer gate. This is intentional: this workflow - # only opens a PR that still needs human review before merging — it never deploys - # anything directly. The approval gate on oppia-android-release-env is reserved for - # build / sign / deploy workflows where a human check before execution is critical. + # oppia-android-automation-env is used here instead of oppia-android-release-env because + # this workflow opens a PR for human review and never deploys anything directly, so + # the required-reviewer gate on oppia-android-release-env is not needed. + # + # The WIF_PROVIDER and GCP_RELEASE_SERVICE_ACCOUNT secrets must be added to + # oppia-android-automation-env (duplicated from oppia-android-release-env — do NOT move + # them, as the existing build/sign/deploy workflows depend on them in release-env). environment: oppia-android-automation-env permissions: id-token: write # Required for Workload Identity Federation (Vertex AI auth). diff --git a/config/changelogs/0.18.md b/config/changelogs/0.18.md deleted file mode 100644 index 72f5a9afdc6..00000000000 --- a/config/changelogs/0.18.md +++ /dev/null @@ -1 +0,0 @@ -Placeholder changelog for 0.18 pre-releases. diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index 3ac475476c9..34609beef0c 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -532,8 +532,7 @@ fun createOrUpdateChangelogPr( "--base", "develop", "--head", branchName, "--title", prTitle, - "--body", prBody, - "--label", "automated-changelog" + "--body", prBody ) if (result.exitCode == 0) { val prUrl = result.output.lastOrNull { it.startsWith("https://") } ?: "(URL not found)" From 422044a5684c46f8f7488931a2601ca2d6b34421 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 11 Aug 2026 00:23:34 +0530 Subject: [PATCH 25/34] Update Set up Bazel step --- .github/workflows/deploy_to_play_console.yml | 4 ++-- .github/workflows/deploy_updated_changelog.yml | 4 ++-- .github/workflows/generate_changelog.yml | 4 ++-- .github/workflows/update_rollout.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy_to_play_console.yml b/.github/workflows/deploy_to_play_console.yml index 1a910b07920..c5d4d5f0af9 100644 --- a/.github/workflows/deploy_to_play_console.yml +++ b/.github/workflows/deploy_to_play_console.yml @@ -91,9 +91,9 @@ jobs: echo "ACCESS_TOKEN=$ACCESS_TOKEN" >> "$GITHUB_ENV" - name: Set up Bazel - uses: abhinavsingh/setup-bazel@v3 + uses: bazel-contrib/setup-bazel@0.19.0 with: - version: 6.5.0 + bazelisk-cache: true - name: Set up build environment uses: ./.github/actions/set-up-android-bazel-build-environment diff --git a/.github/workflows/deploy_updated_changelog.yml b/.github/workflows/deploy_updated_changelog.yml index fbaff722711..e478516b44a 100644 --- a/.github/workflows/deploy_updated_changelog.yml +++ b/.github/workflows/deploy_updated_changelog.yml @@ -155,9 +155,9 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV - name: Set up Bazel - uses: abhinavsingh/setup-bazel@v3 + uses: bazel-contrib/setup-bazel@0.19.0 with: - version: 6.5.0 + bazelisk-cache: true - name: Set up build environment uses: ./.github/actions/set-up-android-bazel-build-environment diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index ff19d3d7a95..54228b17283 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -103,9 +103,9 @@ jobs: echo "GCP_ACCESS_TOKEN=$ACCESS_TOKEN" >> "$GITHUB_ENV" - name: Set up Bazel - uses: abhinavsingh/setup-bazel@v3 + uses: bazel-contrib/setup-bazel@0.19.0 with: - version: 6.5.0 + bazelisk-cache: true - name: Set up Android + Bazel build environment uses: ./.github/actions/set-up-android-bazel-build-environment diff --git a/.github/workflows/update_rollout.yml b/.github/workflows/update_rollout.yml index ed3e93ff058..0de6a9551d5 100644 --- a/.github/workflows/update_rollout.yml +++ b/.github/workflows/update_rollout.yml @@ -69,9 +69,9 @@ jobs: id: validate - name: Set up Bazel - uses: abhinavsingh/setup-bazel@v3 + uses: bazel-contrib/setup-bazel@0.19.0 with: - version: 6.5.0 + bazelisk-cache: true - name: Set up build environment uses: ./.github/actions/set-up-android-bazel-build-environment From 3eed5c3382cc04fe1d297cf89a69d6996cd69bb6 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Fri, 14 Aug 2026 00:27:39 +0530 Subject: [PATCH 26/34] Address review: use dedicated changelog SA, fix release branch refs to use origin/ --- .github/workflows/generate_changelog.yml | 19 ++++++++----------- .../scripts/release/GenerateChangelogs.kt | 19 ++++++++++++------- .../scripts/release/GenerateChangelogsTest.kt | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 54228b17283..e8dfd346142 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -46,9 +46,13 @@ jobs: # this workflow opens a PR for human review and never deploys anything directly, so # the required-reviewer gate on oppia-android-release-env is not needed. # - # The WIF_PROVIDER and GCP_RELEASE_SERVICE_ACCOUNT secrets must be added to - # oppia-android-automation-env (duplicated from oppia-android-release-env — do NOT move - # them, as the existing build/sign/deploy workflows depend on them in release-env). + # GCP_CHANGELOG_SERVICE_ACCOUNT is a dedicated, minimally-scoped service account + # (changelog-generator@<project>.iam.gserviceaccount.com) with only + # roles/aiplatform.user on the specific Vertex model resource. It is intentionally + # separate from GCP_RELEASE_SERVICE_ACCOUNT (which has signing/Play Store permissions) + # so that a compromised changelog workflow cannot mint a token with release-level + # blast radius. The WIF binding for this SA is tightened via attribute.job_workflow_ref + # to only allow tokens when this specific workflow file is running. environment: oppia-android-automation-env permissions: id-token: write # Required for Workload Identity Federation (Vertex AI auth). @@ -62,13 +66,6 @@ jobs: # release branches and needs the complete DAG to find merge bases correctly. fetch-depth: 0 - # actions/checkout only creates a local branch for the ref it checks out. The - # GenerateChangelogs script calls `git merge-base release-X.Y origin/develop` using - # local branch names — without this step the script fails with - # "fatal: Not a valid object name release-X.Y". - - name: Fetch all release branches as local branches - run: git fetch origin '+refs/heads/release-*:refs/heads/release-*' - # Configure a bot identity so that the commit the script creates on the changelog # branch is attributed to github-actions[bot] rather than an arbitrary user. - name: Configure git identity @@ -89,7 +86,7 @@ jobs: uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} - service_account: ${{ secrets.GCP_RELEASE_SERVICE_ACCOUNT }} + service_account: ${{ secrets.GCP_CHANGELOG_SERVICE_ACCOUNT }} - name: Set up Google Cloud SDK uses: google-github-actions/setup-gcloud@v2 diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index 34609beef0c..4055efd5c1a 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -218,12 +218,17 @@ fun parseVersionBzl(workspaceRoot: File): Pair<Int, Int> { /** * Computes the `fromSha..toSha` range for the changelog commit collection. * - * The **toSha** is the merge-base of [releaseBranch] and `develop` — the point where the current - * release branched off (i.e. all commits up to and including the version bump commit). + * The **toSha** is the merge-base of `origin/`[releaseBranch] and `origin/develop` — the point + * where the current release branched off (i.e. all commits up to and including the version bump + * commit). * - * The **fromSha** is the merge-base of [prevReleaseBranch] and `develop` — the point where the - * *previous* release branched off. If the previous release branch doesn't exist (first release), - * falls back to the very first commit on `develop`. + * The **fromSha** is the merge-base of `origin/`[prevReleaseBranch] and `origin/develop` — the + * point where the *previous* release branched off. If the previous release branch doesn't exist on + * the remote, falls back to the very first commit on `develop`. + * + * Both release branch refs are referenced as remote tracking refs (`origin/release-X.Y`) rather + * than local branch names. `actions/checkout` with `fetch-depth: 0` fetches all remote tracking + * refs, so no explicit `git fetch` step is required in the workflow. * * @param prevMinor the previous minor version number, used to detect the first-release edge case * @return a (fromSha, toSha) pair of full commit SHAs @@ -235,13 +240,13 @@ fun findCommitRange( prevReleaseBranch: String, prevMinor: Int ): Pair<String, String> { - val toSha = gitMergeBase(workspaceRoot, commandExecutor, releaseBranch, "$REMOTE/$DEVELOP_BRANCH") + val toSha = gitMergeBase(workspaceRoot, commandExecutor, "$REMOTE/$releaseBranch", "$REMOTE/$DEVELOP_BRANCH") val fromSha = if (prevMinor <= 0) { // First-ever release: include all commits from the beginning of develop. gitFirstCommit(workspaceRoot, commandExecutor) } else { try { - gitMergeBase(workspaceRoot, commandExecutor, prevReleaseBranch, "$REMOTE/$DEVELOP_BRANCH") + gitMergeBase(workspaceRoot, commandExecutor, "$REMOTE/$prevReleaseBranch", "$REMOTE/$DEVELOP_BRANCH") } catch (e: IllegalStateException) { // Re-throw if this isn't a "branch not found" failure — don't mask unrelated errors. if ("unknown revision" !in (e.message ?: "") && diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt index 375ebac4c88..e17bac7b589 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt @@ -593,7 +593,7 @@ class GenerateChangelogsTest { @Test fun testGenerateChangelogs_prevBranchAmbiguousArgument_fallsBackToFirstCommit() { // Simulates the second git error phrase that indicates a missing branch: - // "ambiguous argument" (e.g. git merge-base release-0.16 origin/develop). + // "ambiguous argument" (e.g. git merge-base origin/release-0.16 origin/develop). writeVersionBzl(major = 0, minor = 18) val firstCommit = "firstcommitsha" val toSha = "toshasha456" From de8a9af654bc132af409317b67ad4443e4a26e07 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Fri, 14 Aug 2026 00:29:04 +0530 Subject: [PATCH 27/34] Fix ktlint: wrap long gitMergeBase call --- .../org/oppia/android/scripts/release/GenerateChangelogs.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index 4055efd5c1a..0854d3caae7 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -246,7 +246,9 @@ fun findCommitRange( gitFirstCommit(workspaceRoot, commandExecutor) } else { try { - gitMergeBase(workspaceRoot, commandExecutor, "$REMOTE/$prevReleaseBranch", "$REMOTE/$DEVELOP_BRANCH") + gitMergeBase( + workspaceRoot, commandExecutor, "$REMOTE/$prevReleaseBranch", "$REMOTE/$DEVELOP_BRANCH" + ) } catch (e: IllegalStateException) { // Re-throw if this isn't a "branch not found" failure — don't mask unrelated errors. if ("unknown revision" !in (e.message ?: "") && From cfddd580d88b6e78b13d794f5cb47280171670e7 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Fri, 14 Aug 2026 00:31:08 +0530 Subject: [PATCH 28/34] Fix ktlint: wrap gitMergeBase args --- .../org/oppia/android/scripts/release/GenerateChangelogs.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index 0854d3caae7..b100476fdef 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -240,7 +240,10 @@ fun findCommitRange( prevReleaseBranch: String, prevMinor: Int ): Pair<String, String> { - val toSha = gitMergeBase(workspaceRoot, commandExecutor, "$REMOTE/$releaseBranch", "$REMOTE/$DEVELOP_BRANCH") + val toSha = + gitMergeBase( + workspaceRoot, commandExecutor, "$REMOTE/$releaseBranch", "$REMOTE/$DEVELOP_BRANCH" + ) val fromSha = if (prevMinor <= 0) { // First-ever release: include all commits from the beginning of develop. gitFirstCommit(workspaceRoot, commandExecutor) From 8efcd23878639b3639d532e57c72aa16efe99c18 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Tue, 18 Aug 2026 22:46:43 +0530 Subject: [PATCH 29/34] Update Bazel setup step --- .github/workflows/build_and_sign.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_and_sign.yml b/.github/workflows/build_and_sign.yml index ccec0c78ec5..f4a865b365c 100644 --- a/.github/workflows/build_and_sign.yml +++ b/.github/workflows/build_and_sign.yml @@ -81,7 +81,7 @@ jobs: fetch-depth: 0 - name: Set up Bazel - uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-cache: true From 6317a133a5319a6e6e5d7ca9a89c591740a7a779 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 6 Sep 2026 17:19:46 +0530 Subject: [PATCH 30/34] Address review: trim overinflated docstrings in generate_changelog.yml - Cut top-level block comment from 14 lines to 5 (workflow purpose only) - Trim environment comment to 3 lines - Remove per-line permission comments (not standard in repo) - Remove fetch-depth comment (established pattern) - Remove verbose step pre-comments on git identity, remote credentials, GCP token, and run steps - Remove TARGET_VERSION inline comment (nixed per review) --- .github/workflows/generate_changelog.yml | 68 +++++------------------- 1 file changed, 14 insertions(+), 54 deletions(-) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index e8dfd346142..886b4d9de0f 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -1,17 +1,8 @@ -# Triggered when a version-bump PR is merged to develop (path filter: version.bzl). -# Runs the GenerateChangelogs Kotlin script, which: -# 1. Reads the new MINOR_VERSION from version.bzl and derives the *previous* version -# (e.g. 0.17->0.18 bump generates the changelog for 0.17). -# 2. Collects all PRs merged into develop between the two most recent release branches. -# 3. Parses "Fix #NNNN" / "Fixes #NNNN" references to surface issue titles. -# 4. Calls Vertex AI (Gemini) to produce a 2-3 sentence user-facing changelog summary. -# 5. Writes config/changelogs/<major>.<minor>.md and opens a PR on develop for review. -# -# If the Vertex AI call fails (timeout, quota, API error), the workflow still creates the -# PR with a raw commit list and an <!-- LLM generation failed --> marker. The release -# coordinator then writes the summary manually. -# -# Can also be triggered manually via workflow_dispatch to test or regenerate a changelog. +# Triggered when a version-bump PR merges to develop (path filter: version.bzl). +# Runs GenerateChangelogs, which reads the new version, collects PRs between the +# two most recent release branches, calls Vertex AI for a summary, and opens a PR +# with the generated config/changelogs/<version>.md file. +# Can also be triggered manually via workflow_dispatch. name: Generate Changelog @@ -31,9 +22,8 @@ on: required: false default: '' -# Only one changelog generation may run at a time. An in-flight run is never cancelled -# (cancel-in-progress: false) so that a rapid double-push to version.bzl does not leave -# a half-committed changelog branch in an inconsistent state. +# Only one changelog generation may run at a time; cancel-in-progress: false so that +# a rapid double-push to version.bzl does not leave an inconsistent changelog branch. concurrency: group: generate-changelog cancel-in-progress: false @@ -42,41 +32,26 @@ jobs: generate_changelog: name: Generate Changelog runs-on: ubuntu-24.04 - # oppia-android-automation-env is used here instead of oppia-android-release-env because - # this workflow opens a PR for human review and never deploys anything directly, so - # the required-reviewer gate on oppia-android-release-env is not needed. - # - # GCP_CHANGELOG_SERVICE_ACCOUNT is a dedicated, minimally-scoped service account - # (changelog-generator@<project>.iam.gserviceaccount.com) with only - # roles/aiplatform.user on the specific Vertex model resource. It is intentionally - # separate from GCP_RELEASE_SERVICE_ACCOUNT (which has signing/Play Store permissions) - # so that a compromised changelog workflow cannot mint a token with release-level - # blast radius. The WIF binding for this SA is tightened via attribute.job_workflow_ref - # to only allow tokens when this specific workflow file is running. + # oppia-android-automation-env (not release-env) because this workflow opens a PR + # for human review and never deploys directly. GCP_CHANGELOG_SERVICE_ACCOUNT is + # a dedicated, minimally-scoped SA separate from GCP_RELEASE_SERVICE_ACCOUNT so + # that a compromised changelog workflow cannot reach signing/Play Store resources. environment: oppia-android-automation-env permissions: - id-token: write # Required for Workload Identity Federation (Vertex AI auth). - contents: write # Required so the script can push the changelog branch. - pull-requests: write # Required so the script can open the PR via gh. + id-token: write + contents: write + pull-requests: write steps: - uses: actions/checkout@v4 with: - # Full history is required: the script uses `git log` to collect commits between - # release branches and needs the complete DAG to find merge bases correctly. fetch-depth: 0 - # Configure a bot identity so that the commit the script creates on the changelog - # branch is attributed to github-actions[bot] rather than an arbitrary user. - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Allow the script to `git push` the changelog branch and `gh pr create` without a - # personal access token. The built-in GITHUB_TOKEN is sufficient here because: - # - contents: write allows pushing a new branch. - # - pull-requests: write allows opening the PR. - name: Configure git remote credentials run: | git remote set-url origin \ @@ -91,8 +66,6 @@ jobs: - name: Set up Google Cloud SDK uses: google-github-actions/setup-gcloud@v2 - # Obtain a short-lived OAuth2 access token for the WIF-impersonated service account. - # GenerateChangelogs passes this as the Bearer token when calling the Vertex AI API. - name: Get and mask GCP access token run: | ACCESS_TOKEN="$(gcloud auth print-access-token)" @@ -107,22 +80,9 @@ jobs: - name: Set up Android + Bazel build environment uses: ./.github/actions/set-up-android-bazel-build-environment - # Run the GenerateChangelogs script. It will: - # - Determine the version to generate for from version.bzl. - # - Collect commits between the two newest release branches. - # - Call Vertex AI and write config/changelogs/<version>.md. - # - Push the automated/changelog-<version> branch and open a PR. - # - # The GITHUB_TOKEN env var is picked up by the `gh` CLI (used internally by the - # script for `gh pr create`). GCP_PROJECT, GCP_LOCATION, and VERTEX_MODEL are - # non-sensitive configuration values stored as repository variables - # (Settings -> Variables -> Actions). - name: Generate changelog and open PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Passed through to the GenerateChangelogs script. When set (manual workflow_dispatch - # with a specific version), the script skips reading version.bzl and uses this value - # directly. Empty on automatic push triggers — script falls back to version.bzl. TARGET_VERSION: ${{ inputs.target_version }} run: | bazel run //scripts:generate_changelogs -- \ From 5dbd4e4a50d4b0253ae33136622999e90034ca30 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Sun, 6 Sep 2026 17:20:02 +0530 Subject: [PATCH 31/34] Add 0.18 and 0.18_alpha changelogs from upstream develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.18 was already released. Pulling these files from upstream to resolve the conflict Adhiambo flagged — our branch was missing them. The workflow will target 0.19 on the next version bump. --- config/changelogs/0.18.md | 1 + config/changelogs/0.18_alpha.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 config/changelogs/0.18.md create mode 100644 config/changelogs/0.18_alpha.md diff --git a/config/changelogs/0.18.md b/config/changelogs/0.18.md new file mode 100644 index 00000000000..ea9a634ca49 --- /dev/null +++ b/config/changelogs/0.18.md @@ -0,0 +1 @@ +This release warns users on Android 5/5.1 of retiring support for their OS versions. It also adds full support for Android 16, and a variety of bugfixes. \ No newline at end of file diff --git a/config/changelogs/0.18_alpha.md b/config/changelogs/0.18_alpha.md new file mode 100644 index 00000000000..ab381096764 --- /dev/null +++ b/config/changelogs/0.18_alpha.md @@ -0,0 +1 @@ +This release warns users on Android 5/5.1 of retiring support for their OS versions. It also adds full support for Android 16, a variety of bugfixes and adds an alpha-specific worked examples feature to study guides. \ No newline at end of file From 1a489e2790d78fc3ec8b303dde09342a0d37008a Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 7 Sep 2026 20:00:14 +0530 Subject: [PATCH 32/34] Address review --- .github/workflows/generate_changelog.yml | 4 +-- .../scripts/release/GenerateChangelogs.kt | 27 +++++++++---------- .../scripts/release/GenerateChangelogsTest.kt | 8 +++--- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/.github/workflows/generate_changelog.yml b/.github/workflows/generate_changelog.yml index 886b4d9de0f..ffb2f90be02 100644 --- a/.github/workflows/generate_changelog.yml +++ b/.github/workflows/generate_changelog.yml @@ -87,7 +87,5 @@ jobs: run: | bazel run //scripts:generate_changelogs -- \ "$(pwd)" \ - "${{ vars.GCP_PROJECT }}" \ - "${{ vars.GCP_LOCATION }}" \ - "${{ vars.VERTEX_MODEL }}" \ + "${{ secrets.GCP_PROJECT_ID }}" \ "$GCP_ACCESS_TOKEN" diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index b100476fdef..1ef057dcb67 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -26,32 +26,31 @@ import java.io.File * ### Usage (called by `generate_changelog.yml` via Bazel) * ``` * bazel run //scripts:generate_changelogs -- \ - * <workspace_root> <gcp_project> <gcp_location> <vertex_model> <gcp_access_token> + * <workspace_root> <gcp_project> <gcp_access_token> * ``` * * ### Arguments (positional) * 0. `workspace_root` — absolute path to the local repository root * 1. `gcp_project` — GCP project ID that has Vertex AI enabled - * 2. `gcp_location` — Vertex AI region (e.g. "us-central1") - * 3. `vertex_model` — Vertex AI model ID (e.g. "gemini-1.5-flash") - * 4. `gcp_access_token`— GCP Bearer token for authenticating with Vertex AI + * 2. `gcp_access_token`— GCP Bearer token for authenticating with Vertex AI * - * An optional 6th argument overrides the Vertex AI API base URL; this is used in integration + * An optional 4th argument overrides the Vertex AI API base URL; this is used in integration * tests to route HTTP calls through a local mock server. */ +private const val GCP_LOCATION = "us-central1" +private const val VERTEX_MODEL = "gemini-1.5-flash" + fun main(args: Array<String>) { - require(args.size in 5..6) { - "Usage: generate_changelogs <workspace_root> <gcp_project> <gcp_location> " + - "<vertex_model> <gcp_access_token>\nGot ${args.size} argument(s): ${args.toList()}" + require(args.size in 3..4) { + "Usage: generate_changelogs <workspace_root> <gcp_project> <gcp_access_token>" + + "\nGot ${args.size} argument(s): ${args.toList()}" } val workspaceRoot = args[0] val gcpProject = args[1] - val gcpLocation = args[2] - val vertexModel = args[3] - val gcpAccessToken = args[4] + val gcpAccessToken = args[2] - val overrideApiBaseUrl = if (args.size == 6) args[5] else null + val overrideApiBaseUrl = if (args.size == 4) args[3] else null // TARGET_VERSION is set by the workflow when the user triggers workflow_dispatch with a // specific version (e.g. "0.17"). When empty or absent, version is derived from version.bzl. @@ -60,9 +59,9 @@ fun main(args: Array<String>) { ScriptBackgroundCoroutineDispatcher().use { scriptBgDispatcher -> val commandExecutor = CommandExecutorImpl(scriptBgDispatcher) val vertexAiClient = if (overrideApiBaseUrl != null) { - GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken, overrideApiBaseUrl) + GoogleVertexAiClient(gcpProject, GCP_LOCATION, VERTEX_MODEL, gcpAccessToken, overrideApiBaseUrl) } else { - GoogleVertexAiClient(gcpProject, gcpLocation, vertexModel, gcpAccessToken) + GoogleVertexAiClient(gcpProject, GCP_LOCATION, VERTEX_MODEL, gcpAccessToken) } generateChangelogs( workspaceRoot = File(workspaceRoot), diff --git a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt index e17bac7b589..1a7fd754aee 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/release/GenerateChangelogsTest.kt @@ -45,18 +45,18 @@ class GenerateChangelogsTest { } @Test - fun testMain_fourArguments_throwsWithUsageMessage() { + fun testMain_twoArguments_throwsWithUsageMessage() { val exception = - assertThrows<IllegalArgumentException> { main(arrayOf("a", "b", "c", "d")) } + assertThrows<IllegalArgumentException> { main(arrayOf("a", "b")) } assertThat(exception).hasMessageThat().contains("Usage:") } @Test - fun testMain_sevenArguments_throwsWithUsageMessage() { + fun testMain_fiveArguments_throwsWithUsageMessage() { val exception = assertThrows<IllegalArgumentException> { - main(arrayOf("a", "b", "c", "d", "e", "f", "g")) + main(arrayOf("a", "b", "c", "d", "e")) } assertThat(exception).hasMessageThat().contains("Usage:") From d73380f7e6e3059f477c5cda1d513ca7a7e934b3 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 7 Sep 2026 20:01:57 +0530 Subject: [PATCH 33/34] minor fix --- .../org/oppia/android/scripts/release/GenerateChangelogs.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index 1ef057dcb67..e14e30b4f7d 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -59,7 +59,9 @@ fun main(args: Array<String>) { ScriptBackgroundCoroutineDispatcher().use { scriptBgDispatcher -> val commandExecutor = CommandExecutorImpl(scriptBgDispatcher) val vertexAiClient = if (overrideApiBaseUrl != null) { - GoogleVertexAiClient(gcpProject, GCP_LOCATION, VERTEX_MODEL, gcpAccessToken, overrideApiBaseUrl) + GoogleVertexAiClient( + gcpProject, GCP_LOCATION, VERTEX_MODEL, gcpAccessToken, overrideApiBaseUrl + ) } else { GoogleVertexAiClient(gcpProject, GCP_LOCATION, VERTEX_MODEL, gcpAccessToken) } From 8a045e424689311a6fe80902062837db1bb46792 Mon Sep 17 00:00:00 2001 From: Sandesh <sandeshraj0410@gmail.com> Date: Mon, 7 Sep 2026 21:15:26 +0530 Subject: [PATCH 34/34] Fix KDoc placement: move private constants before KDoc block --- .../org/oppia/android/scripts/release/GenerateChangelogs.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt index e14e30b4f7d..ff179cd7991 100644 --- a/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt +++ b/scripts/src/java/org/oppia/android/scripts/release/GenerateChangelogs.kt @@ -5,6 +5,9 @@ import org.oppia.android.scripts.common.CommandExecutorImpl import org.oppia.android.scripts.common.ScriptBackgroundCoroutineDispatcher import java.io.File +private const val GCP_LOCATION = "us-central1" +private const val VERTEX_MODEL = "gemini-1.5-flash" + /** * Script that automatically generates a changelog for the previous app version whenever the minor * version is bumped in `version.bzl`, and proposes it as a pull request on `develop`. @@ -37,8 +40,6 @@ import java.io.File * An optional 4th argument overrides the Vertex AI API base URL; this is used in integration * tests to route HTTP calls through a local mock server. */ -private const val GCP_LOCATION = "us-central1" -private const val VERTEX_MODEL = "gemini-1.5-flash" fun main(args: Array<String>) { require(args.size in 3..4) {