[AI] Kotlin Spring + Google AI Studio๋กœ Github ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๋ด‡ ๋งŒ๋“ค๊ธฐ

๐Ÿ› ๏ธ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ

  • Kotlin : 1.9.25
  • Spring boot : 3.5.6
  • Github

๐Ÿ’จ ์š”์ฒญ ํ๋ฆ„

์ฝ”๋“œ ๋ฆฌ๋ทฐ๊ฐ€ ์ง„ํ–‰๋˜๋Š” ํ๋ฆ„์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

PR → Github webhooks → Server → Google AI Studio → Get Github Apps(Bot) token → Github PR Comment

๊ฐœ์ธ Token์„ ์‚ฌ์šฉํ•˜์—ฌ Comment๋ฅผ ๋‚จ๊ธธ ๊ฒฝ์šฐ, ๋ณธ์ธ ๊ณ„์ •์œผ๋กœ ๋ฆฌ๋ทฐ๋ฅผ ๋‚จ๊ธฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ๋ช…์ด์„œ ์ž‘์—…ํ•˜๋Š” ํ™˜๊ฒฝ์—์„œ ์˜คํ•ด์˜ ์—ฌ์ง€๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์–ด, Github Apps๋ฅผ ์‚ฌ์šฉํ•ด Bot์„ ์ƒ์„ฑํ•˜์—ฌ ํ•ด๋‹น Bot์˜ Token์„ ๋ฐœํ–‰ํ•ด ๋ฆฌ๋ทฐ๋ฅผ ๋‚จ๊ธฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๐Ÿšจ ์ฃผ์˜ ์‚ฌํ•ญ

์ด ํฌ์ŠคํŒ…์€ EC2 ์ธ์Šคํ„ด์Šค๊ฐ€ ์žˆ์œผ๋ฉฐ, ๋ณธ์ธ์˜ ๋„๋ฉ”์ธ์ด ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ๊ฐ€์ •ํ•˜๊ณ  ์ง„ํ–‰ํ•˜๋ฉฐ, Google AI Studio๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ์— ๋ฌด๋ฃŒ ์„œ๋น„์Šค ์‚ฌ์šฉ ์‹œ, ์ž…๋ ฅํ•œ ๋‚ด์šฉ๊ณผ ์ƒ์„ฑ๋œ ์‘๋‹ต์€ Google์˜ ์ œํ’ˆ ๋ฐ ์„œ๋น„์Šค ๊ฐœ์„ ์„ ์œ„ํ•ด ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์ฃผ์˜ํ•˜์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค.

 

ํ”„๋ฆฌํ‹ฐ์–ด์™€ Google AI Studio์— ๊ฒฐ์ œ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๊ณผ๊ธˆ ์šฐ๋ ค๋Š” ์—†์Šต๋‹ˆ๋‹ค.

 

๋ฌด์ž‘์ • ๋”ฐ๋ผํ•˜๋Š” ๊ฒƒ๋ณด๋‹จ, ๊ธ€์„ ํ›‘์–ด ๋ณธ ๋’ค, ๋ณธ์ธ์—๊ฒŒ ํ•„์š”ํ•œ ๋‚ด์šฉ์ธ์ง€ ํ™•์ธ ํ›„ ๋”ฐ๋ผํ•˜์‹œ๊ธธ ์ถ”์ฒœ๋“œ๋ฆฝ๋‹ˆ๋‹ค. ๋˜ํ•œ, ๊ฐœ๋ฐœ ๊ณผ์ • ๋ฐ ๋ฆฌ๋ทฐ ํ๋ฆ„์„ ์ •๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ํฌ์ŠคํŒ…์ด๋ฏ€๋กœ, ๋ชจ๋“  ์ฝ”๋“œ๊ฐ€ ํฌ์ŠคํŒ…์— ํฌํ•จ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

 

์ „์ฒด ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ, ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”. ๋ฆฌํŒฉํ„ฐ๋ง ๋“ฑ์œผ๋กœ ์ธํ•ด ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŒ…๊ณผ ์‹ค์ œ ๊ฐœ๋ฐœ๋ฌผ์ด ์ฐจ์ด๊ฐ€ ์ƒ๊ธธ ์ˆ˜ ์žˆ๋Š” ์  ์œ ์˜ํ•ด์ฃผ์„ธ์š”. ๊ถ๊ธˆํ•œ ์ ์ด ์žˆ๋‹ค๋ฉด ๋Œ“๊ธ€ ๋˜๋Š” Issue์— ๋‚จ๊ฒจ์ฃผ์‹œ๋ฉด ๊ฐ์‚ฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค!

 

