[AI] LM Studio์™€ n8n์œผ๋กœ Gitlab ์ฝ”๋“œ ๋ฆฌ๋ทฐ ๋ด‡ ๋งŒ๋“ค๊ธฐ

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

  • LM Studio
  • n8n
  • GitLab

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

์ด ๊ตฌ์„ฑ์€ GitLab์—์„œ MR(Merge Request)์ด ์ƒ์„ฑ๋  ๋•Œ, n8n๊ณผ LLM ๊ธฐ๋ฐ˜ ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์‹œ์Šคํ…œ์ด ์ž๋™์œผ๋กœ ์ฝ”๋“œ๋ฅผ ๋ถ„์„ํ•˜๊ณ  GitLab์— ๋ฆฌ๋ทฐ๋ฅผ ๋“ฑ๋กํ•˜๋Š” ํ”„๋กœ์„ธ์Šค๋‹ค. ์ด ๊ณผ์ •์„ ํ†ตํ•ด ๋ฆฌ๋ทฐ ์†๋„์™€ ํ’ˆ์งˆ์„ ๋†’์ผ ์ˆ˜ ์žˆ๋‹ค.

LM Studio ์„ค์ •

LM Studio๋ฅผ ์„ค์น˜ํ•œ ํ›„, gpt-oss-20b ๋ชจ๋ธ์„ ์„ค์น˜ํ•˜๊ณ , ํ™˜๊ฒฝ ์„ค์ •์—์„œ ๋‹ค์Œ ํ•ญ๋ชฉ์„ ๋ฐ˜๋“œ์‹œ ์ฒดํฌํ•œ๋‹ค.

  • When applicable, separate reasoning_content and content in API responses

[์ถœ์ฒ˜] https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/851

์ด ํ•ญ๋ชฉ์„ ์ฒดํฌํ•˜์ง€ ์•Š์œผ๋ฉด LLM์ด ์‘๋‹ตํ•  ๋•Œ reasoning ๊ฒฐ๊ณผ๊ฐ€ content์— ํ•จ๊ป˜ ํฌํ•จ๋˜์–ด ์ „๋‹ฌ๋˜๊ธฐ ๋•Œ๋ฌธ์—, API ์‘๋‹ต ํŒŒ์‹ฑ ์‹œ ๋ถˆํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค๋ฃจ๊ฒŒ ๋œ๋‹ค.

ํฌํŠธํฌ์›Œ๋”ฉ ์„ค์ •์„ ํ†ตํ•ด ์™ธ๋ถ€์—์„œ LM Studio API๋ฅผ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋„๋ก ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑํ•ด์•ผ ํ•œ๋‹ค. ์ด ๊ณผ์ •์€ ํƒ€ ๋ธ”๋กœ๊ทธ์—์„œ ๋‹ค๋ฃจ๋Š” ํฌํŠธํฌ์›Œ๋”ฉ ๋ฐฉ๋ฒ•์„ ์ฐธ๊ณ ํ•˜๋ฉด ๋œ๋‹ค.

n8n ์„ค์ •

1. Webhook ๋…ธ๋“œ ์ƒ์„ฑ

n8n์—์„œ Webhook ๋…ธ๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์„ค์ • ํ™”๋ฉด์œผ๋กœ ๋“ค์–ด๊ฐ€ ๊ธฐ๋ณธ์ ์ธ ๊ฐ’์„ ์„ธํŒ…ํ•œ๋‹ค.

GitLab์˜ Webhook์—์„œ Secret token์„ ์š”๊ตฌํ•  ๋•Œ๋Š” ์ž„์˜์˜ UUID ๊ฐ’์„ ์ž…๋ ฅํ•ด๋„ ๋ฌด๋ฐฉํ•˜๋‹ค. ์ด ๊ฐ’์€ ๋‚˜์ค‘์— GitLab Webhook ์„ค์ • ์‹œ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ๋œ๋‹ค.

