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,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,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.
|