์ด ํฌ์ŠคํŒ…์€ ๋ธ”๋กœ๊ทธ ๋‚ด ์ฝ”๋“œ ๊ฐ€๋…์„ฑ์„ ์œ„ํ•ด ๊ณต์‹ ์ปจ๋ฒค์…˜๊ณผ ๋‹ค๋ฅด๊ฒŒ, ์ผ๋ถ€ ๋ฆฐํŠธ๋ฅผ ์กฐ์ •ํ•œ ๋ถ€๋ถ„์ด ์žˆ์œผ๋‹ˆ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.

โš™๏ธ ์„ค์ • ๋ฐฉ๋ฒ•

์ด ํ”„๋กœ์ ํŠธ๋Š” yaml ํŒŒ์ผ์„ ๋”ฐ๋กœ .gitignore์— ๋“ฑ๋กํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋•Œ๋ฌธ์— ๊ฐ ๋ณ€์ˆ˜์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’๋“ค์€ ๋ชจ๋‘ Environment variables์—์„œ ๊ด€๋ฆฌํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. env ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด์„œ ๊ด€๋ฆฌํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค.

 

@param:Value("...") ์ฝ”๋“œ๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ, ํ•ด๋‹น ๋ณ€์ˆ˜๋ฅผ IntelliJ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์— ์ถ”๊ฐ€ํ•ด์ฃผ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

Run/Debug Configurations → Modify options → Environment variables

ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ๋‹ค์Œ ์‚ฌ์ง„๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

 

 

ํ™˜๊ฒฝ ๋ณ€์ˆ˜์— ์ถ”๊ฐ€ํ•  ๋•Œ๋งˆ๋‹ค Secret์„ ๋ฏธ๋ฆฌ ์ถ”๊ฐ€ํ•ด๋‘๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

Repository → Security → Actions → New repository secret

ํ”„๋กœ์ ํŠธ ์˜์กด์„ฑ ์„ธํŒ…

์ฃผ์š” dependencies๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

dependencies {
    // Github Apps ํ† ํฐ ๋ฐœํ–‰ ๊ด€๋ จ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
    implementation("io.jsonwebtoken:jjwt-api:0.11.5")  
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")  
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")  

    // Google Gemini ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
    implementation("com.google.genai:google-genai:1.0.0")  

    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")  

    // Coroutine ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")  
    implementation("org.jetbrains.kotlin:kotlin-reflect")  
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") 
}

application.yaml์—๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ๋ฐฉ๋ฒ•์œผ๋กœ ๊ด€๋ฆฌํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค.

app:  
  github:  
    app:  
      app-id: ${APP_ID}  
      installation-id: ${APP_INSTALLATION_ID}  
      private-key: ${APP_PEM}  
    webhook:  
      secret-key: ${WEBHOOK_SECRET}  
    api:  
      token: ${API_TOKEN_GITHUB}  
  google:  
    api-key: ${GEMINI_API_KEY}

Github Webhooks ์ƒ์„ฑ

Repository Settings ํƒญ์— ๋“ค์–ด๊ฐ€ Add webook์„ ๋ˆŒ๋Ÿฌ ์ƒ์„ฑํ•ด์ค๋‹ˆ๋‹ค.

Type Content
Payload URL ์›น ํ›…์ด ๋ฐœ์ƒํ–ˆ์„ ๋•Œ, ์š”์ฒญ์„ ๋ณด๋‚ผ URL
Content type Payload URL์— ๋ณด๋‚ผ ๋ฐ์ดํ„ฐ์˜ Content type
Secret ์œ ์ถ”ํ•˜๊ธฐ ์–ด๋ ค์šด ์ž„์˜์˜ UUID
SSL verification ๋ณธ์ธ์˜ ๋„๋ฉ”์ธ์— https๋ฅผ ์ ์šฉํ–ˆ๋‹ค๋ฉด Enable ์„ ํƒ
Trigger Let me select individual events๋ฅผ ๋ˆŒ๋Ÿฌ Pull requests ์ฒดํฌ

