devnarrate 0.1.0a1__py3-none-any.whl

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.
devnarrate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DevNarrate - MCP server for developer workflow automation."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,462 @@
1
+ """Git operations for DevNarrate."""
2
+
3
+ import subprocess
4
+ from typing import Optional
5
+
6
+ import tiktoken
7
+
8
+ # MCP response token limit is 25,000 - we'll use 20,000 to be safe
9
+ MAX_RESPONSE_TOKENS = 20000
10
+
11
+ # Default PR template
12
+ DEFAULT_PR_TEMPLATE = """## Summary
13
+ [Brief description of what this PR does and why]
14
+
15
+ ## Changes
16
+ -
17
+ -
18
+
19
+ ## Testing
20
+ [How to test these changes]
21
+
22
+ ## Related Issues
23
+ [Links to related issues, if any]
24
+ """
25
+
26
+
27
+ def count_tokens(text: str) -> int:
28
+ """Count tokens in text using tiktoken (cl100k_base encoding).
29
+
30
+ Args:
31
+ text: Text to count tokens for
32
+
33
+ Returns:
34
+ Number of tokens
35
+ """
36
+ try:
37
+ encoding = tiktoken.get_encoding("cl100k_base")
38
+ return len(encoding.encode(text))
39
+ except Exception:
40
+ # Fallback to rough estimate: ~4 chars per token
41
+ return len(text) // 4
42
+
43
+
44
+ def get_diff(repo_path: str) -> str:
45
+ """Get git diff output for staged changes only.
46
+
47
+ Args:
48
+ repo_path: Path to the git repository
49
+
50
+ Returns:
51
+ Raw git diff output for staged changes
52
+
53
+ Raises:
54
+ subprocess.CalledProcessError: If git command fails
55
+ """
56
+ result = subprocess.run(
57
+ ['git', 'diff', '--staged'],
58
+ cwd=repo_path,
59
+ capture_output=True,
60
+ text=True,
61
+ check=True
62
+ )
63
+ return result.stdout
64
+
65
+
66
+ def get_file_stats(repo_path: str) -> dict:
67
+ """Get statistics about staged files only.
68
+
69
+ Args:
70
+ repo_path: Path to the git repository
71
+
72
+ Returns:
73
+ Dict with staged file changes
74
+ """
75
+ # Get list of changed files with status
76
+ status_result = subprocess.run(
77
+ ['git', 'status', '--porcelain'],
78
+ cwd=repo_path,
79
+ capture_output=True,
80
+ text=True,
81
+ check=True
82
+ )
83
+
84
+ # Parse file status
85
+ # Format: XY filepath (X=staged, Y=unstaged, space=no change)
86
+ files = []
87
+ for line in status_result.stdout.strip().split('\n'):
88
+ if not line:
89
+ continue
90
+
91
+ # First 2 chars are status codes (XY), rest is filepath
92
+ staged_status = line[0]
93
+ filepath = line[2:].strip() # Skip status codes and strip whitespace
94
+
95
+ # Skip files with no staged changes (untracked or not staged)
96
+ if staged_status in (' ', '?'):
97
+ continue
98
+
99
+ file_status = 'modified'
100
+ if staged_status == 'A':
101
+ file_status = 'added'
102
+ elif staged_status == 'D':
103
+ file_status = 'deleted'
104
+ elif staged_status == 'M':
105
+ file_status = 'modified'
106
+ elif staged_status == 'R':
107
+ file_status = 'renamed'
108
+
109
+ files.append({
110
+ 'path': filepath,
111
+ 'status': file_status
112
+ })
113
+
114
+ return {'files': files}
115
+
116
+
117
+ def paginate_diff(diff_text: str, cursor: Optional[str], max_tokens: int = MAX_RESPONSE_TOKENS) -> dict:
118
+ """Paginate diff output by token count (MCP limit: 25k tokens).
119
+
120
+ This ensures responses stay under the MCP token limit while preserving
121
+ line boundaries for readability. Reusable for PR diffs as well.
122
+
123
+ Args:
124
+ diff_text: Full diff text
125
+ cursor: Current cursor position (line number as string)
126
+ max_tokens: Maximum tokens per chunk (default: 20,000 to stay under 25k limit)
127
+
128
+ Returns:
129
+ Dict with diff chunk, token counts, and nextCursor
130
+ """
131
+ if not diff_text:
132
+ return {
133
+ 'diff_chunk': '',
134
+ 'next_cursor': None,
135
+ 'chunk_info': {
136
+ 'start_line': 0,
137
+ 'end_line': 0,
138
+ 'total_lines': 0,
139
+ 'chunk_tokens': 0,
140
+ 'total_tokens': 0
141
+ }
142
+ }
143
+
144
+ lines = diff_text.split('\n')
145
+ total_lines = len(lines)
146
+ total_tokens = count_tokens(diff_text)
147
+
148
+ # Parse cursor (line number) or start from 0
149
+ start_line = 0
150
+ if cursor:
151
+ try:
152
+ start_line = int(cursor)
153
+ except ValueError:
154
+ start_line = 0
155
+
156
+ # Build chunk line by line, staying under token limit
157
+ chunk_lines = []
158
+ chunk_text = ""
159
+ end_line = start_line
160
+
161
+ for i in range(start_line, total_lines):
162
+ line = lines[i]
163
+ test_chunk = chunk_text + line + '\n'
164
+ test_tokens = count_tokens(test_chunk)
165
+
166
+ if test_tokens > max_tokens and chunk_lines:
167
+ # Would exceed limit, stop here
168
+ break
169
+
170
+ chunk_lines.append(line)
171
+ chunk_text = test_chunk
172
+ end_line = i + 1
173
+
174
+ # Determine next cursor
175
+ next_cursor = None
176
+ if end_line < total_lines:
177
+ next_cursor = str(end_line)
178
+
179
+ chunk_tokens = count_tokens(chunk_text)
180
+
181
+ return {
182
+ 'diff_chunk': '\n'.join(chunk_lines),
183
+ 'next_cursor': next_cursor,
184
+ 'chunk_info': {
185
+ 'start_line': start_line,
186
+ 'end_line': end_line,
187
+ 'total_lines': total_lines,
188
+ 'chunk_tokens': chunk_tokens,
189
+ 'total_tokens': total_tokens,
190
+ 'chunk_percentage': round((chunk_tokens / total_tokens * 100) if total_tokens > 0 else 100, 1)
191
+ }
192
+ }
193
+
194
+
195
+ def execute_commit(repo_path: str, message: str) -> str:
196
+ """Execute git commit with the given message.
197
+
198
+ Args:
199
+ repo_path: Path to the git repository
200
+ message: Commit message
201
+
202
+ Returns:
203
+ Success message with commit hash
204
+
205
+ Raises:
206
+ subprocess.CalledProcessError: If git commit fails
207
+ """
208
+ # Execute commit
209
+ result = subprocess.run(
210
+ ['git', 'commit', '-m', message],
211
+ cwd=repo_path,
212
+ capture_output=True,
213
+ text=True,
214
+ check=True
215
+ )
216
+
217
+ # Get the commit hash
218
+ hash_result = subprocess.run(
219
+ ['git', 'rev-parse', 'HEAD'],
220
+ cwd=repo_path,
221
+ capture_output=True,
222
+ text=True,
223
+ check=True
224
+ )
225
+
226
+ commit_hash = hash_result.stdout.strip()[:7]
227
+
228
+ return f"Successfully committed as {commit_hash}\n{result.stdout}"
229
+
230
+
231
+ def get_current_branch(repo_path: str) -> str:
232
+ """Get the current git branch name.
233
+
234
+ Args:
235
+ repo_path: Path to the git repository
236
+
237
+ Returns:
238
+ Current branch name
239
+
240
+ Raises:
241
+ subprocess.CalledProcessError: If git command fails
242
+ """
243
+ result = subprocess.run(
244
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
245
+ cwd=repo_path,
246
+ capture_output=True,
247
+ text=True,
248
+ check=True
249
+ )
250
+ return result.stdout.strip()
251
+
252
+
253
+ def get_branch_diff(repo_path: str, base_branch: str, head_branch: Optional[str] = None) -> str:
254
+ """Get diff between two branches.
255
+
256
+ Args:
257
+ repo_path: Path to the git repository
258
+ base_branch: Base branch (e.g., "main", "dev")
259
+ head_branch: Head branch (defaults to current branch)
260
+
261
+ Returns:
262
+ Raw git diff output
263
+
264
+ Raises:
265
+ subprocess.CalledProcessError: If git command fails
266
+ """
267
+ if head_branch is None:
268
+ head_branch = get_current_branch(repo_path)
269
+
270
+ # Use three-dot diff to compare from common ancestor
271
+ result = subprocess.run(
272
+ ['git', 'diff', f'{base_branch}...{head_branch}'],
273
+ cwd=repo_path,
274
+ capture_output=True,
275
+ text=True,
276
+ check=True
277
+ )
278
+ return result.stdout
279
+
280
+
281
+ def get_branch_commits(repo_path: str, base_branch: str, head_branch: Optional[str] = None) -> list[dict]:
282
+ """Get list of commits in head branch not in base branch.
283
+
284
+ Args:
285
+ repo_path: Path to the git repository
286
+ base_branch: Base branch (e.g., "main", "dev")
287
+ head_branch: Head branch (defaults to current branch)
288
+
289
+ Returns:
290
+ List of commit dicts with 'hash' and 'message'
291
+
292
+ Raises:
293
+ subprocess.CalledProcessError: If git command fails
294
+ """
295
+ if head_branch is None:
296
+ head_branch = get_current_branch(repo_path)
297
+
298
+ # Get commits in head but not in base
299
+ result = subprocess.run(
300
+ ['git', 'log', f'{base_branch}..{head_branch}', '--oneline'],
301
+ cwd=repo_path,
302
+ capture_output=True,
303
+ text=True,
304
+ check=True
305
+ )
306
+
307
+ commits = []
308
+ for line in result.stdout.strip().split('\n'):
309
+ if line:
310
+ parts = line.split(' ', 1)
311
+ commits.append({
312
+ 'hash': parts[0],
313
+ 'message': parts[1] if len(parts) > 1 else ''
314
+ })
315
+
316
+ return commits
317
+
318
+
319
+ def get_branch_file_stats(repo_path: str, base_branch: str, head_branch: Optional[str] = None) -> dict:
320
+ """Get file statistics for changes between branches.
321
+
322
+ Args:
323
+ repo_path: Path to the git repository
324
+ base_branch: Base branch (e.g., "main", "dev")
325
+ head_branch: Head branch (defaults to current branch)
326
+
327
+ Returns:
328
+ Dict with file changes
329
+
330
+ Raises:
331
+ subprocess.CalledProcessError: If git command fails
332
+ """
333
+ if head_branch is None:
334
+ head_branch = get_current_branch(repo_path)
335
+
336
+ # Get list of changed files with status
337
+ result = subprocess.run(
338
+ ['git', 'diff', '--name-status', f'{base_branch}...{head_branch}'],
339
+ cwd=repo_path,
340
+ capture_output=True,
341
+ text=True,
342
+ check=True
343
+ )
344
+
345
+ files = []
346
+ for line in result.stdout.strip().split('\n'):
347
+ if not line:
348
+ continue
349
+
350
+ parts = line.split('\t', 1)
351
+ if len(parts) < 2:
352
+ continue
353
+
354
+ status_char = parts[0]
355
+ filepath = parts[1]
356
+
357
+ file_status = 'modified'
358
+ if status_char == 'A':
359
+ file_status = 'added'
360
+ elif status_char == 'D':
361
+ file_status = 'deleted'
362
+ elif status_char == 'M':
363
+ file_status = 'modified'
364
+ elif status_char.startswith('R'):
365
+ file_status = 'renamed'
366
+
367
+ files.append({
368
+ 'path': filepath,
369
+ 'status': file_status
370
+ })
371
+
372
+ return {'files': files}
373
+
374
+
375
+ def detect_git_platform(repo_path: str) -> str:
376
+ """Detect git platform from remote URL.
377
+
378
+ Args:
379
+ repo_path: Path to the git repository
380
+
381
+ Returns:
382
+ Platform name: 'github', 'gitlab', 'bitbucket', or 'unknown'
383
+
384
+ Raises:
385
+ subprocess.CalledProcessError: If git command fails
386
+ """
387
+ try:
388
+ result = subprocess.run(
389
+ ['git', 'remote', 'get-url', 'origin'],
390
+ cwd=repo_path,
391
+ capture_output=True,
392
+ text=True,
393
+ check=True
394
+ )
395
+ remote_url = result.stdout.strip().lower()
396
+
397
+ if 'github.com' in remote_url:
398
+ return 'github'
399
+ elif 'gitlab.com' in remote_url or 'gitlab' in remote_url:
400
+ return 'gitlab'
401
+ elif 'bitbucket.org' in remote_url:
402
+ return 'bitbucket'
403
+ else:
404
+ return 'unknown'
405
+ except subprocess.CalledProcessError:
406
+ return 'unknown'
407
+
408
+
409
+ def execute_pr_creation(
410
+ repo_path: str,
411
+ title: str,
412
+ body: str,
413
+ base_branch: str,
414
+ head_branch: Optional[str] = None,
415
+ draft: bool = False
416
+ ) -> str:
417
+ """Create a pull request using platform CLI.
418
+
419
+ Args:
420
+ repo_path: Path to the git repository
421
+ title: PR title
422
+ body: PR description
423
+ base_branch: Base branch (e.g., "main", "dev")
424
+ head_branch: Head branch (defaults to current branch)
425
+ draft: Create as draft PR
426
+
427
+ Returns:
428
+ Success message with PR URL
429
+
430
+ Raises:
431
+ subprocess.CalledProcessError: If command fails
432
+ ValueError: If platform not supported or CLI not available
433
+ """
434
+ if head_branch is None:
435
+ head_branch = get_current_branch(repo_path)
436
+
437
+ platform = detect_git_platform(repo_path)
438
+
439
+ if platform == 'github':
440
+ # Use GitHub CLI
441
+ cmd = ['gh', 'pr', 'create', '--base', base_branch, '--head', head_branch, '--title', title, '--body', body]
442
+ if draft:
443
+ cmd.append('--draft')
444
+
445
+ elif platform == 'gitlab':
446
+ # Use GitLab CLI
447
+ cmd = ['glab', 'mr', 'create', '--target-branch', base_branch, '--source-branch', head_branch, '--title', title, '--description', body]
448
+ if draft:
449
+ cmd.append('--draft')
450
+
451
+ else:
452
+ raise ValueError(f"Platform '{platform}' not supported or CLI not configured. Supported: github (gh), gitlab (glab)")
453
+
454
+ result = subprocess.run(
455
+ cmd,
456
+ cwd=repo_path,
457
+ capture_output=True,
458
+ text=True,
459
+ check=True
460
+ )
461
+
462
+ return result.stdout
devnarrate/server.py ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DevNarrate MCP Server
4
+
5
+ An MCP server that helps developers with:
6
+ - Writing commit messages
7
+ - Generating PR descriptions
8
+ - Posting CI/CD results to Slack
9
+ - Sharing development updates to Slack
10
+ """
11
+
12
+ import json
13
+ import subprocess
14
+ from typing import Optional
15
+
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ from . import git_operations
19
+
20
+ # Create MCP server instance
21
+ mcp = FastMCP("devnarrate")
22
+
23
+
24
+ @mcp.tool()
25
+ async def get_commit_context(
26
+ cursor: Optional[str] = None,
27
+ max_diff_tokens: int = 20000,
28
+ repo_path: Optional[str] = None
29
+ ) -> str:
30
+ """REQUIRED FIRST STEP: Get git diff and file changes to analyze before writing a commit message.
31
+
32
+ IMPORTANT: You MUST call this tool FIRST before generating any commit message.
33
+ Never write a commit message without first seeing the actual git diff from this tool.
34
+
35
+ This tool ONLY shows STAGED changes. We intentionally do not support unstaged changes
36
+ to ensure users have explicit control over what gets committed and prevent accidental commits.
37
+
38
+ CRITICAL: If the diff is empty and there are no files:
39
+ 1. STOP immediately - do NOT proceed with generating a commit message
40
+ 2. Tell the user: "No staged changes found. Please stage the files you want to commit first using: git add <file1> <file2>"
41
+ 3. DO NOT attempt to stage files automatically
42
+ 4. Wait for the user to stage their changes
43
+
44
+ Returns file changes and diff output with TOKEN-BASED pagination (MCP limit: 25k tokens).
45
+ Large diffs are automatically paginated to stay under the token limit.
46
+
47
+ After receiving a non-empty diff, analyze it and generate a commit message following:
48
+ - 50/72 rule: 50 char subject line, 72 char body lines
49
+ - Conventional commits format: type(scope): description
50
+ - DO NOT include AI signatures, attribution, or "Generated with" footers
51
+
52
+ Args:
53
+ cursor: Pagination cursor for large diffs (optional, returned as next_cursor)
54
+ max_diff_tokens: Maximum tokens per response (default: 20000, safe under 25k limit)
55
+ repo_path: Path to git repository (optional, defaults to Claude's working directory)
56
+
57
+ Returns:
58
+ JSON string with:
59
+ - has_changes: boolean - True if there are any staged changes to commit
60
+ - files: list of changed files with status
61
+ - diff: the diff chunk (paginated)
62
+ - next_cursor: pagination cursor for next chunk (if any)
63
+ - pagination_info: token counts and chunk info
64
+ - commit_format_guide: formatting rules for commit messages
65
+ """
66
+ try:
67
+ # Get working directory from MCP roots if repo_path not provided
68
+ if repo_path is None:
69
+ context = mcp.get_context()
70
+ roots_result = await context.session.list_roots()
71
+ if roots_result.roots:
72
+ repo_path = roots_result.roots[0].uri.path
73
+ else:
74
+ return json.dumps({'error': 'No repository path provided and no roots available'})
75
+
76
+ # Get file stats (staged changes only)
77
+ stats = git_operations.get_file_stats(repo_path)
78
+
79
+ # Get full diff (staged changes only)
80
+ diff_output = git_operations.get_diff(repo_path)
81
+
82
+ # Paginate diff by token count
83
+ paginated = git_operations.paginate_diff(diff_output, cursor, max_diff_tokens)
84
+
85
+ # Check if there are any changes - trust the diff as source of truth
86
+ # If git diff --staged is empty, there's nothing to commit
87
+ has_changes = bool(diff_output.strip())
88
+
89
+ result = {
90
+ 'repository': repo_path,
91
+ 'has_changes': has_changes,
92
+ 'files': stats['files'],
93
+ 'diff': paginated['diff_chunk'],
94
+ 'next_cursor': paginated['next_cursor'],
95
+ 'pagination_info': paginated['chunk_info'],
96
+ 'commit_format_guide': {
97
+ 'subject_line': 'Max 50 characters',
98
+ 'body_line_length': 'Max 72 characters per line',
99
+ 'format': 'type(scope): description\\n\\nBody paragraphs...\\n\\nFooter',
100
+ 'types': ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore'],
101
+ 'important': 'DO NOT include AI signatures, attribution, or "Generated with" footers in the commit message'
102
+ }
103
+ }
104
+
105
+ return json.dumps(result, indent=2)
106
+
107
+ except Exception as e:
108
+ return json.dumps({'error': str(e)})
109
+
110
+
111
+ @mcp.tool()
112
+ async def commit_changes(
113
+ message: str,
114
+ user_approved: bool,
115
+ repo_path: Optional[str] = None
116
+ ) -> str:
117
+ """Execute git commit with a user-approved commit message.
118
+
119
+ CRITICAL WORKFLOW - YOU MUST FOLLOW THESE STEPS IN ORDER:
120
+ 1. Call get_commit_context to get the diff
121
+ 2. Generate a commit message based on the actual diff
122
+ 3. SHOW the generated commit message to the user in your response
123
+ 4. ASK the user: "Should I proceed with this commit?" and WAIT for their response
124
+ 5. ONLY call this tool AFTER the user explicitly approves (says "yes", "proceed", "commit it", etc.)
125
+ 6. Set user_approved=True when calling this tool
126
+
127
+ DO NOT call this tool in the same response where you generate the commit message.
128
+ The user MUST see the message and approve it first.
129
+
130
+ Args:
131
+ message: User-approved commit message (should follow 50/72 rule)
132
+ user_approved: REQUIRED - Must be True. Confirms user has seen and approved the commit message.
133
+ repo_path: Path to git repository (optional, defaults to Claude's working directory)
134
+
135
+ Returns:
136
+ Success message with commit hash or error
137
+ """
138
+ # Safety check
139
+ if not user_approved:
140
+ return "Error: user_approved must be True. Show the commit message to the user and get their approval first."
141
+ try:
142
+ # Get working directory from MCP roots if repo_path not provided
143
+ if repo_path is None:
144
+ context = mcp.get_context()
145
+ roots_result = await context.session.list_roots()
146
+ if roots_result.roots:
147
+ repo_path = roots_result.roots[0].uri.path
148
+ else:
149
+ return "Error: No repository path provided and no roots available"
150
+
151
+ result = git_operations.execute_commit(repo_path, message)
152
+ return result
153
+ except Exception as e:
154
+ return f"Error: {str(e)}"
155
+
156
+
157
+ @mcp.tool()
158
+ async def get_pr_context(
159
+ base_branch: str,
160
+ head_branch: Optional[str] = None,
161
+ cursor: Optional[str] = None,
162
+ max_diff_tokens: int = 12000,
163
+ repo_path: Optional[str] = None
164
+ ) -> str:
165
+ """Get diff and commits between branches for PR description.
166
+
167
+ IMPORTANT: After calling this tool, you should:
168
+ 1. Check if .devnarrate/pr-templates/ directory exists (use ls or Bash)
169
+ 2. If templates exist, list them and ask user which template to use
170
+ 3. Read the chosen template file (use Read tool)
171
+ 4. If no templates exist, use git_operations.DEFAULT_PR_TEMPLATE
172
+ 5. Analyze the diff and commits to fill the template
173
+
174
+ Args:
175
+ base_branch: Base branch to compare against (e.g., "main", "dev")
176
+ head_branch: Head branch (defaults to current branch)
177
+ cursor: Pagination cursor for large diffs (optional, returned as next_cursor)
178
+ max_diff_tokens: Maximum tokens per diff chunk (default: 12000, leaves room for commits/files in 25k limit)
179
+ repo_path: Path to git repository (optional, defaults to Claude's working directory)
180
+
181
+ Returns:
182
+ JSON string with commits, files, diff chunk, and pagination info
183
+ """
184
+ try:
185
+ # Get working directory from MCP roots if repo_path not provided
186
+ if repo_path is None:
187
+ context = mcp.get_context()
188
+ roots_result = await context.session.list_roots()
189
+ if roots_result.roots:
190
+ repo_path = roots_result.roots[0].uri.path
191
+ else:
192
+ return json.dumps({'error': 'No repository path provided and no roots available'})
193
+
194
+ # Get current branch if head not specified
195
+ if head_branch is None:
196
+ head_branch = git_operations.get_current_branch(repo_path)
197
+
198
+ # Get commits between branches
199
+ commits = git_operations.get_branch_commits(repo_path, base_branch, head_branch)
200
+
201
+ # Get file stats
202
+ stats = git_operations.get_branch_file_stats(repo_path, base_branch, head_branch)
203
+
204
+ # Get diff between branches
205
+ diff_output = git_operations.get_branch_diff(repo_path, base_branch, head_branch)
206
+
207
+ # Paginate diff by token count
208
+ paginated = git_operations.paginate_diff(diff_output, cursor, max_diff_tokens)
209
+
210
+ # Detect platform
211
+ platform = git_operations.detect_git_platform(repo_path)
212
+
213
+ result = {
214
+ 'repository': repo_path,
215
+ 'base_branch': base_branch,
216
+ 'head_branch': head_branch,
217
+ 'platform': platform,
218
+ 'commits': commits,
219
+ 'commit_count': len(commits),
220
+ 'files': stats['files'],
221
+ 'diff': paginated['diff_chunk'],
222
+ 'next_cursor': paginated['next_cursor'],
223
+ 'pagination_info': paginated['chunk_info'],
224
+ 'template_instructions': {
225
+ 'templates_directory': '.devnarrate/pr-templates/',
226
+ 'default_template_available': True,
227
+ 'steps': [
228
+ '1. Check if .devnarrate/pr-templates/ exists',
229
+ '2. If yes, list templates and ask user which to use',
230
+ '3. Read chosen template or use DEFAULT_PR_TEMPLATE',
231
+ '4. Fill template with analysis of commits and diff'
232
+ ]
233
+ }
234
+ }
235
+
236
+ return json.dumps(result, indent=2)
237
+
238
+ except Exception as e:
239
+ return json.dumps({'error': str(e)})
240
+
241
+
242
+ @mcp.tool()
243
+ async def create_pr(
244
+ title: str,
245
+ body: str,
246
+ base_branch: str,
247
+ user_approved: bool,
248
+ head_branch: Optional[str] = None,
249
+ draft: bool = False,
250
+ repo_path: Optional[str] = None
251
+ ) -> str:
252
+ """Create a pull request on the detected platform (GitHub/GitLab).
253
+
254
+ CRITICAL WORKFLOW - YOU MUST FOLLOW THESE STEPS IN ORDER:
255
+ 1. Call get_pr_context to analyze the changes
256
+ 2. Generate PR title and description based on the diff
257
+ 3. SHOW the generated PR title and body to the user in your response
258
+ 4. ASK the user: "Should I create this PR?" and WAIT for their response
259
+ 5. ONLY call this tool AFTER the user explicitly approves (says "yes", "proceed", "create it", etc.)
260
+ 6. Set user_approved=True when calling this tool
261
+
262
+ DO NOT call this tool in the same response where you generate the PR description.
263
+ The user MUST see the content and approve it first.
264
+
265
+ Args:
266
+ title: PR title (keep it concise, ~50 chars)
267
+ body: PR description (formatted markdown)
268
+ base_branch: Base branch (e.g., "main", "dev")
269
+ user_approved: REQUIRED - Must be True. Confirms user has seen and approved the PR content.
270
+ head_branch: Head branch (defaults to current branch)
271
+ draft: Create as draft PR (default: False)
272
+ repo_path: Path to git repository (optional, defaults to Claude's working directory)
273
+
274
+ Returns:
275
+ Success message with PR URL or error message
276
+ """
277
+ # Safety check
278
+ if not user_approved:
279
+ return "Error: user_approved must be True. Show the PR description to the user and get their approval first."
280
+ try:
281
+ # Get working directory from MCP roots if repo_path not provided
282
+ if repo_path is None:
283
+ context = mcp.get_context()
284
+ roots_result = await context.session.list_roots()
285
+ if roots_result.roots:
286
+ repo_path = roots_result.roots[0].uri.path
287
+ else:
288
+ return "Error: No repository path provided and no roots available"
289
+
290
+ result = git_operations.execute_pr_creation(
291
+ repo_path=repo_path,
292
+ title=title,
293
+ body=body,
294
+ base_branch=base_branch,
295
+ head_branch=head_branch,
296
+ draft=draft
297
+ )
298
+ return result
299
+
300
+ except subprocess.CalledProcessError as e:
301
+ return f"Error creating PR: {e.stderr if e.stderr else str(e)}\n\nMake sure the platform CLI is installed and configured (gh for GitHub, glab for GitLab)"
302
+ except Exception as e:
303
+ return f"Error: {str(e)}"
304
+
305
+
306
+ if __name__ == "__main__":
307
+ mcp.run()
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: devnarrate
3
+ Version: 0.1.0a1
4
+ Summary: MCP server that narrates your code changes, from commits to deployments.
5
+ Author: K Man
6
+ Maintainer: K Man
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai,claude,commit,git,mcp,pr
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: mcp[cli]>=1.20.0
19
+ Requires-Dist: tiktoken>=0.5.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # DevNarrate
23
+ MCP server that narrates your code changes, from commits to deployments.
24
+
25
+ ## Features
26
+
27
+ - **Smart Commit Messages**: Generate conventional commit messages from staged changes with full user control
28
+ - **PR Descriptions**: Create detailed pull request descriptions with customizable templates
29
+ - **Multi-Platform**: Supports GitHub and GitLab
30
+ - **Token-Aware**: Handles large diffs with automatic pagination
31
+ - **Template System**: Use custom PR templates or built-in defaults
32
+ - **Safety First**: Only works with staged changes to prevent accidental commits
33
+
34
+ ## Installation
35
+
36
+ ### Option 1: Install from PyPI (Recommended)
37
+
38
+ 1. **Install the package:**
39
+ ```bash
40
+ pip install devnarrate
41
+
42
+ # Or for pre-release versions:
43
+ pip install --pre devnarrate
44
+ ```
45
+
46
+ 2. **Configure with Claude Code:**
47
+ ```bash
48
+ # Add MCP server using your Python path
49
+ claude mcp add DevNarrate -- python -m devnarrate.server
50
+
51
+ # Verify it's connected
52
+ claude mcp list
53
+ ```
54
+
55
+ 3. **Configure with Cursor:**
56
+
57
+ Edit `~/.cursor/mcp.json`:
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "DevNarrate": {
62
+ "command": "python",
63
+ "args": ["-m", "devnarrate.server"]
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Then restart Cursor.
70
+
71
+ ### Option 2: Install from Source (Development)
72
+
73
+ 1. **Prerequisites:** Install [uv](https://docs.astral.sh/uv/getting-started/installation/):
74
+ ```bash
75
+ curl -LsSf https://astral.sh/uv/install.sh | sh
76
+ ```
77
+
78
+ 2. **Clone and setup:**
79
+ ```bash
80
+ git clone https://github.com/krishnamandanapu/DevNarrate.git
81
+ cd DevNarrate
82
+ uv sync
83
+ ```
84
+
85
+ 3. **Configure with Claude Code:**
86
+ ```bash
87
+ claude mcp add DevNarrate -- uv --directory /path/to/DevNarrate run python -m devnarrate.server
88
+ ```
89
+
90
+ 4. **Configure with Cursor:**
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "DevNarrate": {
95
+ "command": "uv",
96
+ "args": ["--directory", "/path/to/DevNarrate", "run", "python", "-m", "devnarrate.server"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ ### Commit Messages
105
+
106
+ **Important:** DevNarrate only works with **staged changes** to ensure you have full control over what gets committed. This prevents accidental commits of unintended files.
107
+
108
+ 1. First, stage the files you want to commit:
109
+ ```bash
110
+ git add <file1> <file2>
111
+ # or for all tracked files with changes:
112
+ git add -u
113
+ ```
114
+
115
+ 2. Ask Claude to generate the commit message:
116
+ ```
117
+ Ask Claude: "Generate a commit message for my changes"
118
+ ```
119
+
120
+ 3. Claude will analyze your staged changes, show you the proposed commit message, and ask for approval before committing.
121
+
122
+ If you haven't staged any changes, Claude will prompt you to stage them first.
123
+
124
+ ### PR Descriptions
125
+
126
+ 1. Ask Claude: "Create a PR to main from my current branch"
127
+ 2. Claude will analyze the diff and ask which template to use (if you have custom templates)
128
+ 3. Claude generates the PR description and shows it to you
129
+ 4. Review and approve, then Claude creates the PR
130
+
131
+ ### PR Templates (Optional)
132
+ Create custom templates in `.devnarrate/pr-templates/`:
133
+
134
+ ```bash
135
+ mkdir -p .devnarrate/pr-templates
136
+ ```
137
+
138
+ Example template (`.devnarrate/pr-templates/feature.md`):
139
+ ```markdown
140
+ ## Summary
141
+ [What does this PR do?]
142
+
143
+ ## Changes
144
+ -
145
+ -
146
+
147
+ ## Testing
148
+ [How to test]
149
+
150
+ ## Related Issues
151
+ [Links]
152
+ ```
153
+
154
+ If no templates exist, a default template will be used.
155
+
156
+ ## Platform Support
157
+
158
+ **Commits:** Works everywhere (uses git)
159
+
160
+ **PRs:** Requires platform CLI:
161
+ - GitHub: Install [gh](https://cli.github.com/) and run `gh auth login`
162
+ - GitLab: Install [glab](https://gitlab.com/gitlab-org/cli) and run `glab auth login`
163
+
164
+ ## Development
165
+
166
+ For maintainers: See [RELEASING.md](RELEASING.md) for instructions on publishing releases to PyPI.
@@ -0,0 +1,7 @@
1
+ devnarrate/__init__.py,sha256=1h03V_pZh_m06lMN5ihHpN43JlphDiJkEMKa-ujFAfI,88
2
+ devnarrate/git_operations.py,sha256=MLZDSUlqT9NdV2aCJj3ONsxYEJBlbM7X1V61rK40CDE,12200
3
+ devnarrate/server.py,sha256=mdAKKJljhkfTV7tlmeFI4rsTLy4F-bPm00isKitzn3s,12337
4
+ devnarrate-0.1.0a1.dist-info/METADATA,sha256=arQYDu0m_ve-rvKt7FrYIvXAP0YWX3KWqnnZY9F9n3M,4262
5
+ devnarrate-0.1.0a1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ devnarrate-0.1.0a1.dist-info/licenses/LICENSE,sha256=gfDhdn_B7dhZkfIMrv7tT7Y_llChBs_ZSdxpscMipps,1062
7
+ devnarrate-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 K Man
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.