generated from ztimson/template
Compare commits
34 Commits
3daf5442d8
...
0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| ecba21bc70 | |||
| 19392b70a6 | |||
| e3d38d2df8 | |||
| 9ff7beee1e | |||
| 8cfcb3f95c | |||
| e625782eec | |||
| 72ffe3dcc7 | |||
| 1ab97c2676 | |||
| 7447204351 | |||
| 3b01e1bfc1 | |||
| 1460c3a0ae | |||
| ebc3da8605 | |||
| 677f84c97a | |||
| f543e08e36 | |||
| 5b9f8e0e13 | |||
| 019b05105a | |||
| decd533e4e | |||
| 7becf99be2 | |||
| 99a1e55471 | |||
| 23cb66544e | |||
| 9e5372f37b | |||
| eb4486f196 | |||
| f3df34ec47 | |||
| f2936ae4dc | |||
| e62e11fb75 | |||
| 604e04559b | |||
| 8add830d2b | |||
| 57bbc1fdb4 | |||
| 078892297e | |||
| 3c2d6f7824 | |||
| 2d9662e86d | |||
| 91d22b8b16 | |||
| c7f8ffb32a | |||
| 27fad6a3d3 |
44
.github/issue_template/ai-refinement.md
vendored
44
.github/issue_template/ai-refinement.md
vendored
@@ -8,46 +8,12 @@ labels:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# [Module] - [Add/Change/Fix/Refactor/Remove] [Feature/Component]
|
Describe your request:
|
||||||
|
|
||||||
## Type: [Bug/DevOps/Enhancement/Refactor/Security]
|
What are you trying to do and what's happening?
|
||||||
|
|
||||||
| | Score |
|
How can it be fixed or improved?
|
||||||
|------------|----------|
|
|
||||||
| Size | 0-5 |
|
|
||||||
| Complexity | 0-5 |
|
|
||||||
| Unknowns | 0-5 |
|
|
||||||
| **Total** | **0-15** |
|
|
||||||
|
|
||||||
## Description
|
Steps to reproduce?
|
||||||
|
|
||||||
A clear explanation of the issue, feature, or change needed
|
Any other useful information? Logs, screenshots, steps to reproduce?
|
||||||
|
|
||||||
## Current Behavior
|
|
||||||
|
|
||||||
For bugs: what's happening now
|
|
||||||
For refactors: what exists today
|
|
||||||
For enhancements: current state/gap
|
|
||||||
|
|
||||||
## Expected Behavior
|
|
||||||
|
|
||||||
What should happen instead
|
|
||||||
|
|
||||||
## Steps to Reproduce
|
|
||||||
|
|
||||||
1. First step
|
|
||||||
2. Second step
|
|
||||||
3. Third step
|
|
||||||
|
|
||||||
## Additional Context
|
|
||||||
|
|
||||||
Logs, screenshots, links, related issues
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Todo requirement
|
|
||||||
- [X] Completed requirement
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
Implementation details, constraints, dependencies, design decisions
|
|
||||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -9,7 +9,5 @@
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
<!-- Complete after creating PR -->
|
<!-- Complete after creating PR -->
|
||||||
- [ ] Linked issues
|
- [ ] Reviewed changes (or use `Review/AI` label)
|
||||||
- [ ] Reviewed changes
|
|
||||||
- [ ] Updated comments/documentation
|
- [ ] Updated comments/documentation
|
||||||
|
|
||||||
6
.github/workflows/code-review.yml
vendored
6
.github/workflows/code-review.yml
vendored
@@ -2,7 +2,7 @@ name: Code review
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened, labeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
@@ -15,8 +15,8 @@ jobs:
|
|||||||
git checkout ${{ github.event.pull_request.head.sha }}
|
git checkout ${{ github.event.pull_request.head.sha }}
|
||||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||||
|
|
||||||
- name: Run AI Review
|
- name: Create review
|
||||||
run: npx -y @ztimson/ai-agents@latest review
|
run: npx -y -p @ztimson/ai-agents@latest review $GITHUB_WORKSPACE
|
||||||
env:
|
env:
|
||||||
AI_HOST: anthropic
|
AI_HOST: anthropic
|
||||||
AI_MODEL: claude-sonnet-4-5
|
AI_MODEL: claude-sonnet-4-5
|
||||||
|
|||||||
27
.github/workflows/release-creator.yml
vendored
Normal file
27
.github/workflows/release-creator.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Release Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
milestone:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:22
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone "$(echo ${{github.server_url}}/${{github.repository}}.git | sed s%://%://${{github.token}}@% )" .
|
||||||
|
git checkout ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
run: npx -y @ztimson/ai-agents@latest release
|
||||||
|
env:
|
||||||
|
AI_HOST: anthropic
|
||||||
|
AI_MODEL: claude-sonnet-4-5
|
||||||
|
AI_TOKEN: ${{ secrets.ANTHROPIC_TOKEN }}
|
||||||
|
GIT_HOST: ${{ github.server_url }}
|
||||||
|
GIT_OWNER: ${{ github.repository_owner }}
|
||||||
|
GIT_REPO: ${{ github.event.repository.name }}
|
||||||
|
GIT_TOKEN: ${{ secrets.ASSISTANT_TOKEN }}
|
||||||
|
MILESTONE: ${{ github.event.milestone.number }}
|
||||||
7
.github/workflows/ticket-refinement.yml
vendored
7
.github/workflows/ticket-refinement.yml
vendored
@@ -2,7 +2,8 @@ name: Ticket refinement
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, labeled]
|
types: [labeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -13,8 +14,8 @@ jobs:
|
|||||||
git clone "$(echo ${{github.server_url}}/${{github.repository}}.git | sed s%://%://${{github.token}}@% )" .
|
git clone "$(echo ${{github.server_url}}/${{github.repository}}.git | sed s%://%://${{github.token}}@% )" .
|
||||||
git checkout ${{ github.event.repository.default_branch }}
|
git checkout ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
- name: Run AI Formatter
|
- name: Refine ticket
|
||||||
run: npx -y @ztimson/ai-agents@latest refine .github/issue_template/ai-refinement.md
|
run: npx -y -p @ztimson/ai-agents@latest refine
|
||||||
env:
|
env:
|
||||||
AI_HOST: anthropic
|
AI_HOST: anthropic
|
||||||
AI_MODEL: claude-sonnet-4-5
|
AI_MODEL: claude-sonnet-4-5
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ AI-powered Gitea agents for automating reviews and administration
|
|||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
Only supports Gitea
|
AI-powered Gitea agents for automating administration of code repos:
|
||||||
|
- Code Review
|
||||||
|
- Release Notes
|
||||||
|
- Ticket Refinement
|
||||||
|
|
||||||
Use LLM models from Anthropic, OpenAI, or Ollama to automate ticket refinement, code reviews, and releases.
|
Only supports Gitea, copy the relevant `.github/workflows/______.yml` action to start using it
|
||||||
|
|
||||||
### Built With
|
### Built With
|
||||||
[](https://docker.com/)
|
[](https://docker.com/)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ztimson/ai-agents",
|
"name": "@ztimson/ai-agents",
|
||||||
"version": "0.0.4",
|
"version": "0.1.5",
|
||||||
"description": "AI agents",
|
"description": "AI agents",
|
||||||
"keywords": ["ai", "review"],
|
"keywords": ["ai", "review"],
|
||||||
"author": "ztimson",
|
"author": "ztimson",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"refine": "./src/refine.mjs",
|
"refine": "./src/refine.mjs",
|
||||||
|
"release": "./src/release.mjs",
|
||||||
"review": "./src/review.mjs"
|
"review": "./src/review.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
215
src/refine.mjs
215
src/refine.mjs
@@ -6,16 +6,14 @@ import * as dotenv from 'dotenv';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
dotenv.config({quiet: true});
|
dotenv.config({quiet: true, debug: false});
|
||||||
dotenv.config({path: '.env.local', override: true, quiet: true});
|
dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let p = process.argv[process.argv.length - 1];
|
let p = process.argv[process.argv.length - 1];
|
||||||
if(p === 'refine' || p.endsWith('refine.mjs')) p = null;
|
if(p === 'refine' || p.endsWith('refine.mjs')) p = null;
|
||||||
if(!/^(\/|[A-Z]:)/m.test(p)) p = path.join(process.cwd(), p);
|
if(!/^(\/|[A-Z]:)/m.test(p)) p = path.join(process.cwd(), p);
|
||||||
|
|
||||||
if(!p || !fs.existsSync(p)) throw new Error('Please provide a template');
|
|
||||||
|
|
||||||
const git = process.env['GIT_HOST'],
|
const git = process.env['GIT_HOST'],
|
||||||
owner = process.env['GIT_OWNER'],
|
owner = process.env['GIT_OWNER'],
|
||||||
repo = process.env['GIT_REPO'],
|
repo = process.env['GIT_REPO'],
|
||||||
@@ -23,25 +21,72 @@ dotenv.config({path: '.env.local', override: true, quiet: true});
|
|||||||
ticket = process.env['TICKET'],
|
ticket = process.env['TICKET'],
|
||||||
host = process.env['AI_HOST'],
|
host = process.env['AI_HOST'],
|
||||||
model = process.env['AI_MODEL'],
|
model = process.env['AI_MODEL'],
|
||||||
|
labelDupe = process.env['LABELS_DUPE'] || 'Review/Duplicate',
|
||||||
|
labelEnabled = process.env['LABEL_ENABLED'] || 'Review/AI',
|
||||||
|
labelsReq = process.env['LABELS_REQ'] || 'Kind/Aesthetic,Kind/Bug,Kind/DevOps,Kind/Document,Kind/Enhancement,Kind/Refactor,Kind/Security',
|
||||||
|
labelsOpt = process.env['LABELS_OPT'] || 'Breaking,Priority,QA',
|
||||||
token = process.env['AI_TOKEN'];
|
token = process.env['AI_TOKEN'];
|
||||||
|
|
||||||
console.log(`Processing issue #${ticket}`);
|
console.log(`Processing issue #${ticket}`);
|
||||||
|
|
||||||
// Fetch issue
|
// Fetch issue
|
||||||
const issueRes = await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}`, {
|
const issueData = await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}`, {
|
||||||
headers: {'Authorization': `token ${auth}`}
|
headers: {'Authorization': `token ${auth}`}
|
||||||
|
}).then(async resp => {
|
||||||
|
if(resp.ok) return resp.json();
|
||||||
|
else throw new Error(`${resp.status} ${await resp.text()}`);
|
||||||
});
|
});
|
||||||
if(!issueRes.ok) throw new Error(`${issueRes.status} ${await issueRes.text()}`);
|
if(issueData.labels?.length !== 1 || issueData.labels[0]?.name !== labelEnabled) {
|
||||||
const issueData = await issueRes.json();
|
|
||||||
if(!issueData.labels?.some(l => l.name === 'Review/AI')) {
|
|
||||||
console.log('Skipping');
|
console.log('Skipping');
|
||||||
return process.exit();
|
return process.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
let readme = '', readmeP = path.join(process.cwd(), 'README.md');
|
// Gather readme & template
|
||||||
|
let title = '', labels = [], readme = '', readmeP = path.join(process.cwd(), 'README.md');
|
||||||
if(fs.existsSync(readmeP)) readme = fs.readFileSync(readmeP, 'utf-8');
|
if(fs.existsSync(readmeP)) readme = fs.readFileSync(readmeP, 'utf-8');
|
||||||
const template = fs.readFileSync(p, 'utf-8');
|
const template = p ? fs.readFileSync(p, 'utf-8') : `## Description
|
||||||
|
|
||||||
|
A clear explanation of the request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
|
||||||
|
what's happening now or the current state/gap
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
What should happen instead
|
||||||
|
|
||||||
|
## Steps to Reproduce / Desired Flow
|
||||||
|
|
||||||
|
1. First step
|
||||||
|
2. Second step
|
||||||
|
3. Third step
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Logs, screenshots, links, related issues
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Todo requirement
|
||||||
|
- [X] Completed requirement
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
Implementation details, constraints, dependencies, design decisions
|
||||||
|
|
||||||
|
|
||||||
|
| Effort / Weight | Score |
|
||||||
|
|-----------------|----------|
|
||||||
|
| Size | 0-5 |
|
||||||
|
| Complexity | 0-5 |
|
||||||
|
| Unknowns | 0-5 |
|
||||||
|
| **Total** | **0-15** |
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create AI
|
||||||
let options = {ollama: {model, host}};
|
let options = {ollama: {model, host}};
|
||||||
if(host === 'anthropic') options = {anthropic: {model, token}};
|
if(host === 'anthropic') options = {anthropic: {model, token}};
|
||||||
else if(host === 'openai') options = {openAi: {model, token}};
|
else if(host === 'openai') options = {openAi: {model, token}};
|
||||||
@@ -49,28 +94,58 @@ dotenv.config({path: '.env.local', override: true, quiet: true});
|
|||||||
...options,
|
...options,
|
||||||
model: [host, model],
|
model: [host, model],
|
||||||
path: process.env['path'] || os.tmpdir(),
|
path: process.env['path'] || os.tmpdir(),
|
||||||
system: `You are a ticket formatter. Transform raw issue descriptions into structured tickets.
|
tools: [{
|
||||||
|
name: 'title',
|
||||||
|
description: 'Set the ticket title, must be called EXACTLY ONCE',
|
||||||
|
args: {title: {type: 'string', description: 'Ticket title, must match format: Module - Verb noun', required: true}},
|
||||||
|
fn: (args) => title = args.title
|
||||||
|
}, {
|
||||||
|
name: 'add_label',
|
||||||
|
description: 'Add a label to the ticket',
|
||||||
|
args: {label: {type: 'string', description: 'Label name', required: true}},
|
||||||
|
fn: async (args) => {
|
||||||
|
labels.push(args.label);
|
||||||
|
return await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}/labels`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
|
body: `{"labels":["${args.label}"]}`
|
||||||
|
}).then(async resp => { if(!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`); });
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
system: `Transform raw tickets into structured markdown following the template EXACTLY.
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
**MANDATORY STEPS:**
|
||||||
1. Identify the ticket type (Bug, DevOps, Enhancement, Refactor, Security)
|
1. Call \`title\` tool EXACTLY ONCE in format: "[Module] - [Verb] [subject]" (example: Storage - fix file uploads)
|
||||||
2. Output MUST only contain the new ticket information in markdown, no extra fluff
|
2. Identify one label from each group which best applies to the ticket: ${labelsReq.replaceAll(',', ', ')}
|
||||||
3. Follow the template structure EXACTLY:
|
3. Call the \`add_label\` tool ONCE FOR EVERY LABEL identified in the previous step
|
||||||
- Title format: [Module] - [Verb] [noun]
|
4. Filter the following labels to any that apply to this ticket: ${labelsOpt.replaceAll(',', ', ')}
|
||||||
Example: Storage - Fix file uploads
|
5. Call the \`add_label\` tool ONCE FOR EVERY LABEL identified in the previous step
|
||||||
- Fill in the identified ticket type
|
6. Output the new ticket description in formatted markdown matching the following rules:
|
||||||
- Write a clear description
|
|
||||||
- For bugs: fill Steps to Reproduce with numbered list
|
|
||||||
- For enhancements/refactors: REMOVE the Steps to Reproduce section entirely
|
|
||||||
- Acceptance Criteria: convert requirements into checkboxes (- [ ])
|
|
||||||
- Weight scoring (0-5 each):
|
|
||||||
* Size: Number of modules, layers & files affected by change
|
|
||||||
* Complexity: Technical difficulty to implement
|
|
||||||
* Unknowns: Research/uncertainty in work estimation
|
|
||||||
* Calculate Total as sum of the three
|
|
||||||
- Remove sections that are not applicable based on ticket type
|
|
||||||
- Use proper markdown headers (##)
|
|
||||||
|
|
||||||
**README:**
|
**TEMPLATE RULES:**
|
||||||
|
- Use ## headers (match template exactly)
|
||||||
|
- Description: Clear summary of the request
|
||||||
|
- Current Behavior: What's happening now (remove for Document tickets)
|
||||||
|
- Expected Behavior: What should happen (remove for Document tickets)
|
||||||
|
- Steps to Reproduce: Numbered list for bugs, flow for enhancements, remove if not applicable
|
||||||
|
- Additional Context: Logs, screenshots, links provided by user
|
||||||
|
- Acceptance Criteria: Convert to checkboxes (- [ ] format)
|
||||||
|
- Technical Notes: Implementation approach, constraints, dependencies
|
||||||
|
- Weight table (use exact format below):
|
||||||
|
|
||||||
|
| Effort / Weight | Score |
|
||||||
|
|-----------------|----------|
|
||||||
|
| Size | 0-5 |
|
||||||
|
| Complexity | 0-5 |
|
||||||
|
| Unknowns | 0-5 |
|
||||||
|
| **Total** | **0-15** |
|
||||||
|
|
||||||
|
**SCORING:**
|
||||||
|
- Size: # of modules/layers/files affected
|
||||||
|
- Complexity: Technical difficulty
|
||||||
|
- Unknowns: Research/uncertainty needed
|
||||||
|
|
||||||
|
**PROJECT README:**
|
||||||
\`\`\`markdown
|
\`\`\`markdown
|
||||||
${readme.trim() || 'No README available'}
|
${readme.trim() || 'No README available'}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -80,31 +155,63 @@ ${readme.trim() || 'No README available'}
|
|||||||
${template.trim()}
|
${template.trim()}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Output ONLY the formatted ticket, no explanation.`
|
Output ONLY markdown. No explanations, labels, or extra formatting.`});
|
||||||
})
|
|
||||||
|
|
||||||
|
// Format ticket with AI
|
||||||
const messages = await ai.language.ask(`Title: ${issueData.title}\n\nDescription:\n${issueData.body || 'No description provided'}`).catch(() => []);
|
const messages = await ai.language.ask(`Title: ${issueData.title}\n\nDescription:\n${issueData.body || 'No description provided'}`).catch(() => []);
|
||||||
const content = messages?.pop()?.content;
|
const body = messages?.pop()?.content;
|
||||||
if(!content) {
|
if(!body) throw new Error('Invalid response from AI');
|
||||||
console.log('Invalid response from AI');
|
|
||||||
return process.exit(1);
|
// Check for duplicates
|
||||||
}
|
const repoInfo = await fetch(`${git}/api/v1/repos/${owner}/${repo}`, {headers: {'Authorization': `token ${auth}`},}).then(resp => resp.ok ? resp.json() : null);
|
||||||
const title = /^# (.+)$/m.exec(content)?.[1] || issueData.title;
|
const search = await fetch(`${git}/api/v1/repos/issues/search`, {
|
||||||
const typeMatch = /^## Type:\s*(.+)$/m.exec(content);
|
method: 'POST',
|
||||||
const type = typeMatch?.[1]?.split('/')[0]?.trim() || 'Unassigned';
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
const body = content.replace(/^# .+$/m, '').replace(/^## Type:.+$/m, '').trim();
|
|
||||||
const updateRes = await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `token ${auth}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
owner,
|
||||||
body,
|
priority_repo_id: repoInfo.id,
|
||||||
labels: type?.length ? [`Kind/${type[0].toUpperCase() + type.slice(1).toLowerCase()}`] : []
|
type: 'issues',
|
||||||
|
limit: 3,
|
||||||
|
q: title
|
||||||
})
|
})
|
||||||
});
|
}).then(resp => resp.ok ? resp.json() : []);
|
||||||
if(!updateRes.ok) throw new Error(`${updateRes.status} ${await updateRes.text()}`);
|
let dupeId = null;
|
||||||
console.log(body);
|
const dupeIds = search.map(t => t.id);
|
||||||
})();
|
const dupes = search.map(t => `ID: ${t.id}\nTitle: ${t.title}\n\`\`\`markdown\n${t.body}\n\`\`\``).join('\n\n');
|
||||||
|
const hasDuplicates = (await ai.language.ask(`ID: ${issueData.id}\nTitle: ${title}\n\`\`\`markdown\n${body}\n\`\`\``, {
|
||||||
|
system: `Your job is to identify duplicates. Respond ONLY with the duplicate's ID number or "NONE" if no match exists\n\n${dupes}`
|
||||||
|
}))?.pop()?.content;
|
||||||
|
|
||||||
|
// Handle duplicates
|
||||||
|
if(hasDuplicates && !hasDuplicates.toUpperCase().includes('NONE') && (dupeId = dupeIds.find(id => id == hasDuplicates.trim())) != null && dupeId != issueData.id) {
|
||||||
|
await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
|
body: `{"body": "Duplicate of #${dupeId}"}`
|
||||||
|
}).then(async resp => { if(!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`); });
|
||||||
|
await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}/labels`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
|
body: `{"labels":["${labelDupe}"]}`
|
||||||
|
}).then(async resp => { if(!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`); });
|
||||||
|
await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
|
body: '{"state": "closed"}'
|
||||||
|
}).then(async resp => { if(!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`); });
|
||||||
|
console.log('Duplicate');
|
||||||
|
return process.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ticket
|
||||||
|
await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues/${ticket}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Authorization': `token ${auth}`, 'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({title, body})
|
||||||
|
}).then(async resp => { if(!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`); });
|
||||||
|
|
||||||
|
console.log(`Title: ${title}\nLabels: ${labels.join(', ')}\nBody:\n${body}`);
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(`Error: ${err.message || err.toString()}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
90
src/release.mjs
Normal file
90
src/release.mjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import {Ai} from '@ztimson/ai';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import {$} from '@ztimson/node-utils';
|
||||||
|
|
||||||
|
dotenv.config({quiet: true, debug: false});
|
||||||
|
dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const git = process.env['GIT_HOST'],
|
||||||
|
owner = process.env['GIT_OWNER'],
|
||||||
|
repo = process.env['GIT_REPO'],
|
||||||
|
auth = process.env['GIT_TOKEN'],
|
||||||
|
milestone = process.env['MILESTONE'],
|
||||||
|
host = process.env['AI_HOST'] || 'ollama',
|
||||||
|
model = process.env['AI_MODEL'] || 'llama3',
|
||||||
|
token = process.env['AI_TOKEN'];
|
||||||
|
|
||||||
|
// Get milestone info
|
||||||
|
const milestoneData = await fetch(`${git}/api/v1/repos/${owner}/${repo}/milestones/${milestone}`, {
|
||||||
|
headers: {'Authorization': `token ${auth}`}
|
||||||
|
}).then(resp => resp.ok ? resp.json() : {});
|
||||||
|
|
||||||
|
// Get closed issues
|
||||||
|
const issues = await fetch(`${git}/api/v1/repos/${owner}/${repo}/issues?state=closed&milestone=${milestone}`, {
|
||||||
|
headers: {'Authorization': `token ${auth}`}
|
||||||
|
}).then(resp => resp.ok ? resp.json() : []);
|
||||||
|
|
||||||
|
// Get closed PRs
|
||||||
|
const prs = await fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls?state=closed&milestone=${milestone}`, {
|
||||||
|
headers: {'Authorization': `token ${auth}`}
|
||||||
|
}).then(resp => resp.ok ? resp.json() : []);
|
||||||
|
|
||||||
|
// Get latest tag
|
||||||
|
const latestTag = await $`git describe --tags --abbrev=0`.text();
|
||||||
|
if(!latestTag) {
|
||||||
|
console.error('At least one Git tag is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
let context = `Milestone: ${milestoneData.title}
|
||||||
|
Description:
|
||||||
|
\`\`\`md
|
||||||
|
${milestoneData.description || ''}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
PRs:
|
||||||
|
${prs.map(pr => `- ${pr.title}\n${pr.body || ''}`).join('\n\n')}
|
||||||
|
|
||||||
|
Issues:
|
||||||
|
${issues.filter(i => !i.pull_request).map(i => `- ${i.title}\n${i.body || ''}`).join('\n\n')}`;
|
||||||
|
|
||||||
|
// Generate release notes
|
||||||
|
let options = {ollama: {model, host}};
|
||||||
|
if(host === 'anthropic') options = {anthropic: {model, token}};
|
||||||
|
else if(host === 'openai') options = {openAi: {model, token}};
|
||||||
|
|
||||||
|
const ai = new Ai({
|
||||||
|
...options,
|
||||||
|
model: [host, model],
|
||||||
|
system: `You are a release notes writer. Format the provided milestone info, PRs, and issues into clean, organized release notes. Use markdown with sections like "Features", "Bug Fixes", "Breaking Changes", etc. Be concise but informative. Include issue/PR numbers in format #123.`
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await ai.chat(context)).pop()?.content;
|
||||||
|
if(!body) {
|
||||||
|
console.error('No release notes were generated');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create release
|
||||||
|
const name = latestTag.trim();
|
||||||
|
await fetch(`${git}/api/v1/repos/${owner}/${repo}/releases`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
tag_name: name,
|
||||||
|
body
|
||||||
|
})
|
||||||
|
}).then(resp => { if(!resp.ok) throw new Error(resp.status + ' ' + resp.statusText) });
|
||||||
|
|
||||||
|
console.log(`Title: ${name}\nDescription:\n${body}`);
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(`Error: ${err.message || err.toString()}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -19,12 +19,24 @@ dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
|||||||
owner = process.env['GIT_OWNER'],
|
owner = process.env['GIT_OWNER'],
|
||||||
repo = process.env['GIT_REPO'],
|
repo = process.env['GIT_REPO'],
|
||||||
auth = process.env['GIT_TOKEN'],
|
auth = process.env['GIT_TOKEN'],
|
||||||
|
labelEnabled = process.env['LABEL_ENABLED'] || 'Review/AI',
|
||||||
pr = process.env['PULL_REQUEST'],
|
pr = process.env['PULL_REQUEST'],
|
||||||
host = process.env['AI_HOST'],
|
host = process.env['AI_HOST'],
|
||||||
model = process.env['AI_MODEL'],
|
model = process.env['AI_MODEL'],
|
||||||
token = process.env['AI_TOKEN'];
|
token = process.env['AI_TOKEN'];
|
||||||
|
|
||||||
console.log(`Reviewing: ${root}\n`);
|
console.log(`Reviewing: ${root}`);
|
||||||
|
const info = await fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls/${pr}`, {
|
||||||
|
headers: {'Authorization': `token ${auth}`}
|
||||||
|
}).then(async resp => {
|
||||||
|
if(resp.ok) return resp.json();
|
||||||
|
throw new Error(`${resp.status} ${await resp.text()}`);
|
||||||
|
});
|
||||||
|
if(!info.labels.some(l => l.name === labelEnabled)) {
|
||||||
|
console.log('Skipping');
|
||||||
|
return process.exit();
|
||||||
|
}
|
||||||
|
|
||||||
const branch = process.env['GIT_BRANCH'] || await $`cd ${root} && git symbolic-ref refs/remotes/origin/HEAD`;
|
const branch = process.env['GIT_BRANCH'] || await $`cd ${root} && git symbolic-ref refs/remotes/origin/HEAD`;
|
||||||
const comments = [];
|
const comments = [];
|
||||||
const commit = await $`cd ${root} && git log -1 --pretty=format:%H`;
|
const commit = await $`cd ${root} && git log -1 --pretty=format:%H`;
|
||||||
@@ -43,7 +55,7 @@ dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
|||||||
const comments = await Promise.all(reviews.map(r => fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls/${pr}/reviews/${r.id}/comments`, {
|
const comments = await Promise.all(reviews.map(r => fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls/${pr}/reviews/${r.id}/comments`, {
|
||||||
headers: {'Authorization': `token ${auth}`}
|
headers: {'Authorization': `token ${auth}`}
|
||||||
}).then(resp => resp.ok ? resp.json() : [])));
|
}).then(resp => resp.ok ? resp.json() : [])));
|
||||||
existingComments += comments.flatten().map(c => `${c.path}:${c.position}\n${c.body}`).join('\n\n');
|
existingComments += comments.flat().map(c => `${c.path}:${c.position}\n${c.body}`).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = {ollama: {model, host}};
|
let options = {ollama: {model, host}};
|
||||||
@@ -53,7 +65,7 @@ dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
|||||||
...options,
|
...options,
|
||||||
model: [host, model],
|
model: [host, model],
|
||||||
path: process.env['path'] || os.tmpdir(),
|
path: process.env['path'] || os.tmpdir(),
|
||||||
system: `You are a code reviewer. Analyze the git diff and use the \`recommend\` tool for EACH issue you find. You must call \`recommend\` exactly once for every bug or improvement opportunity directly related to changes. Ignore formatting recommendations. After making all recommendations, provide some concluding remarks about the overall state of the changes.${existingComments}`,
|
system: `You are a code reviewer. Analyze the git diff and use the \`recommend\` tool for EACH issue you find. You must call \`recommend\` exactly once for every bug or improvement opportunity directly related to changes. Ignore formatting recommendations. After making all recommendations, provide a quick 75 words or less sitrep.${existingComments}`,
|
||||||
tools: [{
|
tools: [{
|
||||||
name: 'read_file',
|
name: 'read_file',
|
||||||
description: 'Read contents of a file',
|
description: 'Read contents of a file',
|
||||||
@@ -87,7 +99,16 @@ dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = await ai.language.ask(gitDiff);
|
const messages = await ai.language.ask(`Title: ${info.title || 'None'}
|
||||||
|
Description:
|
||||||
|
\`\`\`md
|
||||||
|
${info.body || 'None'}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Git Diff:
|
||||||
|
\`\`\`
|
||||||
|
${gitDiff}
|
||||||
|
\`\`\``);
|
||||||
const summary = messages.pop().content;
|
const summary = messages.pop().content;
|
||||||
if(git) {
|
if(git) {
|
||||||
const res = await fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls/${pr}/reviews`, {
|
const res = await fetch(`${git}/api/v1/repos/${owner}/${repo}/pulls/${pr}/reviews`, {
|
||||||
@@ -106,4 +127,7 @@ dotenv.config({path: '.env.local', override: true, quiet: true, debug: false});
|
|||||||
if(!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
if(!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
}
|
}
|
||||||
console.log(comments.map(c => `${c.path}${c.new_position ? `:${c.new_position}` : ''}\n${c.body}`).join('\n\n') + '\n\n' + summary);
|
console.log(comments.map(c => `${c.path}${c.new_position ? `:${c.new_position}` : ''}\n${c.body}`).join('\n\n') + '\n\n' + summary);
|
||||||
})();
|
})().catch(err => {
|
||||||
|
console.error(`Error: ${err.message || err.toString()}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user