์ด ๊ณผ์ •์„ ๋งˆ์น˜๋ฉด PR์„ ์ƒ์„ฑํ•  ๋•Œ, ๋ณธ์ธ์˜ EC2 ์ธ์Šคํ„ด์Šค์— ์˜ฌ๋ผ๊ฐ€์žˆ๋Š” ์„œ๋ฒ„์— ์›น ํ›…์„ ๋ณด๋‚ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

Controller

Webhook ๊ฒ€์ฆ์„ ์œ„ํ•ด GitHub์—์„œ ๋ฐœ๊ธ‰๋ฐ›์€ Secret ๊ฐ’์„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜(WEBHOOK_SECRET)๋กœ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.

 

๊ธฐ๋ณธ์ ์ธ ํ‹€์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋ฐ‘์—์„œ ์„ค๋ช…ํ•  ๋‹จ์ˆœํ•œ ์ฝ”๋“œ ์กฐ๊ฐ๋“ค์€ net ํ•จ์ˆ˜ ๋‚ด๋ถ€ ๊ตฌํ˜„์ž…๋‹ˆ๋‹ค. ๊ทธ ์™ธ์˜ ๊ฒฝ์šฐ Controller ์ฝ”๋“œ๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค.

@RestController  
class CodeReviewController(  
    @param:Value("\${app.github.webhook.secret-key}")
    private val secret: String,  
    private val codeReviewService: CodeReviewService  
) {
    @PostMapping("/api/code/review")  
    suspend fun net(  
        @RequestHeader("X-GitHub-Event") event: String,  
        @RequestHeader("X-Hub-Signature-256") sig256: String?,  
        @RequestBody rawBody: ByteArray  
    ): ResponseEntity<String> {  
        return ResponseEntity.ok("Request completed")  
    }  

    private fun unauthorized(  
        message: String  
    ) = ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(message)  
}

GithubSignature validation

Webhook ์š”์ฒญ ์‹œ ๊ธฐ๋ณธ์ ์ธ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ชจ๋‘ ๋“ค์–ด์™”๋Š”์ง€ ํ™•์ธํ•˜์—ฌ, ์œ„์กฐ ์—ฌ๋ถ€๋ฅผ ์šฐ์„  ํŒ๋ณ„ํ•ฉ๋‹ˆ๋‹ค.

if (sig256 == null) {  
    logger.warn("Missing signature header for GitHub webhook")  
    return unauthorized("Missing signature")  
}  

if (!GithubSignature.isValid(sig256, secret, rawBody)) {  
    logger.warn("Invalid signature detected from GitHub webhook")  
    return unauthorized("Invalid signature")  
}

์š”์ฒญ ๋ณธ๋ฌธ(payload)๊ณผ ๋ฏธ๋ฆฌ ์„ค์ •ํ•œ ๋น„๋ฐ€ ํ‚ค(secret)๋ฅผ ์ด์šฉํ•ด HMAC-SHA256 ์„œ๋ช…์„ ์ƒ์„ฑํ•˜๊ณ , GitHub๊ฐ€ ๋ณด๋‚ธ ์„œ๋ช…(X-Hub-Signature-256)๊ณผ ์ƒ์ˆ˜ ์‹œ๊ฐ„ ๋น„๊ต(constant-time comparison) ๋ฐฉ์‹์œผ๋กœ ์ผ์น˜ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค.

์ž์„ธํ•œ ๋‚ด์šฉ์€ Webhook ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•ด์ฃผ์„ธ์š”.

object GithubSignature {  
    fun isValid(
        signatureHeader: String?, 
        secret: String, 
        payload: ByteArray
    ): Boolean {  
        if (signatureHeader.isNullOrBlank()) return false  
        val expected = sign256(secret, payload)  
        return constantTimeEquals(expected, signatureHeader)  
    }  

    private fun sign256(
        secret: String, 
        payload: ByteArray
    ): String {  
        ...
    }  

    private fun constantTimeEquals(a: String, b: String): Boolean {  
        ...
    }  
}

Payload parsing

GitHub Webhook์˜ JSON ๋ณธ๋ฌธ์—์„œ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋งŒ ์ถ”์ถœํ•˜๊ธฐ ์œ„ํ•ด DTO๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. @JsonIgnoreProperties(ignoreUnknown = true)๋ฅผ ์‚ฌ์šฉํ•ด ๋ถˆํ•„์š”ํ•œ ํ•„๋“œ๋Š” ์ž๋™์œผ๋กœ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค.

