Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ already use: Claude Code, Codex, OpenCode, Amp, and more.

- Run multiple coding agents at once without juggling terminals.
- Keep every agent isolated in its own Git worktree and branch.
- Send issues and tickets from Linear, GitHub, Jira, GitLab, Asana, Featurebase,
Monday.com, Forgejo, Plain, or YouTrack into an agent.
- Send issues and tickets from Linear, GitHub, Jira (Cloud or Data Center), GitLab,
Asana, Featurebase, Monday.com, Forgejo, Plain, or YouTrack into an agent.
- Review diffs, create pull requests, inspect CI checks, and merge from one place.
- Work locally or on your own remote machines over SSH/SFTP.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ export function useIssues(
const [debouncedTerm, setDebouncedTerm] = useState('');
const accountInventory = useAccounts();
const projectAccountContext = useObserver(() => {
if (!projectId) return { ready: true, choice: undefined, error: null };
if (!projectId) return { ready: true, choice: undefined, filter: '', error: null };
const settings = getProjectSettingsStore(projectId);
const accounts = settings?.durableDomains?.integrationAccounts;
return {
ready: accounts !== undefined,
choice: provider ? accounts?.stored[provider] : undefined,
// The server reads the saved filter itself; it is keyed here so a save refreshes the lists.
filter: (provider && settings?.durableDomains?.issueFilters?.stored[provider]) || '',
error: settings?.pageData.error ?? null,
};
});
Expand All @@ -75,6 +77,7 @@ export function useIssues(
projectPath ?? '',
repositoryUrl ?? '',
searchLimit,
projectAccountContext.filter,
accountKey,
]);

Expand Down Expand Up @@ -102,6 +105,7 @@ export function useIssues(
projectPath ?? '',
repositoryUrl ?? '',
initialLimit,
projectAccountContext.filter,
accountKey,
],
queryFn: async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import type { ProjectIntegrationAccountResolver } from './project-integration-ac

export type PluginIssueProviderDependencies = {
resolveProjectIntegrationAccount: ProjectIntegrationAccountResolver;
/** The project's stored issue filter for a provider, if any. */
getProjectIssueFilter?: (projectId: string, provider: string) => Promise<string | undefined>;
};