๋˜ํ•œ, ์„ค์ •์„ ์ €์žฅํ•  ๋•Œ๋Š” Production URL์„ ์„ ํƒํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•œ๋‹ค. Test ํ™˜๊ฒฝ๊ณผ Production ํ™˜๊ฒฝ์ด ๋™์ผํ•˜๊ธฐ ๋•Œ๋ฌธ์—, Production ํ™˜๊ฒฝ์œผ๋กœ ํ…Œ์ŠคํŠธํ•ด๋„ ํฐ ์ฐจ์ด๊ฐ€ ์—†๋‹ค.

2. GitLab Webhook ์ƒ์„ฑ

์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ›์„ Repository์˜ Webhook ์„ค์ •์— ์ ‘์†ํ•ด ๋‹ค์Œ์„ ์„ค์ •ํ•œ๋‹ค.

  • URL: n8n Webhook ๋…ธ๋“œ์— ํ‘œ์‹œ๋œ URL
  • Secret token: n8n์— ์ž…๋ ฅํ–ˆ๋˜ UUID ๊ฐ’
  • ์ด๋ฒคํŠธ: Merge request events ์ฒดํฌ

3. ๋™์ž‘ ํ…Œ์ŠคํŠธ

์„ค์ •์ด ์™„๋ฃŒ๋˜๋ฉด n8n์—์„œ Execute workflow๋ฅผ ํด๋ฆญํ•˜๊ณ , GitLab์˜ Webhook ์„ค์ • ํ™”๋ฉด์—์„œ Test โ†’ Merge request event๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. ์ •์ƒ์ ์œผ๋กœ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ๋‹ค๋ฉด n8n์—์„œ ์š”์ฒญ์„ ์ˆ˜์‹ ํ•˜๊ณ , ์›Œํฌํ”Œ๋กœ์šฐ๊ฐ€ ๋™์ž‘ํ•˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

4. ์ „์ฒด์ ์ธ ์›Œํฌ ํ”Œ๋กœ์šฐ

๋ชจ๋“  ๊ณผ์ •์„ ์„ค๋ช…ํ•˜๊ธฐ์—” ๋‚ด์šฉ์ด ๋ฐฉ๋Œ€ํ•˜๊ธฐ์—, ๋‚ด๊ฐ€ ๋งŒ๋“  ํŒŒ์ดํ”„๋ผ์ธ์— ๋Œ€ํ•ด์„œ ๊ณต์œ ๋งŒ ํ•˜๋„๋ก ํ•˜๊ฒ ๋‹ค.

  1. MR ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด Webhook ๋…ธ๋“œ๊ฐ€ ์‹คํ–‰๋œ๋‹ค.
  2. ํ•ด๋‹น MR์ด open ์ƒํƒœ์ธ์ง€ ํŒ๋ณ„ํ•œ๋‹ค.
const state = $input.first().json.body.object_attributes.state;
const action = $input.first().json.body.object_attributes.action;

if (state !== "opened" || action !== "open") {
  return [];
}

return $input.all();
  1. ํ•ด๋‹น MR์— ํฌํ•จ๋œ ๋ณ€๊ฒฝ๋œ ์ฝ”๋“œ ์ •๋ณด๋“ค์„ ๊ฐ€์ ธ์˜จ๋‹ค.
    • GET /api/v4/projects/{{ $id }}/merge_requests/{{ $iid }}/changes
  2. ๊ฐ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ LLM ๋ฆฌ๋ทฐ ๋ฐ Comment๋ฅผ ๋‚จ๊ธธ ๋•Œ ํ•„์š”ํ•œ ๊ฐ์ฒด๋กœ ๋งคํ•‘ํ•œ๋‹ค.

