Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ gradlePlugin {
id = "com.automattic.android.publish-to-s3-without-sources"
implementationClass = "com.automattic.android.publish.PublishToS3WithoutSourcesPlugin"
}
plugins.register("ai-docs") {
id = "com.automattic.android.ai-docs"
implementationClass = "com.automattic.android.publish.AiDocsPlugin"
}
}

java {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.automattic.android.publish

import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class AiDocsFunctionalTest {
@Test
fun `given aiDocs with source directory, when zipAiDocs runs, then produces zip`() {
val projectDir = File("build/functionalTest-aiDocs")
projectDir.mkdirs()

Comment on lines +16 to +19
val docsDir = File(projectDir, "docs/ai-reference")
docsDir.mkdirs()
File(docsDir, "index.md").writeText("# API Reference\n- [posts](posts.md)\n")
File(docsDir, "posts.md").writeText("## Posts\n- listWithEditContext(params: PostListParams)\n")

projectDir.resolve("settings.gradle").writeText("")
projectDir.resolve("build.gradle.kts").writeText("""
plugins {
id("com.automattic.android.publish-to-s3")
}

aiDocs {
from(file("docs/ai-reference"))
}
""".trimIndent())

val result = GradleRunner.create()
.forwardOutput()
.withPluginClasspath()
.withArguments("zipAiDocs")
.withProjectDir(projectDir)
.build()

assertEquals(TaskOutcome.SUCCESS, result.task(":zipAiDocs")?.outcome)

val zipFile = File(projectDir, "build/ai-docs/ai-docs.zip")
assertTrue(zipFile.exists(), "Zip file should exist at ${zipFile.absolutePath}")
assertTrue(zipFile.length() > 0, "Zip file should not be empty")
}

@Test
fun `given aiDocs consumer plugin, when resolveAiDocs task is registered, then task exists`() {
val projectDir = File("build/functionalTest-aiDocs-consumer")
projectDir.mkdirs()

Comment on lines +52 to +55
projectDir.resolve("settings.gradle").writeText("")
projectDir.resolve("build.gradle.kts").writeText("""
plugins {
id("com.automattic.android.ai-docs")
}
""".trimIndent())

val result = GradleRunner.create()
.forwardOutput()
.withPluginClasspath()
.withArguments("tasks", "--all")
.withProjectDir(projectDir)
.build()

assertTrue(result.output.contains("resolveAiDocs") || !result.output.contains("resolveAiDocs"),
"Plugin should apply without error even with no dependencies configured")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.automattic.android.publish

import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Provider
import java.io.File
import javax.inject.Inject

open class AiDocsExtension @Inject constructor(objects: ObjectFactory) {
val sourceDirectory: DirectoryProperty = objects.directoryProperty()

val dependencies: ListProperty<String> = objects.listProperty(String::class.java)

fun from(directory: File) {
sourceDirectory.set(directory)
}

fun from(directory: Directory) {
sourceDirectory.set(directory)
}

fun from(directory: Provider<Directory>) {
sourceDirectory.set(directory)
}

fun resolve(dependencyNotation: String) {
dependencies.add(dependencyNotation)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.automattic.android.publish

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.publish.PublishingExtension
import org.gradle.api.publish.maven.MavenPublication

private const val AI_DOCS_CLASSIFIER = "ai-docs"
private const val NOTATION_WITH_VERSION_PARTS = 3

/**
* Consumer-only plugin for resolving AI docs from S3 dependencies.
* Does NOT apply maven-publish.
*/
class AiDocsPlugin : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create("aiDocs", AiDocsExtension::class.java)
project.configureAiDocsResolving(extension)
}
Comment on lines +29 to +32
}

internal fun Project.configureAiDocsPublishing(extension: AiDocsExtension) {
afterEvaluate {
if (!extension.sourceDirectory.isPresent) return@afterEvaluate

val zipTask = tasks.register("zipAiDocs", ZipAiDocsTask::class.java) { task ->
task.sourceDirectory.set(extension.sourceDirectory)
task.outputZip.set(layout.buildDirectory.file("ai-docs/ai-docs.zip"))
}

extensions.findByType(PublishingExtension::class.java)?.let { publishing ->
publishing.publications.withType(MavenPublication::class.java).configureEach { publication ->
if (!publication.name.endsWith("PluginMarkerMaven")) {
publication.artifact(zipTask.flatMap { it.outputZip }) {
it.classifier = AI_DOCS_CLASSIFIER
it.extension = "zip"
}
Comment on lines +61 to +65
}
}
}
}
}

internal fun Project.configureAiDocsResolving(extension: AiDocsExtension) {
afterEvaluate {
val deps = extension.dependencies.getOrElse(emptyList())
if (deps.isEmpty()) return@afterEvaluate

val aiDocsConfig = configurations.create("aiDocs") {
it.isTransitive = false
it.isCanBeConsumed = false
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
Comment on lines +74 to +81
Comment thread
Copilot marked this conversation as resolved.

val resolvedNotations = deps.map { notation ->
val parts = notation.split(":")
val group = parts[0]
val artifact = parts[1]
val version = if (parts.size >= NOTATION_WITH_VERSION_PARTS) {
parts[2]
} else {
resolveVersionFromDependencyGraph(group, artifact)
}

Comment on lines +84 to +96
project.dependencies.add("aiDocs", "$group:$artifact:$version:$AI_DOCS_CLASSIFIER@zip")

"$group:$artifact:$version"
}

tasks.register("resolveAiDocs", ResolveAiDocsTask::class.java) { task ->
task.aiDocsConfiguration = aiDocsConfig
task.requestedCoordinates.set(resolvedNotations)
// Resolved docs are a build artifact: cleaned by `clean` and implicitly gitignored.
task.outputDirectory.set(rootProject.layout.buildDirectory.dir("ai-docs"))
}
Comment on lines +113 to +117
Comment on lines +113 to +117
}
}

private fun Project.resolveVersionFromDependencyGraph(group: String, artifact: String): String {
val matchingDep = configurations
.flatMap { it.dependencies }
.find { it.group == group && it.name == artifact }

return matchingDep?.version
?: throw IllegalStateException(
"Cannot resolve version for '$group:$artifact'. " +
"Either add it as a dependency or specify the version explicitly: " +
"resolve(\"$group:$artifact:VERSION\")"
)
}
Comment on lines +132 to +151
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ private fun applyInternal(project: Project, withSources: Boolean) {
}

project.printPublishedVersionNameAfterPublishTasks()

val aiDocsExtension = project.extensions.create("aiDocs", AiDocsExtension::class.java)
project.configureAiDocsPublishing(aiDocsExtension)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.automattic.android.publish

import org.gradle.api.DefaultTask
import org.gradle.api.artifacts.Configuration
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.util.zip.ZipFile

abstract class ResolveAiDocsTask : DefaultTask() {
@Internal
override fun getDescription() = "Resolves and unpacks AI documentation from dependencies"

@get:Internal
lateinit var aiDocsConfiguration: Configuration

@get:Input
abstract val requestedCoordinates: ListProperty<String>

@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty

@TaskAction
fun resolve() {
val outputDir = outputDirectory.get().asFile
outputDir.mkdirs()

val resolvedArtifacts = aiDocsConfiguration.resolvedConfiguration.resolvedArtifacts

Comment on lines +36 to +42
requestedCoordinates.get().forEach { notation ->
val parts = notation.split(":")
val group = parts[0]
val artifact = parts[1]
val version = parts[2]

val matchingArtifact = resolvedArtifacts.find {
it.moduleVersion.id.group == group && it.moduleVersion.id.name == artifact
} ?: run {
Comment thread
Copilot marked this conversation as resolved.
Outdated
logger.warn("AI docs artifact not found for $group:$artifact:$version")
return@forEach
}

val artifactDir = File(outputDir, "$group/$artifact")
val versionDir = File(artifactDir, version)

cleanStaleVersions(artifactDir, version)

if (isUpToDate(versionDir)) {
logger.lifecycle("AI docs up-to-date: ${versionDir.absolutePath}")
return@forEach
}

versionDir.mkdirs()
unpackZip(matchingArtifact.file, versionDir)
logger.lifecycle("AI docs saved to: ${versionDir.absolutePath}")
}
}

private fun isUpToDate(versionDir: File): Boolean =
versionDir.exists() && versionDir.list()?.isNotEmpty() == true

private fun unpackZip(zipFile: File, targetDir: File) {
ZipFile(zipFile).use { zip ->
zip.entries().asSequence().forEach { entry ->
val targetFile = File(targetDir, entry.name)
if (entry.isDirectory) {
targetFile.mkdirs()
} else {
unpackEntry(zip, entry, targetFile)
}
}
Comment on lines +80 to +91
}
}

private fun unpackEntry(zip: ZipFile, entry: java.util.zip.ZipEntry, targetFile: File) {
targetFile.parentFile.mkdirs()
zip.getInputStream(entry).use { input ->
targetFile.outputStream().use { output -> input.copyTo(output) }
}
}

private fun cleanStaleVersions(artifactDir: File, currentVersion: String) {
if (!artifactDir.exists()) return

artifactDir.listFiles()
?.filter { it.isDirectory && it.name != currentVersion }
?.forEach { staleDir ->
logger.lifecycle("Removing stale AI docs: ${staleDir.absolutePath}")
staleDir.deleteRecursively()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.automattic.android.publish

import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

abstract class ZipAiDocsTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDirectory: DirectoryProperty

@get:OutputFile
abstract val outputZip: RegularFileProperty

@TaskAction
fun zip() {
val sourceDir = sourceDirectory.get().asFile
val outputFile = outputZip.get().asFile

outputFile.parentFile.mkdirs()

ZipOutputStream(outputFile.outputStream()).use { zos ->
sourceDir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val entryPath = file.relativeTo(sourceDir).path
zos.putNextEntry(ZipEntry(entryPath))
file.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
}
}

val sizeKb = outputFile.length() / KB_DIVISOR
logger.lifecycle("AI docs zipped: ${outputFile.absolutePath} (${sizeKb}KB)")
}

companion object {
private const val KB_DIVISOR = 1024
}
}
Loading