val payload = parsePayload(rawBody)
@JsonIgnoreProperties(ignoreUnknown = true)  
data class GithubPayload(  
    val action: String,  
    val number: String,  
    val pull_request: PullRequestPayload  
)  

@JsonIgnoreProperties(ignoreUnknown = true)  
data class PullRequestPayload(  
    val url: String,  
    val head: PullRequestHeadPayload  
) {  
    private val _urls = url.removePrefix("https://").split("/")  
    val owner = _urls[2]  
    val repo = _urls[3]  
    val prNumber = _urls[5]  
}  

@JsonIgnoreProperties(ignoreUnknown = true)  
data class PullRequestHeadPayload(  
    val sha: String  
)  

val mapper = jacksonObjectMapper()  

fun parsePayload(rawBody: ByteArray): GithubPayload {  
    return mapper.readValue(rawBody)  
}

Request review

์ด์ œ, payload์— ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ๋ฐ›์•„์™”์œผ๋‹ˆ, pull_request์ด๊ณ , opened ์ƒํƒœ์ธ ๊ฒฝ์šฐ์— ๋ฆฌ๋ทฐ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค.

val action = GithubActionType(payload.action)  
logger.info("payload = {}, action = {}", payload, action)  

// PR ์ƒ์„ฑ ์‹œ์—๋งŒ ๋ฆฌ๋ทฐ ์ง„ํ–‰  
when (event) {  
    "pull_request" -> when (GithubActionType(payload.action)) {  
        GithubActionType.OPENED -> codeReviewService.review(payload)  
        else -> logger.info("Ignored pull_request event: ${payload.action}")  
    }  
    else -> logger.debug("Unhandled GitHub event: $event")  
}
enum class GithubActionType(val value: String) {  
    ASSIGNED("assigned"),  
    // ์ผ๋ถ€ ํƒ€์ž… ์ค‘๋žต
    OPENED("opened"),
    ;  

    companion object {  
        operator fun invoke(value: String) = entries.find { it.value == value }  
    }  
}

Service

๊ธฐ๋ณธ์ ์ธ ํ‹€์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. ์•„๋ž˜์— ์„ค๋ช…ํ•  ์„ธ๋ถ€ ๊ตฌํ˜„์€ review ํ•จ์ˆ˜ ๋‚ด๋ถ€ ๋กœ์ง์ž…๋‹ˆ๋‹ค. ์ด ํฌ์ŠคํŒ…์—์„œ๋Š” ๊ตฌ์กฐํ™”๋œ ๋™์‹œ์„ฑ์ด ์•„๋‹Œ, ๋‹จ์ˆœ ์ฝ”๋ฃจํ‹ด ์Šค์ฝ”ํ”„ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

@Service  
class CodeReviewService {
    private val scope = CoroutineScope(Dispatchers.IO)  

    fun review(payload: GithubPayload) {  

    }  
}

PR ์ฝ”๋“œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ

PR์— ์˜ฌ๋ผ์˜จ ์ฝ”๋“œ๋“ค์˜ ์ •๋ณด๋ฅผ ๋ฐ›์˜ต๋‹ˆ๋‹ค. ์ด ๊ณผ์ •์—์„œ repo์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์„ ๊ฐ€์ง„ GitHub Personal Access Token์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

val diff = payload.pull_request.run {  
    githubDiffClient.getPrDiff(owner, repo, prNumber)  
}
val parts = GithubDiffUtils.splitDiffByFile(diff)
@Service  
class GithubDiffClient(  
    @param:Value("\${app.github.api.token}") private val token: String,  
) {  
    private val client = WebClient.builder()  
        .baseUrl("https://api.github.com")  
        .defaultHeader("Accept", "application/vnd.github.v3.diff")  
        .build()  

    fun getPrDiff(  
        owner: String,  
        repo: String,  
        prNumber: String  
    ): String = client.get()  
        .uri("/repos/$owner/$repo/pulls/$prNumber")  
        .header("Authorization", "Bearer $token")  
        .retrieve()  
        .bodyToMono(String::class.java)  
        .block()!!
}

๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ์˜ hunk๋ฅผ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋•Œ, ํ•ด๋‹น ํŒŒ์ผ(filePath), ๋ณ€๊ฒฝ ์‚ฌํ•ญ(content), ๋งˆ์ง€๋ง‰ ๋ผ์ธ(line) ์ •๋ณด๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

object GithubDiffUtils {  
    data class FileDiff(  
        val filePath: String,  
        val content: String,  
        val line: Int  
    )

    private fun findLastChangedLine(diffContent: String): Int {  
        ...
    }  

    fun splitDiffByFile(
        diff: String
    ): List<FileDiff> = diff.split("diff --git")  
        .drop(1)  
        .mapNotNull { chunk ->  
            ...
            FileDiff(filePath, content, lastChangedLine)  
        }  
}

Gemini์— ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์š”์ฒญ

parts.forEach { part ->  
    scope.launch {  
        try {  
            val prompt = "```diff\\n${part.content}\\n```"  

            val review = googleGeminiClient.chat(prompt)

            githubReviewClient.addReviewComment(
                GithubReviewDto(payload.pull_request, part, review)
            )
        } catch (e: Exception) {  
            logger.error("[Review Failed] file = ${part.filePath}", e)  
        }  
    }  
}

์ด์ œ, part์— ๋‹ด๊ธด content๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Gemini์—๊ฒŒ ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ app.google.api-key๋Š” Google AI Studio์—์„œ ๋ฐœ๊ธ‰ ๋ฐ›์€ ๊ฐ’์„ ์˜๋ฏธํ•ฉ๋‹ˆ๋‹ค.

 

SYSTEM_PROMPT๋Š” ๋ณธ์ธ์ด ๋ฆฌ๋ทฐ๋ฅผ ์›ํ•˜๋Š” ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์—ญํ• ์„ ์ง€์ •ํ•ด์ฃผ๊ณ , ์–ด๋–ค ๋ถ€๋ถ„ ์œ„์ฃผ๋กœ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ํ•ด๋‹ฌ๋ผ๋Š” ๋“ฑ์— ๋Œ€ํ•œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

@Component  
class GoogleGeminiClient(  
    @param:Value("\${app.google.api-key}") val apiKey: String  
) {  
    private val instruction = Content.builder()  
        .role("system")  
        .parts(  
            listOf(  
                Part.builder()  
                    .text(SYSTEM_PROMPT)  
                    .build()  
            )  
        )  
        .build()  

    private val think = ThinkingConfig.builder()  
        .thinkingBudget(500)  
        .build()  

    private val clientPool = ConcurrentHashMap<String, Client>()  

    private fun getClient(filePath: String): Client {  
        return clientPool.computeIfAbsent(filePath) {  
            Client.builder().apiKey(apiKey).build()  
        }  
    }  

    suspend fun chat(filePath: String, prompt: String): String? = withContext(Dispatchers.IO) {  
        val client = getClient(filePath)  
        try {  
            client.models.generateContentStream(  
                MODEL, prompt,  
                GenerateContentConfig.builder()  
                    .systemInstruction(instruction)  
                    .thinkingConfig(think)  
                    .build()  
            ).use { stream ->  
                buildString {  
                    for (response in stream) append(response.text())  
                }  
            }        
        } catch (e: Exception) {  
            null  
        } finally {  
            clientPool.remove(filePath)  
        }  
    }  
}

PR ์ฝ”๋ฉ˜ํŠธ ๋‚จ๊ธฐ๊ธฐ

์ฝ”๋ฉ˜ํŠธ๋ฅผ ๋‚จ๊ธธ ๋•Œ, ๊ฐœ์ธ Token์„ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ, ๋ณธ์ธ์˜ ํ”„๋กœํ•„๋กœ ๋ฆฌ๋ทฐ๊ฐ€ ๋‹ฌ๋ฆฝ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ ๋ช…์ด์„œ ์ž‘์—…ํ•˜๋Š” ๊ฒฝ์šฐ, ์˜คํ•ด์˜ ์†Œ์ง€๊ฐ€ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, Github Apps๋ฅผ ํ†ตํ•ด Bot์„ ๋งŒ๋“ค์–ด ๋ฆฌ๋ทฐ๋ฅผ ๋‹ฌ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

