Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
285 changes: 132 additions & 153 deletions .github/scripts/create-pr-from-issue.mjs

Large diffs are not rendered by default.

629 changes: 430 additions & 199 deletions .github/scripts/tests/create-pr-from-issue.test.mjs

Large diffs are not rendered by default.

154 changes: 154 additions & 0 deletions .github/scripts/tests/test_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from json import dumps
from os import environ
from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
from typing import Optional
from unittest import TestCase
from unittest.mock import (
MagicMock,
patch,
)


class ReleaseNotesTests(TestCase):
def setUp(self) -> None:
self.directory: TemporaryDirectory = TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.notes: Path = Path(self.directory.name) / 'release-notes' / 'README.md'
workflow: str = (Path(__file__).resolve().parents[3] / '.github' / 'workflows' / 'ci.yml').read_text(encoding='utf-8')
step: str = workflow.split(' - name: Collect branch changelog for release\n', 1)[1]
script: str = step.split(" python - <<'PY'\n", 1)[1].split('\n PY', 1)[0]
self.script: str = dedent(script)
self.check_output: MagicMock = MagicMock()
return

def pull(self, body: Optional[str], branch: str = 'task/12') -> dict[str, object]:
return {
'merged_at': '2026-01-01T00:00:00Z',
'merge_commit_sha': 'test-head',
'base': {'ref': 'master', 'repo': {'full_name': 'example/project'}},
'head': {'ref': branch},
'body': body,
}

def run_collector(self, version: str = 'true') -> bytes:
env: dict[str, str] = {
'GITHUB_SHA': 'test-head',
'GITHUB_REPOSITORY': 'example/project',
'BEFORE': 'test-before',
'NEW_VERSION': version,
'RELEASE_TAG': 'v1',
'RUNNER_TEMP': self.directory.name,
}
with patch.dict(environ, env), patch('subprocess.check_output', self.check_output):
exec(self.script, {})
return self.notes.read_bytes()

def test_explicit_issue_and_non_issue_paths_override_branch(self) -> None:
for path in (
'issues/task/12/CHANGELOG.md',
'tasks/1794057212842/CHANGELOG.md',
):
with self.subTest(path=path):
self.check_output.reset_mock()
self.check_output.side_effect = [
dumps([[self.pull(f'- Changes: {path}', 'copilot/change')]]).encode(),
b'100644 blob abc\tfile\n',
b'approved notes',
]
self.assertEqual(self.run_collector(), b'approved notes')
self.assertEqual(self.check_output.call_args_list[1].args[0], ['git', 'ls-tree', 'test-head', '--', path])
self.assertEqual(self.check_output.call_args_list[2].args[0], ['git', 'show', f'test-head:{path}'])
return

def test_missing_marker_keeps_branch_based_path(self) -> None:
for body in (
None,
'- Changes: tasks/999/CHANGELOG.md\nordinary final line',
'<details>\n- Changes: tasks/111/CHANGELOG.md\n</details>\n'
'```\n- Changes: tasks/222/CHANGELOG.md\n```\n'
'<!--\n- Changes: tasks/333/CHANGELOG.md\n-->\n'
' - Changes: tasks/444/CHANGELOG.md',
'\t- Changes: tasks/444/CHANGELOG.md',
):
with self.subTest(body=body):
self.check_output.reset_mock()
self.check_output.side_effect = [
dumps([[self.pull(body, 'task/12')]]).encode(),
b'100644 blob abc\tfile\n',
b'legacy notes',
]
self.assertEqual(self.run_collector(), b'legacy notes')
self.assertEqual(self.check_output.call_args_list[1].args[0], ['git', 'ls-tree', 'test-head', '--', 'issues/task/12/CHANGELOG.md'])
return

def test_invalid_markers_do_not_fall_back(self) -> None:
for body in (
'- Changes: ../CHANGELOG.md',
'- Changes: tasks/../CHANGELOG.md',
'- Changes: tasks/123/OTHER.md',
'- Changes:',
'- Changes tasks/123/CHANGELOG.md',
'- Changes=tasks/123/CHANGELOG.md',
'- Notes:\n - Changes: tasks/123/CHANGELOG.md',
' - Changes: tasks/123/CHANGELOG.md',
' - Changes: tasks/123/CHANGELOG.md',
):
with self.subTest(body=body):
self.check_output.reset_mock()
self.check_output.side_effect = [dumps([[self.pull(body)]]).encode()]
with self.assertRaises(ValueError):
self.run_collector()
self.assertEqual(self.check_output.call_count, 1)
return

