ساخت پیام Commit با هوش مصنوعی؛ تولید Conventional Commit و توضیحات Pull Request

در این آموزش ابزاری می‌سازید که Git Diff را تحلیل می‌کند، پیام Conventional Commit معتبر، توضیحات Pull Request و Changelog تولید می‌کند و ادعاهای خروجی را با فایل‌های تغییرکرده تطبیق می‌دهد.

Share
ساخت پیام Commit با هوش مصنوعی؛ تولید Conventional Commit و توضیحات Pull Request

ساخت پیام Commit با هوش مصنوعی؛ از Git Diff تا Conventional Commit و Pull Request

پیام‌هایی مانند موارد زیر اطلاعات چندانی درباره تغییر ارائه نمی‌کنند:

fix
update code
changes
final fix 2

وقتی تعداد Commitها زیاد شود، این پیام‌ها مشکلاتی ایجاد می‌کنند:

  • پیدا کردن زمان ایجاد یک تغییر دشوار می‌شود.
  • بررسی تاریخچه پروژه زمان بیشتری می‌گیرد.
  • تولید Changelog دقیق ممکن نیست.
  • هدف Pull Request به‌درستی مشخص نمی‌شود.
  • بازبین باید تمام Diff را برای درک تغییر بخواند.
  • Rollback و عیب‌یابی تغییرات سخت‌تر می‌شود.
  • مشخص نیست تغییر Feature، Fix یا Refactor بوده است.

هوش مصنوعی می‌تواند Git Diff را بخواند و پیش‌نویس‌های زیر را تولید کند:

  • پیام Conventional Commit
  • عنوان Pull Request
  • خلاصه تغییرات
  • فهرست فایل‌های مهم
  • نحوه آزمایش
  • ریسک‌های قابل مشاهده
  • Checklist بازبینی
  • Changelog کاربرمحور
  • توضیح Migration در صورت وجود تغییر قرارداد

اما مدل نباید صرفاً با مشاهده نام فایل، قابلیت یا تستی را اختراع کند. برای مثال، وجود فایل tests/test_cart.py اثبات نمی‌کند تمام تست‌ها اجرا و موفق شده‌اند.

خروجی درست:

Tests added:
- Added pagination boundary tests

Test execution:
- Not verified

خروجی نادرست:

All tests pass successfully.

مگر اینکه نتیجه واقعی اجرای تست در Context وجود داشته باشد.

در این مقاله یک ابزار عملی با Python، Git و API درواره می‌سازیم که تغییرات Stageشده را تحلیل می‌کند و پیام Commit و توضیحات PR ساختاریافته تولید می‌کند.

چرا پیام Commit اهمیت دارد؟

یک پیام مناسب باید پاسخ دهد:

  • چه چیزی تغییر کرده است؟
  • چرا تغییر کرده است؟
  • اثر اصلی آن چیست؟
  • آیا رفتار عمومی تغییر کرده است؟
  • آیا تغییر ناسازگار وجود دارد؟
  • چه چیزی خارج از Commit باقی مانده است؟

پیام خوب:

fix(pagination): calculate offset from one-based page number

Body:

Use (page - 1) * page_size so the first page starts from
the first item instead of skipping one page.

Add boundary tests for the first, second and out-of-range pages.

Conventional Commits چیست؟

Conventional Commits یک قرارداد برای ساختار پیام Commit است:

type(scope): subject

نمونه:

feat(cart): add percentage discount support
fix(api): return 404 for missing products
refactor(pricing): extract money rounding helper
test(pagination): cover one-based page boundaries

ساختار کامل:

type(scope)!: subject

body

footer

علامت ! تغییر ناسازگار را مشخص می‌کند:

feat(api)!: rename user_id response field to id

Footer:

BREAKING CHANGE: API clients must read the id field instead of user_id.

Typeهای رایج Commit

Typeکاربرد
featاضافه‌شدن رفتار یا قابلیت جدید
fixاصلاح رفتار اشتباه
refactorتغییر ساختار بدون تغییر رفتار
testافزودن یا اصلاح تست
docsتغییر مستندات
perfبهبود عملکرد
buildتغییر Build System یا Dependency
ciتغییر CI
choreنگهداری عمومی
revertبازگرداندن تغییر قبلی

مدل نباید هر تغییر را feat معرفی کند. اگر فقط ساختار داخلی تغییر کرده و رفتار حفظ شده است، refactor مناسب‌تر است.

پیام Commit خوب چه ویژگی‌هایی دارد؟

  • کوتاه و مشخص است.
  • فعل امری دارد.
  • موضوع واقعی تغییر را بیان می‌کند.
  • فقط Diff فعلی را پوشش می‌دهد.
  • ادعای اثبات‌نشده ندارد.
  • جزئیات غیرضروری فایل‌ها را تکرار نمی‌کند.
  • Scope معنادار دارد.
  • تغییر ناسازگار را مخفی نمی‌کند.
  • با قرارداد تیم هماهنگ است.
  • Body درباره چرایی یا اثر تغییر توضیح می‌دهد.

چه اطلاعاتی را به مدل بدهیم؟

Git Diff Stageشده

برای ساخت پیام Commit باید فقط تغییراتی تحلیل شوند که قرار است Commit شوند:

git diff --cached

فهرست فایل‌ها

git diff --cached --name-status

آمار تغییر

git diff --cached --stat

Context پروژه

  • نام پروژه
  • زبان
  • ساختار Scopeها
  • نوع‌های مجاز Commit
  • محدودیت طول Subject
  • زبان پیام
  • قرارداد Breaking Change

نتیجه واقعی تست

اگر تست اجرا شده است:

Command: pytest -q
Exit code: 0
Result: 28 passed

Issue یا هدف تغییر

