diff --git a/firebase-dataconnect/ci/post_comment_for_job_results.py b/firebase-dataconnect/ci/post_comment_for_job_results.py deleted file mode 100644 index 738ac2c9b9d..00000000000 --- a/firebase-dataconnect/ci/post_comment_for_job_results.py +++ /dev/null @@ -1,229 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import argparse -import dataclasses -import logging -import pathlib -import subprocess -import tempfile -import typing - -from util import fetch_pr_info, pr_number_from_github_ref - -if typing.TYPE_CHECKING: - from collections.abc import Iterable, Sequence - - -def main() -> None: - args = parse_args() - logging.basicConfig(format="%(message)s", level=logging.INFO) - - message_lines = tuple(generate_message_lines(args)) - - issue_url = f"{args.github_repository_html_url}/issues/{args.github_issue}" - logging.info("Posting the following comment to GitHub Issue %s", issue_url) - for line in message_lines: - logging.info(line) - - message_bytes = "\n".join(message_lines).encode("utf8", errors="replace") - with tempfile.TemporaryDirectory() as tempdir_path: - message_file = pathlib.Path(tempdir_path) / "message_text.txt" - message_file.write_bytes(message_bytes) - post_github_issue_comment( - issue_number=args.github_issue, - body_file=message_file, - github_repository=args.github_repository, - ) - - -def generate_message_lines(data: ParsedArgs) -> Iterable[str]: - logging.info("Extracting PR number from string: %s", data.github_ref) - pr_number = pr_number_from_github_ref(data.github_ref) - pr_title: str | None - if pr_number is None: - logging.info("No PR number extracted") - pr_title = None - else: - pr_info = fetch_pr_info( - pr_number=pr_number, - github_repository=data.github_repository, - ) - pr_title = pr_info.title - - if pr_number is not None: - yield ( - f"Posting from Pull Request {data.github_repository_html_url}/pull/{pr_number} ({pr_title})" - ) - - yield f"Result of workflow '{data.github_workflow}' at {data.github_sha}:" - - for job_result in data.job_results: - result_symbol = "✅" if job_result.result == "success" else "❌" - yield f" - {job_result.job_id}: {result_symbol} {job_result.result}" - - yield "" - yield f"{data.github_repository_html_url}/actions/runs/{data.github_run_id}" - - yield "" - yield ( - f"event_name=`{data.github_event_name}` " - f"run_id=`{data.github_run_id}` " - f"run_number=`{data.github_run_number}` " - f"run_attempt=`{data.github_run_attempt}`" - ) - - -def post_github_issue_comment( - issue_number: int, body_file: pathlib.Path, github_repository: str -) -> None: - gh_args = post_issue_comment_gh_args( - issue_number=issue_number, body_file=body_file, github_repository=github_repository - ) - gh_args = tuple(gh_args) - logging.info("Running command: %s", subprocess.list2cmdline(gh_args)) - subprocess.check_call(gh_args) # noqa: S603 - - -def post_issue_comment_gh_args( - issue_number: int, body_file: pathlib.Path, github_repository: str -) -> Iterable[str]: - yield "gh" - yield "issue" - - yield "comment" - yield str(issue_number) - yield "--body-file" - yield str(body_file) - yield "-R" - yield github_repository - - -@dataclasses.dataclass(frozen=True) -class JobResult: - job_id: str - result: str - - @classmethod - def parse(cls, s: str) -> JobResult: - colon_index = s.find(":") - if colon_index < 0: - raise ParseError( - "no colon (:) character found in job result specification, " - "which is required to delimit the job ID from the job result" - ) - job_id = s[:colon_index] - job_result = s[colon_index + 1 :] - return cls(job_id=job_id, result=job_result) - - -class ParsedArgs(typing.Protocol): - job_results: Sequence[JobResult] - github_issue: int - github_repository: str - github_event_name: str - github_ref: str - github_workflow: str - github_sha: str - github_repository_html_url: str - github_run_id: str - github_run_number: str - github_run_attempt: str - - -class ParseError(Exception): - pass - - -def parse_args() -> ParsedArgs: - arg_parser = argparse.ArgumentParser() - arg_parser.add_argument( - "job_results", - nargs="+", - help="The results of the jobs in question, of the form " - "'job-id:${{ needs.job-id.result }}' where 'job-id' is the id of the corresponding job " - "in the 'needs' section of the job.", - ) - arg_parser.add_argument( - "--github-issue", - type=int, - required=True, - help="The GitHub Issue number to which to post a comment", - ) - arg_parser.add_argument( - "--github-repository", - required=True, - help="The value of ${{ github.repository }} in the workflow", - ) - arg_parser.add_argument( - "--github-event-name", - required=True, - help="The value of ${{ github.event_name }} in the workflow", - ) - arg_parser.add_argument( - "--github-ref", - required=True, - help="The value of ${{ github.ref }} in the workflow", - ) - arg_parser.add_argument( - "--github-workflow", - required=True, - help="The value of ${{ github.workflow }} in the workflow", - ) - arg_parser.add_argument( - "--github-sha", - required=True, - help="The value of ${{ github.sha }} in the workflow", - ) - arg_parser.add_argument( - "--github-repository-html-url", - required=True, - help="The value of ${{ github.event.repository.html_url }} in the workflow", - ) - arg_parser.add_argument( - "--github-run-id", - required=True, - help="The value of ${{ github.run_id }} in the workflow", - ) - arg_parser.add_argument( - "--github-run-number", - required=True, - help="The value of ${{ github.run_number }} in the workflow", - ) - arg_parser.add_argument( - "--github-run-attempt", - required=True, - help="The value of ${{ github.run_attempt }} in the workflow", - ) - - parse_result = arg_parser.parse_args() - - job_results: list[JobResult] = [] - for job_result_str in parse_result.job_results: - try: - job_result = JobResult.parse(job_result_str) - except ParseError as e: - arg_parser.error(f"invalid job result specification: {job_result_str} ({e})") - typing.assert_never("the line above should have raised an exception") - else: - job_results.append(job_result) - parse_result.job_results = tuple(job_results) - - return typing.cast("ParsedArgs", parse_result) - - -if __name__ == "__main__": - main() diff --git a/firebase-dataconnect/firebase-dataconnect.gradle.kts b/firebase-dataconnect/firebase-dataconnect.gradle.kts index 2467e8d6707..5aa162b5091 100644 --- a/firebase-dataconnect/firebase-dataconnect.gradle.kts +++ b/firebase-dataconnect/firebase-dataconnect.gradle.kts @@ -16,6 +16,7 @@ import com.google.firebase.dataconnect.gradle.plugin.DataConnectExecutableVersionsRegistry import com.google.firebase.dataconnect.gradle.plugin.UpdateDataConnectExecutableVersionsTask +import com.google.firebase.dataconnect.gradle.ci.PostCommentForJobResultsTask import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile @@ -176,3 +177,7 @@ tasks.register("updateJson") { ) workDirectory.set(project.layout.buildDirectory.dir("updateJson")) } + +tasks.register("postCommentForJobResults") { + workDirectory.set(project.layout.buildDirectory.dir("postCommentForJobResults")) +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/GithubClient.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/GithubClient.kt new file mode 100644 index 00000000000..2daa67ea20b --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/GithubClient.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.gradle.ci + +import com.google.firebase.dataconnect.gradle.plugin.nextAlphanumericString +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URL +import kotlin.random.Random +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.gradle.api.logging.Logger +import org.gradle.process.ExecOperations + +/** Wrapper around the "gh" GitHub client application, for interacting with GitHub. */ +class GithubClient( + val execOperations: ExecOperations, + val tempDirectory: File, + val githubRepository: String, + val logger: Logger +) { + + fun fetchIssueInfo(issueNumber: Int): IssueInfo { + logger.info("Fetching information from GitHub about issue #{}", issueNumber) + + val stdoutBytes = + runGithubClient( + listOf( + "issue", + "view", + issueNumber.toString(), + "--json", + "title,body", + ) + ) + + val jsonParser = Json { ignoreUnknownKeys = true } + @OptIn(ExperimentalSerializationApi::class) + val issueInfo = jsonParser.decodeFromStream(ByteArrayInputStream(stdoutBytes)) + + logger.info("Fetched information from GitHub about issue #{}: {}", issueNumber, issueInfo) + return issueInfo + } + + @Serializable data class IssueInfo(val title: String, val body: String) + + /** + * Posts a comment onto a GitHub issue or pull request. + * + * @param issueNumber the issue or pull request number on which to comment. + * @param messageLines the lines of text of the comment to post. + * @return the URL of the newly-created comment that was posted by this method call. + */ + fun postComment(issueNumber: Int, messageLines: Iterable): URL { + val tempFile = File(tempDirectory, Random.nextAlphanumericString(30)) + if (!tempDirectory.exists() && !tempDirectory.mkdirs()) { + logger.warn( + "WARNING: unable to create directory: {} [warning code kxd2j66gzm]", + tempDirectory.absolutePath + ) + } + tempFile.writeText(messageLines.joinToString("\n")) + + val stdoutBytes = + try { + runGithubClient( + listOf( + "issue", + "comment", + issueNumber.toString(), + "--body-file", + tempFile.absolutePath, + ) + ) + } finally { + tempFile.delete() + } + + return URL(String(stdoutBytes).trim()) + } + + private fun runGithubClient(args: Iterable = emptyList()): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + execOperations.exec { execSpec -> + execSpec.standardOutput = byteArrayOutputStream + execSpec.executable("gh") + args.forEach { execSpec.args(it) } + execSpec.args("-R") + execSpec.args(githubRepository) + logger.info("Running command: {}", execSpec.commandLine.joinToString(" ")) + } + return byteArrayOutputStream.toByteArray() + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResults.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResults.kt new file mode 100644 index 00000000000..88f1102063e --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResults.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.gradle.ci + +import java.io.File +import org.gradle.api.logging.Logger +import org.gradle.process.ExecOperations + +class PostCommentForJobResults( + val jobResults: List, + val githubIssue: Int, + val githubRepository: String, + val githubEventName: String, + val githubRef: String, + val githubWorkflow: String, + val githubSha: String, + val githubRepositoryHtmlUrl: String, + val githubRunId: String, + val githubRunNumber: String, + val githubRunAttempt: String, + val workDirectory: File, + execOperations: ExecOperations, + val logger: Logger +) { + + private val githubClient = GithubClient(execOperations, workDirectory, githubRepository, logger) + + fun run() { + logger.info("jobResults=[{}]{", jobResults.size) + jobResults.forEach { logger.info(" {}: {}", it.jobId, it.result) } + logger.info("}") + logger.info("githubIssue={}", githubIssue) + logger.info("githubRepository={}", githubRepository) + logger.info("githubEventName={}", githubEventName) + logger.info("githubRef={}", githubRef) + logger.info("githubWorkflow={}", githubWorkflow) + logger.info("githubSha={}", githubSha) + logger.info("githubRepositoryHtmlUrl={}", githubRepositoryHtmlUrl) + logger.info("githubRunId={}", githubRunId) + logger.info("githubRunNumber={}", githubRunNumber) + logger.info("githubRunAttempt={}", githubRunAttempt) + logger.info("workDirectory={}", workDirectory.absolutePath) + + val messageLines = calculateMessageLines() + + val issueUrl = "$githubRepositoryHtmlUrl/issues/$githubIssue" + logger.info("Posting the following comment to GitHub Issue {}:", issueUrl) + messageLines.forEach { logger.info("> {}", it) } + val commentUrl = githubClient.postComment(githubIssue, messageLines) + logger.lifecycle("Comment posted successfully: {}", commentUrl) + } + + private fun calculateMessageLines(): List = buildList { + parseGithubPrNumberFromGithubRef()?.let { prNumber -> + val prInfo = githubClient.fetchIssueInfo(prNumber) + add("Posting from Pull Request $githubRepositoryHtmlUrl/pull/$prNumber (${prInfo.title})") + } + + add("Result of workflow '$githubWorkflow' at $githubSha:") + for (jobResult in jobResults) { + jobResult.run { + val resultSymbol = if (result == "success") "✅" else "❌" + add(" - $jobId: $resultSymbol $result") + } + } + + add("") + add("$githubRepositoryHtmlUrl/actions/runs/$githubRunId") + + add("") + add( + listOf( + "event_name=`$githubEventName`", + "run_id=`$githubRunId`", + "run_number=`$githubRunNumber`", + "run_attempt=`$githubRunAttempt`" + ) + .joinToString(" ") + ) + } + + private fun parseGithubPrNumberFromGithubRef(): Int? { + logger.info("Extracting PR number from githubRef: {}", githubRef) + val prNumber: Int? = + Regex("refs/pull/([0-9]+)/merge").matchEntire(githubRef)?.groupValues?.get(1)?.toInt() + logger.info("Extracted PR number from githubRef {}: {}", githubRef, prNumber) + return prNumber + } + + data class JobResult(val jobId: String, val result: String) +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResultsTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResultsTask.kt new file mode 100644 index 00000000000..151f8888504 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/ci/PostCommentForJobResultsTask.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.gradle.ci + +import com.google.firebase.dataconnect.gradle.ci.PostCommentForJobResults.JobResult +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import org.gradle.process.ExecOperations + +@Suppress("unused") +abstract class PostCommentForJobResultsTask : DefaultTask() { + + @get:Input + @get:Option( + option = "job-result", + description = + "The results of the jobs in question, of the form 'job-id:\${{ needs.job-id.result }}' " + + "where 'job-id' is the id of the corresponding job in the 'needs' section of the job." + ) + abstract val jobResults: ListProperty + + @get:Input + @get:Option( + option = "github-issue", + description = "The GitHub Issue number to which to post a comment" + ) + abstract val githubIssue: Property + + @get:Input + @get:Option( + option = "github-repository", + description = "The value of \${{ github.repository }} in the workflow" + ) + abstract val githubRepository: Property + + @get:Input + @get:Option( + option = "github-event-name", + description = "The value of \${{ github.event_name }} in the workflow" + ) + abstract val githubEventName: Property + + @get:Input + @get:Option( + option = "github-ref", + description = "The value of \${{ github.ref }} in the workflow" + ) + abstract val githubRef: Property + + @get:Input + @get:Option( + option = "github-workflow", + description = "The value of \${{ github.workflow }} in the workflow" + ) + abstract val githubWorkflow: Property + + @get:Input + @get:Option( + option = "github-sha", + description = "The value of \${{ github.sha }} in the workflow" + ) + abstract val githubSha: Property + + @get:Input + @get:Option( + option = "github-repository-html-url", + description = "The value of \${{ github.event.repository.html_url }} in the workflow" + ) + abstract val githubRepositoryHtmlUrl: Property + + @get:Input + @get:Option( + option = "github-run-id", + description = "The value of \${{ github.run_id }} in the workflow" + ) + abstract val githubRunId: Property + + @get:Input + @get:Option( + option = "github-run-number", + description = "The value of \${{ github.run_number }} in the workflow" + ) + abstract val githubRunNumber: Property + + @get:Input + @get:Option( + option = "github-run-attempt", + description = "The value of \${{ github.run_attempt }} in the workflow" + ) + abstract val githubRunAttempt: Property + + @get:Internal abstract val workDirectory: DirectoryProperty + + @get:Inject abstract val execOperations: ExecOperations + + init { + // Make sure the task ALWAYS runs and is never skipped because Gradle deems it "up to date". + outputs.upToDateWhen { false } + } + + @TaskAction + fun run() { + PostCommentForJobResults( + jobResults = jobResults.get().map { it.toJobResult() }, + githubIssue = githubIssue.get(), + githubRepository = githubRepository.get(), + githubEventName = githubEventName.get(), + githubRef = githubRef.get(), + githubWorkflow = githubWorkflow.get(), + githubSha = githubSha.get(), + githubRepositoryHtmlUrl = githubRepositoryHtmlUrl.get(), + githubRunId = githubRunId.get(), + githubRunNumber = githubRunNumber.get(), + githubRunAttempt = githubRunAttempt.get(), + workDirectory = workDirectory.get().asFile, + execOperations = execOperations, + logger = logger, + ) + .run() + } + + class JobResultParseException(message: String) : Exception(message) + + companion object { + + fun String.toJobResult(): JobResult { + val colonIndex = indexOf(':') + if (colonIndex < 0) { + throw JobResultParseException( + "Invalid job result: $this (should have the form: jobId:jobResult)" + ) + } + return JobResult(jobId = substring(0, colonIndex), result = substring(colonIndex + 1)) + } + } +}