Merge pull request #4 from existedinnettw/feat/discord-notifications

This commit is contained in:
existedinnettw 2026-04-05 15:58:30 +08:00 committed by GitHub
commit d691ecec15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 576 additions and 28 deletions

View File

@ -2,26 +2,17 @@
GitHub issue thread assistant with an Agent Orchestrator style YAML config.
The app polls configured GitHub repositories with `gh`, reads issue threads and
issue comments, and uses configured CLI responders such as `codex`,
`opencode`, or `copilot-cli` to reply when:
It polls configured GitHub repositories with `gh`, reads issue threads, and
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
when to reply. It supports multi-repo config, responder fallback chains, and
optional Discord webhook notifications.
- a user explicitly mentions the assistant
- a new comment changes the thread enough that the responder decides a reply is
useful
## Highlights
It supports multi-repo configuration, responder fallback chains.
## Features
- Agent Orchestrator style YAML config with `defaults` and `projects`
- Multiple repositories in one process
- Comment-triggered issue assistance
- Planning / architecture guidance in issue threads
- Bug verification flows in isolated temporary git worktrees
- CLI-based responder fallback chain
- Durable state with `drift` + SQLite
- BDD-style tests using `package:test` with Given / When / Then wording
- Poll GitHub issues across multiple repositories
- Trigger replies from mentions or meaningful thread updates
- Run planning and bug-verification flows in isolated worktrees
- Send optional Discord notifications for assistant events
## Requirements
@ -32,7 +23,9 @@ It supports multi-repo configuration, responder fallback chains.
## Config
Create a local `agent-orchestrator.yaml` and follow [`/examples/README.md`](examples/README.md) as a starting point.
Create a local `agent-orchestrator.yaml` and use
[`examples/README.md`](examples/README.md) plus
[`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference.
Generate a starter config:
@ -79,13 +72,6 @@ dart analyze
dart test
```
All Dart tests under `test/` must follow the repo's Gherkin doc-comment format:
- `group(...)` has a `Feature` doc block above it
- `test(...)` has a `Scenario` doc block above it
- runtime names stay plain, without `Feature:` or `Scenario:` prefixes
- test bodies include `Given` / `When` / `Then` inline comments
## ref
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)

View File

@ -3,7 +3,7 @@
The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`.
Project entries can override `issueAssistant` fields when a repo needs a
different prompt, mention triggers, or responder chain.
different prompt, mention triggers, responder chain, or notification list.
Generated comments include both a visible prefix and footer by default so it is
clear the reply came from automation even when `gh` is authenticated as a human
@ -12,6 +12,16 @@ account. Those markers support template variables such as `{{gh_login}}`,
`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty
string to disable that section.
Discord notifications are configured under `issueAssistant.notifications`.
Currently supported fields are:
- `type: discordWebhook`
- `enabled`
- `events`: any of `mention_detected`, `reply_posted`, `no_reply`, `responder_failed`
- `webhookUrl`
- `username`
- `avatarUrl`
Start from [`examples/simple-github.yaml`](simple-github.yaml). This
repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
bug-verification worktrees.

View File

@ -30,6 +30,13 @@ defaults:
# - id: opencode
# command: opencode
# timeout: 10m
notifications:
# Optional Discord notifications for assistant activity.
- type: discordWebhook
enabled: true
events: ["mention_detected", "reply_posted", "responder_failed"]
webhookUrl: ${CWS_DISCORD_WEBHOOK}
username: code-work-spawner
projects:
code-work-spawner:
@ -37,3 +44,7 @@ projects:
path: ~/code_work_spawner
defaultBranch: main
# sessionPrefix: app
# issueAssistant:
# notifications:
# - type: discordWebhook
# events: ["no_reply"]

View File

@ -3,4 +3,5 @@ export 'src/config.dart';
export 'src/database.dart';
export 'src/github_client.dart';
export 'src/issue_assistant_app.dart';
export 'src/notifier.dart';
export 'src/workspace_manager.dart';

View File