Diff همیشه چرایی تغییر را نشان نمی‌دهد. هدف باید جداگانه ارائه شود.

پرامپت آماده برای تولید پیام Commit

از Git Diff زیر یک پیام Conventional Commit تولید کن.

هدف تغییر:
[GOAL]

نوع‌های مجاز:
feat, fix, refactor, test, docs, perf, build, ci, chore

قواعد:
- فقط تغییر Stageشده را توصیف کن.
- Subject حداکثر 72 کاراکتر باشد.
- Subject با فعل امری نوشته شود.
- در انتهای Subject نقطه نگذار.
- Scope را فقط از ماژول اصلی تغییر انتخاب کن.
- اگر چند تغییر نامرتبط وجود دارد، پیشنهاد Split Commit بده.
- نتیجه موفق تست را فقط در صورت وجود خروجی واقعی اعلام کن.
- Breaking Change را فقط با شاهد مشخص گزارش کن.
- نام فایل‌ها را بی‌دلیل در Subject تکرار نکن.
- خروجی JSON معتبر باشد.

Diff:
[STAGED DIFF]

Test Result:
[TEST RESULT]

پروژه عملی این آموزش

ابزار ما:

  1. تغییرات Stageشده را می‌خواند.
  2. فایل‌های تغییرکرده را استخراج می‌کند.
  3. Diffهای بزرگ یا Generated را فیلتر می‌کند.
  4. Context و نتیجه تست را اضافه می‌کند.
  5. خروجی ساختاریافته از مدل دریافت می‌کند.
  6. Conventional Commit را اعتبارسنجی می‌کند.
  7. ادعاهای مربوط به فایل و تست را بررسی می‌کند.
  8. پیام Commit را در فایل ذخیره می‌کند.
  9. توضیحات Pull Request تولید می‌کند.
  10. هیچ Commit یا Push خودکاری انجام نمی‌دهد.

ایجاد پروژه

mkdir ai-git-writer
cd ai-git-writer

python -m venv .venv

فعال‌سازی در Linux و macOS:

source .venv/bin/activate

فعال‌سازی در Windows:

.venv\Scripts\Activate.ps1

نصب وابستگی‌ها:

pip install \
  openai \
  python-dotenv \
  pydantic

ساخت پوشه‌ها:

mkdir git_writer output context

فایل‌های زیر را ایجاد کنید:

git_writer/__init__.py
git_writer/config.py
git_writer/schemas.py
git_writer/git_reader.py
git_writer/generator.py
git_writer/validator.py
git_writer/renderer.py
generate_git_text.py
context/change_goal.md
context/project_rules.md

تنظیم API درواره

فایل .env:

DARVAREH_API_KEY=YOUR_API_KEY
DARVAREH_MODEL=MODEL_ID_DARVAREH
COMMIT_LANGUAGE=en
PR_LANGUAGE=fa

فایل .gitignore:

.env
.venv/
__pycache__/
output/

برای دریافت API Key در درواره ثبت‌نام کنید. مدل مناسب را از صفحه مدل‌های درواره انتخاب کنید.

تعریف قواعد پروژه

فایل context/project_rules.md:

# Git writing rules

## Commit

- از Conventional Commits استفاده شود.
- Typeهای مجاز:
  - feat
  - fix
  - refactor
  - test
  - docs
  - perf
  - build
  - ci
  - chore
  - revert
- Subject انگلیسی باشد.
- حداکثر طول Subject برابر 72 کاراکتر است.
- Subject با فعل امری نوشته شود.
- Subject نقطه پایانی ندارد.
- Scope کوتاه و با حروف کوچک باشد.
- Breaking Change فقط با شاهد مستقیم در Diff ثبت شود.

## Pull Request

- عنوان کوتاه و انگلیسی باشد.
- توضیحات اصلی فارسی باشند.
- نتیجه تست فقط از خروجی واقعی Test Command نوشته شود.
- موارد بررسی‌نشده صریحاً مشخص شوند.
- تغییر خارج از Diff ادعا نشود.

تعریف هدف تغییر

فایل context/change_goal.md:

# Change goal

خطای محاسبه Offset در Pagination اصلاح شود.

شماره صفحه از یک شروع می‌شود و صفحه اول باید از اولین رکورد آغاز شود.
تست‌های مرزی صفحه اول، دوم و صفحه خارج از محدوده نیز اضافه شوند.

هدف تغییر اطلاعاتی را فراهم می‌کند که همیشه از Diff قابل استخراج نیست.

تعریف Schema خروجی

فایل git_writer/schemas.py:

from typing import Literal

from pydantic import BaseModel, Field


CommitType = Literal[
    "feat",
    "fix",
    "refactor",
    "test",
    "docs",
    "perf",
    "build",
    "ci",
    "chore",
    "revert",
]


class CommitMessage(BaseModel):
    type: CommitType
    scope: str | None = None
    breaking: bool = False
    subject: str
    body: list[str] = Field(
        default_factory=list
    )
    footers: list[str] = Field(
        default_factory=list
    )


class ChangedFileSummary(BaseModel):
    path: str
    status: Literal[
        "added",
        "modified",
        "deleted",
        "renamed",
    ]
    summary: str


class TestEvidence(BaseModel):
    command: str | None = None
    executed: bool = False
    passed: bool | None = None
    summary: str


class PullRequestContent(BaseModel):
    title: str
    summary: str
    motivation: str
    changes: list[str]
    changed_files: list[
        ChangedFileSummary
    ]
    test_evidence: TestEvidence
    review_notes: list[str] = Field(
        default_factory=list
    )
    risks: list[str] = Field(
        default_factory=list
    )
    out_of_scope: list[str] = Field(
        default_factory=list
    )
    breaking_change: str | None = None