@Service  
class GithubReviewClient(  
    private val tokenProvider: GithubAppTokenProvider  
) {  
    data class ReviewCommentRequest(  
        val body: String,  
        val path: String,  
        val commit_id: String,  
        val subject_type: String = "file"  
    )  

    val client = WebClient.builder()  
        .baseUrl("https://api.github.com")  
        .defaultHeader("Accept", "application/vnd.github+json")  
        .defaultHeader("Authorization", "Bearer ${tokenProvider.getInstallationToken()}")  
        .build()  

    suspend fun addReviewComment(  
        dto: GithubReviewDto  
    ) {  
        val uri = dto.payload.run {  
            "/repos/$owner/$repo/pulls/$prNumber/comments"  
        }  

        client.post()  
            .uri(uri)  
            .bodyValue(dto.toReviewCommentRequest())  
            .retrieve()  
            .bodyToMono(String::class.java)  
            .block()  
    }  
}

Github Apps ์ƒ์„ฑ

New Github App์— ๋“ค์–ด๊ฐ€ ์ƒˆ๋กœ์šด App์„ ์ƒ์„ฑํ•ด์ค๋‹ˆ๋‹ค. ์ด App์€ ๋ฆฌ๋ทฐ ์š”์ฒญ ์‹œ ์‚ฌ์šฉํ•  ํ† ํฐ ๋ฐœ๊ธ‰์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉ๋˜๋ฏ€๋กœ ํ•„์ˆ˜ ํ•ญ๋ชฉ๋“ค๋งŒ ๊ฐ„๋žตํ•˜๊ฒŒ ์ž…๋ ฅ์„ ํ•ด์ค๋‹ˆ๋‹ค.

 

์ด๋ฏธ ์ฒดํฌ๊ฐ€ ๋˜์–ด์žˆ๋Š” ํ•ญ๋ชฉ์€ ๋ชจ๋‘ ํ•ด์ œํ•˜๊ณ , Homepage URL ๋˜ํ•œ ์•„๋ฌด ์ฃผ์†Œ๋‚˜ ์ž…๋ ฅํ•ด๋„ ๋ฌด๋ฐฉํ•ฉ๋‹ˆ๋‹ค. Permissions ๋ถ€๋ถ„์— Repository permissions ์ค‘ Pull requests๋งŒ Read and write๋กœ ์„ค์ •ํ•ด์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

 

App์„ ์ƒ์„ฑํ•œ ํ›„, ๋‹ค์Œ ํ•ญ๋ชฉ๋“ค์„ ๊ฐ€์ ธ์™€ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์™€ Repository Secret์— ์ถ”๊ฐ€ํ•ด์ค๋‹ˆ๋‹ค.

  • App ID
  • Installation ID
    • ๋ชจ๋“  ์„ค์ • ํ›„, Install App ํƒญ์— ๋“ค์–ด๊ฐ€์„œ ์„ค์น˜
    • ์„ค์น˜ ํ›„ ํ†ฑ๋‹ˆ๋ฐ”ํ€ด ๋ชจ์–‘์„ ๋ˆŒ๋Ÿฌ Integrations์— ์ง„์ž…
    • URL์˜ ์ˆซ์ž๊ฐ€ Installation ID๋ฅผ ์˜๋ฏธ
      • https://github.com/settings/installations/123456 ํ˜•ํƒœ์ž„
  • Private key(์•„๋ž˜ ๋‚ด์šฉ์— ๋”ฐ๋ผ์„œ ์ ์šฉ)

 

 

Generate a private key๋ฅผ ๋ฐœ๊ธ‰๋ฐ›์œผ๋ฉด, pem ํŒŒ์ผ์ด ๋‹ค์šด๋กœ๋“œ ๋ ํ…๋ฐ, ์ด ํŒŒ์ผ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. -----BEGIN RSA PRIVATE KEY-----

 

์ด ํ‚ค์˜ ํ˜•์‹์€ PKCS#1์ธ๋ฐ, Java ๊ณ„์—ด์—์„œ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์€ PKCS#8์ด๋‹ค. ๋•Œ๋ฌธ์—, ๋‹ค์Œ ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•ด PKCS#1๋ฅผ PKCS#8 ํ‚ค๋กœ ๋ณ€ํ™˜ํ•ด์ค๋‹ˆ๋‹ค.

openssl pkcs8 -topk8 -inform PEM -outform PEM -in private-key.pem -out private-key-pkcs8.pem -nocrypt