export function createPluginIssueProvider(
Expand Down Expand Up @@ -150,13 +152,16 @@ export function createPluginIssueProvider(

...createIssueListOperations(plugin, async (opts) => {
const account = await getConnectedAccount(opts);
return account.success
? ok({
host: { log: pluginLog, credentials: account.data.credentials },
accountId: account.data.accountId,
repositoryUrl: repositoryUrl(opts),
})
: account;
if (!account.success) return account;
const issueFilter = opts.projectId
? await dependencies.getProjectIssueFilter?.(opts.projectId, provider)
: undefined;
return ok({
host: { log: pluginLog, credentials: account.data.credentials },
accountId: account.data.accountId,
repositoryUrl: repositoryUrl(opts),
...(issueFilter ? { issueFilter } : {}),
});
}),

getIssueContext: plugin.behavior.issues?.getIssue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ describe('useIssues', () => {
expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('0');
});

it('fetches again when the project saves a new issue filter', async () => {
mocks.listIssues.mockResolvedValueOnce({ success: true, data: [{ identifier: 'A-1' }] });
await renderProbe();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('1');
mocks.listIssues.mockReturnValue(new Promise(() => {}));
mocks.getProjectSettingsStore.mockReturnValue({
durableDomains: {
integrationAccounts: { stored: { github: { kind: 'account', accountId: 'a' } } },
issueFilters: { stored: { github: 'label = bug' } },
},
pageData: { error: null },
});
await renderProbe();
expect(mocks.listIssues).toHaveBeenCalledTimes(2);
expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('0');
});

it('does not retain another account search results as placeholder data', async () => {
mocks.searchIssues.mockResolvedValueOnce({ success: true, data: [{ identifier: 'A-1' }] });
await renderProbe();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import type { IntegrationProviderDescriptor } from '../api/contract';
function issueCapabilities(integrationId: string): IssueProviderCapabilities {
const plugin = issuesPluginRegistry.get(integrationId);
const requiredInputs = plugin?.capabilities.issues.requiredInputs ?? [];
const issueFilter = plugin?.capabilities.issues.issueFilter;
return {
requiresRepositoryUrl: requiredInputs.includes('repositoryUrl'),
supportsIssueContext: !!plugin?.behavior.issues?.getIssue,
...(issueFilter ? { issueFilter } : {}),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,40 @@ describe('createPluginIssueProvider', () => {
expect(mockGetCredentials).toHaveBeenCalledWith('linear', 'linear:acct-2');
});

it("hands the project's issue filter to the plugin, and none without a project", async () => {
const listIssues = vi.fn(async () => ok([]));
const getProjectIssueFilter = vi.fn(async () => '"Epic Link" = WEB-123');
const provider = createPluginIssueProvider(makePlugin({ listIssues }), {
resolveProjectIntegrationAccount: vi.fn(async () => ({
value: {
providerId: 'linear',
accountId: 'linear:acct-2',
displayName: 'Second workspace',
isDefault: false,
},
provenance: { kind: 'set' as const },
accounts: [],
contextKey: '',
})),
getProjectIssueFilter,
});

await provider.listIssues({ projectId: 'p1' });
await provider.listIssues({});

expect(getProjectIssueFilter).toHaveBeenCalledWith('p1', 'linear');
expect(listIssues).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ issueFilter: '"Epic Link" = WEB-123' })
);
expect(listIssues).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.not.objectContaining({ issueFilter: expect.anything() })
);
});

it('fails closed when the integration is explicitly disabled for the project', async () => {
const provider = createPluginIssueProvider(makePlugin({}), {
resolveProjectIntegrationAccount: vi.fn(async () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type ResolvedIssueAccess = {
host: ConnectedIntegrationHostContext;
accountId: string;
repositoryUrl?: string;
issueFilter?: string;
};

/** Provider adapters prepare access; list/search execution has one policy and result mapping. */
Expand All @@ -28,7 +29,7 @@ export function createIssueListOperations(
async function invoke(opts: IssueQueryOpts, searchTerm?: string): Promise<IssueListResult> {
const context = await prepare(opts);
if (!context.success) return context;
const { repositoryUrl, host } = context.data;
const { repositoryUrl, host, issueFilter } = context.data;
if (capabilities.requiresRepositoryUrl && !repositoryUrl) {
return err({ type: 'invalid_input', message: 'Repository URL is required.' });
}
Expand All @@ -37,10 +38,12 @@ export function createIssueListOperations(
? await plugin.behavior.issues?.listIssues(host, {
limit: clampIssueProviderLimit(opts.limit, DEFAULT_LIST_LIMIT),
repositoryUrl,
issueFilter,
})
: await plugin.behavior.issues?.searchIssues(host, {
limit: clampIssueProviderLimit(opts.limit, DEFAULT_SEARCH_LIMIT),
repositoryUrl,
issueFilter,
searchTerm,
});
if (!result) return ok([]);
Expand Down Expand Up @@ -68,10 +71,11 @@ export function clampIssueProviderLimit(limit: number | undefined, fallback: num
export function toIssueProviderCapabilities(
plugin: IssuesPluginProvider
): IssueProviderCapabilities {
const requiredInputs = plugin.capabilities.issues.requiredInputs;
const { requiredInputs, issueFilter } = plugin.capabilities.issues;
return {
requiresRepositoryUrl: requiredInputs.includes('repositoryUrl'),
supportsIssueContext: !!plugin.behavior.issues?.getIssue,
...(issueFilter ? { issueFilter } : {}),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type RepoFacts,
type StoredProjectGitSettings,
type StoredIntegrationAccounts,
type StoredIssueFilters,
} from '@core/primitives/project-settings/api';
import type { AppDb } from '@core/services/app-db/node/db';
import { projectSettings } from '@core/services/app-db/node/schema';
Expand Down Expand Up @@ -90,6 +91,7 @@ export function storedGitSettingsFromRow(
): StoredProjectGitSettings {
const {
integrationAccounts: _accounts,
issueFilters: _issueFilters,
tmux: _tmux,
tmuxDefaultMigrated: _migration,
...git
Expand Down Expand Up @@ -136,3 +138,15 @@ export async function loadStoredIntegrationAccounts(
.limit(1);
return row ? (readStoredProjectSettings(row.base).integrationAccounts ?? {}) : {};
}

export async function loadStoredIssueFilters(
db: AppDb,
projectId: string
): Promise<StoredIssueFilters> {
const [row] = await db
.select({ base: projectSettings.baseProjectSettingsJson })
.from(projectSettings)
.where(eq(projectSettings.projectId, projectId))
.limit(1);
return row ? (readStoredProjectSettings(row.base).issueFilters ?? {}) : {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function fixture() {
const readDurable = vi.fn(async () =>
ok({
integrationAccounts: { stored: {} },
issueFilters: { stored: {} },
gitIdentity: {
stored: {
baseRemote: 'origin',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,13 @@ export class ProjectSettingsService implements Hookable<ProjectSettingsHooks> {
if (
patch.gitIdentity ||
patch.integrationAccounts ||
patch.issueFilters ||
patch.placement?.stored.tmux !== undefined
) {
const durable = await this.durableSettings.patch(projectId, {
...(patch.gitIdentity ? { gitIdentity: patch.gitIdentity } : {}),
...(patch.integrationAccounts ? { integrationAccounts: patch.integrationAccounts } : {}),
...(patch.issueFilters ? { issueFilters: patch.issueFilters } : {}),
...(patch.placement && Object.hasOwn(patch.placement.stored, 'tmux')
? { placement: { stored: { tmux: patch.placement.stored.tmux } } }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
StoredDefaultBranch,
StoredIntegrationAccount,
StoredIntegrationAccounts,
StoredIssueFilters,
StoredProjectGitSettings,
} from '@core/primitives/project-settings/api';
import type { UpdateProjectSettingsError } from '@core/primitives/projects/api';
Expand Down Expand Up @@ -71,6 +72,10 @@ export type ProjectIntegrationAccountsDomainSnapshot = {
stored: StoredIntegrationAccounts;
};

export type ProjectIssueFiltersDomainSnapshot = {
stored: StoredIssueFilters;
};

export type ProjectPlacementDomainSnapshot = {
stored: {
worktreeRoot?: string;
Expand All @@ -89,12 +94,14 @@ export type ProjectSettingsDomains = {
environment: ProjectEnvironmentDomainSnapshot;
gitIdentity: ProjectGitIdentityDomainSnapshot;
integrationAccounts: ProjectIntegrationAccountsDomainSnapshot;
issueFilters: ProjectIssueFiltersDomainSnapshot;
placement: ProjectPlacementDomainSnapshot;
};

export type ProjectDurableSettingsDomains = {
gitIdentity: ProjectGitIdentityDomainSnapshot;
integrationAccounts: ProjectIntegrationAccountsDomainSnapshot;
issueFilters: ProjectIssueFiltersDomainSnapshot;
placement: Pick<ProjectPlacementDomainSnapshot, 'stored'>;
};

Expand All @@ -119,6 +126,9 @@ export type ProjectIntegrationAccountsPatch = Partial<
Record<string, StoredIntegrationAccount | null>
>;

/** Values set one provider's filter; null clears it; absent keys are untouched. */
export type ProjectIssueFiltersPatch = Partial<Record<string, string | null>>;

export type ProjectPlacementStoredPatch = {
worktreeRoot?: string | null;
tmux?: boolean | null;
Expand All @@ -140,6 +150,9 @@ export type ProjectSettingsDomainPatch = {
integrationAccounts?: {
stored: ProjectIntegrationAccountsPatch;
};
issueFilters?: {
stored: ProjectIssueFiltersPatch;
};
placement?: {
stored: ProjectPlacementStoredPatch;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function makeForm(overrides: FormOverrides = {}): FormState {
...overrides.gitIdentity,
},
integrationAccounts: overrides.integrationAccounts ?? {},
issueFilters: {},
placement: { tmux: undefined, worktreeDirectory: '', ...overrides.placement },
};
}
Expand Down Expand Up @@ -86,6 +87,7 @@ function domains(): ProjectSettingsDomains {
},
gitIdentity: { stored: { baseRemote: 'origin' } },
integrationAccounts: { stored: {} },
issueFilters: { stored: {} },
placement: {
stored: { tmux: false },
layers: {
Expand Down Expand Up @@ -120,6 +122,14 @@ describe('project settings form model', () => {
});
});

it('patches only the issue filter that was edited', () => {
const form = projectSettingsDomainsToForm(domains(), [origin]);
form.issueFilters = { jira: '"Epic Link" = WEB-123', youtrack: 'for: me' };
expect(
formToProjectSettingsDomainPatch(form, new Set<FormFieldPath>(['issueFilters.jira']))
).toEqual({ issueFilters: { stored: { jira: '"Epic Link" = WEB-123' } } });
});

it('clears only the reset provider even when another provider has a stored pin', () => {
const form = projectSettingsDomainsToForm(domains(), [origin]);
form.integrationAccounts = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export type EnvironmentFormState = {
*/
export type IntegrationAccountsFormState = Partial<Record<string, StoredIntegrationAccount | null>>;

/** Per-provider issue filter text; blank clears the stored filter on save. */
export type IssueFiltersFormState = Partial<Record<string, string>>;

export type GitIdentityFormState = {
defaultBranch: GitBranchRef | null;
baseRemote: string;
Expand All @@ -68,6 +71,7 @@ export type FormState = {
environment: EnvironmentFormState;
gitIdentity: GitIdentityFormState;
integrationAccounts: IntegrationAccountsFormState;
issueFilters: IssueFiltersFormState;
placement: PlacementFormState;
};

Expand Down Expand Up @@ -157,6 +161,7 @@ export function projectSettingsDomainsToForm(
environment: environmentToForm(domains.environment),
gitIdentity: gitIdentityToForm(domains.gitIdentity, remotes),
integrationAccounts: domains.integrationAccounts.stored,
issueFilters: domains.issueFilters.stored,
placement: placementToForm(domains.placement),
};
}
Expand Down Expand Up @@ -276,6 +281,11 @@ export function formToProjectSettingsDomainPatch(
isTouched(touchedFields, `integrationAccounts.${providerId}`)
)
);
const issueFilters = Object.fromEntries(
Object.entries(form.issueFilters).filter(([providerId]) =>
isTouched(touchedFields, `issueFilters.${providerId}`)
)
);
const placement = placementToPatch(form.placement, touchedFields);
return {
...(lifecycle ? { lifecycle } : {}),
Expand All @@ -285,6 +295,7 @@ export function formToProjectSettingsDomainPatch(
...(Object.keys(integrationAccounts).length > 0
? { integrationAccounts: { stored: integrationAccounts } }
: {}),
...(Object.keys(issueFilters).length > 0 ? { issueFilters: { stored: issueFilters } } : {}),
...(placement ? { placement } : {}),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({
projectId={projectId}
gitIdentityForm={formModel.form.gitIdentity}
integrationAccountsForm={formModel.form.integrationAccounts}
issueFiltersForm={formModel.form.issueFilters}
placementForm={formModel.form.placement}
placement={domains.placement}
projectType={projectType}
remotes={remotes}
worktreeDirectoryError={formModel.worktreeDirectoryError}
updateGitIdentity={formModel.updateGitIdentity}
updateIntegrationAccounts={formModel.updateIntegrationAccounts}
updateIssueFilters={formModel.updateIssueFilters}
updatePlacement={formModel.updatePlacement}
hostActionReason={hostActionReason}
hostObservationKind={hostObservationKind}
Expand Down
Loading