class SplitSuggestion(BaseModel):
    recommended: bool
    reason: str | None = None
    suggested_commits: list[str] = Field(
        default_factory=list
    )


class GitWritingResult(BaseModel):
    commit: CommitMessage
    pull_request: PullRequestContent
    split_suggestion: SplitSuggestion
    assumptions: list[str] = Field(
        default_factory=list
    )
    warnings: list[str] = Field(
        default_factory=list
    )

خواندن تغییرات Git

فایل git_writer/git_reader.py:

import subprocess
from dataclasses import dataclass


MAX_DIFF_CHARS = 60_000


class GitReadError(RuntimeError):
    pass


@dataclass(frozen=True)
class GitContext:
    diff: str
    name_status: str
    stat: str
    changed_files: list[str]


def run_git(
    arguments: list[str],
) -> str:
    result = subprocess.run(
        ["git", *arguments],
        capture_output=True,
        text=True,
        timeout=30,
        check=False,
    )

    if result.returncode != 0:
        raise GitReadError(
            result.stderr.strip()
            or "Git command failed."
        )

    return result.stdout


def get_staged_context() -> GitContext:
    diff = run_git(
        [
            "diff",
            "--cached",
            "--unified=10",
            "--",
            ".",
        ]
    )

    name_status = run_git(
        [
            "diff",
            "--cached",
            "--name-status",
            "--",
            ".",
        ]
    )

    stat = run_git(
        [
            "diff",
            "--cached",
            "--stat",
            "--",
            ".",
        ]
    )

    changed_files = []

    for line in (
        name_status.splitlines()
    ):
        parts = line.split("\t")

        if len(parts) >= 2:
            changed_files.append(
                parts[-1]
            )

    if len(diff) > MAX_DIFF_CHARS:
        diff = (
            diff[:MAX_DIFF_CHARS]
            + "\n\n[DIFF TRUNCATED]"
        )

    return GitContext(
        diff=diff,
        name_status=name_status,
        stat=stat,
        changed_files=changed_files,
    )

ابزار فقط تغییرات Stageشده را می‌خواند. اگر فایل هنوز Stage نشده باشد در پیام Commit لحاظ نمی‌شود.

فیلتر فایل‌های نامناسب برای تحلیل متنی

فایل‌های Generated یا Lockfile ممکن است Diff را بسیار بزرگ کنند.

IGNORED_SUFFIXES = {
    ".min.js",
    ".map",
    ".lock",
}

IGNORED_NAMES = {
    "package-lock.json",
    "pnpm-lock.yaml",
    "yarn.lock",
    "poetry.lock",
}


def should_ignore_file(
    file_path: str,
) -> bool:
    if file_path.split("/")[-1] in (
        IGNORED_NAMES
    ):
        return True

    return any(
        file_path.endswith(suffix)
        for suffix in IGNORED_SUFFIXES
    )

وجود تغییر Dependency باید در آمار فایل‌ها حفظ شود، اما لازم نیست تمام Lockfile برای مدل ارسال شود.

دریافت نتیجه واقعی تست

فایل git_writer/test_runner.py:

import subprocess
import sys


def run_tests(
    enabled: bool,
    timeout_seconds: int = 120,
) -> dict:
    if not enabled:
        return {
            "executed": False,
            "command": None,
            "return_code": None,
            "stdout": "",
            "stderr": "",
            "timed_out": False,
        }

    command = [
        sys.executable,
        "-m",
        "pytest",
        "-q",
    ]

    try:
        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=timeout_seconds,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return {
            "executed": True,
            "command": command,
            "return_code": None,
            "stdout": "",
            "stderr": "",
            "timed_out": True,
        }

    return {
        "executed": True,
        "command": command,
        "return_code": result.returncode,
        "stdout": result.stdout[-10_000:],
        "stderr": result.stderr[-5_000:],
        "timed_out": False,
    }

اجرای Test باید اختیاری باشد. ابزار نباید ادعا کند Testها Pass شده‌اند، مگر اینکه return_code برابر صفر باشد.

تولید Commit و PR با API درواره

فایل git_writer/generator.py:

import json
import os

from dotenv import load_dotenv
from openai import OpenAI
from pydantic import ValidationError

from git_writer.schemas import (
    GitWritingResult,
)


load_dotenv()

api_key = os.getenv("DARVAREH_API_KEY")
model = os.getenv("DARVAREH_MODEL")

if not api_key:
    raise RuntimeError(
        "DARVAREH_API_KEY is not configured."
    )

if not model:
    raise RuntimeError(
        "DARVAREH_MODEL is not configured."
    )

client = OpenAI(
    api_key=api_key,
    base_url="https://api.darvareh.ir/v1",
)


SYSTEM_PROMPT = """
تو یک مهندس نرم‌افزار ارشد هستی و از روی Git Diff
پیام Conventional Commit و توضیحات Pull Request می‌نویسی.

منبع حقیقت:
- Git Diff
- فهرست فایل‌های Stageشده
- هدف تغییر
- نتیجه واقعی تست

قواعد Commit:
- فقط تغییرات Stageشده را توصیف کن.
- Type را بر اساس اثر اصلی تغییر انتخاب کن.
- Subject حداکثر 72 کاراکتر باشد.
- Subject انگلیسی و به شکل فعل امری باشد.
- Subject نقطه پایانی نداشته باشد.
- Scope کوتاه و lowercase باشد.
- Breaking Change را فقط با شاهد صریح اعلام کن.

قواعد Pull Request:
- عنوان انگلیسی و توضیحات فارسی باشند.
- نتیجه موفق تست را فقط اگر اجرا و موفق شده بنویس.
- اگر تست اجرا نشده، صریحاً اعلام کن.
- فایل یا رفتار موجودنبوده اختراع نکن.
- تغییرات نامرتبط را برای Split پیشنهاد کن.
- Diff ناقص یا Truncated را در warnings اعلام کن.
- خروجی فقط JSON معتبر باشد.
"""