def test_document_examples_before_footer_do_not_affect_path(self) -> None:
body: str = (
'<details>\n<summary>Document</summary>\n\n'
'Inline `</details>`\n'
'- Changes: tasks/111/CHANGELOG.md\n</details>\n\n'
'```text\n- Changes: tasks/222/CHANGELOG.md\n```\n\n'
'<!--\n- Changes: tasks/444/CHANGELOG.md\n-->\n\n'
' - Changes: tasks/555/CHANGELOG.md\n\n'
'- Closes #12\n- Changes: tasks/123/CHANGELOG.md\n\n'
'- Changes: tasks/333/CHANGELOG.md\n\n'
)
self.check_output.side_effect = [
dumps([[self.pull(body)]]).encode(),
b'100644 blob abc\tfile\n',
b'notes',
]
self.assertEqual(self.run_collector(), b'notes')
self.assertEqual(self.check_output.call_args_list[1].args[0][-1], 'tasks/333/CHANGELOG.md')
return

def test_missing_non_regular_or_empty_changelog_fails(self) -> None:
for entry, contents in (
(b'', None),
(b'120000 blob abc\tfile\n', None),
(b'100644 blob abc\tfile\n', b' \n'),
):
with self.subTest(entry=entry, contents=contents):
self.check_output.reset_mock()
results: list[bytes] = [dumps([[self.pull('- Changes: tasks/123/CHANGELOG.md')]]).encode(), entry]
if contents is not None:
results.append(contents)
self.check_output.side_effect = results
with self.assertRaises(ValueError):
self.run_collector()
return

def test_direct_push_keeps_commit_based_notes(self) -> None:
self.check_output.side_effect = [dumps([[]]).encode(), b'- Change (abc)\n']
self.assertEqual(self.run_collector(), '## master 직접 push\n\n'.encode('utf-8') + b'- Change (abc)\n')
self.assertEqual(self.check_output.call_args_list[1].args[0], ['git', 'log', '--reverse', '--format=- %s (%h)', 'test-before..test-head'])
return

def test_existing_release_notes_are_reused(self) -> None:
self.check_output.side_effect = [
dumps([[self.pull('- Changes: invalid')]]).encode(),
dumps({'tag_name': 'v1', 'body': 'existing notes'}).encode(),
]
self.assertEqual(self.run_collector('false'), b'existing notes')
return
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ jobs:
from json import loads
from os import environ
from pathlib import Path
from re import fullmatch, match
from subprocess import check_output