@ -7,7 +7,12 @@ import 'package:yaml/yaml.dart';
import 'comment_templates.dart';
import 'config_schema.dart';
export 'config_schema.dart' show AssistantCapability;
export 'config_schema.dart'
show
AssistantCapability,
NotificationConfigDocument,
NotificationEvent,
NotificationType;
const String defaultIssueAssistantPrompt = '''
You are a GitHub issue thread assistant.
@ -212,6 +217,7 @@ class IssueAssistantConfig {
required this.prompt,
required this.responders,
required this.capabilities,
required this.notifications,
required this.commentTag,
required this.commentPrefixTemplate,
required this.commentFooterTemplate,
@ -224,6 +230,7 @@ class IssueAssistantConfig {
final String prompt;
final List<CliResponderConfig> responders;
final Set<AssistantCapability> capabilities;
final List<NotificationConfig> notifications;
final String commentTag;
final String commentPrefixTemplate;
final String commentFooterTemplate;
@ -288,6 +295,43 @@ class CliResponderConfig {
}
}
class NotificationConfig {
NotificationConfig({
required this.type,
required this.enabled,
required this.events,
required this.webhookUrl,
required this.username,
required this.avatarUrl,
});
final NotificationType type;
final bool enabled;
final Set<NotificationEvent> events;
final String? webhookUrl;
final String? username;
final String? avatarUrl;
factory NotificationConfig.fromDocument(NotificationConfigDocument document) {
final type = document.type;
if (type == null) {
throw const FormatException('Notification type is required.');
}
return NotificationConfig(
type: type,
enabled: document.enabled ?? true,
events:
(document.events ??
const <NotificationEvent>[NotificationEvent.replyPosted])
.toSet(),
webhookUrl: _expandOptionalEnvironmentString(document.webhookUrl),
username: _expandOptionalEnvironmentString(document.username),
avatarUrl: _expandOptionalEnvironmentString(document.avatarUrl),
);
}
}
class _IssueAssistantConfigResolver {
const _IssueAssistantConfigResolver();
@ -296,6 +340,7 @@ class _IssueAssistantConfigResolver {
required IssueAssistantConfigDocument? overlay,
}) {
final responders = overlay?.responders;
final notifications = overlay?.notifications;
return IssueAssistantConfig(
enabled: overlay?.enabled ?? base?.enabled ?? true,
pollInterval: overlay?.pollInterval != null
@ -321,6 +366,11 @@ class _IssueAssistantConfigResolver {
AssistantCapability.planning,
AssistantCapability.bugVerification,
},
notifications: notifications != null
? notifications
.map(NotificationConfig.fromDocument)
.toList(growable: false)
: base?.notifications ?? const <NotificationConfig>[],
commentTag:
overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner',
commentPrefixTemplate:
@ -419,6 +469,17 @@ String? _expandOptionalPath(String? path) {
return _expandHome(path);
}
String? _expandOptionalEnvironmentString(String? value) {
if (value == null || value.isEmpty) {
return value;
}
return value.replaceAllMapped(RegExp(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'), (
match,
) {
return Platform.environment[match.group(1)!] ?? '';
});
}
void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
_assertCamelCaseMap(root, scope: '');
@ -438,6 +499,17 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
}
}
}
final notifications = issueAssistant['notifications'];
if (notifications is List) {
for (final notification in notifications) {
if (notification is Map<String, dynamic>) {
_assertCamelCaseMap(
notification,
scope: 'defaults.issueAssistant.notifications',
);
}
}
}
}
}
@ -466,6 +538,17 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
}
}
}
final notifications = issueAssistant['notifications'];
if (notifications is List) {
for (final notification in notifications) {
if (notification is Map<String, dynamic>) {
_assertCamelCaseMap(
notification,
scope: 'projects.${entry.key}.issueAssistant.notifications',
);
}
}
}
}
}
}
@ -535,6 +618,12 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')}
# - id: opencode
# command: opencode
# timeout: 10m
# notifications:
# - type: discordWebhook
# enabled: true
# events: ["reply_posted", "responder_failed"]
# webhookUrl: \${CWS_DISCORD_WEBHOOK}
# username: code-work-spawner
${_YamlEmitter().write({'projects': projectsSection})}

View File

@ -90,6 +90,7 @@ class IssueAssistantConfigDocument {
this.prompt,
this.responders,
this.capabilities,
this.notifications,
this.commentTag,
this.commentPrefixTemplate,
this.commentFooterTemplate,
@ -102,6 +103,7 @@ class IssueAssistantConfigDocument {
final String? prompt;
final List<CliResponderConfigDocument>? responders;
final List<AssistantCapability>? capabilities;
final List<NotificationConfigDocument>? notifications;
final String? commentTag;
final String? commentPrefixTemplate;
final String? commentFooterTemplate;
@ -142,5 +144,45 @@ class CliResponderConfigDocument {
Map<String, dynamic> toJson() => _$CliResponderConfigDocumentToJson(this);
}
@JsonSerializable(
checked: true,
createToJson: true,
disallowUnrecognizedKeys: true,
explicitToJson: true,
includeIfNull: false,
)
class NotificationConfigDocument {
const NotificationConfigDocument({
this.type,
this.enabled,
this.events,
this.webhookUrl,
this.username,
this.avatarUrl,
});
final NotificationType? type;
final bool? enabled;
final List<NotificationEvent>? events;
final String? webhookUrl;
final String? username;
final String? avatarUrl;
factory NotificationConfigDocument.fromJson(Map<String, dynamic> json) =>
_$NotificationConfigDocumentFromJson(json);
Map<String, dynamic> toJson() => _$NotificationConfigDocumentToJson(this);
}
@JsonEnum(fieldRename: FieldRename.snake)
enum AssistantCapability { planning, bugVerification }
enum NotificationType { discordWebhook }
@JsonEnum(fieldRename: FieldRename.snake)
enum NotificationEvent {
mentionDetected,
replyPosted,
noReply,
responderFailed,
}

View File

@ -9,6 +9,7 @@ import 'cli_responder.dart';
import 'config.dart';
import 'database.dart';
import 'github_client.dart';
import 'notifier.dart';
import 'workspace_manager.dart';
class IssueAssistantApp {
@ -21,6 +22,7 @@ class IssueAssistantApp {
required this.githubClient,
required this.responderRunner,
required this.workspaceManager,
required this.notifierRegistryFactory,
});
final AppConfig config;
@ -28,6 +30,8 @@ class IssueAssistantApp {
final GitHubClient githubClient;
final CliResponderRunner responderRunner;
final WorkspaceManager workspaceManager;
final NotifierRegistry Function(ProjectConfig project)
notifierRegistryFactory;
final Map<String, _ProjectPollState> _projectPollStates = {};
final Set<Future<void>> _activePolls = <Future<void>>{};
Timer? _schedulerTimer;
@ -47,6 +51,10 @@ class IssueAssistantApp {
githubClient: GitHubClient(ghCommand: ghCommand),
responderRunner: CliResponderRunner(),
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
project.issueAssistant.notifications,
log: _logger.warning,
),
);
}
@ -352,6 +360,7 @@ class IssueAssistantApp {
ProjectConfig project,
GitHubIssueSummary issue,
) async {
final notifierRegistry = notifierRegistryFactory(project);
final thread = await githubClient.fetchThread(
repo: project.repoSlug,
issue: issue,
@ -370,6 +379,20 @@ class IssueAssistantApp {
'evaluate issue=${project.key}#${issue.number} '
'comments=${thread.comments.length} trigger=$trigger',
);
final baseNotificationContext = NotificationContext(
projectKey: project.key,
repo: project.repo,
issueNumber: issue.number,
issueTitle: thread.issue.title,
issueUrl: thread.issue.url,
trigger: trigger,
);
if (trigger == 'mention') {
await notifierRegistry.notify(
event: NotificationEvent.mentionDetected,
context: baseNotificationContext,
);
}
final jobId = await database.createJob(
projectKey: project.key,
issueNumber: issue.number,
@ -495,6 +518,19 @@ class IssueAssistantApp {
'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error',
);
await notifierRegistry.notify(
event: NotificationEvent.responderFailed,
context: NotificationContext(
projectKey: project.key,
repo: project.repo,
issueNumber: issue.number,
issueTitle: thread.issue.title,
issueUrl: thread.issue.url,
trigger: trigger,
responderId: responder.id,
errorSummary: error.toString(),
),
);
await database.addExecutionRun(
jobId: jobId,
responderId: responder.id,
@ -534,6 +570,21 @@ class IssueAssistantApp {
);
await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} completed decision=no_reply');
await notifierRegistry.notify(
event: NotificationEvent.noReply,
context: NotificationContext(
projectKey: project.key,
repo: project.repo,
issueNumber: issue.number,
issueTitle: thread.issue.title,
issueUrl: thread.issue.url,
trigger: trigger,
mode: selectedResult.mode,
responderId: selectedResult.responderId,
decision: selectedResult.decision,
summary: selectedResult.summary,
),
);
return;
}
@ -570,6 +621,21 @@ class IssueAssistantApp {
);
await database.updateJobStatus(jobId, JobStatus.completed);
_log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}');
await notifierRegistry.notify(
event: NotificationEvent.replyPosted,
context: NotificationContext(
projectKey: project.key,
repo: project.repo,
issueNumber: issue.number,
issueTitle: thread.issue.title,
issueUrl: thread.issue.url,
trigger: trigger,
mode: selectedResult.mode,
responderId: selectedResult.responderId,
decision: selectedResult.decision,
summary: selectedResult.summary,
),
);
}
String _buildPrompt({

157
lib/src/notifier.dart Normal file
View File

@ -0,0 +1,157 @@
import 'dart:convert';
import 'dart:io';
import 'config.dart';
typedef NotificationLogger = void Function(String message);
class NotificationContext {
const NotificationContext({
required this.projectKey,
required this.repo,
required this.issueNumber,
required this.issueTitle,
required this.issueUrl,
required this.trigger,
this.mode,
this.responderId,
this.decision,
this.summary,
this.errorSummary,
});
final String projectKey;
final String repo;
final int issueNumber;
final String issueTitle;
final String issueUrl;
final String trigger;
final String? mode;
final String? responderId;
final String? decision;
final String? summary;
final String? errorSummary;
}
abstract interface class Notifier {
Future<void> notify({
required NotificationEvent event,
required NotificationContext context,
});
}
class NotifierRegistry {
NotifierRegistry({required this.notifiersByEvent, required this.log});
final Map<NotificationEvent, List<Notifier>> notifiersByEvent;
final NotificationLogger log;
factory NotifierRegistry.fromConfigs(
List<NotificationConfig> configs, {
required NotificationLogger log,
}) {
final notifiersByEvent = <NotificationEvent, List<Notifier>>{};
for (final config in configs) {
if (!config.enabled) {
continue;
}
final notifier = switch (config.type) {
NotificationType.discordWebhook => DiscordWebhookNotifier(config),
};
for (final event in config.events) {
notifiersByEvent.putIfAbsent(event, () => <Notifier>[]).add(notifier);
}
}
return NotifierRegistry(notifiersByEvent: notifiersByEvent, log: log);
}
Future<void> notify({
required NotificationEvent event,
required NotificationContext context,
}) async {
final notifiers = notifiersByEvent[event];
if (notifiers == null || notifiers.isEmpty) {
return;
}
for (final notifier in notifiers) {
try {
await notifier.notify(event: event, context: context);
} catch (error) {
log('notification_failed event=${event.name} error=$error');
}
}
}
}
class DiscordWebhookNotifier implements Notifier {
DiscordWebhookNotifier(this.config, {HttpClient? httpClient})
: _httpClient = httpClient ?? HttpClient();
final NotificationConfig config;
final HttpClient _httpClient;
@override
Future<void> notify({
required NotificationEvent event,
required NotificationContext context,
}) async {
final webhookUrl = config.webhookUrl;
if (webhookUrl == null || webhookUrl.isEmpty) {
throw const FormatException(
'discordWebhook notifier requires webhookUrl.',
);
}
final uri = Uri.parse(webhookUrl);
final request = await _httpClient.postUrl(uri);
request.headers.contentType = ContentType.json;
request.write(
jsonEncode({
'content': _formatDiscordMessage(event: event, context: context),
if (config.username != null && config.username!.isNotEmpty)
'username': config.username,
if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty)
'avatar_url': config.avatarUrl,
}),
);
final response = await request.close();
final responseBody = await utf8.decoder.bind(response).join();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Discord webhook returned ${response.statusCode}: $responseBody',
uri: uri,
);
}
}
String _formatDiscordMessage({
required NotificationEvent event,
required NotificationContext context,
}) {
final headline = switch (event) {
NotificationEvent.mentionDetected => 'Mention detected',
NotificationEvent.replyPosted => 'Reply posted',
NotificationEvent.noReply => 'No reply',
NotificationEvent.responderFailed => 'Responder failed',
};
final details = <String>[
'**$headline**',
'`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}',
context.issueUrl,
'repo: `${context.repo}`',
'trigger: `${context.trigger}`',
if (context.mode != null) 'mode: `${context.mode}`',
if (context.responderId != null) 'responder: `${context.responderId}`',
if (context.decision != null) 'decision: `${context.decision}`',
if (context.summary != null && context.summary!.isNotEmpty)
'summary: ${context.summary}',
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
'error: ${context.errorSummary}',
];
return details.join('\n');
}
}

View File

@ -181,6 +181,60 @@ projects:
]);
});
/// ```gherkin
/// Scenario: Merge notification defaults with project overrides
/// Given camelCase config with default notifications and a project-specific notification override
/// When loading the app config from disk
/// Then the project replaces inherited notifications with its own list
/// And notification event enums are parsed from snake_case values
/// ```
test('Merge notification defaults with project overrides', () async {
// Given camelCase config with default notifications and a project-specific notification override.
final tempDir = await Directory.systemTemp.createTemp(
'cws-notification-config-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
notifications:
- type: discordWebhook
enabled: true
events: ["reply_posted"]
webhookUrl: https://default.example.test/webhook
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
notifications:
- type: discordWebhook
enabled: true
events: ["responder_failed", "no_reply"]
webhookUrl: https://project.example.test/webhook
username: cws
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project replaces inherited notifications with its own list.
expect(
config.projects['sample']!.issueAssistant.notifications,
hasLength(1),
);
final notification =
config.projects['sample']!.issueAssistant.notifications.single;
expect(notification.webhookUrl, 'https://project.example.test/webhook');
expect(notification.username, 'cws');
// And notification event enums are parsed from snake_case values.
expect(notification.events, {
NotificationEvent.responderFailed,
NotificationEvent.noReply,
});
});
/// ```gherkin
/// Scenario: Load camelCase config with shared worktree root
/// Given camelCase config with dataDir, worktreeDir, and project settings

View File

@ -37,6 +37,8 @@ void main() {
final stdoutText = result.stdout as String;
expect(stdoutText, contains('dataDir:'));
expect(stdoutText, contains('# responders:'));
expect(stdoutText, contains('# notifications:'));
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
expect(stdoutText, contains('projects:'));
// And the printed config is valid when written to disk and loaded.

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
@ -777,6 +779,134 @@ projects:
);
});
/// ```gherkin
/// Scenario: Send Discord webhook notifications for mention and reply events
/// Given a temporary project checkout that can be used as the local repo path
/// And GitHub issue data containing an explicit assistant mention
/// And a responder that emits a planning reply
/// And a local webhook server that records notification requests
/// And an orchestrator-style config with a discord webhook notifier
/// When the app processes one polling cycle
/// Then the webhook server receives one mention_detected notification
/// And the webhook server receives one reply_posted notification
/// ```
test(
'Send Discord webhook notifications for mention and reply events',
() async {
// Given a temporary project checkout that can be used as the local repo path.
final sandbox = await Directory.systemTemp.createTemp(
'cws-discord-notify-',
);
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
await initGitRepo(repoDir);
// And GitHub issue data containing an explicit assistant mention.
final ghScript = File(p.join(sandbox.path, 'gh'));
final issueData = [
{
'number': 61,
'title': 'Need architecture help',
'body': 'Please review the notifier design.',
'state': 'open',
'html_url': 'https://example.test/issues/61',
'updated_at': '2026-04-04T17:10:00Z',
'user': {'login': 'reporter'},
'labels': const [],
},
];
final commentsData = [
{
'id': 610,
'body': '@helper please review the notifier design',
'html_url': 'https://example.test/issues/61#issuecomment-610',
'created_at': '2026-04-04T17:10:00Z',
'updated_at': '2026-04-04T17:10:00Z',
'user': {'login': 'reporter'},
},
];
await writeFakeGhScript(
ghScript: ghScript,
issueListResponse: issueData,
commentResponses: {61: commentsData},
postCommentIssueNumber: 61,
);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
await writeResponderScript(responderScript, {
'decision': 'reply',
'mode': 'planning',
'markdown': 'discord notification reply',
'summary': 'posted planning advice',
});
// And a local webhook server that records notification requests.
final requests = <Map<String, dynamic>>[];
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
unawaited(() async {
await for (final request in server) {
final body = await utf8.decoder.bind(request).join();
requests.add(jsonDecode(body) as Map<String, dynamic>);
request.response.statusCode = HttpStatus.noContent;
await request.response.close();
}
}());
// And an orchestrator-style config with a discord webhook notifier.
final configFile = File(
p.join(sandbox.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
defaults:
issueAssistant:
enabled: true
pollInterval: 5m
mentionTriggers: ["@helper"]
responders:
- id: primary
command: ${responderScript.path}
notifications:
- type: discordWebhook
enabled: true
events: ["mention_detected", "reply_posted"]
webhookUrl: http://${server.address.host}:${server.port}/discord
username: code-work-spawner
projects:
sample:
repo: owner/sample
path: ${repoDir.path}
''');
final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path,
);
// When the app processes one polling cycle.
await app.runOnce();
await app.close();
await server.close(force: true);
// Then the webhook server receives one mention_detected notification.
expect(requests, hasLength(2));
expect(requests.first['username'], 'code-work-spawner');
expect(
requests.first['content'] as String,
contains('**Mention detected**'),
);
// And the webhook server receives one reply_posted notification.
expect(
requests.last['content'] as String,
contains('**Reply posted**'),
);
expect(
requests.last['content'] as String,
contains('summary: posted planning advice'),
);
},
);
/// ```gherkin
/// Scenario: Process multiple issues concurrently up to the configured limit
/// Given a temporary project checkout that can be used as the local repo path