OUTPUT_TEMPLATE = {
    "commit": {
        "type": "fix",
        "scope": "pagination",
        "breaking": False,
        "subject": (
            "calculate offset from "
            "one-based page number"
        ),
        "body": [
            "string"
        ],
        "footers": [],
    },
    "pull_request": {
        "title": (
            "Fix one-based pagination offset"
        ),
        "summary": "string",
        "motivation": "string",
        "changes": [
            "string"
        ],
        "changed_files": [
            {
                "path": "app/pagination.py",
                "status": "modified",
                "summary": "string",
            }
        ],
        "test_evidence": {
            "command": "pytest -q",
            "executed": True,
            "passed": True,
            "summary": "string",
        },
        "review_notes": [],
        "risks": [],
        "out_of_scope": [],
        "breaking_change": None,
    },
    "split_suggestion": {
        "recommended": False,
        "reason": None,
        "suggested_commits": [],
    },
    "assumptions": [],
    "warnings": [],
}


def generate_git_writing(
    context: dict,
) -> GitWritingResult:
    response = client.chat.completions.create(
        model=model,
        temperature=0.1,
        messages=[
            {
                "role": "system",
                "content": SYSTEM_PROMPT,
            },
            {
                "role": "user",
                "content": (
                    "Context تغییر:\n\n"
                    + json.dumps(
                        context,
                        ensure_ascii=False,
                        indent=2,
                    )
                    + "\n\nقالب خروجی:\n"
                    + json.dumps(
                        OUTPUT_TEMPLATE,
                        ensure_ascii=False,
                        indent=2,
                    )
                ),
            },
        ],
    )

    raw_output = (
        response.choices[0]
        .message.content
    )

    if not raw_output:
        raise RuntimeError(
            "The model returned an "
            "empty result."
        )

    try:
        parsed = json.loads(raw_output)
    except json.JSONDecodeError as error:
        raise RuntimeError(
            f"Invalid JSON from model: "
            f"{error}"
        ) from error

    try:
        return (
            GitWritingResult
            .model_validate(parsed)
        )
    except ValidationError as error:
        raise RuntimeError(
            f"Generated Git content failed "
            f"validation: {error}"
        ) from error

اعتبارسنجی Conventional Commit

فایل git_writer/validator.py:

import re

from git_writer.schemas import (
    GitWritingResult,
)


SUBJECT_MAX_LENGTH = 72

SCOPE_PATTERN = re.compile(
    r"^[a-z0-9][a-z0-9._-]*$"
)


class GitWritingValidationError(
    ValueError
):
    pass


def validate_commit(
    result: GitWritingResult,
) -> None:
    commit = result.commit

    if len(commit.subject) > (
        SUBJECT_MAX_LENGTH
    ):
        raise GitWritingValidationError(
            f"Commit subject exceeds "
            f"{SUBJECT_MAX_LENGTH} characters."
        )

    if commit.subject.endswith("."):
        raise GitWritingValidationError(
            "Commit subject must not end "
            "with a period."
        )

    if "\n" in commit.subject:
        raise GitWritingValidationError(
            "Commit subject must be "
            "a single line."
        )

    if (
        commit.scope is not None
        and not SCOPE_PATTERN.fullmatch(
            commit.scope
        )
    ):
        raise GitWritingValidationError(
            "Commit scope is invalid."
        )

    has_breaking_footer = any(
        footer.startswith(
            "BREAKING CHANGE:"
        )
        for footer in commit.footers
    )

    if (
        commit.breaking
        != has_breaking_footer
    ):
        raise GitWritingValidationError(
            "Breaking flag and footer "
            "are inconsistent."
        )


def validate_files(
    result: GitWritingResult,
    changed_files: list[str],
) -> None:
    changed_file_set = set(
        changed_files
    )

    documented_file_set = {
        item.path
        for item in (
            result.pull_request
            .changed_files
        )
    }

    unknown_files = (
        documented_file_set
        - changed_file_set
    )

    if unknown_files:
        raise GitWritingValidationError(
            "The model documented files "
            "outside the staged diff: "
            + ", ".join(
                sorted(unknown_files)
            )
        )


def validate_test_claim(
    result: GitWritingResult,
    test_run: dict,
) -> None:
    evidence = (
        result.pull_request
        .test_evidence
    )

    actually_executed = bool(
        test_run["executed"]
    )

    actually_passed = (
        test_run["return_code"] == 0
        if actually_executed
        and not test_run["timed_out"]
        else None
    )

    if (
        evidence.executed
        != actually_executed
    ):
        raise GitWritingValidationError(
            "Test execution claim does "
            "not match actual execution."
        )

    if (
        evidence.passed
        != actually_passed
    ):
        raise GitWritingValidationError(
            "Test result claim does not "
            "match actual result."
        )

مدل اجازه ندارد فایل یا نتیجه تست را اختراع کند.

ساخت متن نهایی Commit

فایل git_writer/renderer.py:

from pathlib import Path

from git_writer.schemas import (
    CommitMessage,
    PullRequestContent,
)


def render_commit_header(
    commit: CommitMessage,
) -> str:
    scope = (
        f"({commit.scope})"
        if commit.scope
        else ""
    )

    breaking = (
        "!"
        if commit.breaking
        else ""
    )

    return (
        f"{commit.type}"
        f"{scope}"
        f"{breaking}: "
        f"{commit.subject}"
    )


def render_commit_message(
    commit: CommitMessage,
) -> str:
    lines = [
        render_commit_header(commit),
    ]

    if commit.body:
        lines.append("")
        lines.extend(commit.body)

    if commit.footers:
        lines.append("")
        lines.extend(commit.footers)

    return "\n".join(lines)