head = environ['GITHUB_SHA']
Expand Down Expand Up @@ -146,7 +147,22 @@ jobs:
print('Direct master push: generating release notes from commits')
else:
branch = pulls[0]['head']['ref']
path = f'issues/{branch}/CHANGELOG.md'
pr_body = pulls[0].get('body')
if pr_body is not None and not isinstance(pr_body, str):
raise ValueError('PR body must be text')
lines = [line for line in (pr_body or '').splitlines() if line.strip()]
last = lines[-1] if lines else ''
marker = r'^(?:[-*]\s*Changes\b|Changes:)'
if not last.startswith((' ', '\t')) and match(marker, last.strip()):
entry_match = fullmatch(
r'- Changes: ((?:issues/(?:bug|fix|feature|task)/[A-Za-z0-9][A-Za-z0-9._-]*|tasks/[0-9]+)/CHANGELOG\.md)',
last.rstrip(),
)
if not entry_match:
raise ValueError(f'Invalid Changes entry: {last.strip()}')
path = entry_match[1]
else:
path = f'issues/{branch}/CHANGELOG.md'
entry = check_output(['git', 'ls-tree', head, '--', path]).decode().split()
if not entry or entry[0] not in ('100644', '100755'):
raise ValueError(f'Changelog must exist as a regular file: {path}')
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/create-pr-from-issue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
- fix/**
- feature/**
- task/**
- copilot/**
- codex/**
Comment thread
yong5eon marked this conversation as resolved.
Resolved

permissions:
contents: read
Expand Down
32 changes: 32 additions & 0 deletions .instructions.assistant/DEV.Javascript.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
description: "JavaScript 브라우저·Node.js 애플리케이션과 스크립트의 모듈 구조, 실행 흐름, 오류 및 테스트 지침"
applyTo: "**/*.js, **/*.jsx, **/*.mjs, **/*.cjs"
---

# JavaScript 개발

## 기본 원칙

- 공통 개발 지침과 프로젝트의 기존 프레임워크·도구·코드 관례를 따른다. 요청 없이 프레임워크나 의존성을 도입하거나 교체하지 않는다.
- 대상 런타임과 빌드 환경에서 지원하는 JavaScript 기능을 확인한다. 호환성이 필요한 경우 추측으로 문법을 제한하거나 별도 변환 계층을 추가하지 말고 프로젝트 설정을 따른다.
- `.mjs`, `.cjs`, `package.json`의 `"type"` 등으로 정해지는 모듈 형식과 프로젝트의 실행 방식을 확인하고, 해당 형식의 import/export 또는 CommonJS 계약을 일관되게 따른다. 불필요한 모듈 형식 변환이나 상호운용 래퍼를 만들지 않는다.

## Node.js 애플리케이션과 스크립트

- 명령행 인자, 환경 변수, 파일 경로와 외부 프로세스 등 외부 입력을 경계에서 해석하고 검증한다. 요청하지 않은 파일 덮어쓰기, 삭제, 네트워크 통신 등 부작용을 추가하지 않는다.
- 실행 진입점은 입력 처리, 핵심 동작 호출과 종료 결과가 드러나게 구성한다. 입력 파싱, 업무 규칙과 출력·파일 처리를 분리하되, 단순 스크립트에 불필요한 계층이나 래퍼를 만들지 않는다.
- 파일·스트림·프로세스 등 자원은 사용한 코드의 책임과 생명주기에 맞게 정리한다. 비동기 작업이 끝나기 전에 프로세스가 종료되거나 실패가 성공 종료로 보고되지 않도록 실행 계약을 확인한다.
- 스크립트가 파일이나 외부 시스템을 변경하면 대상, 입력, 출력과 부작용이 실행 흐름에서 명확해야 한다. 재시도, 폴백, 광범위한 복구는 명시된 요구나 확인된 필요가 있을 때만 추가한다.

## 브라우저와 UI

- UI 구성요소, 화면 상태, 도메인 동작과 브라우저 입출력의 책임을 필요한 범위에서 구분한다. 단순한 화면 동작에 불필요한 계층이나 추상화를 추가하지 않는다.
- 상태의 소유 위치와 갱신 흐름을 명확히 한다. 같은 상태를 여러 곳에서 독립적으로 보관해 서로 다른 값이 되지 않도록 하며, 화면 갱신은 프로젝트의 기존 UI 패턴을 따른다.
- 이벤트 처리기는 이벤트 경계에서 입력을 읽고 적절한 동작으로 연결한다. 처리기에서 무관한 정책·변환·상태 관리를 함께 수행하지 않으며, 호출만 연쇄 위임하는 계층을 만들지 않는다.
- DOM 요소와 이벤트 리스너는 생성·등록한 구성요소의 생명주기와 연결해 관리한다. 화면에서 제거되거나 동작이 끝날 때 필요한 리스너와 자원을 정리하고, 동일 처리기가 중복 등록되지 않게 한다.
- 외부 데이터와 사용자 입력을 DOM에 표시할 때 프로젝트에서 사용하는 안전한 텍스트·렌더링 API를 따른다. 문자열을 실행 가능한 HTML로 취급하거나 검증 없이 삽입하지 않는다.

## 비동기 처리와 오류

- 비동기 작업의 완료·실패·취소 처리는 호출자의 계약과 작업 생명주기에 맞춘다. 브라우저 UI에서는 요구사항에 따라 로딩·성공·실패 상태와 필요한 오류 정보를 화면에 전달한다.
- 취소·화면 전환·재요청으로 오래된 비동기 결과가 현재 UI 상태를 덮어쓸 수 있는 경우, 프로젝트의 기존 방식으로 결과 반영 시점을 관리한다. 실제 위험이 없는 곳에 불필요한 취소·동시성 계층을 추가하지 않는다.
42 changes: 42 additions & 0 deletions .instructions.assistant/DEV.Python.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
description: "파이썬 개발 지침"
applyTo: '**/*.py'
---