n8n์—์„œ ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ๋“ฑ๋กํ•  ๋•Œ ๊ฐ€์žฅ ์• ๋ฅผ ๋จน์€ ๋ถ€๋ถ„์€ ๊ฐ๊ฐ์˜ hunk๋ฅผ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. GPT์—๊ฒŒ ๋ถ€ํƒํ•˜์—ฌ ์–ป์€ ๊ฒฐ๊ณผ๋ฌผ์ด์ง€๋งŒ, 100% ๋งˆ์Œ์— ๋“ค์ง€๋Š” ์•Š๋Š” ๊ฒƒ ๊ฐ™๋‹ค.

onst mr = $json;
const diffRefs = mr.diff_refs;

/** @@ ํ—ค๋” ๊ธฐ์ค€์œผ๋กœ diff๋ฅผ hunk ๋ฐฐ์—ด๋กœ ๋ถ„๋ฆฌ */
function splitDiffByHunks(diff) {
  // CRLF ๋ฐฉ์–ด
  const lines = diff.split('\n').map(l => l.replace(/\r$/, ''));
  const hunks = [];
  let current = [];

  for (const line of lines) {
    if (line.startsWith('@@')) {
      if (current.length) hunks.push(current.join('\n'));
      current = [];
    }
    current.push(line);
  }
  if (current.length) hunks.push(current.join('\n'));
  return hunks;
}

/** @@ -a,b +c,d @@ (b,d ์ƒ๋žต ๊ฐ€๋Šฅ) */
function parseHunkHeader(headerLine) {
  const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(headerLine);
  if (!m) return null;
  const oldStart = parseInt(m[1], 10);
  const oldLen = m[2] ? parseInt(m[2], 10) : 1;
  const newStart = parseInt(m[3], 10);
  const newLen = m[4] ? parseInt(m[4], 10) : 1;
  return { oldStart, oldLen, newStart, newLen };
}

/** ๋‹จ์ผ hunk์—์„œ position๊ณผ changes(hunk ํ…์ŠคํŠธ) ์ถ”์ถœ */
function extractFromHunk(hunk, new_path, old_path) {
  const lines = hunk.split('\n');
  if (lines.length === 0 || !lines[0].startsWith('@@')) return null;

  const header = parseHunkHeader(lines[0]);
  if (!header) return null;

  const { newStart, newLen } = header;

  // ์‹ ๊ทœ ํŒŒ์ผ ์ธก ์ค„์ด ํ•˜๋‚˜๋„ ์—†๋Š” hunk(์‚ญ์ œ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ) โ†’ ์Šคํ‚ต
  if (newLen === 0) return null;

  let oldLine = header.oldStart;
  let newLine = newStart;

  const changes = [lines[0]];
  const addedNewLines = []; // ๋น„-import ์ถ”๊ฐ€ ๋ผ์ธ์˜ new ๋ผ์ธ ๋ฒˆํ˜ธ๋“ค

  for (let i = 1; i < lines.length; i++) {
    const line = lines[i];
    changes.push(line);

    // Git diff ํŠน์ˆ˜ ๋ผ์ธ: ๋ผ์ธ ์นด์šดํŠธ ์ฆ๊ฐ€ X
    if (line.startsWith('\\')) {
      continue;
    }

    const isAdd = line.startsWith('+') && !line.startsWith('+++');
    const isDel = line.startsWith('-') && !line.startsWith('---');

    if (isAdd) {
      const content = line.slice(1);
      // import ๋ผ์ธ์€ ์ œ์™ธ(์ •์ฑ… ์œ ์ง€). ํฌํ•จํ•˜๋ ค๋ฉด ์•„๋ž˜ if ์ œ๊ฑฐ
      if (!/^\s*import\b/.test(content)) {
        addedNewLines.push(newLine);
      }
      newLine++; // ์ถ”๊ฐ€ ๋ผ์ธ์€ new ํŒŒ์ผ๋งŒ ์ฆ๊ฐ€
    } else if (isDel) {
      oldLine++; // ์‚ญ์ œ ๋ผ์ธ์€ old ํŒŒ์ผ๋งŒ ์ฆ๊ฐ€
    } else {
      // context ๋ผ์ธ
      oldLine++;
      newLine++;
    }
  }

  // ๋น„-import ์ถ”๊ฐ€ ๋ผ์ธ์ด ์—†๋‹ค๋ฉด ์ฝ”๋ฉ˜ํŠธ ์œ„์น˜๋ฅผ ๋งŒ๋“ค์ง€ ์•Š์Œ
  if (addedNewLines.length === 0) return null;

  // GitLab์ด ์œ ํšจํ•œ line_code๋ฅผ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ, ์‹ค์ œ ์ถ”๊ฐ€๋œ ๋ผ์ธ ์ค‘ ๋งˆ์ง€๋ง‰ ์ค„๋กœ ์ง€์ •
  const anchorNewLine = Math.max(...addedNewLines);

  const position = {
    position_type: "text",
    old_path: old_path,        // GitLab diff์™€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•ด์•ผ ํ•จ
    new_path: new_path,        // (rename ๋“ฑ ๋Œ€๋น„, ๋‘ ๊ฐ’ ๋ชจ๋‘ ์ „๋‹ฌ)
    base_sha: diffRefs.base_sha,
    start_sha: diffRefs.start_sha,
    head_sha: diffRefs.head_sha,
    new_line: anchorNewLine
  };

  return {
    json: {
      position,
      changes: hunk
    }
  };
}