def render_pull_request(
    pull_request: PullRequestContent,
) -> str:
    lines = [
        f"# {pull_request.title}",
        "",
        "## خلاصه",
        "",
        pull_request.summary,
        "",
        "## دلیل تغییر",
        "",
        pull_request.motivation,
        "",
        "## تغییرات",
        "",
    ]

    for change in pull_request.changes:
        lines.append(f"- {change}")

    lines.extend(
        [
            "",
            "## فایل‌های مهم",
            "",
        ]
    )

    for file_item in (
        pull_request.changed_files
    ):
        lines.append(
            f"- `{file_item.path}`: "
            f"{file_item.summary}"
        )

    lines.extend(
        [
            "",
            "## تست",
            "",
        ]
    )

    test_evidence = (
        pull_request.test_evidence
    )

    if test_evidence.executed:
        status = (
            "موفق"
            if test_evidence.passed
            else "ناموفق"
        )

        lines.append(
            f"- وضعیت: {status}"
        )

        if test_evidence.command:
            lines.append(
                f"- دستور: "
                f"`{test_evidence.command}`"
            )

        lines.append(
            f"- نتیجه: "
            f"{test_evidence.summary}"
        )
    else:
        lines.append(
            "- تست خودکار در این مرحله "
            "اجرا نشده است."
        )

    if pull_request.review_notes:
        lines.extend(
            [
                "",
                "## نکات بازبینی",
                "",
            ]
        )

        for note in (
            pull_request.review_notes
        ):
            lines.append(f"- {note}")

    if pull_request.risks:
        lines.extend(
            [
                "",
                "## ریسک‌ها و موارد قابل توجه",
                "",
            ]
        )

        for risk in pull_request.risks:
            lines.append(f"- {risk}")

    if pull_request.out_of_scope:
        lines.extend(
            [
                "",
                "## خارج از محدوده",
                "",
            ]
        )

        for item in (
            pull_request.out_of_scope
        ):
            lines.append(f"- {item}")

    if pull_request.breaking_change:
        lines.extend(
            [
                "",
                "## تغییر ناسازگار",
                "",
                pull_request.breaking_change,
            ]
        )

    return "\n".join(lines)


def save_outputs(
    commit: CommitMessage,
    pull_request: PullRequestContent,
    output_directory: Path,
) -> None:
    output_directory.mkdir(
        parents=True,
        exist_ok=True,
    )

    (
        output_directory
        / "commit-message.txt"
    ).write_text(
        render_commit_message(commit),
        encoding="utf-8",
    )

    (
        output_directory
        / "pull-request.md"
    ).write_text(
        render_pull_request(
            pull_request
        ),
        encoding="utf-8",
    )

در قالب Pull Request از Divider خطی استفاده نشده است.

ساخت برنامه اصلی

فایل generate_git_text.py:

import argparse
from pathlib import Path

from git_writer.generator import (
    generate_git_writing,
)
from git_writer.git_reader import (
    get_staged_context,
)
from git_writer.renderer import (
    render_commit_message,
    save_outputs,
)
from git_writer.test_runner import (
    run_tests,
)
from git_writer.validator import (
    validate_commit,
    validate_files,
    validate_test_claim,
)


def read_context_file(
    file_path: str,
) -> str:
    path = Path(file_path)

    if not path.exists():
        return ""

    return path.read_text(
        encoding="utf-8"
    )


def parse_arguments():
    parser = argparse.ArgumentParser(
        description=(
            "Generate commit and PR text "
            "from staged Git changes."
        )
    )

    parser.add_argument(
        "--run-tests",
        action="store_true",
    )

    return parser.parse_args()


def main():
    arguments = parse_arguments()

    git_context = get_staged_context()

    if not git_context.diff.strip():
        raise RuntimeError(
            "No staged changes found."
        )

    test_run = run_tests(
        enabled=arguments.run_tests
    )

    context = {
        "change_goal": read_context_file(
            "context/change_goal.md"
        ),
        "project_rules": (
            read_context_file(
                "context/project_rules.md"
            )
        ),
        "staged_diff": (
            git_context.diff
        ),
        "name_status": (
            git_context.name_status
        ),
        "diff_stat": git_context.stat,
        "changed_files": (
            git_context.changed_files
        ),
        "test_run": test_run,
    }

    result = generate_git_writing(
        context
    )

    validate_commit(result)

    validate_files(
        result,
        git_context.changed_files,
    )

    validate_test_claim(
        result,
        test_run,
    )

    save_outputs(
        result.commit,
        result.pull_request,
        Path("output"),
    )

    print(
        "Suggested commit message:\n"
    )

    print(
        render_commit_message(
            result.commit
        )
    )

    if (
        result.split_suggestion
        .recommended
    ):
        print(
            "\nWarning: split commit "
            "is recommended."
        )

        print(
            result.split_suggestion.reason
        )


if __name__ == "__main__":
    main()

آماده‌کردن تغییرات

فایل‌های مورد نظر را Stage کنید:

git add app/pagination.py tests/test_pagination.py

بررسی:

git diff --cached

تولید پیام بدون اجرای تست

python generate_git_text.py

در این حالت PR باید صریحاً اعلام کند:

تست خودکار در این مرحله اجرا نشده است.

تولید پیام همراه اجرای تست

python generate_git_text.py --run-tests

فایل‌های خروجی:

output/commit-message.txt
output/pull-request.md

استفاده از پیام پیشنهادی

پس از بررسی:

git commit -F output/commit-message.txt

ابزار مقاله به‌طور خودکار Commit نمی‌کند. ابتدا Diff و متن پیشنهادی را بررسی کنید.