# Python 개발

## 코드 스타일

- PEP 8을 기본 스타일로 적용하지 않는다. 아래 규칙과 저장소의 기존 관례를 따른다.
- 클래스 이름은 PascalCase, 메소드와 멤버 변수 이름은 스네이크 케이스를 사용한다.
- 함수 이름은 스네이크 케이스를 사용한다.
- 상수는 대문자 SNAKE_CASE를 사용한다. 이름의 약어는 대문자로 표기한다.
- 프라이빗 메소드와 멤버 변수에도 선행 밑줄을 사용하지 않는다. `__init__` 같은 특수 메소드 이름은 그대로 사용한다.
- nullable 타입은 `Optional[T]`로 표기한다.
- 들여쓰기는 탭을 사용하고, 탭과 스페이스를 혼용하지 않는다.
- import 절의 대상, 클래스 선언의 상속 대상, 함수·메소드 시그니처의 매개변수, 리스트·튜플 등 컬렉션의 멤버가 2개 이상이면 반드시 멀티라인으로 작성하고 각 항목을 한 줄에 하나씩 배치한다.
- 여러 줄 import, 선언, 호출은 괄호나 해당 구문의 구분자를 사용해 읽기 쉽게 나누고 저장소의 기존 형식을 따른다.

## Import

- import는 기본적으로 모듈 최상위에 작성한다.
- 특별한 이유가 없으면 `import module` 대신 `from module import Class, function` 형식으로 필요한 클래스·함수 등을 직접 임포트한다. 대상이 2개 이상이면 위 멀티라인 규칙을 적용한다.
- 순환 참조 회피, 선택적 의존성, 플랫폼별 모듈 등 구체적인 필요가 있으면 함수 내부 import나 조건부 import를 허용한다.

## 클래스와 함수 문서화

- 공개 API, 복잡한 계약, 코드만으로 의도가 분명하지 않은 클래스·함수·메소드에는 독스트링을 작성한다.
- 독스트링의 여는 `"""`와 닫는 `"""`는 각각 별도 줄에 작성하고, 본문은 여는 구분자 다음 줄부터 작성한다. 한 줄 독스트링에도 같은 형식을 적용한다.
- 독스트링과 주석은 공통 개발 지침을 따르며, 프로젝트 지침에 별도의 언어 정책이 있으면 해당 범위에 적용한다.
- 생성·수정 이력은 주석으로 중복 기록하지 않고 버전 관리 이력을 사용한다.

## 타입 힌트

- 함수·메소드의 매개변수와 반환값에 타입 힌트를 작성한다. 호출 결과를 변수에 할당할 때도 `변수명: 반환타입 = 호출(...)` 형식으로 타입을 명시하여 받는 값의 계약을 분명히 한다.
- 타입은 호출 대상의 선언과 실제 반환 계약을 확인해 작성한다. 제네릭 타입은 원소·키·값 등의 타입 인자까지 명시한다.
- 반환값에서 사용하는 메소드도 해당 타입의 계약에 포함되어야 한다. 예를 들어 스트림의 `close()`를 호출한다면 `Iterator[T]`가 아니라 실제 반환 계약에 맞는 `Generator[T, None, None]` 또는 `close()`를 제공하는 타입인지 확인한다. 원소 타입도 실제 스트림 항목에 맞추며 임의로 `str`로 지정하지 않는다.
- 타입 힌트로 진단을 숨기기 위해 실제 계약과 다른 타입이나 `Any`, 근거 없는 `cast`를 사용하지 않는다. 반환 선언이 잘못되었거나 불충분하면 선언부를 확인하고 필요한 범위에서 바로잡는다.

## 구현 규칙

- 반환값이 없는 함수와 메소드도 끝에 명시적으로 `return`을 작성해 종료 지점을 코드에서 분명히 드러낸다.
Loading
Loading