lcdp-back-merge 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcdp_back_merge-1.0.0/PKG-INFO +114 -0
- lcdp_back_merge-1.0.0/README.md +94 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/__init__.py +1 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/__main__.py +4 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/ai_resolver.py +241 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/cli.py +139 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/git_utils.py +153 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/platforms/__init__.py +63 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/platforms/base.py +69 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/platforms/bitbucket.py +158 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/platforms/github.py +222 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/runner.py +482 -0
- lcdp_back_merge-1.0.0/lcdp_back_merge/slack.py +61 -0
- lcdp_back_merge-1.0.0/pyproject.toml +32 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lcdp-back-merge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Cross-platform automated back-merge bot (Bitbucket / GitHub) with AI-assisted conflict resolution
|
|
5
|
+
Author: Le Comptoir Des Pharmacies
|
|
6
|
+
Author-email: webmaster@lecomptoirdespharmacies.fr
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: anthropic (>=0.40.0)
|
|
16
|
+
Requires-Dist: pyjwt[crypto] (>=2.4.0)
|
|
17
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# lcdp-back-merge
|
|
21
|
+
|
|
22
|
+
Bot de back-merge automatique multi-plateforme (Bitbucket Cloud / GitHub) avec résolution IA des conflits.
|
|
23
|
+
|
|
24
|
+
## Quoi ça fait
|
|
25
|
+
|
|
26
|
+
Pour chaque branche divergente de la branche source courante :
|
|
27
|
+
|
|
28
|
+
1. Crée une branche `auto-merge/<source>-into-<target>`
|
|
29
|
+
2. Tente le merge ; si conflit, demande à Claude une proposition de résolution par fichier
|
|
30
|
+
3. Ouvre une PR avec une description riche (statut, fichiers en conflit, suggestions IA)
|
|
31
|
+
4. **Auto-merge** si pas de conflit OU si l'IA a résolu tous les fichiers avec confiance `high`
|
|
32
|
+
5. **Notifie Slack** sinon (review requise)
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
### Variables d'environnement requises
|
|
37
|
+
|
|
38
|
+
| Variable | Plateforme | Description |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `BB_AUTH_STRING` | Bitbucket | `client_id:client_secret` OAuth pour l'API Bitbucket |
|
|
41
|
+
| `GITHUB_APP_ID` | GitHub | App ID du GitHub App (auth App uniquement, pas de PAT) |
|
|
42
|
+
| `GITHUB_APP_PRIVATE_KEY` | GitHub | Clé privée PEM du GitHub App. Permissions requises : `contents:write` + `pull-requests:write` |
|
|
43
|
+
|
|
44
|
+
La plateforme est détectée automatiquement via les variables CI standard (`BITBUCKET_BRANCH`, `GITHUB_ACTIONS`).
|
|
45
|
+
|
|
46
|
+
> GitHub : l'auth se fait **exclusivement via GitHub App** (le script génère lui-même un installation token court). L'App doit être **installée sur le repo** cible.
|
|
47
|
+
|
|
48
|
+
### Variables d'environnement optionnelles
|
|
49
|
+
|
|
50
|
+
| Variable | Défaut | Description |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `ANTHROPIC_API_KEY` | — | Active la résolution IA des conflits |
|
|
53
|
+
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Modèle Claude utilisé pour la résolution |
|
|
54
|
+
| `SLACK_WEBHOOK_URL` | — | Active les notifications Slack |
|
|
55
|
+
| `AUTO_MERGE_TARGET_PATTERNS` | (toutes) | Globs séparés par virgule : `develop,release/*` |
|
|
56
|
+
| `AUTO_MERGE_BRANCH_MAP` | — | Mapping source→patterns : `master:develop;develop:feature/*` |
|
|
57
|
+
| `AUTO_MERGE_DEFAULT_REVIEWERS` | — | Reviewers fallback en CSV (UUIDs Bitbucket ou logins GitHub) |
|
|
58
|
+
| `AUTO_MERGE_PR_RUN_LIMIT` | -1 (illimité) | Nombre max de PR créées par run |
|
|
59
|
+
|
|
60
|
+
## Utilisation en pipeline
|
|
61
|
+
|
|
62
|
+
### Bitbucket Pipelines
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
branches:
|
|
66
|
+
"{master}":
|
|
67
|
+
- step:
|
|
68
|
+
name: Back merge
|
|
69
|
+
# image uv officielle (debian bookworm) → uv + git déjà présents
|
|
70
|
+
image: ghcr.io/astral-sh/uv:python3.11-bookworm
|
|
71
|
+
clone:
|
|
72
|
+
depth: full # toutes les branches divergentes
|
|
73
|
+
script:
|
|
74
|
+
- export AUTO_MERGE_TARGET_PATTERNS="develop"
|
|
75
|
+
- uvx lcdp-back-merge
|
|
76
|
+
# Variables (repository/deployment) : BB_AUTH_STRING, ANTHROPIC_API_KEY, SLACK_WEBHOOK_URL
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### GitHub Actions
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
- uses: actions/checkout@v4
|
|
83
|
+
with:
|
|
84
|
+
fetch-depth: 0 # toutes les branches divergentes
|
|
85
|
+
persist-credentials: false # le script gère l'auth git via le token App
|
|
86
|
+
- uses: astral-sh/setup-uv@v5
|
|
87
|
+
- name: Back-merge
|
|
88
|
+
run: uvx lcdp-back-merge
|
|
89
|
+
env:
|
|
90
|
+
# Noms des secrets GitHub explicites (à gauche = ce que lit le script,
|
|
91
|
+
# à droite = le secret/variable côté repo, nommé sans ambiguïté).
|
|
92
|
+
GITHUB_APP_ID: ${{ vars.LCDP_BACKMERGE_APP_ID }}
|
|
93
|
+
GITHUB_APP_PRIVATE_KEY: ${{ secrets.LCDP_BACKMERGE_APP_PRIVATE_KEY }}
|
|
94
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
95
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Architecture
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
lcdp_back_merge/
|
|
102
|
+
├── cli.py # argparse, entry point
|
|
103
|
+
├── runner.py # boucle principale, orchestration
|
|
104
|
+
├── ai_resolver.py # résolution de conflits via Claude
|
|
105
|
+
├── slack.py # notifications Slack
|
|
106
|
+
├── git_utils.py # wrappers subprocess git
|
|
107
|
+
└── platforms/
|
|
108
|
+
├── base.py # abstract Platform
|
|
109
|
+
├── bitbucket.py # adaptateur Bitbucket Cloud
|
|
110
|
+
└── github.py # adaptateur GitHub
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Ajouter une plateforme = ajouter une sous-classe de `Platform` dans `platforms/`, l'enregistrer dans `platforms.__init__.detect_platform()`.
|
|
114
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# lcdp-back-merge
|
|
2
|
+
|
|
3
|
+
Bot de back-merge automatique multi-plateforme (Bitbucket Cloud / GitHub) avec résolution IA des conflits.
|
|
4
|
+
|
|
5
|
+
## Quoi ça fait
|
|
6
|
+
|
|
7
|
+
Pour chaque branche divergente de la branche source courante :
|
|
8
|
+
|
|
9
|
+
1. Crée une branche `auto-merge/<source>-into-<target>`
|
|
10
|
+
2. Tente le merge ; si conflit, demande à Claude une proposition de résolution par fichier
|
|
11
|
+
3. Ouvre une PR avec une description riche (statut, fichiers en conflit, suggestions IA)
|
|
12
|
+
4. **Auto-merge** si pas de conflit OU si l'IA a résolu tous les fichiers avec confiance `high`
|
|
13
|
+
5. **Notifie Slack** sinon (review requise)
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
### Variables d'environnement requises
|
|
18
|
+
|
|
19
|
+
| Variable | Plateforme | Description |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `BB_AUTH_STRING` | Bitbucket | `client_id:client_secret` OAuth pour l'API Bitbucket |
|
|
22
|
+
| `GITHUB_APP_ID` | GitHub | App ID du GitHub App (auth App uniquement, pas de PAT) |
|
|
23
|
+
| `GITHUB_APP_PRIVATE_KEY` | GitHub | Clé privée PEM du GitHub App. Permissions requises : `contents:write` + `pull-requests:write` |
|
|
24
|
+
|
|
25
|
+
La plateforme est détectée automatiquement via les variables CI standard (`BITBUCKET_BRANCH`, `GITHUB_ACTIONS`).
|
|
26
|
+
|
|
27
|
+
> GitHub : l'auth se fait **exclusivement via GitHub App** (le script génère lui-même un installation token court). L'App doit être **installée sur le repo** cible.
|
|
28
|
+
|
|
29
|
+
### Variables d'environnement optionnelles
|
|
30
|
+
|
|
31
|
+
| Variable | Défaut | Description |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `ANTHROPIC_API_KEY` | — | Active la résolution IA des conflits |
|
|
34
|
+
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Modèle Claude utilisé pour la résolution |
|
|
35
|
+
| `SLACK_WEBHOOK_URL` | — | Active les notifications Slack |
|
|
36
|
+
| `AUTO_MERGE_TARGET_PATTERNS` | (toutes) | Globs séparés par virgule : `develop,release/*` |
|
|
37
|
+
| `AUTO_MERGE_BRANCH_MAP` | — | Mapping source→patterns : `master:develop;develop:feature/*` |
|
|
38
|
+
| `AUTO_MERGE_DEFAULT_REVIEWERS` | — | Reviewers fallback en CSV (UUIDs Bitbucket ou logins GitHub) |
|
|
39
|
+
| `AUTO_MERGE_PR_RUN_LIMIT` | -1 (illimité) | Nombre max de PR créées par run |
|
|
40
|
+
|
|
41
|
+
## Utilisation en pipeline
|
|
42
|
+
|
|
43
|
+
### Bitbucket Pipelines
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
branches:
|
|
47
|
+
"{master}":
|
|
48
|
+
- step:
|
|
49
|
+
name: Back merge
|
|
50
|
+
# image uv officielle (debian bookworm) → uv + git déjà présents
|
|
51
|
+
image: ghcr.io/astral-sh/uv:python3.11-bookworm
|
|
52
|
+
clone:
|
|
53
|
+
depth: full # toutes les branches divergentes
|
|
54
|
+
script:
|
|
55
|
+
- export AUTO_MERGE_TARGET_PATTERNS="develop"
|
|
56
|
+
- uvx lcdp-back-merge
|
|
57
|
+
# Variables (repository/deployment) : BB_AUTH_STRING, ANTHROPIC_API_KEY, SLACK_WEBHOOK_URL
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### GitHub Actions
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
with:
|
|
65
|
+
fetch-depth: 0 # toutes les branches divergentes
|
|
66
|
+
persist-credentials: false # le script gère l'auth git via le token App
|
|
67
|
+
- uses: astral-sh/setup-uv@v5
|
|
68
|
+
- name: Back-merge
|
|
69
|
+
run: uvx lcdp-back-merge
|
|
70
|
+
env:
|
|
71
|
+
# Noms des secrets GitHub explicites (à gauche = ce que lit le script,
|
|
72
|
+
# à droite = le secret/variable côté repo, nommé sans ambiguïté).
|
|
73
|
+
GITHUB_APP_ID: ${{ vars.LCDP_BACKMERGE_APP_ID }}
|
|
74
|
+
GITHUB_APP_PRIVATE_KEY: ${{ secrets.LCDP_BACKMERGE_APP_PRIVATE_KEY }}
|
|
75
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
76
|
+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Architecture
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
lcdp_back_merge/
|
|
83
|
+
├── cli.py # argparse, entry point
|
|
84
|
+
├── runner.py # boucle principale, orchestration
|
|
85
|
+
├── ai_resolver.py # résolution de conflits via Claude
|
|
86
|
+
├── slack.py # notifications Slack
|
|
87
|
+
├── git_utils.py # wrappers subprocess git
|
|
88
|
+
└── platforms/
|
|
89
|
+
├── base.py # abstract Platform
|
|
90
|
+
├── bitbucket.py # adaptateur Bitbucket Cloud
|
|
91
|
+
└── github.py # adaptateur GitHub
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Ajouter une plateforme = ajouter une sous-classe de `Platform` dans `platforms/`, l'enregistrer dans `platforms.__init__.detect_platform()`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""lcdp-back-merge — Cross-platform automated back-merge bot."""
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Résolution de conflits Git via Claude.
|
|
2
|
+
|
|
3
|
+
Port direct de l'ancien ai-resolve-conflict.py. Lit base/ours/theirs depuis
|
|
4
|
+
l'index Git (le merge doit être en cours, c.-à-d. avant tout `git add` /
|
|
5
|
+
`git commit`) pour éviter de parser les marqueurs de conflit (qui peuvent être
|
|
6
|
+
au format standard ou diff3 selon la config locale).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import anthropic
|
|
20
|
+
|
|
21
|
+
# Sonnet 4.6 plutôt qu'Opus 4.7 : la résolution de conflits ne nécessite pas la
|
|
22
|
+
# capacité d'Opus, et Sonnet a une limite TPM plus élevée. Override via
|
|
23
|
+
# ANTHROPIC_MODEL si besoin.
|
|
24
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
25
|
+
|
|
26
|
+
MAX_FILE_BYTES = 200_000
|
|
27
|
+
MAX_DIFF_BYTES = 30_000
|
|
28
|
+
MAX_BASE_BYTES = 10_000
|
|
29
|
+
MAX_LOG_COMMITS = 8
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Resolution:
|
|
34
|
+
confidence: str # "high" | "medium" | "low"
|
|
35
|
+
explanation: str
|
|
36
|
+
should_apply: bool
|
|
37
|
+
resolved_path: Optional[Path] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git(args: list[str]) -> str:
|
|
41
|
+
try:
|
|
42
|
+
return subprocess.check_output(
|
|
43
|
+
["git", *args], text=True, stderr=subprocess.DEVNULL
|
|
44
|
+
)
|
|
45
|
+
except subprocess.CalledProcessError:
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_index_stage(stage: int, file_path: str) -> str:
|
|
50
|
+
return _git(["show", f":{stage}:{file_path}"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_index_diff(stage_from: int, stage_to: int, file_path: str) -> str:
|
|
54
|
+
out = _git(["diff", f":{stage_from}:{file_path}", f":{stage_to}:{file_path}"])
|
|
55
|
+
if len(out) > MAX_DIFF_BYTES:
|
|
56
|
+
out = out[:MAX_DIFF_BYTES] + f"\n\n[... diff truncated, showed first {MAX_DIFF_BYTES} bytes ...]"
|
|
57
|
+
return out or "(no changes)"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_commit_log(branch: str, n: int = MAX_LOG_COMMITS) -> str:
|
|
61
|
+
out = _git(["log", f"-{n}", "--format=%h %s", branch])
|
|
62
|
+
return out.strip() or "(no log available)"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_available() -> bool:
|
|
66
|
+
"""True si ANTHROPIC_API_KEY est défini et anthropic est importable."""
|
|
67
|
+
return bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve(
|
|
71
|
+
file_path: Path,
|
|
72
|
+
source_branch: str,
|
|
73
|
+
target_branch: str,
|
|
74
|
+
out_path: Path,
|
|
75
|
+
) -> Resolution:
|
|
76
|
+
"""Demande à Claude une résolution pour un fichier en conflit.
|
|
77
|
+
|
|
78
|
+
Écrit la résolution dans `out_path` UNIQUEMENT si `should_apply=True`.
|
|
79
|
+
L'orchestrateur applique ensuite ces fichiers selon sa stratégie.
|
|
80
|
+
"""
|
|
81
|
+
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
|
82
|
+
|
|
83
|
+
if len(raw.encode("utf-8")) > MAX_FILE_BYTES:
|
|
84
|
+
return Resolution(
|
|
85
|
+
confidence="low",
|
|
86
|
+
explanation="File too large for AI resolution.",
|
|
87
|
+
should_apply=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
file_str = str(file_path)
|
|
91
|
+
|
|
92
|
+
base_content = _get_index_stage(1, file_str)
|
|
93
|
+
diff_ours = _get_index_diff(1, 2, file_str)
|
|
94
|
+
diff_theirs = _get_index_diff(1, 3, file_str)
|
|
95
|
+
|
|
96
|
+
if diff_ours == "(no changes)" and diff_theirs == "(no changes)":
|
|
97
|
+
return Resolution(
|
|
98
|
+
confidence="low",
|
|
99
|
+
explanation="No unmerged stages found for this file in the Git index.",
|
|
100
|
+
should_apply=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
source_log = _get_commit_log(source_branch)
|
|
104
|
+
target_log = _get_commit_log(target_branch)
|
|
105
|
+
|
|
106
|
+
base_section = (
|
|
107
|
+
f"--- Common ancestor version of `{file_str}` ---\n```\n{base_content}\n```"
|
|
108
|
+
if base_content and len(base_content.encode("utf-8")) <= MAX_BASE_BYTES
|
|
109
|
+
else f"--- Common ancestor version of `{file_str}`: omitted (too large or not present) ---"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# max_retries=5 : le SDK gère la backoff exponentielle sur les 429 et 5xx.
|
|
113
|
+
# Utile car un back-merge avec beaucoup de fichiers en conflit peut taper
|
|
114
|
+
# sporadiquement les rate limits.
|
|
115
|
+
client = anthropic.Anthropic(max_retries=5)
|
|
116
|
+
model = os.environ.get("ANTHROPIC_MODEL", DEFAULT_MODEL)
|
|
117
|
+
|
|
118
|
+
system_prompt = """You are a senior software engineer resolving Git merge conflicts.
|
|
119
|
+
|
|
120
|
+
You will be given:
|
|
121
|
+
- A file currently containing Git conflict markers (<<<<<<<, =======, >>>>>>>)
|
|
122
|
+
- The diff of changes made on the TARGET branch (where the merge is happening) since the common ancestor, scoped to this file
|
|
123
|
+
- The diff of changes made on the SOURCE branch (the incoming changes) since the common ancestor, scoped to this file
|
|
124
|
+
- Optionally the common ancestor version of the file
|
|
125
|
+
- Recent commit messages from both branches for intent context
|
|
126
|
+
|
|
127
|
+
Your job:
|
|
128
|
+
1. Use the diffs to understand WHAT each side intended to do — the markers alone hide the
|
|
129
|
+
real intent.
|
|
130
|
+
2. Resolve every conflict in the file by producing the most likely correct merged version
|
|
131
|
+
that preserves the intent of BOTH sides whenever possible.
|
|
132
|
+
3. If the two sides are TRULY incompatible (they implement opposite behaviors, or modify
|
|
133
|
+
the same business logic in contradictory ways), say so honestly — do NOT invent a
|
|
134
|
+
resolution that papers over the disagreement.
|
|
135
|
+
4. Be especially careful with: authentication, authorization, cryptography, payment logic,
|
|
136
|
+
database migrations, security-critical paths. Default to should_apply=false on these
|
|
137
|
+
even if the resolution looks straightforward.
|
|
138
|
+
|
|
139
|
+
Output format — respond EXACTLY like this and nothing else:
|
|
140
|
+
|
|
141
|
+
<RESOLVED_FILE>
|
|
142
|
+
...the fully resolved file content with no conflict markers...
|
|
143
|
+
</RESOLVED_FILE>
|
|
144
|
+
<METADATA>
|
|
145
|
+
{"confidence": "high"|"medium"|"low", "explanation": "one or two sentences", "should_apply": true|false}
|
|
146
|
+
</METADATA>
|
|
147
|
+
|
|
148
|
+
Be conservative. A bad resolution is worse than no resolution because it can mask bugs
|
|
149
|
+
behind code that compiles and looks reasonable."""
|
|
150
|
+
|
|
151
|
+
user_prompt = f"""Merging source branch `{source_branch}` INTO target branch `{target_branch}`.
|
|
152
|
+
File path: `{file_str}`
|
|
153
|
+
|
|
154
|
+
=== Recent commits on `{source_branch}` (the INCOMING changes) ===
|
|
155
|
+
{source_log}
|
|
156
|
+
|
|
157
|
+
=== Recent commits on `{target_branch}` (the branch BEING UPDATED) ===
|
|
158
|
+
{target_log}
|
|
159
|
+
|
|
160
|
+
=== Changes on `{target_branch}` (ours/target) since common ancestor, for this file ===
|
|
161
|
+
```diff
|
|
162
|
+
{diff_ours}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
=== Changes on `{source_branch}` (theirs/source) since common ancestor, for this file ===
|
|
166
|
+
```diff
|
|
167
|
+
{diff_theirs}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
{base_section}
|
|
171
|
+
|
|
172
|
+
=== Current file with unresolved conflict markers ===
|
|
173
|
+
In the markers below, "ours" (between <<<<<<< and =======) is the TARGET branch's version,
|
|
174
|
+
and "theirs" (between ======= and >>>>>>>) is the SOURCE branch's version.
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
{raw}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Resolve all conflicts and respond in the format specified."""
|
|
181
|
+
|
|
182
|
+
# Prompt caching sur le system prompt : identique entre tous les appels d'un
|
|
183
|
+
# même back-merge run. NB : pour que le cache se déclenche, le préfixe doit
|
|
184
|
+
# dépasser ~2048 tokens sur Sonnet 4.6.
|
|
185
|
+
response = client.messages.create(
|
|
186
|
+
model=model,
|
|
187
|
+
max_tokens=8000,
|
|
188
|
+
system=[
|
|
189
|
+
{
|
|
190
|
+
"type": "text",
|
|
191
|
+
"text": system_prompt,
|
|
192
|
+
"cache_control": {"type": "ephemeral"},
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
text = response.content[0].text
|
|
199
|
+
|
|
200
|
+
file_match = re.search(r"<RESOLVED_FILE>\n?(.*?)\n?</RESOLVED_FILE>", text, re.DOTALL)
|
|
201
|
+
meta_match = re.search(r"<METADATA>\n?(.*?)\n?</METADATA>", text, re.DOTALL)
|
|
202
|
+
|
|
203
|
+
if not file_match or not meta_match:
|
|
204
|
+
return Resolution(
|
|
205
|
+
confidence="low",
|
|
206
|
+
explanation="AI response did not match expected format.",
|
|
207
|
+
should_apply=False,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
resolved_content = file_match.group(1)
|
|
211
|
+
try:
|
|
212
|
+
metadata = json.loads(meta_match.group(1).strip())
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return Resolution(
|
|
215
|
+
confidence="low",
|
|
216
|
+
explanation="AI metadata was not valid JSON.",
|
|
217
|
+
should_apply=False,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# `<<<<<<<` ne peut pas apparaître naturellement, canary suffisant pour les
|
|
221
|
+
# deux formats (standard et diff3).
|
|
222
|
+
if "<<<<<<<" in resolved_content:
|
|
223
|
+
return Resolution(
|
|
224
|
+
confidence="low",
|
|
225
|
+
explanation="AI output still contains conflict markers; not applied.",
|
|
226
|
+
should_apply=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
should_apply = bool(metadata.get("should_apply"))
|
|
230
|
+
resolved_path = None
|
|
231
|
+
if should_apply:
|
|
232
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
out_path.write_text(resolved_content, encoding="utf-8")
|
|
234
|
+
resolved_path = out_path
|
|
235
|
+
|
|
236
|
+
return Resolution(
|
|
237
|
+
confidence=metadata.get("confidence", "low"),
|
|
238
|
+
explanation=metadata.get("explanation", ""),
|
|
239
|
+
should_apply=should_apply,
|
|
240
|
+
resolved_path=resolved_path,
|
|
241
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Entry point CLI : `lcdp-back-merge`.
|
|
2
|
+
|
|
3
|
+
Les options peuvent être passées via flags CLI OU variables d'environnement
|
|
4
|
+
(noms historiques `AUTO_MERGE_*` pour compat avec les pipelines existantes).
|
|
5
|
+
Le flag CLI a priorité sur la variable d'env.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from lcdp_back_merge.platforms import detect_platform
|
|
15
|
+
from lcdp_back_merge.runner import (
|
|
16
|
+
BackMergeRunner,
|
|
17
|
+
Config,
|
|
18
|
+
parse_branch_map,
|
|
19
|
+
parse_csv,
|
|
20
|
+
parse_patterns,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="lcdp-back-merge",
|
|
27
|
+
description="Cross-platform automated back-merge bot (Bitbucket / GitHub).",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--target-patterns",
|
|
31
|
+
default=None,
|
|
32
|
+
help=(
|
|
33
|
+
"Globs de branches cibles, séparés par virgule (ex: 'develop,release/*'). "
|
|
34
|
+
"Par défaut : toutes les branches divergentes. "
|
|
35
|
+
"Override de AUTO_MERGE_TARGET_PATTERNS."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--branch-map",
|
|
40
|
+
default=None,
|
|
41
|
+
help=(
|
|
42
|
+
"Mapping source→patterns pour piloter plusieurs branches sans dupliquer "
|
|
43
|
+
"la pipeline. Format : 'src1:patterns1;src2:patterns2'. "
|
|
44
|
+
"Override de AUTO_MERGE_BRANCH_MAP."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--default-reviewers",
|
|
49
|
+
default=None,
|
|
50
|
+
help=(
|
|
51
|
+
"Reviewers fallback en CSV (UUIDs Bitbucket ou logins GitHub). "
|
|
52
|
+
"Override de AUTO_MERGE_DEFAULT_REVIEWERS."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--pr-limit",
|
|
57
|
+
type=int,
|
|
58
|
+
default=None,
|
|
59
|
+
help="Nombre max de PR créées par run (-1 = illimité). Override de AUTO_MERGE_PR_RUN_LIMIT.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--dry-run",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="N'effectue aucun push, aucune PR ni aucun merge — uniquement les logs.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--bot-name",
|
|
68
|
+
default="Merge Resolver Bot",
|
|
69
|
+
help="Nom du commiter utilisé pour les commits auto-merge.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--bot-email",
|
|
73
|
+
default="merge-resolver-bot@noreply.local",
|
|
74
|
+
help="Email du commiter utilisé pour les commits auto-merge.",
|
|
75
|
+
)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_config(args: argparse.Namespace) -> Config:
|
|
80
|
+
target_patterns = parse_patterns(
|
|
81
|
+
args.target_patterns
|
|
82
|
+
if args.target_patterns is not None
|
|
83
|
+
else os.environ.get("AUTO_MERGE_TARGET_PATTERNS", "")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
branch_map = parse_branch_map(
|
|
87
|
+
args.branch_map
|
|
88
|
+
if args.branch_map is not None
|
|
89
|
+
else os.environ.get("AUTO_MERGE_BRANCH_MAP", "")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
default_reviewers = parse_csv(
|
|
93
|
+
args.default_reviewers
|
|
94
|
+
if args.default_reviewers is not None
|
|
95
|
+
else os.environ.get("AUTO_MERGE_DEFAULT_REVIEWERS", "")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
pr_run_limit = (
|
|
99
|
+
args.pr_limit
|
|
100
|
+
if args.pr_limit is not None
|
|
101
|
+
else int(os.environ.get("AUTO_MERGE_PR_RUN_LIMIT", "-1"))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return Config(
|
|
105
|
+
target_patterns=target_patterns,
|
|
106
|
+
branch_map=branch_map,
|
|
107
|
+
default_reviewers=default_reviewers,
|
|
108
|
+
pr_run_limit=pr_run_limit,
|
|
109
|
+
dry_run=args.dry_run,
|
|
110
|
+
bot_name=args.bot_name,
|
|
111
|
+
bot_email=args.bot_email,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> int:
|
|
116
|
+
args = _build_parser().parse_args()
|
|
117
|
+
config = _build_config(args)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
platform = detect_platform()
|
|
121
|
+
except RuntimeError as exc:
|
|
122
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
print(f"Detected platform: {platform.name}")
|
|
126
|
+
print(f" Source branch: {platform.ctx.source_branch}")
|
|
127
|
+
print(f" Repository: {platform.ctx.repo_owner}/{platform.ctx.repo_slug}")
|
|
128
|
+
|
|
129
|
+
runner = BackMergeRunner(config, platform)
|
|
130
|
+
try:
|
|
131
|
+
runner.run()
|
|
132
|
+
except RuntimeError as exc:
|
|
133
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
sys.exit(main())
|