نمونه پیام Commit

fix(pagination): calculate offset from one-based page number

Use (page - 1) * page_size so the first page starts from
the first item instead of skipping one page.

Add boundary tests for first, second and out-of-range pages.

نمونه توضیحات Pull Request

# Fix one-based pagination offset

## خلاصه

محاسبه Offset صفحه‌بندی اصلاح شد تا صفحه اول از اولین رکورد آغاز شود.

## دلیل تغییر

شماره صفحه در قرارداد API از یک شروع می‌شود، اما فرمول قبلی Offset را مانند شماره صفحه صفرمبنا محاسبه می‌کرد.

## تغییرات

- اصلاح فرمول Offset برای صفحه‌بندی یک‌مبنا
- افزودن تست صفحه اول
- افزودن تست صفحه دوم
- افزودن تست صفحه خارج از محدوده

## فایل‌های مهم

- `app/pagination.py`: اصلاح محاسبه Offset
- `tests/test_pagination.py`: افزودن تست‌های مرزی

## تست

- وضعیت: موفق
- دستور: `python -m pytest -q`
- نتیجه: تمام تست‌های اجراشده موفق بودند

## نکات بازبینی

- رفتار Validation شماره صفحه تغییر نکرده است
- ساختار خروجی Pagination بدون تغییر باقی مانده است

تشخیص تغییرات نامرتبط

فرض کنید Diff شامل این موارد باشد:

  • اصلاح Pagination
  • تغییر رنگ Button
  • ارتقای Dependency
  • ویرایش README

یک Commit واحد احتمالاً بیش از یک مسئولیت دارد.

خروجی:

{
  "recommended": true,
  "reason": "تغییرات Pagination، رابط کاربری و Dependency هدف مشترک مستقیمی ندارند.",
  "suggested_commits": [
    "fix(pagination): calculate one-based page offset",
    "style(ui): update primary button color",
    "build(deps): update HTTP client dependency",
    "docs(readme): clarify local setup"
  ]
}

هوش مصنوعی فقط Split را پیشنهاد می‌دهد. انتخاب و Stage کردن فایل‌ها باید آگاهانه انجام شود.

ساخت Commitهای کوچک با Git

ابتدا همه تغییرها را از Stage خارج نکنید مگر اینکه قصد و وضعیت Worktree را می‌شناسید. روش معمول، Stage انتخابی است:

git add -p

سپس:

git diff --cached

بعد ابزار تولید پیام را اجرا کنید.

تشخیص Type از روی اثر تغییر

feat

رفتار جدید برای کاربر یا مصرف‌کننده اضافه شده است.

feat(search): add category filter

fix

رفتار موجود اشتباه بوده و اصلاح شده است.

fix(pagination): calculate one-based page offset

refactor

ساختار تغییر کرده اما Contract حفظ شده است.

refactor(pricing): extract money rounding helper

test

فقط Test تغییر کرده است.

test(cart): cover full discount boundary

docs

فقط مستندات تغییر کرده‌اند.

docs(api): add product creation example

مدل باید اثر اصلی Diff را بسنجد، نه فقط نام پوشه را.

تشخیص Scope

Scope معمولاً یکی از این موارد است:

  • ماژول
  • قابلیت
  • سرویس
  • Package
  • Component
  • دامنه کسب‌وکار

Scope خوب:

pagination
cart
billing
api
docs
ci

Scope ضعیف:

src
code
files
changes
misc

اگر تغییر چند Scope مرتبط دارد، می‌توان Scope را حذف کرد یا Commit را تقسیم کرد.

Breaking Change را چگونه تشخیص دهیم؟

نمونه‌های احتمالی:

  • حذف Endpoint
  • تغییر نام فیلد Response
  • اجباری‌شدن پارامتر اختیاری
  • تغییر نوع داده
  • حذف Event
  • تغییر امضای API عمومی
  • حذف Configuration قدیمی

مدل باید شاهد ارائه کند:

Response field user_id was removed and replaced with id.

وجود تغییر بزرگ در Diff به‌تنهایی Breaking Change را اثبات نمی‌کند.

تولید Changelog کاربرمحور

Commit Message با Changelog یکسان نیست.

Commit:

refactor(cache): extract key builder

این تغییر ممکن است برای Changelog کاربر اهمیتی نداشته باشد.

Commit:

feat(export): add CSV export for monthly reports

نسخه Changelog:

- امکان دریافت خروجی CSV از گزارش‌های ماهانه اضافه شد.

پرامپت Changelog:

تغییرات زیر را برای کاربر محصول خلاصه کن.

قواعد:
- Refactor داخلی بدون اثر کاربر را حذف کن.
- جزئیات فایل و تابع را ننویس.
- فقط رفتار قابل مشاهده را توضیح بده.
- قابلیت یا Fix موجودنبوده اختراع نکن.
- Breaking Change را همراه اقدام لازم بنویس.

تولید Release Notes از چند Commit

ورودی مناسب:

  • پیام Commitهای معتبر
  • عنوان PR
  • Labelها
  • نسخه قبلی و جدید
  • Breaking Changeها
  • نتیجه تست یا انتشار
  • مخاطب Release Notes

خروجی:

{
  "highlights": [],
  "added": [],
  "fixed": [],
  "changed": [],
  "deprecated": [],
  "removed": [],
  "migration_steps": []
}

برای Release Notes فقط Git Diff آخرین Commit کافی نیست.

ساخت Git Hook برای پیشنهاد پیام

Hook می‌تواند هنگام Commit پیشنهاد تولید کند، اما نباید مانع کار توسعه‌دهنده شود.

فایل نمونه:

.git/hooks/prepare-commit-msg

اسکریپت می‌تواند در صورت خالی‌بودن پیام، ابزار را اجرا و پیشنهاد را در فایل Commit Message قرار دهد.

