Skip to content
Merged
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
621 changes: 621 additions & 0 deletions .claude/PRPs/plans/github-username-mapping-cli-design.plan.md

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion src/adapters/github/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export class GitHubApiClient {
});
}

async listIssues(options: { maxPages?: number } = {}): Promise<GitHubIssue[]> {
async listIssues(
options: { maxPages?: number } = {}
): Promise<GitHubIssue[]> {
const { maxPages = 20 } = options; // Default: up to 2,000 issues

try {
Expand Down Expand Up @@ -136,4 +138,27 @@ export class GitHubApiClient {
async closeIssue(issueNumber: number): Promise<GitHubIssue> {
return this.updateIssue(issueNumber, { state: 'closed' });
}

/**
* Check if a username can be assigned to issues in this repository
* This checks if the user is a collaborator with triage permissions or higher
*/
async checkUserCanBeAssigned(username: string): Promise<boolean> {
try {
await this.octokit.rest.repos.checkCollaborator({
owner: this.config.owner,
repo: this.config.repo,
username,
});
return true;
} catch (error: unknown) {
const apiError = error as { status?: number };
// GitHub API returns 404 for non-collaborators
if (apiError.status === 404) {
return false;
}
// Re-throw other errors (authentication issues, etc.)
throw error;
}
}
}
49 changes: 44 additions & 5 deletions src/adapters/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ export class GitHubAdapter implements WorkAdapter {
throw new WorkItemNotFoundError(id);
}

const updates: { title?: string; body?: string; labels?: string[]; assignees?: string[] } = {};
const updates: {
title?: string;
body?: string;
labels?: string[];
assignees?: string[];
} = {};

if (request.title !== undefined) {
updates.title = request.title;
Expand All @@ -144,15 +149,17 @@ export class GitHubAdapter implements WorkAdapter {
// Fetch current issue to get existing labels
const currentIssue = await this.apiClient.getIssue(issueNumber);
const currentLabels = currentIssue.labels.map(l => l.name);

// Remove all agent:* labels
const labelsWithoutAgent = currentLabels.filter(name => !name.startsWith('agent:'));

const labelsWithoutAgent = currentLabels.filter(
name => !name.startsWith('agent:')
);

// Add new agent label if provided (clearing if null/undefined)
if (request.agent) {
labelsWithoutAgent.push(`agent:${request.agent}`);
}

updates.labels = labelsWithoutAgent;
} else if (request.labels !== undefined) {
updates.labels = [...request.labels];
Expand Down Expand Up @@ -360,4 +367,36 @@ export class GitHubAdapter implements WorkAdapter {
const schema = await this.getSchema();
return schema.relationTypes;
}

/**
* Resolve @notation or team-based assignment to adapter-specific username
* GitHub: passes through the resolved username from teams (assumes it's a GitHub username)
*/
resolveAssignee(notation: string): Promise<string> {
// GitHub adapter expects the notation to already be resolved to a GitHub username
// by the AssigneeResolver using teams.xml platform mappings
return Promise.resolve(notation);
}

/**
* Validate if a username/assignee is valid for this adapter
* GitHub: uses GitHub API to check if user can be assigned to repository issues
*/
async validateAssignee(assignee: string): Promise<boolean> {
if (!this.apiClient || !this.config) {
throw new Error('GitHub adapter not initialized');
}

return this.apiClient.checkUserCanBeAssigned(assignee);
}

/**
* Get information about supported assignee patterns for this adapter
* GitHub: requires valid GitHub usernames that have access to the repository
*/
getAssigneeHelp(): Promise<string> {
return Promise.resolve(
'GitHub adapter requires valid GitHub usernames that have access to the repository. Use teams.xml platform mappings to map @notation to GitHub usernames.'
);
}
}
28 changes: 28 additions & 0 deletions src/adapters/local-fs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,32 @@ export class LocalFsAdapter implements WorkAdapter {
const schema = await this.getSchema();
return schema.relationTypes;
}

/**
* Resolve @notation or team-based assignment to adapter-specific username
* Local filesystem: simple passthrough - accepts any assignee string as-is
*/
resolveAssignee(notation: string): Promise<string> {
// Local-fs has no user management - all assignees valid, no transformation
return Promise.resolve(notation);
}

/**
* Validate if a username/assignee is valid for this adapter
* Local filesystem: all assignees are valid (no user management system)
*/
validateAssignee(_assignee: string): Promise<boolean> {
// Local-fs accepts any assignee string
return Promise.resolve(true);
}

/**
* Get information about supported assignee patterns for this adapter
* Local filesystem: accepts any string, passes through @notation unchanged
*/
getAssigneeHelp(): Promise<string> {
return Promise.resolve(
'Local filesystem adapter accepts any assignee string. @notation is passed through unchanged.'
);
}
}
44 changes: 42 additions & 2 deletions src/cli/commands/create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Args, Flags } from '@oclif/core';
import { WorkEngine } from '../../core/index.js';
import { TeamsEngine } from '../../core/teams-engine.js';
import { AssigneeResolver } from '../../core/assignee-resolver.js';
import { WorkItemKind, Priority } from '../../types/index.js';
import { BaseCommand } from '../base-command.js';
import { formatOutput } from '../formatter.js';
Expand All @@ -17,6 +19,8 @@ export default class Create extends BaseCommand {
static override examples = [
'<%= config.bin %> <%= command.id %> "Fix login bug" --kind bug --priority high',
'<%= config.bin %> <%= command.id %> "Implement user dashboard" --kind task',
'<%= config.bin %> <%= command.id %> "Review PR" --assignee @tech-lead',
'<%= config.bin %> <%= command.id %> "Deploy feature" --assignee @dev-team/lead',
];

static override flags = {
Expand All @@ -39,7 +43,8 @@ export default class Create extends BaseCommand {
}),
assignee: Flags.string({
char: 'a',
description: 'assignee username',
description:
'assignee username or @notation (e.g., @tech-lead, @team/member)',
}),
agent: Flags.string({
description: 'agent identifier',
Expand All @@ -48,6 +53,10 @@ export default class Create extends BaseCommand {
char: 'l',
description: 'comma-separated labels',
}),
team: Flags.string({
char: 't',
description: 'default team for @notation resolution',
}),
};

public async run(): Promise<void> {
Expand All @@ -56,16 +65,40 @@ export default class Create extends BaseCommand {
const engine = new WorkEngine();

try {
await engine.ensureDefaultContext();

const labels = flags.labels
? flags.labels.split(',').map(l => l.trim())
: [];

// Resolve assignee using AssigneeResolver if provided
let resolvedAssignee = flags.assignee;
if (flags.assignee) {
try {
const teamsEngine = new TeamsEngine();
const adapter = engine.getAdapter(); // Get current adapter from engine
const resolver = new AssigneeResolver(adapter, teamsEngine);

resolvedAssignee = await resolver.resolveAssignee(flags.assignee, {
currentUser: process.env['USER'] || process.env['USERNAME'],
defaultTeam: flags.team,
validateWithAdapter: true,
});
} catch (error) {
// If resolution fails, show helpful error but continue with original assignee
this.warn(`Assignee resolution warning: ${(error as Error).message}`);
this.warn(
'Using assignee as-is. Use --team flag or check teams.xml configuration.'
);
}
}

const workItem = await engine.createWorkItem({
title: args.title,
kind: flags.kind as WorkItemKind,
priority: flags.priority as Priority,
description: flags.description,
assignee: flags.assignee,
assignee: resolvedAssignee,
agent: flags.agent,
labels,
});
Expand All @@ -79,6 +112,13 @@ export default class Create extends BaseCommand {
);
} else {
this.log(`Created ${workItem.kind} ${workItem.id}: ${workItem.title}`);
if (workItem.assignee && workItem.assignee !== flags.assignee) {
this.log(
` Assigned to: ${workItem.assignee} (resolved from ${flags.assignee})`
);
} else if (workItem.assignee) {
this.log(` Assigned to: ${workItem.assignee}`);
}
}
} catch (error) {
this.handleError(
Expand Down
Loading