From eaf04ae1f34a74722660e86510a99cdda3e77073 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 16 Mar 2026 02:06:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20GitHub=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20Discord=20=EC=9B=B9=ED=9B=85=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/discord-notify-lib.spec.mjs | 230 ++++++++++++++++++ .github/scripts/discord-notify-lib.mjs | 225 +++++++++++++++++ .github/scripts/discord-notify.mjs | 67 +++++ .github/workflows/discord-notify.yml | 34 +++ 4 files changed, 556 insertions(+) create mode 100644 .github/scripts/__tests__/discord-notify-lib.spec.mjs create mode 100644 .github/scripts/discord-notify-lib.mjs create mode 100644 .github/scripts/discord-notify.mjs create mode 100644 .github/workflows/discord-notify.yml diff --git a/.github/scripts/__tests__/discord-notify-lib.spec.mjs b/.github/scripts/__tests__/discord-notify-lib.spec.mjs new file mode 100644 index 0000000..abea5f0 --- /dev/null +++ b/.github/scripts/__tests__/discord-notify-lib.spec.mjs @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + resolveEventType, + buildEmbed, + buildPrMergedEmbed, + buildBranchCreatedEmbed, + buildBranchPushEmbed, + buildIssueOpenedEmbed, + buildWebhookBody, +} from '../discord-notify-lib.mjs'; + +const META = { repository: 'CaQuick/caquick-be', serverUrl: 'https://github.com' }; + +// ── resolveEventType ── + +test('pull_request closed + merged=true → pr_merged', () => { + const payload = { pull_request: { merged: true } }; + assert.equal(resolveEventType('pull_request', payload), 'pr_merged'); +}); + +test('pull_request closed + merged=false → null', () => { + const payload = { pull_request: { merged: false } }; + assert.equal(resolveEventType('pull_request', payload), null); +}); + +test('create + ref_type=branch → branch_created', () => { + const payload = { ref_type: 'branch' }; + assert.equal(resolveEventType('create', payload), 'branch_created'); +}); + +test('create + ref_type=tag → null', () => { + const payload = { ref_type: 'tag' }; + assert.equal(resolveEventType('create', payload), null); +}); + +test('push → branch_push', () => { + assert.equal(resolveEventType('push', {}), 'branch_push'); +}); + +test('issues + action=opened → issue_opened', () => { + const payload = { action: 'opened' }; + assert.equal(resolveEventType('issues', payload), 'issue_opened'); +}); + +test('issues + action=closed → null', () => { + const payload = { action: 'closed' }; + assert.equal(resolveEventType('issues', payload), null); +}); + +test('unknown event → null', () => { + assert.equal(resolveEventType('deployment', {}), null); +}); + +// ── buildPrMergedEmbed ── + +test('PR 머지 Embed가 올바른 색상과 필드를 갖는다', () => { + const payload = { + pull_request: { + number: 42, + title: 'feat: 로그인 기능 추가', + user: { login: 'chado' }, + head: { ref: 'feature/login' }, + base: { ref: 'main' }, + html_url: 'https://github.com/CaQuick/caquick-be/pull/42', + }, + }; + + const embed = buildPrMergedEmbed(payload, META); + + assert.equal(embed.title, '🔀 PR #42 Merged'); + assert.equal(embed.description, 'feat: 로그인 기능 추가'); + assert.equal(embed.color, 0x6f42c1); + assert.equal(embed.url, 'https://github.com/CaQuick/caquick-be/pull/42'); + assert.equal(embed.fields.length, 3); + assert.equal(embed.fields[0].value, 'CaQuick/caquick-be'); + assert.equal(embed.fields[1].value, '@chado'); + assert.equal(embed.fields[2].value, '`feature/login` → `main`'); +}); + +// ── buildBranchCreatedEmbed ── + +test('브랜치 생성 Embed가 올바른 색상과 필드를 갖는다', () => { + const payload = { + ref: 'feature/discord-notify', + ref_type: 'branch', + sender: { login: 'chado' }, + }; + + const embed = buildBranchCreatedEmbed(payload, META); + + assert.equal(embed.title, '🌿 New Branch Created'); + assert.equal(embed.description, '`feature/discord-notify`'); + assert.equal(embed.color, 0x28a745); + assert.ok(embed.url.includes('feature/discord-notify')); + assert.equal(embed.fields.length, 2); +}); + +// ── buildBranchPushEmbed ── + +test('브랜치 푸시 Embed가 올바른 색상과 커밋 목록을 갖는다', () => { + const payload = { + ref: 'refs/heads/feature/discord-notify', + sender: { login: 'chado' }, + compare: 'https://github.com/CaQuick/caquick-be/compare/abc...def', + commits: [ + { id: 'abc1234567890', message: 'feat: 웹훅 스크립트 추가' }, + { id: 'def5678901234', message: 'test: 테스트 추가' }, + { id: 'ghi9012345678', message: 'fix: 오타 수정' }, + ], + }; + + const embed = buildBranchPushEmbed(payload, META); + + assert.equal(embed.title, '📦 Push to feature/discord-notify'); + assert.equal(embed.description, '3 commit(s) pushed'); + assert.equal(embed.color, 0x0366d6); + assert.equal(embed.fields.length, 3); // Repository, Author, Commits + + const commitsField = embed.fields[2]; + assert.equal(commitsField.name, 'Commits'); + assert.ok(commitsField.value.includes('[`abc1234`]')); + assert.ok(commitsField.value.includes('/commit/abc1234567890')); + assert.ok(commitsField.value.includes('웹훅 스크립트 추가')); +}); + +test('커밋이 5개를 초과하면 나머지는 요약된다', () => { + const commits = Array.from({ length: 7 }, (_, i) => ({ + id: `sha${i}0000000000`, + message: `commit ${i}`, + })); + + const payload = { + ref: 'refs/heads/main', + sender: { login: 'chado' }, + commits, + }; + + const embed = buildBranchPushEmbed(payload, META); + const commitsField = embed.fields.find((f) => f.name === 'Commits'); + + assert.ok(commitsField.value.includes('... and 2 more')); +}); + +test('커밋이 없는 push도 정상 처리된다', () => { + const payload = { + ref: 'refs/heads/main', + sender: { login: 'chado' }, + commits: [], + }; + + const embed = buildBranchPushEmbed(payload, META); + + assert.equal(embed.description, '0 commit(s) pushed'); + assert.equal(embed.fields.length, 2); // Commits 필드 없음 +}); + +// ── buildIssueOpenedEmbed ── + +test('이슈 생성 Embed가 올바른 색상과 필드를 갖는다', () => { + const payload = { + action: 'opened', + issue: { + number: 45, + title: '로그인 시 세션 만료 처리 안 됨', + user: { login: 'chado' }, + html_url: 'https://github.com/CaQuick/caquick-be/issues/45', + labels: [{ name: 'bug' }, { name: 'auth' }], + }, + }; + + const embed = buildIssueOpenedEmbed(payload, META); + + assert.equal(embed.title, '📋 New Issue #45'); + assert.equal(embed.description, '로그인 시 세션 만료 처리 안 됨'); + assert.equal(embed.color, 0xd73a49); + assert.equal(embed.fields.length, 3); // Repository, Author, Labels + + const labelsField = embed.fields[2]; + assert.equal(labelsField.name, 'Labels'); + assert.equal(labelsField.value, 'bug, auth'); +}); + +test('라벨이 없는 이슈는 Labels 필드가 없다', () => { + const payload = { + action: 'opened', + issue: { + number: 46, + title: '기능 요청', + user: { login: 'chado' }, + html_url: 'https://github.com/CaQuick/caquick-be/issues/46', + labels: [], + }, + }; + + const embed = buildIssueOpenedEmbed(payload, META); + + assert.equal(embed.fields.length, 2); // Labels 필드 없음 +}); + +// ── buildEmbed ── + +test('buildEmbed가 이벤트 타입에 따라 올바른 빌더를 호출한다', () => { + const prPayload = { + pull_request: { + number: 1, + title: 'test', + user: { login: 'user' }, + head: { ref: 'a' }, + base: { ref: 'b' }, + }, + }; + + const embed = buildEmbed('pr_merged', prPayload, META); + assert.equal(embed.color, 0x6f42c1); +}); + +test('buildEmbed에 알 수 없는 이벤트 타입을 전달하면 에러가 발생한다', () => { + assert.throws(() => buildEmbed('unknown', {}, META), /Unknown event type/); +}); + +// ── buildWebhookBody ── + +test('buildWebhookBody가 embed를 embeds 배열로 감싼다', () => { + const embed = { title: 'test', color: 0x000000 }; + const body = buildWebhookBody(embed); + + assert.deepEqual(body, { embeds: [{ title: 'test', color: 0x000000 }] }); +}); diff --git a/.github/scripts/discord-notify-lib.mjs b/.github/scripts/discord-notify-lib.mjs new file mode 100644 index 0000000..2044ae6 --- /dev/null +++ b/.github/scripts/discord-notify-lib.mjs @@ -0,0 +1,225 @@ +// Discord Webhook 알림 라이브러리 +// 이벤트 판별 + Discord Embed 메시지 빌드 (순수 함수, I/O 없음) + +// ── 색상 상수 ── +const COLORS = { + PR_MERGED: 0x6f42c1, // 보라 + BRANCH_CREATED: 0x28a745, // 초록 + BRANCH_PUSH: 0x0366d6, // 파랑 + ISSUE_OPENED: 0xd73a49, // 빨강 +}; + +// ── 이벤트 타입 ── + +/** + * GitHub 이벤트를 알림 가능한 이벤트 타입으로 변환한다. + * 알림 대상이 아니면 null을 반환한다. + * + * @param {string} eventName - GitHub event name (e.g. 'pull_request', 'push') + * @param {Record} payload - GitHub event payload + * @returns {'pr_merged' | 'branch_created' | 'branch_push' | 'issue_opened' | null} + */ +export function resolveEventType(eventName, payload) { + if (eventName === 'pull_request') { + const pr = payload?.pull_request; + if (pr?.merged === true) return 'pr_merged'; + return null; + } + + if (eventName === 'create') { + if (payload?.ref_type === 'branch') return 'branch_created'; + return null; + } + + if (eventName === 'push') { + return 'branch_push'; + } + + if (eventName === 'issues') { + if (payload?.action === 'opened') return 'issue_opened'; + return null; + } + + return null; +} + +// ── Embed 빌더 ── + +/** + * @typedef {Object} EmbedMeta + * @property {string} repository - 'owner/repo' 형태 + * @property {string} serverUrl - GitHub 서버 URL (e.g. 'https://github.com') + */ + +/** + * 이벤트 타입에 따라 Discord Embed 객체를 생성한다. + * + * @param {'pr_merged' | 'branch_created' | 'branch_push' | 'issue_opened'} eventType + * @param {Record} payload + * @param {EmbedMeta} meta + * @returns {object} Discord Embed 객체 + */ +export function buildEmbed(eventType, payload, meta) { + switch (eventType) { + case 'pr_merged': + return buildPrMergedEmbed(payload, meta); + case 'branch_created': + return buildBranchCreatedEmbed(payload, meta); + case 'branch_push': + return buildBranchPushEmbed(payload, meta); + case 'issue_opened': + return buildIssueOpenedEmbed(payload, meta); + default: + throw new Error(`Unknown event type: ${eventType}`); + } +} + +/** + * PR 머지 Embed를 생성한다. + */ +export function buildPrMergedEmbed(payload, meta) { + const pr = payload.pull_request; + const prNumber = pr.number; + const prTitle = pr.title; + const author = pr.user?.login ?? 'unknown'; + const headBranch = pr.head?.ref ?? 'unknown'; + const baseBranch = pr.base?.ref ?? 'unknown'; + const prUrl = pr.html_url ?? `${meta.serverUrl}/${meta.repository}/pull/${prNumber}`; + + return { + title: `🔀 PR #${prNumber} Merged`, + description: prTitle, + color: COLORS.PR_MERGED, + url: prUrl, + fields: [ + { name: 'Repository', value: meta.repository, inline: true }, + { name: 'Author', value: `@${author}`, inline: true }, + { name: 'Branch', value: `\`${headBranch}\` → \`${baseBranch}\``, inline: false }, + ], + timestamp: new Date().toISOString(), + }; +} + +/** + * 브랜치 생성 Embed를 생성한다. + */ +export function buildBranchCreatedEmbed(payload, meta) { + const branchName = payload.ref; + const author = payload.sender?.login ?? 'unknown'; + const branchUrl = `${meta.serverUrl}/${meta.repository}/tree/${branchName}`; + + return { + title: '🌿 New Branch Created', + description: `\`${branchName}\``, + color: COLORS.BRANCH_CREATED, + url: branchUrl, + fields: [ + { name: 'Repository', value: meta.repository, inline: true }, + { name: 'Author', value: `@${author}`, inline: true }, + ], + timestamp: new Date().toISOString(), + }; +} + +/** + * 브랜치 푸시 Embed를 생성한다. + */ +export function buildBranchPushEmbed(payload, meta) { + const ref = payload.ref ?? ''; + const branchName = ref.replace(/^refs\/heads\//, ''); + const author = payload.sender?.login ?? 'unknown'; + const commits = Array.isArray(payload.commits) ? payload.commits : []; + const commitCount = commits.length; + const compareUrl = payload.compare ?? `${meta.serverUrl}/${meta.repository}`; + + const repoUrl = `${meta.serverUrl}/${meta.repository}`; + const commitLines = commits + .slice(0, 5) + .map((c) => { + const sha = (c.id ?? '').slice(0, 7); + const commitUrl = `${repoUrl}/commit/${c.id}`; + const msg = truncate(c.message?.split('\n')[0] ?? '', 60); + return `[\`${sha}\`](${commitUrl}) ${msg}`; + }); + + if (commits.length > 5) { + commitLines.push(`... and ${commits.length - 5} more`); + } + + const fields = [ + { name: 'Repository', value: meta.repository, inline: true }, + { name: 'Author', value: `@${author}`, inline: true }, + ]; + + if (commitLines.length > 0) { + fields.push({ name: 'Commits', value: commitLines.join('\n'), inline: false }); + } + + return { + title: `📦 Push to ${branchName}`, + description: `${commitCount} commit(s) pushed`, + color: COLORS.BRANCH_PUSH, + url: compareUrl, + fields, + timestamp: new Date().toISOString(), + }; +} + +/** + * 이슈 생성 Embed를 생성한다. + */ +export function buildIssueOpenedEmbed(payload, meta) { + const issue = payload.issue; + const issueNumber = issue.number; + const issueTitle = issue.title; + const author = issue.user?.login ?? 'unknown'; + const issueUrl = issue.html_url ?? `${meta.serverUrl}/${meta.repository}/issues/${issueNumber}`; + const labels = Array.isArray(issue.labels) ? issue.labels : []; + + const fields = [ + { name: 'Repository', value: meta.repository, inline: true }, + { name: 'Author', value: `@${author}`, inline: true }, + ]; + + if (labels.length > 0) { + const labelNames = labels.map((l) => l.name).filter(Boolean).join(', '); + if (labelNames) { + fields.push({ name: 'Labels', value: labelNames, inline: false }); + } + } + + return { + title: `📋 New Issue #${issueNumber}`, + description: issueTitle, + color: COLORS.ISSUE_OPENED, + url: issueUrl, + fields, + timestamp: new Date().toISOString(), + }; +} + +// ── Webhook body ── + +/** + * Discord webhook 요청 body를 생성한다. + * + * @param {object} embed - Discord Embed 객체 + * @returns {{ embeds: object[] }} + */ +export function buildWebhookBody(embed) { + return { embeds: [embed] }; +} + +// ── 유틸 ── + +/** + * 문자열을 최대 길이로 자른다. + * + * @param {string} str + * @param {number} maxLength + * @returns {string} + */ +function truncate(str, maxLength) { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength - 3) + '...'; +} diff --git a/.github/scripts/discord-notify.mjs b/.github/scripts/discord-notify.mjs new file mode 100644 index 0000000..556dff9 --- /dev/null +++ b/.github/scripts/discord-notify.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +// Discord Webhook 알림 메인 스크립트 +// GitHub Actions에서 실행되며, 이벤트를 판별하고 Discord로 알림을 보낸다. + +import { readFile } from 'node:fs/promises'; +import { resolveEventType, buildEmbed, buildWebhookBody } from './discord-notify-lib.mjs'; + +// ── 환경변수 ── + +function getRequiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Required environment variable ${name} is not set.`); + } + return value; +} + +// ── 메인 ── + +async function main() { + const webhookUrl = getRequiredEnv('DISCORD_WEBHOOK_URL'); + const eventName = getRequiredEnv('GITHUB_EVENT_NAME'); + const eventPath = getRequiredEnv('GITHUB_EVENT_PATH'); + const repository = getRequiredEnv('GITHUB_REPOSITORY'); + const serverUrl = process.env.GITHUB_SERVER_URL ?? 'https://github.com'; + + // 이벤트 payload 로드 + const rawPayload = await readFile(eventPath, 'utf-8'); + const payload = JSON.parse(rawPayload); + + // 이벤트 타입 판별 + const eventType = resolveEventType(eventName, payload); + + if (!eventType) { + console.log(`[discord-notify] skip: not a notifiable event (${eventName})`); + return; + } + + console.log(`[discord-notify] event detected: ${eventType}`); + + // Embed 생성 + const meta = { repository, serverUrl }; + const embed = buildEmbed(eventType, payload, meta); + const body = buildWebhookBody(embed); + + // Discord webhook 호출 + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const responseText = await response.text().catch(() => 'unknown'); + throw new Error( + `Discord webhook failed: ${response.status} ${response.statusText} - ${responseText}`, + ); + } + + console.log(`[discord-notify] notification sent successfully (${eventType})`); +} + +main().catch((error) => { + console.error('[discord-notify] fatal:', error.message ?? error); + process.exit(1); +}); diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml new file mode 100644 index 0000000..2f60897 --- /dev/null +++ b/.github/workflows/discord-notify.yml @@ -0,0 +1,34 @@ +name: Discord Notify + +on: + pull_request: + types: [closed] + create: + push: + issues: + types: [opened] + +jobs: + notify: + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' + + steps: + - name: Checkout scripts + uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Send Discord notification + run: node .github/scripts/discord-notify.mjs + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} From ca5e7853523fed5c5b41621b478105e611a9215f Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 16 Mar 2026 02:25:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20Discord=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/discord-notify-lib.spec.mjs | 4 +- .github/scripts/discord-notify-lib.mjs | 38 +++++++++++++++---- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/scripts/__tests__/discord-notify-lib.spec.mjs b/.github/scripts/__tests__/discord-notify-lib.spec.mjs index abea5f0..ccc20c9 100644 --- a/.github/scripts/__tests__/discord-notify-lib.spec.mjs +++ b/.github/scripts/__tests__/discord-notify-lib.spec.mjs @@ -74,8 +74,8 @@ test('PR 머지 Embed가 올바른 색상과 필드를 갖는다', () => { assert.equal(embed.color, 0x6f42c1); assert.equal(embed.url, 'https://github.com/CaQuick/caquick-be/pull/42'); assert.equal(embed.fields.length, 3); - assert.equal(embed.fields[0].value, 'CaQuick/caquick-be'); - assert.equal(embed.fields[1].value, '@chado'); + assert.equal(embed.fields[0].value, '[CaQuick/caquick-be](https://github.com/CaQuick/caquick-be)'); + assert.equal(embed.fields[1].value, '[@chado](https://github.com/chado)'); assert.equal(embed.fields[2].value, '`feature/login` → `main`'); }); diff --git a/.github/scripts/discord-notify-lib.mjs b/.github/scripts/discord-notify-lib.mjs index 2044ae6..047f3ff 100644 --- a/.github/scripts/discord-notify-lib.mjs +++ b/.github/scripts/discord-notify-lib.mjs @@ -92,8 +92,8 @@ export function buildPrMergedEmbed(payload, meta) { color: COLORS.PR_MERGED, url: prUrl, fields: [ - { name: 'Repository', value: meta.repository, inline: true }, - { name: 'Author', value: `@${author}`, inline: true }, + { name: 'Repository', value: repoLink(meta.repository, meta.serverUrl), inline: true }, + { name: 'Author', value: authorLink(author, meta.serverUrl), inline: true }, { name: 'Branch', value: `\`${headBranch}\` → \`${baseBranch}\``, inline: false }, ], timestamp: new Date().toISOString(), @@ -114,8 +114,8 @@ export function buildBranchCreatedEmbed(payload, meta) { color: COLORS.BRANCH_CREATED, url: branchUrl, fields: [ - { name: 'Repository', value: meta.repository, inline: true }, - { name: 'Author', value: `@${author}`, inline: true }, + { name: 'Repository', value: repoLink(meta.repository, meta.serverUrl), inline: true }, + { name: 'Author', value: authorLink(author, meta.serverUrl), inline: true }, ], timestamp: new Date().toISOString(), }; @@ -147,8 +147,8 @@ export function buildBranchPushEmbed(payload, meta) { } const fields = [ - { name: 'Repository', value: meta.repository, inline: true }, - { name: 'Author', value: `@${author}`, inline: true }, + { name: 'Repository', value: repoLink(meta.repository, meta.serverUrl), inline: true }, + { name: 'Author', value: authorLink(author, meta.serverUrl), inline: true }, ]; if (commitLines.length > 0) { @@ -177,8 +177,8 @@ export function buildIssueOpenedEmbed(payload, meta) { const labels = Array.isArray(issue.labels) ? issue.labels : []; const fields = [ - { name: 'Repository', value: meta.repository, inline: true }, - { name: 'Author', value: `@${author}`, inline: true }, + { name: 'Repository', value: repoLink(meta.repository, meta.serverUrl), inline: true }, + { name: 'Author', value: authorLink(author, meta.serverUrl), inline: true }, ]; if (labels.length > 0) { @@ -212,6 +212,28 @@ export function buildWebhookBody(embed) { // ── 유틸 ── +/** + * Repository 필드용 마크다운 링크를 생성한다. + * + * @param {string} repository - 'owner/repo' 형태 + * @param {string} serverUrl + * @returns {string} + */ +function repoLink(repository, serverUrl) { + return `[${repository}](${serverUrl}/${repository})`; +} + +/** + * Author 필드용 마크다운 링크를 생성한다. + * + * @param {string} login - GitHub 사용자 login + * @param {string} serverUrl + * @returns {string} + */ +function authorLink(login, serverUrl) { + return `[@${login}](${serverUrl}/${login})`; +} + /** * 문자열을 최대 길이로 자른다. *