بااین‌حال Hook باید:

  • سریع باشد.
  • در صورت نبود شبکه Commit را متوقف نکند.
  • پیام موجود کاربر را بازنویسی نکند.
  • امکان غیرفعال‌سازی داشته باشد.
  • خطای مدل را به Git Failure تبدیل نکند.

برای تیم‌های بزرگ، اجرای دستی یا دستور اختصاصی معمولاً قابل کنترل‌تر است.

ساخت Alias محلی

git config alias.ai-message \
  '!python generate_git_text.py'

سپس:

git ai-message

این Alias وابسته به محل پروژه و محیط Python است و باید متناسب با ساختار Repository تنظیم شود.

استفاده در CI برای PR Description

CI می‌تواند:

  1. Base و Head Branch را مشخص کند.
  2. Diff را استخراج کند.
  3. تست‌ها را اجرا کند.
  4. خلاصه PR بسازد.
  5. خروجی را به‌صورت Artifact ذخیره کند.
  6. توسعه‌دهنده آن را بررسی کند.

در شروع بهتر است AI متن PR را جایگزین نکند؛ فقط پیش‌نویس پیشنهاد دهد.

جلوگیری از ادعاهای نادرست

قواعد مهم:

تست

فقط اگر Command واقعاً اجرا شده باشد:

{
  "executed": true,
  "passed": true
}

فایل

فایل مستندشده باید در git diff --cached --name-status وجود داشته باشد.

رفتار

هر تغییر رفتاری باید شاهدی در Diff یا هدف تغییر داشته باشد.

Issue

شماره Issue نباید بدون ورودی معتبر ساخته شود.

Performance

ادعای «بهبود ۵۰ درصدی» فقط با Benchmark واقعی مجاز است.

سازگاری

ادعای Backward Compatible بودن باید بر اساس Contract و تست مشخص باشد.

مدیریت Diff بزرگ

برای Diff بزرگ:

  1. هر فایل جداگانه خلاصه شود.
  2. فایل‌های Generated فیلتر شوند.
  3. خلاصه‌های فایل ترکیب شوند.
  4. هدف مشترک تغییر پیدا شود.
  5. Split Commit پیشنهاد شود.
  6. فقط سپس Commit و PR ساخته شوند.

معماری Map-Reduce:

File Diffs
    ↓
Per-file Summaries
    ↓
Change Groups
    ↓
Commit Suggestions
    ↓
PR Summary

Chunk کردن Diff

هر Chunk باید:

  • فقط به یک فایل تعلق داشته باشد.
  • Hunk کامل را حفظ کند.
  • نام فایل داشته باشد.
  • خطوط Context کافی داشته باشد.
  • از حد Context عبور نکند.

مدل نباید Summary چند فایل نامرتبط را در یک مرحله تولید کند.

ارزیابی AI Git Writer

Dataset مرجع:

  • Bug Fix ساده
  • Feature
  • Refactor خالص
  • فقط Test
  • فقط Docs
  • Dependency Update
  • چند تغییر نامرتبط
  • Breaking Change
  • Diff بدون هدف روشن
  • تست اجراشده و موفق
  • تست اجراشده و ناموفق
  • تست اجرا‌نشده
  • Diff Truncated

معیارها:

معیارتوضیح
Type Accuracyانتخاب صحیح Commit Type
Scope AccuracyScope مناسب
Subject Validityرعایت ساختار و طول
Diff Groundingتطابق ادعاها با Diff
Test Claim Accuracyتطابق با اجرای واقعی
Breaking Change Recallکشف تغییر ناسازگار
False Breaking Rateاعلام اشتباه Breaking Change
Split Accuracyتشخیص تغییر نامرتبط
Human Edit Rateمیزان ویرایش لازم
Acceptance Rateدرصد پیام‌های پذیرفته‌شده

انتخاب مدل مناسب

مدل مناسب باید:

  • Git Diff را درک کند.
  • تغییر رفتار و Refactor را جدا کند.
  • از Conventional Commits پیروی کند.
  • JSON معتبر تولید کند.
  • فارسی و انگلیسی را درست ترکیب کند.
  • از ادعای بدون شاهد اجتناب کند.
  • Context کافی برای Diff داشته باشد.

برای Commitهای کوچک، مدل سریع و اقتصادی کافی است. برای PRهای چندفایلی، مدل قوی‌تر می‌تواند خلاصه بهتری تولید کند.

مدل‌ها و قیمت‌های به‌روز را در صفحه مدل‌های درواره ببینید.

اشتباهات رایج

تولید پیام از Working Tree کامل

فقط تغییرات Stageشده باید پیام Commit را تعیین کنند.

ندادن هدف تغییر

Diff همیشه چرایی تغییر را نشان نمی‌دهد.

اعلام موفقیت تست بدون اجرا

Test Evidence باید از Command واقعی ساخته شود.

انتخاب feat برای هر تغییر

Type بر اساس اثر تغییر تعیین شود.

Commit بزرگ و چندمنظوره

ابزار باید Split Commit پیشنهاد دهد.

Commit خودکار بدون Review

متن پیشنهادی را قبل از Commit بررسی کنید.

استفاده از نام فایل در Subject

Subject باید رفتار یا هدف را بیان کند.

تولید Changelog از تغییر داخلی

همه Commitها برای کاربر محصول مهم نیستند.

نقشه راه Production

مرحله اول: ابزار محلی

فقط پیام Commit و PR Draft تولید شود.

مرحله دوم: اعتبارسنجی

Type، Scope، طول، فایل و Test Evidence بررسی شوند.

مرحله سوم: Diff بزرگ

Summary فایل‌محور و Split Detection اضافه شود.

