fix: infer tracker provider from event source (#38)

This commit is contained in:
insleker 2026-04-10 09:14:11 +08:00
parent f1eea7abd8
commit da256d068c
12 changed files with 456 additions and 319 deletions

View File

@ -6,11 +6,13 @@ Project entries can override `issueAssistant` fields when a repo needs a
different prompt, mention triggers, responder chain, notification list, or different prompt, mention triggers, responder chain, notification list, or
event source. event source.
Each project also has a separate SCM config. `issueTracker.provider` selects Each project also has separate tracker and SCM config. `issueTracker.provider`
the issue tracker integration, while `scm.provider` selects how temporary repo selects the logical tracker family, `issueTracker.eventSource.type` selects the
workspaces are prepared for responders. Legacy top-level project `provider` is concrete adapter used to receive updates, and `scm.provider` selects how
still accepted during load for backward compatibility. When `scm` is omitted temporary repo workspaces are prepared for responders. Legacy top-level project
it defaults to a matching provider for existing GitHub, GitLab, and Gitea configs. `provider` and legacy `issueAssistant.eventSource` are still accepted during
load for backward compatibility. When `scm` is omitted it defaults to a
matching provider for existing GitHub, GitLab, and Gitea configs.
GitHub projects default to `gh/polling`, GitLab projects default to GitHub projects default to `gh/polling`, GitLab projects default to
`glab/polling`, and Gitea projects default to `tea/polling`. OpenProject `glab/polling`, and Gitea projects default to `tea/polling`. OpenProject
@ -32,11 +34,12 @@ recovery, or use `reconciliation: startup-only` to skip background polling
after startup. `--once` still uses a single polling reconciliation cycle so after startup. `--once` still uses a single polling reconciliation cycle so
existing backlog handling keeps working. existing backlog handling keeps working.
Use a nested `issueAssistant.eventSource` object so source-specific fields stay Use a nested `issueTracker.eventSource` object so adapter-specific fields stay
grouped with the selected source type. For example: grouped with the selected adapter key. For example:
```yaml ```yaml
issueAssistant: issueTracker:
provider: github
eventSource: eventSource:
type: gh/webhook-forward type: gh/webhook-forward
reconciliation: continuous reconciliation: continuous
@ -44,7 +47,8 @@ issueAssistant:
``` ```
```yaml ```yaml
issueAssistant: issueTracker:
provider: github
eventSource: eventSource:
type: gh/polling type: gh/polling
pollInterval: 30s pollInterval: 30s

View File

@ -7,14 +7,6 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# Use tea/gosmee for long-running runs.
eventSource:
type: tea/polling
pollInterval: 30s
# eventSource:
# type: tea/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:
@ -27,6 +19,14 @@ projects:
sample-gitea: sample-gitea:
issueTracker: issueTracker:
provider: gitea provider: gitea
# Use tea/gosmee for long-running runs.
eventSource:
type: tea/polling
pollInterval: 30s
# eventSource:
# type: tea/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
scm: scm:
provider: gitea provider: gitea
repo: owner/repo repo: owner/repo

View File

@ -7,14 +7,6 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# Use gh/gosmee or gh/webhook-forward for long-running runs.
eventSource:
type: gh/polling
pollInterval: 30s
# eventSource:
# type: gh/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
commentPrefixTemplate: | commentPrefixTemplate: |
@ -53,6 +45,14 @@ projects:
code-work-spawner: code-work-spawner:
issueTracker: issueTracker:
provider: github provider: github
# Use gh/gosmee or gh/webhook-forward for long-running runs.
eventSource:
type: gh/polling
pollInterval: 30s
# eventSource:
# type: gh/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
scm: scm:
provider: github provider: github
repo: existedinnettw/code_work_spawner repo: existedinnettw/code_work_spawner

View File

@ -7,10 +7,6 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
# GitLab currently supports polling.
eventSource:
type: glab/polling
pollInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:
@ -23,6 +19,10 @@ projects:
sample-gitlab: sample-gitlab:
issueTracker: issueTracker:
provider: gitlab provider: gitlab
# GitLab currently supports polling.
eventSource:
type: glab/polling
pollInterval: 30s
scm: scm:
provider: gitlab provider: gitlab
repo: group/subgroup/repo repo: group/subgroup/repo

View File

@ -8,9 +8,6 @@ worktreeDir: ~/.worktrees
defaults: defaults:
issueAssistant: issueAssistant:
enabled: true enabled: true
eventSource:
type: op/polling
pollInterval: 30s
maxConcurrentIssues: 3 maxConcurrentIssues: 3
mentionTriggers: ["@codex", "@helper"] mentionTriggers: ["@codex", "@helper"]
responders: responders:
@ -23,6 +20,9 @@ projects:
sample-openproject: sample-openproject:
issueTracker: issueTracker:
provider: openproject provider: openproject
eventSource:
type: op/polling
pollInterval: 30s
scm: scm:
provider: github provider: github
# OpenProject project name or numeric project id. # OpenProject project name or numeric project id.

View File

@ -11,8 +11,8 @@ import 'config_schema.dart';
export 'config_schema.dart' export 'config_schema.dart'
show show
AssistantCapability, AssistantCapability,
IssueAssistantEventSourceKind, IssueTrackerEventSourceKind,
IssueAssistantReconciliationKind, IssueTrackerReconciliationKind,
IssueTrackerProvider, IssueTrackerProvider,
ScmProvider, ScmProvider,
NotificationConfigDocument, NotificationConfigDocument,
@ -139,7 +139,7 @@ class AppConfig {
issueAssistant: IssueAssistantConfigDocument( issueAssistant: IssueAssistantConfigDocument(
enabled: true, enabled: true,
eventSource: IssueAssistantEventSourceDocument( eventSource: IssueAssistantEventSourceDocument(
type: IssueAssistantEventSourceKind.ghPolling, type: IssueTrackerEventSourceKind.ghPolling,
pollInterval: '30s', pollInterval: '30s',
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
@ -202,10 +202,33 @@ class ProjectConfig {
); );
} }
final provider = final resolvedIssueAssistantDocument =
document.issueTracker?.provider ?? _resolveProjectIssueAssistantDocument(
document.provider ?? key: key,
IssueTrackerProvider.github; issueTracker: document.issueTracker,
issueAssistant: document.issueAssistant,
);
final hasExplicitEventSourceType =
resolvedIssueAssistantDocument?.eventSource?.type != null;
var issueAssistant = defaultAssistant.merge(resolvedIssueAssistantDocument);
final configuredProvider =
document.issueTracker?.provider ?? document.provider;
final inferredProvider = _issueTrackerProviderForEventSource(
issueAssistant.eventSource.type,
);
if (hasExplicitEventSourceType &&
configuredProvider != null &&
configuredProvider != inferredProvider) {
throw FormatException(
'projects.$key.issueAssistant.eventSource '
'${_eventSourceConfigValue(issueAssistant.eventSource.type)} '
'implies issue tracker provider ${inferredProvider.name}, '
'but the legacy configured provider is ${configuredProvider.name}.',
);
}
final provider = hasExplicitEventSourceType
? inferredProvider
: configuredProvider ?? inferredProvider;
if (provider == IssueTrackerProvider.gitlab) { if (provider == IssueTrackerProvider.gitlab) {
_validateGitLabProjectPath(repo, field: 'projects.$key.repo'); _validateGitLabProjectPath(repo, field: 'projects.$key.repo');
} }
@ -213,15 +236,14 @@ class ProjectConfig {
if (document.scm?.provider == null && fallbackScmProvider == null) { if (document.scm?.provider == null && fallbackScmProvider == null) {
throw FormatException( throw FormatException(
'projects.$key.scm.provider is required for ' 'projects.$key.scm.provider is required for '
'issueTracker.provider: ${provider.name}.', 'issue tracking provider: ${provider.name}.',
); );
} }
final scm = ScmConfig.fromDocument( final scm = ScmConfig.fromDocument(
document.scm, document.scm,
fallbackProvider: fallbackScmProvider, fallbackProvider: fallbackScmProvider,
); );
var issueAssistant = defaultAssistant.merge(document.issueAssistant); if (!hasExplicitEventSourceType &&
if (document.issueAssistant?.eventSource?.type == null &&
issueAssistant.eventSource is PollingIssueEventSourceConfig) { issueAssistant.eventSource is PollingIssueEventSourceConfig) {
issueAssistant = issueAssistant.copyWith( issueAssistant = issueAssistant.copyWith(
eventSource: eventSource:
@ -229,58 +251,17 @@ class ProjectConfig {
.copyWith( .copyWith(
type: switch (provider) { type: switch (provider) {
IssueTrackerProvider.github => IssueTrackerProvider.github =>
IssueAssistantEventSourceKind.ghPolling, IssueTrackerEventSourceKind.ghPolling,
IssueTrackerProvider.gitlab => IssueTrackerProvider.gitlab =>
IssueAssistantEventSourceKind.glabPolling, IssueTrackerEventSourceKind.glabPolling,
IssueTrackerProvider.gitea => IssueTrackerProvider.gitea =>
IssueAssistantEventSourceKind.teaPolling, IssueTrackerEventSourceKind.teaPolling,
IssueTrackerProvider.openproject => IssueTrackerProvider.openproject =>
IssueAssistantEventSourceKind.opPolling, IssueTrackerEventSourceKind.opPolling,
}, },
), ),
); );
} }
switch (issueAssistant.eventSource.type) {
case IssueAssistantEventSourceKind.ghPolling:
case IssueAssistantEventSourceKind.ghGosmee:
case IssueAssistantEventSourceKind.ghWebhookForward:
if (provider != IssueTrackerProvider.github) {
throw FormatException(
'projects.$key.issueAssistant.eventSource '
'${_eventSourceConfigValue(issueAssistant.eventSource.type)} '
'requires issueTracker.provider: github.',
);
}
case IssueAssistantEventSourceKind.glabPolling:
if (provider != IssueTrackerProvider.gitlab) {
throw FormatException(
'projects.$key.issueAssistant.eventSource glab/polling '
'requires issueTracker.provider: gitlab.',
);
}
case IssueAssistantEventSourceKind.teaPolling:
if (provider != IssueTrackerProvider.gitea) {
throw FormatException(
'projects.$key.issueAssistant.eventSource tea/polling '
'requires issueTracker.provider: gitea.',
);
}
case IssueAssistantEventSourceKind.teaGosmee:
if (provider != IssueTrackerProvider.gitea) {
throw FormatException(
'projects.$key.issueAssistant.eventSource tea/gosmee '
'requires issueTracker.provider: gitea.',
);
}
case IssueAssistantEventSourceKind.opPolling:
if (provider != IssueTrackerProvider.openproject) {
throw FormatException(
'projects.$key.issueAssistant.eventSource op/polling '
'requires issueTracker.provider: openproject.',
);
}
}
if (provider == IssueTrackerProvider.openproject && repo.trim().isEmpty) { if (provider == IssueTrackerProvider.openproject && repo.trim().isEmpty) {
throw FormatException( throw FormatException(
'Invalid OpenProject project reference for projects.$key.repo: ' 'Invalid OpenProject project reference for projects.$key.repo: '
@ -343,6 +324,49 @@ class ProjectConfig {
} }
} }
IssueAssistantConfigDocument? _resolveProjectIssueAssistantDocument({
required String key,
required IssueTrackerConfigDocument? issueTracker,
required IssueAssistantConfigDocument? issueAssistant,
}) {
final trackerEventSource = issueTracker?.eventSource;
final assistantEventSource = issueAssistant?.eventSource;
if (trackerEventSource != null &&
assistantEventSource != null &&
!_eventSourceDocumentsMatch(trackerEventSource, assistantEventSource)) {
throw FormatException(
'projects.$key.issueTracker.eventSource and '
'projects.$key.issueAssistant.eventSource must match when both are set.',
);
}
if (trackerEventSource == null) {
return issueAssistant;
}
return IssueAssistantConfigDocument(
enabled: issueAssistant?.enabled,
eventSource: trackerEventSource,
maxConcurrentIssues: issueAssistant?.maxConcurrentIssues,
mentionTriggers: issueAssistant?.mentionTriggers,
prompt: issueAssistant?.prompt,
responders: issueAssistant?.responders,
capabilities: issueAssistant?.capabilities,
notifications: issueAssistant?.notifications,
commentTag: issueAssistant?.commentTag,
commentPrefixTemplate: issueAssistant?.commentPrefixTemplate,
commentFooterTemplate: issueAssistant?.commentFooterTemplate,
);
}
bool _eventSourceDocumentsMatch(
IssueAssistantEventSourceDocument left,
IssueAssistantEventSourceDocument right,
) {
return left.type == right.type &&
left.pollInterval == right.pollInterval &&
left.reconciliation == right.reconciliation &&
left.reconcileInterval == right.reconcileInterval;
}
class ScmConfig { class ScmConfig {
const ScmConfig({required this.provider}); const ScmConfig({required this.provider});
@ -422,7 +446,7 @@ class IssueAssistantConfig {
sealed class IssueEventSourceConfig { sealed class IssueEventSourceConfig {
const IssueEventSourceConfig({required this.type}); const IssueEventSourceConfig({required this.type});
final IssueAssistantEventSourceKind type; final IssueTrackerEventSourceKind type;
bool get usesChannelEventSource => !_isPollingEventSource(type); bool get usesChannelEventSource => !_isPollingEventSource(type);
} }
@ -436,7 +460,7 @@ class PollingIssueEventSourceConfig extends IssueEventSourceConfig {
final Duration pollInterval; final Duration pollInterval;
PollingIssueEventSourceConfig copyWith({ PollingIssueEventSourceConfig copyWith({
IssueAssistantEventSourceKind? type, IssueTrackerEventSourceKind? type,
Duration? pollInterval, Duration? pollInterval,
}) { }) {
return PollingIssueEventSourceConfig( return PollingIssueEventSourceConfig(
@ -453,12 +477,12 @@ class ChannelIssueEventSourceConfig extends IssueEventSourceConfig {
required this.reconcileInterval, required this.reconcileInterval,
}); });
final IssueAssistantReconciliationKind reconciliation; final IssueTrackerReconciliationKind reconciliation;
final Duration reconcileInterval; final Duration reconcileInterval;
ChannelIssueEventSourceConfig copyWith({ ChannelIssueEventSourceConfig copyWith({
IssueAssistantEventSourceKind? type, IssueTrackerEventSourceKind? type,
IssueAssistantReconciliationKind? reconciliation, IssueTrackerReconciliationKind? reconciliation,
Duration? reconcileInterval, Duration? reconcileInterval,
}) { }) {
return ChannelIssueEventSourceConfig( return ChannelIssueEventSourceConfig(
@ -608,7 +632,7 @@ class _IssueAssistantConfigResolver {
required IssueAssistantEventSourceDocument? overlay, required IssueAssistantEventSourceDocument? overlay,
}) { }) {
final type = final type =
overlay?.type ?? base?.type ?? IssueAssistantEventSourceKind.ghPolling; overlay?.type ?? base?.type ?? IssueTrackerEventSourceKind.ghPolling;
if (_isPollingEventSource(type)) { if (_isPollingEventSource(type)) {
if (overlay?.reconciliation != null || if (overlay?.reconciliation != null ||
overlay?.reconcileInterval != null) { overlay?.reconcileInterval != null) {
@ -643,7 +667,7 @@ class _IssueAssistantConfigResolver {
switch (base) { switch (base) {
ChannelIssueEventSourceConfig(:final reconciliation) => ChannelIssueEventSourceConfig(:final reconciliation) =>
reconciliation, reconciliation,
_ => IssueAssistantReconciliationKind.continuous, _ => IssueTrackerReconciliationKind.continuous,
}, },
reconcileInterval: overlay?.reconcileInterval != null reconcileInterval: overlay?.reconcileInterval != null
? _parseDuration(overlay!.reconcileInterval!) ? _parseDuration(overlay!.reconcileInterval!)
@ -654,27 +678,27 @@ class _IssueAssistantConfigResolver {
} }
} }
String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { String _eventSourceConfigValue(IssueTrackerEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => 'gh/polling', IssueTrackerEventSourceKind.ghPolling => 'gh/polling',
IssueAssistantEventSourceKind.glabPolling => 'glab/polling', IssueTrackerEventSourceKind.glabPolling => 'glab/polling',
IssueAssistantEventSourceKind.teaPolling => 'tea/polling', IssueTrackerEventSourceKind.teaPolling => 'tea/polling',
IssueAssistantEventSourceKind.opPolling => 'op/polling', IssueTrackerEventSourceKind.opPolling => 'op/polling',
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee', IssueTrackerEventSourceKind.ghGosmee => 'gh/gosmee',
IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward', IssueTrackerEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee', IssueTrackerEventSourceKind.teaGosmee => 'tea/gosmee',
}; };
} }
bool _isPollingEventSource(IssueAssistantEventSourceKind value) { bool _isPollingEventSource(IssueTrackerEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => true, IssueTrackerEventSourceKind.ghPolling => true,
IssueAssistantEventSourceKind.glabPolling => true, IssueTrackerEventSourceKind.glabPolling => true,
IssueAssistantEventSourceKind.teaPolling => true, IssueTrackerEventSourceKind.teaPolling => true,
IssueAssistantEventSourceKind.opPolling => true, IssueTrackerEventSourceKind.opPolling => true,
IssueAssistantEventSourceKind.ghGosmee => false, IssueTrackerEventSourceKind.ghGosmee => false,
IssueAssistantEventSourceKind.ghWebhookForward => false, IssueTrackerEventSourceKind.ghWebhookForward => false,
IssueAssistantEventSourceKind.teaGosmee => false, IssueTrackerEventSourceKind.teaGosmee => false,
}; };
} }
@ -864,6 +888,13 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
issueTracker, issueTracker,
scope: 'projects.${entry.key}.issueTracker', scope: 'projects.${entry.key}.issueTracker',
); );
final issueTrackerEventSource = issueTracker['eventSource'];
if (issueTrackerEventSource is Map<String, dynamic>) {
_assertCamelCaseMap(
issueTrackerEventSource,
scope: 'projects.${entry.key}.issueTracker.eventSource',
);
}
} }
_assertCamelCaseMap(project, scope: 'projects.${entry.key}'); _assertCamelCaseMap(project, scope: 'projects.${entry.key}');
final scm = project['scm']; final scm = project['scm'];
@ -919,6 +950,20 @@ ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) {
}; };
} }
IssueTrackerProvider _issueTrackerProviderForEventSource(
IssueTrackerEventSourceKind type,
) {
return switch (type) {
IssueTrackerEventSourceKind.ghPolling => IssueTrackerProvider.github,
IssueTrackerEventSourceKind.glabPolling => IssueTrackerProvider.gitlab,
IssueTrackerEventSourceKind.teaPolling => IssueTrackerProvider.gitea,
IssueTrackerEventSourceKind.opPolling => IssueTrackerProvider.openproject,
IssueTrackerEventSourceKind.ghGosmee => IssueTrackerProvider.github,
IssueTrackerEventSourceKind.ghWebhookForward => IssueTrackerProvider.github,
IssueTrackerEventSourceKind.teaGosmee => IssueTrackerProvider.gitea,
};
}
void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) { void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
void normalizeProjectTracker(Map<String, dynamic> project) { void normalizeProjectTracker(Map<String, dynamic> project) {
final repo = project['repo']; final repo = project['repo'];
@ -942,9 +987,31 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
} }
} }
void normalizeIssueTrackerEventSource(
Map<String, dynamic> issueTracker, {
required IssueTrackerEventSourceKind? defaultType,
}) {
final rawEventSource = issueTracker['eventSource'];
Map<String, dynamic>? normalizedEventSource;
if (rawEventSource is String) {
normalizedEventSource = <String, dynamic>{'type': rawEventSource};
} else if (rawEventSource is Map<String, dynamic>) {
normalizedEventSource = Map<String, dynamic>.from(rawEventSource);
} else {
return;
}
if (defaultType != null && !normalizedEventSource.containsKey('type')) {
normalizedEventSource['type'] = _eventSourceConfigValue(defaultType);
}
issueTracker['eventSource'] = normalizedEventSource;
}
void normalizeIssueAssistant( void normalizeIssueAssistant(
Map<String, dynamic> issueAssistant, { Map<String, dynamic> issueAssistant, {
required IssueAssistantEventSourceKind? defaultType, required IssueTrackerEventSourceKind? defaultType,
}) { }) {
final rawEventSource = issueAssistant['eventSource']; final rawEventSource = issueAssistant['eventSource'];
Map<String, dynamic>? normalizedEventSource; Map<String, dynamic>? normalizedEventSource;
@ -989,7 +1056,7 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
if (issueAssistant is Map<String, dynamic>) { if (issueAssistant is Map<String, dynamic>) {
normalizeIssueAssistant( normalizeIssueAssistant(
issueAssistant, issueAssistant,
defaultType: IssueAssistantEventSourceKind.ghPolling, defaultType: IssueTrackerEventSourceKind.ghPolling,
); );
} }
} }
@ -1002,8 +1069,28 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
continue; continue;
} }
normalizeProjectTracker(project); normalizeProjectTracker(project);
final issueTracker = project['issueTracker'];
if (issueTracker is Map<String, dynamic>) {
normalizeIssueTrackerEventSource(
issueTracker,
defaultType: switch (issueTracker['provider'] as String?) {
'gitlab' => IssueTrackerEventSourceKind.glabPolling,
'gitea' => IssueTrackerEventSourceKind.teaPolling,
'openproject' => IssueTrackerEventSourceKind.opPolling,
_ => IssueTrackerEventSourceKind.ghPolling,
},
);
}
final issueAssistant = project['issueAssistant']; final issueAssistant = project['issueAssistant'];
if (issueAssistant is! Map<String, dynamic>) { if (issueAssistant is! Map<String, dynamic>) {
if (issueTracker is Map<String, dynamic> &&
issueTracker['eventSource'] is Map<String, dynamic>) {
project['issueAssistant'] = <String, dynamic>{
'eventSource': Map<String, dynamic>.from(
issueTracker['eventSource'] as Map<String, dynamic>,
),
};
}
continue; continue;
} }
normalizeIssueAssistant( normalizeIssueAssistant(
@ -1011,11 +1098,19 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
defaultType: switch ((project['issueTracker'] defaultType: switch ((project['issueTracker']
as Map<String, dynamic>?)?['provider'] as Map<String, dynamic>?)?['provider']
as String?) { as String?) {
'gitlab' => IssueAssistantEventSourceKind.glabPolling, 'gitlab' => IssueTrackerEventSourceKind.glabPolling,
'gitea' => IssueAssistantEventSourceKind.teaPolling, 'gitea' => IssueTrackerEventSourceKind.teaPolling,
_ => IssueAssistantEventSourceKind.ghPolling, 'openproject' => IssueTrackerEventSourceKind.opPolling,
_ => IssueTrackerEventSourceKind.ghPolling,
}, },
); );
if (issueTracker is Map<String, dynamic> &&
issueTracker['eventSource'] == null &&
issueAssistant['eventSource'] is Map<String, dynamic>) {
issueTracker['eventSource'] = Map<String, dynamic>.from(
issueAssistant['eventSource'] as Map<String, dynamic>,
);
}
} }
} }
} }
@ -1098,10 +1193,6 @@ ${_YamlEmitter().write({'projects': projectsSection})}
# issueAssistant: # issueAssistant:
# enabled: ${assistant?.enabled ?? true} # enabled: ${assistant?.enabled ?? true}
# eventSource:
# type: gh/gosmee
# reconciliation: startup-only
# reconcileInterval: 30s
# mentionTriggers: ["@helper"] # mentionTriggers: ["@helper"]
# sessionPrefix: sample # sessionPrefix: sample
''' '''