const results = mr.changes
  .flatMap(change =>
    splitDiffByHunks(change.diff)
      .map(h => extractFromHunk(h, change.new_path, change.old_path))
      .filter(Boolean)
  );

return results;
  1. LLM์—๊ฒŒ ์š”์ฒญ์„ ๋ณด๋‚ด๊ธฐ ์œ„ํ•ด changes์™€ system prompt๋ฅผ ๋งคํ•‘ํ•ด์ค€๋‹ค.
const systemPrompt = "..."

return $input.all().map(data => {
  return {
    json: {
      model: "openai/gpt-oss-20b",
      messages: [
        {
          role: "system",
          content: systemPrompt
        },
        {
          role: "user",
          content: data.json.changes
        }
      ]
    }
  };
});
  1. ๋ชจ๋“  ์‘๋‹ต์ด ์™„๋ฃŒ๋˜๋ฉด, ์ด์ „ ๋ฐ์ดํ„ฐ ํ˜•ํƒœ์™€ Merge ํ•œ๋‹ค.
  2. Gitlab์— Comment ๋“ฑ๋ก ์š”์ฒญ์„ ๋ณด๋‚ธ๋‹ค.
    • POST /api/v4/projects/{{ $project_id }}/merge_requests/{{ $iid }}/discussions

์ด ๊ณผ์ •์„ ๊ฑฐ์น˜๋ฉด, LM Studio์— ์š”์ฒญํ•œ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ›์•„์™€ Gitlab์— ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋œ๋‹ค.

๐Ÿค” ํšŒ๊ณ 

์ฝ”๋“œ ๋ฆฌ๋ทฐ ์„œ๋ฒ„๋ฅผ ๋„์šฐ๋Š” ๋ฐฉ๋ฒ•๋„ ์žˆ์ง€๋งŒ, ๊ฐ„ํŽธํ•˜๊ฒŒ n8n์„ ์‚ฌ์šฉํ•ด๋„ ์ข‹์€ ๊ฒƒ ๊ฐ™๋‹ค. n8n์„ ์‚ฌ์šฉํ•˜๋ฉด์„œ ๋ถˆํŽธํ–ˆ๋˜ ์ ์€, ์ด์ „ ๊ฒฐ๊ณผ๋ฅผ ๊ณ„์† ์œ ์ง€ํ•˜๊ธฐ ์–ด๋ ต๋‹ค๋Š” ๊ฒƒ์ด๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, Comment๋ฅผ ๋‚จ๊ธธ ๋•Œ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ณ„์† ์œ ์ง€ํ•˜๋‹ค๊ฐ€, HTTP ์š”์ฒญ์„ ๋‹ค๋…€์˜ค๋ฉด ๋ฐ์ดํ„ฐ๋ฅผ ์ด์ „ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ์–ด๋ ต๋‹ค๋Š” ๊ฒƒ์ด๋‹ค. ๋•Œ๋ฌธ์— Merge ๋…ธ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ธด ํ–ˆ์ง€๋งŒ, ๋” ํšจ์œจ์ ์ธ ๋ฐฉ๋ฒ•์ด ์žˆ๋Š”์ง€ ์ฐพ์•„๋ณด๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™๋‹ค.

