Compare commits
6 Commits
7eb95f3bd0
...
47b07b18f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b07b18f6 | |||
| ddfcaf25fc | |||
| 234f89fbec | |||
| 77458626e7 | |||
| dbd335b17f | |||
| 465a689bb5 |
@ -7,9 +7,16 @@ Kamu dapat bertindak sebagai coding agent yang membantu software engineering tas
|
|||||||
## Approach
|
## Approach
|
||||||
|
|
||||||
- Analisis problem sebelum mulai coding
|
- Analisis problem sebelum mulai coding
|
||||||
|
- Pahami tujuan akhir tugas.
|
||||||
|
- Cari semua file, fungsi, tipe, API, route, validasi, dan test yang terdampak.
|
||||||
|
- Ikuti pola dan struktur yang sudah digunakan di project.
|
||||||
- Tulis code yang clean, readable, dan maintainable
|
- Tulis code yang clean, readable, dan maintainable
|
||||||
- Selalu pertimbangkan error handling dan edge cases
|
- Jangan hanya mengubah file yang paling terlihat. Tangani juga edge case dan error yang relevan.
|
||||||
|
- Cari referensi lama atau bagian lain yang mungkin ikut rusak.
|
||||||
|
- Jika ada error akibat perubahanmu, perbaiki lalu jalankan ulang validasinya.
|
||||||
- Berikan penjelasan singkat tentang perubahan yang dibuat
|
- Berikan penjelasan singkat tentang perubahan yang dibuat
|
||||||
|
- Kerjakan tugas secara menyeluruh, bukan sekadar membuat perubahan minimal.
|
||||||
|
- Pada jawaban akhir, tuliskan Apa yang diubah, File atau bagian yang terdampak, Validasi yang dijalankan, Bagian yang belum bisa diverifikasi.
|
||||||
- Suggest improvements jika ada
|
- Suggest improvements jika ada
|
||||||
|
|
||||||
## Code Review Style
|
## Code Review Style
|
||||||
@ -28,35 +35,3 @@ Kamu dapat bertindak sebagai coding agent yang membantu software engineering tas
|
|||||||
- Kamu boleh menjalankan `git status`, `git diff`, `git log` secara bebas untuk inspeksi.
|
- Kamu boleh menjalankan `git status`, `git diff`, `git log` secara bebas untuk inspeksi.
|
||||||
- Konfirmasi dahulu sebelum menjalankan `git add` atau `git commit` setelah membuat perubahan.
|
- Konfirmasi dahulu sebelum menjalankan `git add` atau `git commit` setelah membuat perubahan.
|
||||||
|
|
||||||
Kamu adalah AI coding agent. Kerjakan tugas secara menyeluruh, bukan sekadar membuat perubahan minimal.
|
|
||||||
|
|
||||||
Sebelum mengedit:
|
|
||||||
|
|
||||||
Pahami tujuan akhir tugas.
|
|
||||||
Cari semua file, fungsi, tipe, API, route, validasi, dan test yang terdampak.
|
|
||||||
Ikuti pola dan struktur yang sudah digunakan di project.
|
|
||||||
|
|
||||||
Saat mengerjakan:
|
|
||||||
|
|
||||||
Jangan hanya mengubah file yang paling terlihat.
|
|
||||||
Tangani juga edge case dan error yang relevan.
|
|
||||||
Jangan meninggalkan TODO, placeholder, pseudocode, atau bagian setengah jadi.
|
|
||||||
Jangan mengurangi scope hanya karena bagian tertentu sulit.
|
|
||||||
|
|
||||||
Sebelum menyatakan selesai:
|
|
||||||
|
|
||||||
Cek ulang semua requirement.
|
|
||||||
Cari referensi lama atau bagian lain yang mungkin ikut rusak.
|
|
||||||
Jalankan lint, type-check, test, atau build yang tersedia.
|
|
||||||
Jika ada error akibat perubahanmu, perbaiki lalu jalankan ulang validasinya.
|
|
||||||
|
|
||||||
Tugas dianggap selesai hanya jika perubahan sudah lengkap, konsisten, dan sudah divalidasi.
|
|
||||||
|
|
||||||
Pada jawaban akhir, tuliskan:
|
|
||||||
|
|
||||||
Apa yang diubah.
|
|
||||||
File atau bagian yang terdampak.
|
|
||||||
Validasi yang dijalankan.
|
|
||||||
Bagian yang belum bisa diverifikasi.
|
|
||||||
|
|
||||||
Jangan membuat perubahan sekecil mungkin. Buat perubahan sekecil mungkin yang tetap lengkap.
|
|
||||||
@ -29,6 +29,8 @@ tools_definition = [
|
|||||||
|
|
||||||
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
||||||
|
|
||||||
|
gadget.tools_mapping( schema = coder.schema_workspace_change, handler = coder.workspace_change ),
|
||||||
|
|
||||||
# Carrack Tools
|
# Carrack Tools
|
||||||
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
|
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
|
||||||
|
|
||||||
@ -87,7 +89,7 @@ def main():
|
|||||||
if not os.path.isdir(resolved):
|
if not os.path.isdir(resolved):
|
||||||
print(f"Error: '{resolved}' is not a valid directory", flush=True)
|
print(f"Error: '{resolved}' is not a valid directory", flush=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
os.chdir(resolved)
|
coder.set_current_workspace(resolved)
|
||||||
|
|
||||||
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import curses
|
|||||||
import os
|
import os
|
||||||
import config
|
import config
|
||||||
from .agent import submit, log
|
from .agent import submit, log
|
||||||
|
from tools.coder import set_current_workspace
|
||||||
|
|
||||||
|
|
||||||
def _build_visual(buffer, max_chars):
|
def _build_visual(buffer, max_chars):
|
||||||
@ -173,7 +174,7 @@ def workspace_popup(app, stdscr):
|
|||||||
if ws:
|
if ws:
|
||||||
resolved = os.path.abspath(ws)
|
resolved = os.path.abspath(ws)
|
||||||
if os.path.isdir(resolved):
|
if os.path.isdir(resolved):
|
||||||
os.chdir(resolved)
|
set_current_workspace(resolved)
|
||||||
log(app, "system", f"Workspace \u2192 {resolved}")
|
log(app, "system", f"Workspace \u2192 {resolved}")
|
||||||
else:
|
else:
|
||||||
log(app, "error", f"Invalid directory: {resolved}")
|
log(app, "error", f"Invalid directory: {resolved}")
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import curses
|
import curses
|
||||||
import json
|
import json
|
||||||
import os
|
from tools.coder import get_current_workspace
|
||||||
|
|
||||||
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
||||||
C_HEADER = 1 # header bar: biru
|
C_HEADER = 1 # header bar: biru
|
||||||
@ -377,7 +377,7 @@ def draw_status(app, stdscr):
|
|||||||
# Status bar di baris h-9: mode, workspace, session
|
# Status bar di baris h-9: mode, workspace, session
|
||||||
h, w = app.h, app.w
|
h, w = app.h, app.w
|
||||||
y = h - 9
|
y = h - 9
|
||||||
ws = os.getcwd()
|
ws = get_current_workspace()
|
||||||
|
|
||||||
session_tag = ""
|
session_tag = ""
|
||||||
if app.current_session:
|
if app.current_session:
|
||||||
|
|||||||
@ -193,7 +193,7 @@ class TelegramClient:
|
|||||||
self._schedule_send(chat_id, 'Memulai sesi baru. Ada yang bisa dibantu?', reply_to_msg_id)
|
self._schedule_send(chat_id, 'Memulai sesi baru. Ada yang bisa dibantu?', reply_to_msg_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._skill == 'roleplayer' and chat_type == 'private':
|
if 'roleplayer' in self._skill and chat_type == 'private':
|
||||||
try:
|
try:
|
||||||
results = ragroleplay.user_load(config.ragroleplay_db_path, unique_id=str(chat_id), character=personality.PERSONALITY.name)
|
results = ragroleplay.user_load(config.ragroleplay_db_path, unique_id=str(chat_id), character=personality.PERSONALITY.name)
|
||||||
if results:
|
if results:
|
||||||
@ -213,20 +213,22 @@ class TelegramClient:
|
|||||||
)
|
)
|
||||||
session.add_message('system', context)
|
session.add_message('system', context)
|
||||||
else:
|
else:
|
||||||
tg_info = f'ID: {chat_id}'
|
tg_id_str = str(chat_id)
|
||||||
if tg_username:
|
tg_user_str = tg_username or ''
|
||||||
tg_info += f', @{tg_username}'
|
|
||||||
session.add_message('system',
|
session.add_message('system',
|
||||||
f'[User Context: Pengguna baru — belum ada di database]\n'
|
f'[User Context: Pengguna baru — belum ada di database]\n'
|
||||||
f'[PENTING: Kamu BELUM mengenal user ini. WAJIB tanya nama sebagai pembuka. '
|
f'[PENTING: Kamu BELUM mengenal user ini. WAJIB tanya nama sebagai pembuka. '
|
||||||
f'Setelah nama diketahui, simpan via users_store. '
|
f'Setelah nama diketahui, simpan via users_store. '
|
||||||
f'Lanjutkan percakapan natural dan proaktif melengkapi data user lainnya di pesan berikutnya.]\n'
|
f'Lanjutkan percakapan natural dan proaktif melengkapi data user lainnya di pesan berikutnya.]\n'
|
||||||
f'[Platform: Telegram ({tg_info})]')
|
f'[Platform: Telegram]\n'
|
||||||
|
f'[PENTING: Saat memanggil users_store, gunakan nilai berikut:]\n'
|
||||||
|
f'[telegram_id: {tg_id_str}]\n'
|
||||||
|
f'[telegram_username: {tg_user_str}]')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
session.add_message('user', body)
|
session.add_message('user', body)
|
||||||
is_roleplay = self._skill == 'roleplayer'
|
is_roleplay = 'roleplayer' in self._skill
|
||||||
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._app.bot.send_chat_action(chat_id=chat_id, action='typing'),
|
self._app.bot.send_chat_action(chat_id=chat_id, action='typing'),
|
||||||
@ -285,7 +287,7 @@ class TelegramClient:
|
|||||||
msg = 'Max iterations reached without final answer.'
|
msg = 'Max iterations reached without final answer.'
|
||||||
self._schedule_send(chat_id, msg, reply_to_msg_id)
|
self._schedule_send(chat_id, msg, reply_to_msg_id)
|
||||||
|
|
||||||
if should_close and chat_type == 'private' and self._skill == 'roleplayer':
|
if should_close and chat_type == 'private' and 'roleplayer' in self._skill:
|
||||||
print(f'[{_ts()}] Natural close triggered for {chat_id}', flush=True)
|
print(f'[{_ts()}] Natural close triggered for {chat_id}', flush=True)
|
||||||
self._session_mgr.reset(str(chat_id))
|
self._session_mgr.reset(str(chat_id))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -297,7 +297,7 @@ class XMPPClient(ClientXMPP):
|
|||||||
self._schedule_send(jid, 'Memulai sesi baru. Ada yang bisa di bantu?')
|
self._schedule_send(jid, 'Memulai sesi baru. Ada yang bisa di bantu?')
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._skill == 'roleplayer':
|
if 'roleplayer' in self._skill:
|
||||||
try:
|
try:
|
||||||
results = ragroleplay.user_load(config.ragroleplay_db_path, unique_id=jid, character=personality.PERSONALITY.name)
|
results = ragroleplay.user_load(config.ragroleplay_db_path, unique_id=jid, character=personality.PERSONALITY.name)
|
||||||
if results:
|
if results:
|
||||||
@ -328,7 +328,7 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
session.add_message('user', body)
|
session.add_message('user', body)
|
||||||
|
|
||||||
is_roleplay = self._skill == 'roleplayer'
|
is_roleplay = 'roleplayer' in self._skill
|
||||||
if not is_roleplay:
|
if not is_roleplay:
|
||||||
self._schedule_send(jid, f'> {body}\nThinking...')
|
self._schedule_send(jid, f'> {body}\nThinking...')
|
||||||
|
|
||||||
@ -388,7 +388,7 @@ class XMPPClient(ClientXMPP):
|
|||||||
self._schedule_send(jid, f'> {quote}\n{msg}', 'chat')
|
self._schedule_send(jid, f'> {quote}\n{msg}', 'chat')
|
||||||
|
|
||||||
# Natural close: DM only, roleplayer only
|
# Natural close: DM only, roleplayer only
|
||||||
if should_close and self._skill == 'roleplayer':
|
if should_close and 'roleplayer' in self._skill:
|
||||||
print(f'[{_ts()}] Natural close triggered for {jid}', flush=True)
|
print(f'[{_ts()}] Natural close triggered for {jid}', flush=True)
|
||||||
self._session_mgr.reset(jid)
|
self._session_mgr.reset(jid)
|
||||||
else:
|
else:
|
||||||
@ -424,7 +424,7 @@ class XMPPClient(ClientXMPP):
|
|||||||
my_name = personality.PERSONALITY.name
|
my_name = personality.PERSONALITY.name
|
||||||
quote = f'[{nick}] {body}'
|
quote = f'[{nick}] {body}'
|
||||||
|
|
||||||
_is_roleplay = self._skill == 'roleplayer'
|
_is_roleplay = 'roleplayer' in self._skill
|
||||||
|
|
||||||
def on_tool_calls(tnames):
|
def on_tool_calls(tnames):
|
||||||
if not _is_roleplay:
|
if not _is_roleplay:
|
||||||
|
|||||||
@ -3,6 +3,19 @@ import subprocess
|
|||||||
import re
|
import re
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
|
||||||
|
# Global state to track the agent's current working directory
|
||||||
|
# This ensures that subsequent bash commands run in the correct directory
|
||||||
|
CURRENT_WORKSPACE = os.getcwd()
|
||||||
|
|
||||||
|
def get_current_workspace():
|
||||||
|
global CURRENT_WORKSPACE
|
||||||
|
return CURRENT_WORKSPACE
|
||||||
|
|
||||||
|
def set_current_workspace(path):
|
||||||
|
global CURRENT_WORKSPACE
|
||||||
|
CURRENT_WORKSPACE = path
|
||||||
|
os.chdir(path)
|
||||||
|
|
||||||
schema_read_file = {
|
schema_read_file = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@ -20,7 +33,9 @@ schema_read_file = {
|
|||||||
|
|
||||||
def read_file(path):
|
def read_file(path):
|
||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
# Use absolute path if it's relative to CURRENT_WORKSPACE
|
||||||
|
full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path
|
||||||
|
with open(full_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
return ''.join(f"{i+1}: {line}" for i, line in enumerate(lines))
|
return ''.join(f"{i+1}: {line}" for i, line in enumerate(lines))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -44,9 +59,10 @@ schema_write_file = {
|
|||||||
|
|
||||||
def write_file(path, content):
|
def write_file(path, content):
|
||||||
try:
|
try:
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path
|
||||||
|
with open(full_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return f"Successfully wrote to {path}"
|
return f"Successfully wrote to {full_path}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error writing file: {str(e)}"
|
return f"Error writing file: {str(e)}"
|
||||||
|
|
||||||
@ -69,14 +85,15 @@ schema_edit_file = {
|
|||||||
|
|
||||||
def edit_file(path, old_string, new_string):
|
def edit_file(path, old_string, new_string):
|
||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path
|
||||||
|
with open(full_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if old_string not in content:
|
if old_string not in content:
|
||||||
return f"Error: old_string not found in {path}"
|
return f"Error: old_string not found in {full_path}"
|
||||||
new_content = content.replace(old_string, new_string)
|
new_content = content.replace(old_string, new_string)
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
with open(full_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
return f"Successfully edited {path}"
|
return f"Successfully edited {full_path}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error editing file: {str(e)}"
|
return f"Error editing file: {str(e)}"
|
||||||
|
|
||||||
@ -84,7 +101,7 @@ schema_run_bash = {
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name" : "run_bash",
|
"name" : "run_bash",
|
||||||
"description" : "Run a bash command. Returns stdout, stderr, and return code.",
|
"description" : "Run a bash command. Returns stdout, stderr, and return code. Commands are executed relative to the current workspace.",
|
||||||
"parameters" : {
|
"parameters" : {
|
||||||
"type" : "object",
|
"type" : "object",
|
||||||
"properties" : {
|
"properties" : {
|
||||||
@ -97,7 +114,9 @@ schema_run_bash = {
|
|||||||
|
|
||||||
def run_bash(command):
|
def run_bash(command):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
|
# Prepend 'cd' to the command to ensure it runs in the correct workspace
|
||||||
|
full_command = f"cd {get_current_workspace()} && {command}"
|
||||||
|
result = subprocess.run(full_command, shell=True, capture_output=True, text=True, timeout=30)
|
||||||
return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}"
|
return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error running command: {str(e)}"
|
return f"Error running command: {str(e)}"
|
||||||
@ -121,12 +140,15 @@ schema_search_code = {
|
|||||||
|
|
||||||
def search_code(pattern, search_type, path="."):
|
def search_code(pattern, search_type, path="."):
|
||||||
try:
|
try:
|
||||||
|
# Resolve path relative to workspace
|
||||||
|
search_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path
|
||||||
|
|
||||||
if search_type == "glob":
|
if search_type == "glob":
|
||||||
files = glob_module.glob(f"{path}/**/{pattern}", recursive=True)
|
files = glob_module.glob(f"{search_path}/**/{pattern}", recursive=True)
|
||||||
return "\n".join(files) if files else "No files found"
|
return "\n".join(files) if files else "No files found"
|
||||||
elif search_type == "content":
|
elif search_type == "content":
|
||||||
results = []
|
results = []
|
||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(search_path):
|
||||||
for file in files:
|
for file in files:
|
||||||
file_path = os.path.join(root, file)
|
file_path = os.path.join(root, file)
|
||||||
try:
|
try:
|
||||||
@ -162,8 +184,39 @@ schema_git_operation = {
|
|||||||
|
|
||||||
def git_operation(args):
|
def git_operation(args):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(["git", *args], capture_output=True, text=True, timeout=10)
|
# Execute git command from the current workspace
|
||||||
|
full_command = ["git", *args]
|
||||||
|
result = subprocess.run(full_command, cwd=get_current_workspace(), capture_output=True, text=True, timeout=10)
|
||||||
return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}"
|
return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error running git command: {str(e)}"
|
return f"Error running git command: {str(e)}"
|
||||||
|
|
||||||
|
schema_workspace_change = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "workspace_change",
|
||||||
|
"description": "Change the current working directory of the agent session. Supports tilde (~) expansion for the user home directory (/home/aji/).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "The target directory path to change to."}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def workspace_change(path):
|
||||||
|
try:
|
||||||
|
if path.startswith("~"):
|
||||||
|
target_path = os.path.expanduser(path)
|
||||||
|
else:
|
||||||
|
target_path = os.path.abspath(path)
|
||||||
|
|
||||||
|
if os.path.exists(target_path) and os.path.isdir(target_path):
|
||||||
|
set_current_workspace(target_path)
|
||||||
|
return f"Successfully changed workspace to: {target_path}"
|
||||||
|
else:
|
||||||
|
return f"Error: The path {target_path} does not exist or is not a directory."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error changing workspace: {str(e)}"
|
||||||
|
|||||||
@ -684,6 +684,10 @@ def user_load(query_name=None, unique_id=None, persona_keyword=None, user_id=Non
|
|||||||
f"Character: {row.get('character', '-') or '-'}\n"
|
f"Character: {row.get('character', '-') or '-'}\n"
|
||||||
f"Salutation: {row.get('salutation', '-') or '-'}\n"
|
f"Salutation: {row.get('salutation', '-') or '-'}\n"
|
||||||
f"Persona: {row['persona']}\n"
|
f"Persona: {row['persona']}\n"
|
||||||
|
f"Telegram ID: {row.get('telegram_id', '-') or '-'}\n"
|
||||||
|
f"Telegram Username: {row.get('telegram_username', '-') or '-'}\n"
|
||||||
|
f"XAMPP Username: {row.get('xampp_username', '-') or '-'}\n"
|
||||||
|
f"Alias: {row.get('alias', '-') or '-'}\n"
|
||||||
)
|
)
|
||||||
output.append(user_info)
|
output.append(user_info)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user