View File

@ -88,9 +88,10 @@ class ProjectConfigDocument {
includeIfNull: false, includeIfNull: false,
) )
class IssueTrackerConfigDocument { class IssueTrackerConfigDocument {
const IssueTrackerConfigDocument({this.provider}); const IssueTrackerConfigDocument({this.provider, this.eventSource});
final IssueTrackerProvider? provider; final IssueTrackerProvider? provider;
final IssueAssistantEventSourceDocument? eventSource;
factory IssueTrackerConfigDocument.fromJson(Map<String, dynamic> json) => factory IssueTrackerConfigDocument.fromJson(Map<String, dynamic> json) =>
_$IssueTrackerConfigDocumentFromJson(json); _$IssueTrackerConfigDocumentFromJson(json);
@ -171,9 +172,9 @@ class IssueAssistantEventSourceDocument {
this.reconcileInterval, this.reconcileInterval,
}); });
final IssueAssistantEventSourceKind? type; final IssueTrackerEventSourceKind? type;
final String? pollInterval; final String? pollInterval;
final IssueAssistantReconciliationKind? reconciliation; final IssueTrackerReconciliationKind? reconciliation;
final String? reconcileInterval; final String? reconcileInterval;
factory IssueAssistantEventSourceDocument.fromJson( factory IssueAssistantEventSourceDocument.fromJson(
@ -247,7 +248,7 @@ class NotificationConfigDocument {
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)
enum AssistantCapability { planning, bugVerification } enum AssistantCapability { planning, bugVerification }
enum IssueAssistantEventSourceKind { enum IssueTrackerEventSourceKind {
@JsonValue('gh/polling') @JsonValue('gh/polling')
ghPolling, ghPolling,
@JsonValue('glab/polling') @JsonValue('glab/polling')
@ -264,7 +265,7 @@ enum IssueAssistantEventSourceKind {
teaGosmee, teaGosmee,
} }
enum IssueAssistantReconciliationKind { enum IssueTrackerReconciliationKind {
@JsonValue('startup-only') @JsonValue('startup-only')
startupOnly, startupOnly,
@JsonValue('continuous') @JsonValue('continuous')

View File

@ -63,21 +63,21 @@ class IssueAssistantApp {
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
issueEventSource: RoutedIssueEventSource( issueEventSource: RoutedIssueEventSource(
sources: { sources: {
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, IssueTrackerEventSourceKind.ghPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.glabPolling: pollingIssueEventSource, IssueTrackerEventSourceKind.glabPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, IssueTrackerEventSourceKind.teaPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource, IssueTrackerEventSourceKind.opPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( IssueTrackerEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand, gosmeeCommand: gosmeeCommand,
logger: _logger, logger: _logger,
), ),
IssueAssistantEventSourceKind.ghWebhookForward: IssueTrackerEventSourceKind.ghWebhookForward:
GitHubWebhookForwardIssueEventSource( GitHubWebhookForwardIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
logger: _logger, logger: _logger,
), ),
IssueAssistantEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource( IssueTrackerEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand, gosmeeCommand: gosmeeCommand,
logger: _logger, logger: _logger,

View File

@ -39,7 +39,7 @@ abstract class IssueEventSource {
class RoutedIssueEventSource implements IssueEventSource { class RoutedIssueEventSource implements IssueEventSource {
RoutedIssueEventSource({required this.sources}); RoutedIssueEventSource({required this.sources});
final Map<IssueAssistantEventSourceKind, IssueEventSource> sources; final Map<IssueTrackerEventSourceKind, IssueEventSource> sources;
@override @override
Future<void> run({ Future<void> run({
@ -50,17 +50,15 @@ class RoutedIssueEventSource implements IssueEventSource {
.where(_shouldRunStartupReconciliation) .where(_shouldRunStartupReconciliation)
.toList(growable: false); .toList(growable: false);
if (startupReconcileProjects.isNotEmpty) { if (startupReconcileProjects.isNotEmpty) {
await sources[IssueAssistantEventSourceKind.ghPolling]!.runOnce( await sources[IssueTrackerEventSourceKind.ghPolling]!.runOnce(
projects: startupReconcileProjects, projects: startupReconcileProjects,
onBatch: onBatch, onBatch: onBatch,
); );
} }
final projectsBySource = final projectsBySource = <IssueTrackerEventSourceKind, List<ProjectConfig>>{
<IssueAssistantEventSourceKind, List<ProjectConfig>>{ for (final eventSource in sources.keys) eventSource: <ProjectConfig>[],
for (final eventSource in sources.keys) };
eventSource: <ProjectConfig>[],
};
for (final project in projects) { for (final project in projects) {
for (final projectSource in _registeredSourcesFor(project)) { for (final projectSource in _registeredSourcesFor(project)) {
final sourceProjects = projectsBySource[projectSource]; final sourceProjects = projectsBySource[projectSource];
@ -90,7 +88,7 @@ class RoutedIssueEventSource implements IssueEventSource {
required List<ProjectConfig> projects, required List<ProjectConfig> projects,
required IssueEventBatchHandler onBatch, required IssueEventBatchHandler onBatch,
}) { }) {
return sources[IssueAssistantEventSourceKind.ghPolling]!.runOnce( return sources[IssueTrackerEventSourceKind.ghPolling]!.runOnce(
projects: projects, projects: projects,
onBatch: onBatch, onBatch: onBatch,
); );
@ -103,33 +101,33 @@ class RoutedIssueEventSource implements IssueEventSource {
} }
} }
Iterable<IssueAssistantEventSourceKind> _registeredSourcesFor( Iterable<IssueTrackerEventSourceKind> _registeredSourcesFor(
ProjectConfig project, ProjectConfig project,
) sync* { ) sync* {
switch (project.issueAssistant.eventSource.type) { switch (project.issueAssistant.eventSource.type) {
case IssueAssistantEventSourceKind.ghPolling: case IssueTrackerEventSourceKind.ghPolling:
yield IssueAssistantEventSourceKind.ghPolling; yield IssueTrackerEventSourceKind.ghPolling;
case IssueAssistantEventSourceKind.glabPolling: case IssueTrackerEventSourceKind.glabPolling:
yield IssueAssistantEventSourceKind.glabPolling; yield IssueTrackerEventSourceKind.glabPolling;
case IssueAssistantEventSourceKind.teaPolling: case IssueTrackerEventSourceKind.teaPolling:
yield IssueAssistantEventSourceKind.teaPolling; yield IssueTrackerEventSourceKind.teaPolling;
case IssueAssistantEventSourceKind.opPolling: case IssueTrackerEventSourceKind.opPolling:
yield IssueAssistantEventSourceKind.opPolling; yield IssueTrackerEventSourceKind.opPolling;
case IssueAssistantEventSourceKind.ghGosmee: case IssueTrackerEventSourceKind.ghGosmee:
if (_shouldRunContinuousReconciliation(project)) { if (_shouldRunContinuousReconciliation(project)) {
yield IssueAssistantEventSourceKind.ghPolling; yield IssueTrackerEventSourceKind.ghPolling;
} }
yield IssueAssistantEventSourceKind.ghGosmee; yield IssueTrackerEventSourceKind.ghGosmee;
case IssueAssistantEventSourceKind.ghWebhookForward: case IssueTrackerEventSourceKind.ghWebhookForward:
if (_shouldRunContinuousReconciliation(project)) { if (_shouldRunContinuousReconciliation(project)) {
yield IssueAssistantEventSourceKind.ghPolling; yield IssueTrackerEventSourceKind.ghPolling;
} }
yield IssueAssistantEventSourceKind.ghWebhookForward; yield IssueTrackerEventSourceKind.ghWebhookForward;
case IssueAssistantEventSourceKind.teaGosmee: case IssueTrackerEventSourceKind.teaGosmee:
if (_shouldRunContinuousReconciliation(project)) { if (_shouldRunContinuousReconciliation(project)) {
yield IssueAssistantEventSourceKind.teaPolling; yield IssueTrackerEventSourceKind.teaPolling;
} }
yield IssueAssistantEventSourceKind.teaGosmee; yield IssueTrackerEventSourceKind.teaGosmee;
} }
} }
@ -141,7 +139,7 @@ class RoutedIssueEventSource implements IssueEventSource {
project.issueAssistant.eventSource is ChannelIssueEventSourceConfig && project.issueAssistant.eventSource is ChannelIssueEventSourceConfig &&
(project.issueAssistant.eventSource as ChannelIssueEventSourceConfig) (project.issueAssistant.eventSource as ChannelIssueEventSourceConfig)
.reconciliation == .reconciliation ==
IssueAssistantReconciliationKind.continuous; IssueTrackerReconciliationKind.continuous;
} }
class PollingIssueEventSource implements IssueEventSource { class PollingIssueEventSource implements IssueEventSource {

View File

@ -407,21 +407,22 @@ projects:
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Load an explicit Gitea issue tracker provider /// Scenario: Infer a Gitea issue tracker provider from the event source
/// Given a config whose project sets issueTracker.provider to gitea /// Given a config whose project sets tea/polling as the event source type
/// When loading the app config from disk /// When loading the app config from disk
/// Then the project keeps the configured Gitea provider /// Then the project infers the Gitea provider
/// And the repository slug still parses normally /// And the repository slug still parses normally
/// ``` /// ```
test('Load an explicit Gitea issue tracker provider', () async { test('Infer a Gitea issue tracker provider from the event source', () async {
// Given a config whose project sets issueTracker.provider to gitea. // Given a config whose project sets tea/polling as the event source type.
final tempDir = await Directory.systemTemp.createTemp('cws-gitea-'); final tempDir = await Directory.systemTemp.createTemp('cws-gitea-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: gitea eventSource:
type: tea/polling
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
'''); ''');
@ -429,7 +430,7 @@ projects:
// When loading the app config from disk. // When loading the app config from disk.
final config = await AppConfig.load(configFile.path); final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured Gitea provider. // Then the project infers the Gitea provider.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea); expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
expect(config.projects['sample']!.scm.provider, ScmProvider.gitea); expect(config.projects['sample']!.scm.provider, ScmProvider.gitea);
@ -437,26 +438,27 @@ projects:
expect(config.projects['sample']!.repoSlug!.fullName, 'owner/sample'); expect(config.projects['sample']!.repoSlug!.fullName, 'owner/sample');
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaPolling, IssueTrackerEventSourceKind.teaPolling,
); );
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Load an explicit GitLab issue tracker provider /// Scenario: Infer a GitLab issue tracker provider from the event source
/// Given a config whose project sets issueTracker.provider to gitlab /// Given a config whose project sets glab/polling as the event source type
/// When loading the app config from disk /// When loading the app config from disk
/// Then the project keeps the configured GitLab provider /// Then the project infers the GitLab provider
/// And the project uses GitLab polling and GitLab SCM by default /// And the project uses GitLab polling and GitLab SCM by default
/// ``` /// ```
test('Load an explicit GitLab issue tracker provider', () async { test('Infer a GitLab issue tracker provider from the event source', () async {
// Given a config whose project sets issueTracker.provider to gitlab. // Given a config whose project sets glab/polling as the event source type.
final tempDir = await Directory.systemTemp.createTemp('cws-gitlab-'); final tempDir = await Directory.systemTemp.createTemp('cws-gitlab-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: gitlab eventSource:
type: glab/polling
repo: group/subgroup/sample repo: group/subgroup/sample
path: ${tempDir.path} path: ${tempDir.path}
'''); ''');
@ -464,67 +466,75 @@ projects:
// When loading the app config from disk. // When loading the app config from disk.
final config = await AppConfig.load(configFile.path); final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured GitLab provider. // Then the project infers the GitLab provider.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab); expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab);
// And the project uses GitLab polling and GitLab SCM by default. // And the project uses GitLab polling and GitLab SCM by default.
expect(config.projects['sample']!.scm.provider, ScmProvider.gitlab); expect(config.projects['sample']!.scm.provider, ScmProvider.gitlab);
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.glabPolling, IssueTrackerEventSourceKind.glabPolling,
); );
expect(config.projects['sample']!.repo, 'group/subgroup/sample'); expect(config.projects['sample']!.repo, 'group/subgroup/sample');
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Load an explicit OpenProject issue tracker provider /// Scenario: Infer an OpenProject issue tracker provider from the event source
/// Given a config whose project sets issueTracker.provider to openproject /// Given a config whose project sets op/polling as the event source type
/// And the project declares an explicit SCM provider /// And the project declares an explicit SCM provider
/// When loading the app config from disk /// When loading the app config from disk
/// Then the project keeps the configured OpenProject provider /// Then the project infers the OpenProject provider
/// And the project uses op/polling as its default issue event source /// And the project uses op/polling as its default issue event source
/// ``` /// ```
test('Load an explicit OpenProject issue tracker provider', () async { test(
// Given a config whose project sets issueTracker.provider to openproject. 'Infer an OpenProject issue tracker provider from the event source',
final tempDir = await Directory.systemTemp.createTemp('cws-openproject-'); () async {
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); // Given a config whose project sets op/polling as the event source type.
await configFile.writeAsString(''' final tempDir = await Directory.systemTemp.createTemp(
'cws-openproject-',
);
final configFile = File(
p.join(tempDir.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: openproject eventSource:
type: op/polling
scm: scm:
provider: github provider: github
repo: 42 repo: 42
path: ${tempDir.path} path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
final config = await AppConfig.load(configFile.path); final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured OpenProject provider. // Then the project infers the OpenProject provider.
expect( expect(
config.projects['sample']!.provider, config.projects['sample']!.provider,
IssueTrackerProvider.openproject, IssueTrackerProvider.openproject,
); );
expect(config.projects['sample']!.scm.provider, ScmProvider.github); expect(config.projects['sample']!.scm.provider, ScmProvider.github);
// And the project uses op/polling as its default issue event source. // And the project uses op/polling as its default issue event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.opPolling, IssueTrackerEventSourceKind.opPolling,
); );
}); },
);
/// ```gherkin /// ```gherkin
/// Scenario: Require an explicit SCM provider for OpenProject projects /// Scenario: Require an explicit SCM provider for OpenProject projects
/// Given a config whose project uses the OpenProject issue tracker /// Given a config whose project uses op/polling as the event source
/// And the project omits scm.provider /// And the project omits scm.provider
/// When loading the app config from disk /// When loading the app config from disk
/// Then loading fails with a clear SCM requirement error /// Then loading fails with a clear SCM requirement error
/// ``` /// ```
test('Require an explicit SCM provider for OpenProject projects', () async { test('Require an explicit SCM provider for OpenProject projects', () async {
// Given a config whose project uses the OpenProject issue tracker. // Given a config whose project uses op/polling as the event source.
final tempDir = await Directory.systemTemp.createTemp( final tempDir = await Directory.systemTemp.createTemp(
'cws-openproject-missing-scm-', 'cws-openproject-missing-scm-',
); );
@ -533,7 +543,8 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: openproject eventSource:
type: op/polling
repo: 42 repo: 42
path: ${tempDir.path} path: ${tempDir.path}
'''); ''');
@ -551,7 +562,7 @@ projects:
(error) => error.message, (error) => error.message,
'message', 'message',
contains( contains(
'projects.sample.scm.provider is required for issueTracker.provider: openproject', 'projects.sample.scm.provider is required for issue tracking provider: openproject',
), ),
), ),
), ),
@ -560,13 +571,13 @@ projects:
/// ```gherkin /// ```gherkin
/// Scenario: Accept an OpenProject project name reference /// Scenario: Accept an OpenProject project name reference
/// Given a config whose project uses the OpenProject issue tracker /// Given a config whose project uses op/polling as the event source
/// And the project repo field is a human-readable project name /// And the project repo field is a human-readable project name
/// When loading the app config from disk /// When loading the app config from disk
/// Then loading succeeds without requiring a numeric project id /// Then loading succeeds without requiring a numeric project id
/// ``` /// ```
test('Accept an OpenProject project name reference', () async { test('Accept an OpenProject project name reference', () async {
// Given a config whose project uses the OpenProject issue tracker. // Given a config whose project uses op/polling as the event source.
final tempDir = await Directory.systemTemp.createTemp( final tempDir = await Directory.systemTemp.createTemp(
'cws-openproject-project-name-', 'cws-openproject-project-name-',
); );
@ -575,7 +586,8 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: openproject eventSource:
type: op/polling
scm: scm:
provider: github provider: github
repo: Demo project repo: Demo project
@ -610,12 +622,10 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: gh/gosmee type: gh/gosmee
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -624,7 +634,7 @@ projects:
// Then the project keeps gh/gosmee as its event source. // Then the project keeps gh/gosmee as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghGosmee, IssueTrackerEventSourceKind.ghGosmee,
); );
// And the GitHub provider remains valid for that project. // And the GitHub provider remains valid for that project.
@ -652,7 +662,8 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: gitea eventSource:
type: tea/polling
scm: scm:
provider: github provider: github
repo: owner/sample repo: owner/sample
@ -671,15 +682,17 @@ projects:
); );
/// ```gherkin /// ```gherkin
/// Scenario: Reject gh/gosmee for non-GitHub providers /// Scenario: Keep the legacy issue tracker provider as a fallback
/// Given a Gitea project config that opts into gh/gosmee /// Given a config whose project still sets issueTracker.provider to gitea
/// And the project omits issueAssistant.eventSource.type
/// When loading the app config from disk /// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error /// Then the project keeps the configured Gitea provider
/// And the polling event source is rewritten to tea/polling
/// ``` /// ```
test('Reject gh/gosmee for non-GitHub providers', () async { test('Keep the legacy issue tracker provider as a fallback', () async {
// Given a Gitea project config that opts into gh/gosmee. // Given a config whose project still sets issueTracker.provider to gitea.
final tempDir = await Directory.systemTemp.createTemp( final tempDir = await Directory.systemTemp.createTemp(
'cws-gh-gosmee-gitea-', 'cws-legacy-provider-fallback-',
); );
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString(''' await configFile.writeAsString('''
@ -689,22 +702,60 @@ projects:
provider: gitea provider: gitea
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: ''');
// And the project omits issueAssistant.eventSource.type.
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the project keeps the configured Gitea provider.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
// And the polling event source is rewritten to tea/polling.
expect(
config.projects['sample']!.issueAssistant.eventSource.type,
IssueTrackerEventSourceKind.teaPolling,
);
});
/// ```gherkin
/// Scenario: Reject conflicting legacy tracker and event source settings
/// Given a project config whose legacy tracker provider is gitea
/// And the project opts into gh/gosmee
/// When loading the app config from disk
/// Then loading fails with a clear conflict error
/// ```
test('Reject conflicting legacy tracker and event source settings', () async {
// Given a project config whose legacy tracker provider is gitea.
final tempDir = await Directory.systemTemp.createTemp(
'cws-gh-gosmee-gitea-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: gitea
eventSource: eventSource:
type: gh/gosmee type: gh/gosmee
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
final load = AppConfig.load(configFile.path); final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error. // Then loading fails with a clear conflict error.
await expectLater( await expectLater(
load, load,
throwsA( throwsA(
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'message', 'message',
contains('gh/gosmee requires issueTracker.provider: github'), contains(
'gh/gosmee implies issue tracker provider github, but the legacy configured provider is gitea',
),
), ),
), ),
); );
@ -727,12 +778,10 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: gh/webhook-forward type: gh/webhook-forward
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -741,7 +790,7 @@ projects:
// Then the project keeps gh/webhook-forward as its event source. // Then the project keeps gh/webhook-forward as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.ghWebhookForward, IssueTrackerEventSourceKind.ghWebhookForward,
); );
// And the GitHub provider remains valid for that project. // And the GitHub provider remains valid for that project.
@ -765,14 +814,12 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: gh/webhook-forward type: gh/webhook-forward
reconciliation: startup-only reconciliation: startup-only
reconcileInterval: 5m reconcileInterval: 5m
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -783,7 +830,7 @@ projects:
(config.projects['sample']!.issueAssistant.eventSource (config.projects['sample']!.issueAssistant.eventSource
as ChannelIssueEventSourceConfig) as ChannelIssueEventSourceConfig)
.reconciliation, .reconciliation,
IssueAssistantReconciliationKind.startupOnly, IssueTrackerReconciliationKind.startupOnly,
); );
// And the configured reconcile interval is parsed for channel recovery. // And the configured reconcile interval is parsed for channel recovery.
@ -812,13 +859,11 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: gh/webhook-forward type: gh/webhook-forward
pollInterval: 45s pollInterval: 45s
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -840,46 +885,51 @@ projects:
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Reject gh/webhook-forward for non-GitHub providers /// Scenario: Reject a legacy Gitea provider that conflicts with gh/webhook-forward
/// Given a Gitea project config that opts into gh/webhook-forward /// Given a project config whose legacy tracker provider is gitea
/// And the project opts into gh/webhook-forward
/// When loading the app config from disk /// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error /// Then loading fails with a clear conflict error
/// ``` /// ```
test('Reject gh/webhook-forward for non-GitHub providers', () async { test(
// Given a Gitea project config that opts into gh/webhook-forward. 'Reject a legacy Gitea provider that conflicts with gh/webhook-forward',
final tempDir = await Directory.systemTemp.createTemp( () async {
'cws-webhook-forward-gitea-', // Given a project config whose legacy tracker provider is gitea.
); final tempDir = await Directory.systemTemp.createTemp(
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); 'cws-webhook-forward-gitea-',
await configFile.writeAsString(''' );
final configFile = File(
p.join(tempDir.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: gitea provider: gitea
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: gh/webhook-forward type: gh/webhook-forward
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
final load = AppConfig.load(configFile.path); final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error. // Then loading fails with a clear conflict error.
await expectLater( await expectLater(
load, load,
throwsA( throwsA(
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'message', 'message',
contains( contains(
'gh/webhook-forward requires issueTracker.provider: github', 'gh/webhook-forward implies issue tracker provider github, but the legacy configured provider is gitea',
),
), ),
), ),
), );
); },
}); );
/// ```gherkin /// ```gherkin
/// Scenario: Load tea/gosmee as the issue event source /// Scenario: Load tea/gosmee as the issue event source
@ -896,12 +946,10 @@ projects:
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: tea/gosmee type: tea/gosmee
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
@ -910,7 +958,7 @@ projects:
// Then the project keeps tea/gosmee as its event source. // Then the project keeps tea/gosmee as its event source.
expect( expect(
config.projects['sample']!.issueAssistant.eventSource.type, config.projects['sample']!.issueAssistant.eventSource.type,
IssueAssistantEventSourceKind.teaGosmee, IssueTrackerEventSourceKind.teaGosmee,
); );
// And the Gitea provider remains valid for that project. // And the Gitea provider remains valid for that project.
@ -918,44 +966,51 @@ projects:
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Reject tea/gosmee for non-Gitea providers /// Scenario: Reject a legacy GitHub provider that conflicts with tea/gosmee
/// Given a GitHub project config that opts into tea/gosmee /// Given a project config whose legacy tracker provider is github
/// And the project opts into tea/gosmee
/// When loading the app config from disk /// When loading the app config from disk
/// Then loading fails with a clear provider compatibility error /// Then loading fails with a clear conflict error
/// ``` /// ```
test('Reject tea/gosmee for non-Gitea providers', () async { test(
// Given a GitHub project config that opts into tea/gosmee. 'Reject a legacy GitHub provider that conflicts with tea/gosmee',
final tempDir = await Directory.systemTemp.createTemp( () async {
'cws-gosmee-github-', // Given a project config whose legacy tracker provider is github.
); final tempDir = await Directory.systemTemp.createTemp(
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); 'cws-gosmee-github-',
await configFile.writeAsString(''' );
final configFile = File(
p.join(tempDir.path, 'agent-orchestrator.yaml'),
);
await configFile.writeAsString('''
projects: projects:
sample: sample:
issueTracker: issueTracker:
provider: github provider: github
repo: owner/sample
path: ${tempDir.path}
issueAssistant:
eventSource: eventSource:
type: tea/gosmee type: tea/gosmee
repo: owner/sample
path: ${tempDir.path}
'''); ''');
// When loading the app config from disk. // When loading the app config from disk.
final load = AppConfig.load(configFile.path); final load = AppConfig.load(configFile.path);
// Then loading fails with a clear provider compatibility error. // Then loading fails with a clear conflict error.
await expectLater( await expectLater(
load, load,
throwsA( throwsA(
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'message', 'message',
contains('tea/gosmee requires issueTracker.provider: gitea'), contains(
'tea/gosmee implies issue tracker provider gitea, but the legacy configured provider is github',
),
),
), ),
), );
); },
}); );
/// ```gherkin /// ```gherkin
/// Scenario: Reject snake_case config keys /// Scenario: Reject snake_case config keys
@ -1097,16 +1152,6 @@ projects:
.replaceFirst(' # timeout: 10m', ' timeout: 10m') .replaceFirst(' # timeout: 10m', ' timeout: 10m')
.replaceFirst(' # issueAssistant:', ' issueAssistant:') .replaceFirst(' # issueAssistant:', ' issueAssistant:')
.replaceFirst(' # enabled: true', ' enabled: true') .replaceFirst(' # enabled: true', ' enabled: true')
.replaceFirst(' # eventSource:', ' eventSource:')
.replaceFirst(' # type: gh/gosmee', ' type: gh/gosmee')
.replaceFirst(
' # reconciliation: startup-only',
' reconciliation: startup-only',
)
.replaceFirst(
' # reconcileInterval: 30s',
' reconcileInterval: 30s',
)
.replaceFirst( .replaceFirst(
' # mentionTriggers: ["@helper"]', ' # mentionTriggers: ["@helper"]',
' mentionTriggers: ["@helper"]', ' mentionTriggers: ["@helper"]',
@ -1138,10 +1183,8 @@ projects:
'@helper', '@helper',
]); ]);
expect( expect(
(config.projects['sample']!.issueAssistant.eventSource config.projects['sample']!.issueAssistant.eventSource,
as ChannelIssueEventSourceConfig) isA<PollingIssueEventSourceConfig>(),
.reconciliation,
IssueAssistantReconciliationKind.startupOnly,
); );
}); });
}); });

View File

@ -25,16 +25,16 @@ void main() {
final webhookForward = _RecordingIssueEventSource(); final webhookForward = _RecordingIssueEventSource();
final routed = RoutedIssueEventSource( final routed = RoutedIssueEventSource(
sources: { sources: {
IssueAssistantEventSourceKind.ghPolling: polling, IssueTrackerEventSourceKind.ghPolling: polling,
IssueAssistantEventSourceKind.teaPolling: polling, IssueTrackerEventSourceKind.teaPolling: polling,
IssueAssistantEventSourceKind.ghWebhookForward: webhookForward, IssueTrackerEventSourceKind.ghWebhookForward: webhookForward,
}, },
); );
// And a project that uses gh/webhook-forward with startup-only reconciliation. // And a project that uses gh/webhook-forward with startup-only reconciliation.
final project = _project( final project = _project(
eventSource: IssueAssistantEventSourceKind.ghWebhookForward, eventSource: IssueTrackerEventSourceKind.ghWebhookForward,
reconciliation: IssueAssistantReconciliationKind.startupOnly, reconciliation: IssueTrackerReconciliationKind.startupOnly,
); );
// When the routed event source starts in long-running mode. // When the routed event source starts in long-running mode.
@ -64,16 +64,16 @@ void main() {
final webhookForward = _RecordingIssueEventSource(); final webhookForward = _RecordingIssueEventSource();
final routed = RoutedIssueEventSource( final routed = RoutedIssueEventSource(
sources: { sources: {
IssueAssistantEventSourceKind.ghPolling: polling, IssueTrackerEventSourceKind.ghPolling: polling,
IssueAssistantEventSourceKind.teaPolling: polling, IssueTrackerEventSourceKind.teaPolling: polling,
IssueAssistantEventSourceKind.ghWebhookForward: webhookForward, IssueTrackerEventSourceKind.ghWebhookForward: webhookForward,
}, },
); );
// And a project that uses gh/webhook-forward with continuous reconciliation. // And a project that uses gh/webhook-forward with continuous reconciliation.
final project = _project( final project = _project(
eventSource: IssueAssistantEventSourceKind.ghWebhookForward, eventSource: IssueTrackerEventSourceKind.ghWebhookForward,
reconciliation: IssueAssistantReconciliationKind.continuous, reconciliation: IssueTrackerReconciliationKind.continuous,
); );
// When the routed event source starts in long-running mode. // When the routed event source starts in long-running mode.
@ -115,8 +115,8 @@ class _RecordingIssueEventSource implements IssueEventSource {
} }
ProjectConfig _project({ ProjectConfig _project({
required IssueAssistantEventSourceKind eventSource, required IssueTrackerEventSourceKind eventSource,
required IssueAssistantReconciliationKind reconciliation, required IssueTrackerReconciliationKind reconciliation,
}) { }) {
return ProjectConfig( return ProjectConfig(
key: 'sample', key: 'sample',

View File

@ -78,7 +78,7 @@ void main() {
issueAssistant: IssueAssistantConfig( issueAssistant: IssueAssistantConfig(
enabled: true, enabled: true,
eventSource: const PollingIssueEventSourceConfig( eventSource: const PollingIssueEventSourceConfig(
type: IssueAssistantEventSourceKind.ghPolling, type: IssueTrackerEventSourceKind.ghPolling,
pollInterval: Duration(seconds: 30), pollInterval: Duration(seconds: 30),
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,
@ -131,7 +131,7 @@ void main() {
issueAssistant: IssueAssistantConfig( issueAssistant: IssueAssistantConfig(
enabled: true, enabled: true,
eventSource: const PollingIssueEventSourceConfig( eventSource: const PollingIssueEventSourceConfig(
type: IssueAssistantEventSourceKind.glabPolling, type: IssueTrackerEventSourceKind.glabPolling,
pollInterval: Duration(seconds: 30), pollInterval: Duration(seconds: 30),
), ),
maxConcurrentIssues: 3, maxConcurrentIssues: 3,