ํšจ๊ณผ์ ์œผ๋กœ System prompt๋ฅผ ๋งŒ๋“œ๋ ค๋ฉด ์—ฌ๋Ÿฌ ๋ฒˆ์˜ ๊ณผ์ •์„ ๊ฑฐ์ณ์•ผํ•œ๋‹ค. ํ•„์ž๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ณผ์ •์„ ํ†ตํ•ด ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ƒ์„ฑํ–ˆ๋‹ค.

  1. ๊ฐ„๋‹จํ•œ ์ดˆ๊ธฐ ํ”„๋กฌํ”„ํŠธ๋ฅผ GPT์—๊ฒŒ ๋งŒ๋“ค์–ด๋‹ฌ๋ผ๊ณ  ํ•œ๋‹ค.
  2. ๋ฆฐํŠธ ์—๋Ÿฌ, ์˜คํƒ€, ๋น„ํšจ์œจ์ ์ธ ์ฝ”๋“œ๋ฅผ GPT์—๊ฒŒ ๋งŒ๋“ค์–ด๋‹ฌ๋ผ๊ณ  ํ•œ๋‹ค.
  3. LM Studio์—์„œ gpt-oss-20b ๋ชจ๋ธ์—๊ฒŒ 1๋ฒˆ์—์„œ ๋งŒ๋“  System prompt๋ฅผ ๋„ฃ๊ณ , 2๋ฒˆ ์ฝ”๋“œ๋ฅผ ์งˆ์˜ํ•œ๋‹ค.
  4. 1, 2, 3๋ฒˆ์˜ ๋‚ด์šฉ์„ Google AI Studio์˜ Gemini์—๊ฒŒ ์ œ๊ณตํ•˜๊ณ , ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆ˜์ •ํ•ด๋‹ฌ๋ผ๊ณ  ํ•œ๋‹ค.
  5. ๋งŒ์กฑํ•˜๋Š” ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€ 3, 4๋ฒˆ ๊ณผ์ •์„ ๋ฐ˜๋ณตํ•œ๋‹ค.

๋‚˜๋Š” System prompt๋ฅผ ๋งŒ๋“ค ๋•Œ, ํ•œ 100๋ฒˆ ์ •๋„ ๊ณผ์ •์„ ๊ฑฐ์ณค๋Š”๋ฐ, ์•„์ง๋„ ์™„๋ฒฝํ•˜์ง€๋Š” ์•Š์€ ๊ฒƒ ๊ฐ™๋‹ค. Google AI Studio๋ฅผ ์‚ฌ์šฉํ•œ ์ด์œ ๋Š”, ๋ฌด๋ฃŒ์ด๊ธฐ๋„ ํ•˜์ง€๋งŒ, ๋‹จ์ˆœํžˆ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๋งŒ๋“œ๋Š” ๊ฒƒ๋งŒ ํ•˜๋ฉด ๋˜๊ธฐ ๋•Œ๋ฌธ์—, ๊ตณ์ด GPT๋ฅผ ์‚ฌ์šฉํ•  ํ•„์š”๊ฐ€ ์—†์—ˆ๋‹ค.