์ด ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํŒŒ์ผ ํ—ค๋”๊ฐ€ -----BEGIN PRIVATE KEY-----๋กœ ๋ฐ”๋€ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค. ์ด์ œ ์ด ๊ฐ’์„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์™€ secret์— ์ถ”๊ฐ€ํ•ด์ฃผ์–ด์•ผ ํ•˜๋Š”๋ฐ, secret์—๋Š” ๊ทธ๋Œ€๋กœ ๋„ฃ์–ด๋„ ๋˜์ง€๋งŒ, ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—๋Š” ์—ฌ๋Ÿฌ ์ค„์„ ์œ ์ง€ํ•ด์„œ ๋„ฃ์„ ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ ๋ผ์ธ ๋์— \n๋ฅผ ํฌํ•จํ•˜์—ฌ ํ•œ ์ค„๋กœ ๋งŒ๋“ค์–ด์„œ ๋„ฃ์–ด์ฃผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

@Component  
class GithubAppTokenProvider(  
    @param:Value("\${app.github.app.app-id}") private val appId: String,  
    @param:Value("\${app.github.app.installation-id}") private val installationId: String,  
    @param:Value("\${app.github.app.private-key}") private val appPem: String  
) {  
    @Volatile private var cachedToken: String? = null  
    @Volatile private var expiresAt: Instant? = null  

    private val privateKey = run {  
        val pem = appPem.replace("\\n", "\n")  
            .replace("-----BEGIN PRIVATE KEY-----", "")  
            .replace("-----END PRIVATE KEY-----", "")  
            .replace("\\s+".toRegex(), "")  

        val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(pem))  
        KeyFactory.getInstance("RSA").generatePrivate(keySpec)  
    }  

    private fun generateJwt(): String {  
        val now = Instant.now()  
        return Jwts.builder()  
            .setIssuer(appId)
            .setIssuedAt(Date.from(now))  
            .setExpiration(Date.from(now.plusSeconds(9 * 60)))
            .signWith(privateKey, SignatureAlgorithm.RS256)  
            .compact()  
    }  

    fun getInstallationToken(): String {  
        val now = Instant.now()  
        if (cachedToken != null && expiresAt?.isAfter(now.plusSeconds(30)) == true) {  
            return cachedToken!!
        }  

        val jwt = generateJwt()  
        val resp = WebClient.builder()  
            .baseUrl("https://api.github.com")  
            .defaultHeader("Authorization", "Bearer $jwt")  
            .defaultHeader("Accept", "application/vnd.github+json")  
            .build()  
            .post()  
            .uri("/app/installations/$installationId/access_tokens")  
            .retrieve()  
            .bodyToMono(Map::class.java)  
            .block()!!  

        cachedToken = resp["token"] as String  
        expiresAt = Instant.parse(resp["expires_at"] as String)  
        return cachedToken!!  
    }  
}

์ด ๊ณผ์ •๊นŒ์ง€ ์™„๋ฃŒ๋˜๋ฉด, Pull Request๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ ์ž๋™์œผ๋กœ Gemini๊ฐ€ diff๋ฅผ ๋ถ„์„ํ•˜๊ณ , Github App ๊ณ„์ •์œผ๋กœ ๋ฆฌ๋ทฐ ์ฝ”๋ฉ˜ํŠธ๋ฅผ ๋‚จ๊ธฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

 

๐Ÿค” ํšŒ๊ณ 

๋ชจ๋“  ๋‚ด์šฉ์„ ์ •๋ฆฌํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์€๋ฐ, ์ „์ฒด์ ์ธ ํ๋ฆ„๋งŒ ๋‹ด์€๊ฒŒ ์กฐ๊ธˆ์€ ์•„์‰ฌ์šด ๊ฒƒ ๊ฐ™๋‹ค. ํŒŒ์ผ ํ•˜๋‚˜ํ•˜๋‚˜์— ๋งŽ์€ ๋‚ด์šฉ์ด ์žˆ์–ด, ์—ญํ• ์„ ๋” ๋ถ„๋ฆฌํ•˜๋„๋ก ๋ฆฌํŒฉํ„ฐ๋ง์ด ํ•„์š”ํ•  ๊ฒƒ ๊ฐ™๋‹ค.