مرحله چهارم: Git Hook اختیاری

پیشنهاد داخل Commit Editor قرار گیرد.

مرحله پنجم: CI

PR Draft به‌صورت Artifact یا Comment تولید شود.

مرحله ششم: Release Notes

Commitهای تأییدشده به Changelog کاربرمحور تبدیل شوند.

چک‌لیست پیام Commit تولیدشده

  • فقط تغییرات Stageشده را توصیف می‌کند.
  • Type درست است.
  • Scope معنادار است.
  • Subject حداکثر ۷۲ کاراکتر است.
  • Subject فعل امری دارد.
  • Subject نقطه پایانی ندارد.
  • Body چرایی یا اثر تغییر را توضیح می‌دهد.
  • ادعای خارج از Diff ندارد.
  • نتیجه تست واقعی است.
  • Breaking Change دارای شاهد است.
  • تغییرات نامرتبط شناسایی شده‌اند.
  • پیام قبل از Commit بررسی شده است.
  • API Key فقط در Backend یا محیط محلی امن نگهداری می‌شود.

پرسش‌های متداول

آیا هوش مصنوعی می‌تواند پیام Commit بسازد؟

بله. AI می‌تواند Git Diff را تحلیل و پیام Conventional Commit پیشنهاد کند. پیام باید پیش از Commit توسط توسعه‌دهنده بررسی شود.

آیا باید کل Repository را برای مدل بفرستیم؟

خیر. Diff Stageشده، فهرست فایل‌ها، هدف تغییر و قواعد پروژه معمولاً کافی‌اند. برای تغییر پیچیده می‌توان Context فایل مرتبط را اضافه کرد.

Conventional Commit چیست؟

قراردادی برای پیام Commit با ساختار type(scope): subject است که نوع و محدوده تغییر را مشخص می‌کند.

چگونه پیام Commit خودکار بسازیم؟

با git diff --cached تغییرات Stageشده را بخوانید، آن را به مدل ارسال و خروجی را با قواعد Conventional Commits اعتبارسنجی کنید.

آیا AI می‌تواند توضیحات Pull Request تولید کند؟

بله. عنوان، خلاصه، تغییرات، تست‌ها و نکات Review را می‌توان از Diff و Context ساخت. نتیجه Test فقط باید از اجرای واقعی گرفته شود.

آیا ابزار باید خودش Commit کند؟

بهتر است در نسخه اولیه فقط فایل پیام را بسازد. توسعه‌دهنده پس از بررسی می‌تواند با git commit -F از آن استفاده کند.

چگونه چند تغییر نامرتبط را تشخیص دهیم؟

فایل‌ها، Scopeها و هدف‌های تغییر را گروه‌بندی کنید. اگر هدف مشترک مستقیمی ندارند، Split Commit پیشنهاد شود.

بهترین مدل برای نوشتن پیام Commit چیست؟

مدل باید درک مناسبی از کد، Git Diff و خروجی ساختاریافته داشته باشد. مدل‌های موجود را در صفحه مدل‌های درواره مقایسه کنید.

API درواره چگونه استفاده می‌شود؟

در Backend یا ابزار محلی، base_url را برابر https://api.darvareh.ir/v1 قرار دهید و Diff و Context کنترل‌شده را برای مدل ارسال کنید.

جمع‌بندی

هوش مصنوعی می‌تواند پیام Commit و توضیحات Pull Request را سریع‌تر و دقیق‌تر تولید کند، اما منبع حقیقت باید Git Diff Stageشده و نتیجه واقعی ابزارهای پروژه باشد.

یک ابزار قابل اعتماد نباید فقط متن آزاد تولید کند. Type، Scope، Subject، Breaking Change، فایل‌های تغییرکرده و Test Evidence باید در خروجی ساختاریافته باشند و پیش از استفاده اعتبارسنجی شوند.

در پروژه این مقاله یک AI Git Writer با Python، Git، Pydantic و API درواره ساختیم. ابزار تغییرات Stageشده را می‌خواند، نتیجه واقعی تست را جمع‌آوری می‌کند، پیام Conventional Commit و PR فارسی می‌سازد و ادعاهای مربوط به فایل‌ها و تست را بررسی می‌کند.

برای شروع، در درواره ثبت‌نام و API Key دریافت کنید. سپس مدل مناسب را از صفحه مدل‌های درواره انتخاب کرده و ابزار را ابتدا روی Commitهای کوچک و تک‌هدفه آزمایش کنید.

مقالات مرتبط

برای مطالعه شرایط استفاده و محدودیت‌های مسئولیت، صفحه «سلب مسئولیت» را مشاهده کنید.

Read more

اتوماسیون هوش مصنوعی چیست؟ کاربردها و آموزش ساخت AI Automation

اتوماسیون هوش مصنوعی چیست؟ کاربردها و آموزش ساخت AI Automation

اتوماسیون هوش مصنوعی با ترکیب گردش‌کارهای خودکار و مدل‌های هوش مصنوعی، پردازش متن، دسته‌بندی، استخراج اطلاعات و تصمیم‌های پیشنهادی را خودکار می‌کند. در این راهنما، معماری و ساخت نمونه عملی آن با API درواره را می‌آموزید.

Agentic Commerce چیست؟ آینده خرید با ایجنت هوش مصنوعی

Agentic Commerce چیست؟ آینده خرید با ایجنت هوش مصنوعی

Agentic Commerce شیوه‌ای جدید برای خرید اینترنتی است که در آن ایجنت هوش مصنوعی می‌تواند نیاز کاربر را بفهمد، محصولات را جست‌وجو و مقایسه کند و فرایند خرید را پیش ببرد. در این راهنما با معماری، UCP، ACP و پیاده‌سازی آن با API درواره آشنا می‌شوید.