Skip to content

Implement changes to "il plan" and "il start" to support pasting in urls to Github issue + PR, linear, JIRA urls#1010

Draft
acreeger wants to merge 1 commit into
mainfrom
feat/issue-1009__parse-tracker-urls
Draft

Implement changes to "il plan" and "il start" to support pasting in urls to Github issue + PR, linear, JIRA urls#1010
acreeger wants to merge 1 commit into
mainfrom
feat/issue-1009__parse-tracker-urls

Conversation

@acreeger
Copy link
Copy Markdown
Collaborator

@acreeger acreeger commented May 1, 2026

Fixes #1009

Implement changes to "il plan" and "il start" to support pasting in urls to Github issue + PR, linear, JIRA urls


This PR was created automatically by iloom.

@acreeger
Copy link
Copy Markdown
Collaborator Author

acreeger commented May 1, 2026

Enhancement Analysis

Open Questions (autonomous-execution assumptions filled in)

Question Assumed Answer
Should pasted URLs be accepted on every command that takes an identifier (start, plan, finish, cleanup, enhance, commit, add-issue)? @acreeger — Yes for read-style commands that resolve an identifier (start, plan, enhance, add-issue). For commands that look up an existing loom on disk (finish, cleanup, commit, recap), URLs are accepted and normalized to the canonical identifier before lookup.
Should GitHub PR URLs be valid input for il plan? No. plan is for decomposing issues/epics; PR URLs error out with a hint to use the issue URL or number. PR URLs remain valid for il start.
Cross-repo GitHub URLs (URL points at a different owner/repo than cwd): allow, warn, or reject? il plan accepts cross-repo URLs (matches commit 8873e1a cross-repo epic guidance). il start rejects them with an actionable error (il contribute or cd into the correct repo) because creating a worktree for an issue from another repo is ambiguous.
Provider mismatch (URL is a Linear URL but configured tracker is GitHub, or vice versa): switch providers, error, or prompt? Error with a clear message naming both providers and the setting that controls the configured tracker. No silent provider switching — that would corrupt tracker-typed metadata downstream.
Should the CLI accept Linear's mobile/short URL forms (linear.app/<workspace>/issue/TEAM-123 with various trailing path/query) as well as the copy-as-link form that includes a slug? Yes. Strip slug, query, and fragment; only the team-issue identifier (TEAM-123) is required.
Jira self-hosted (custom domain) vs Jira Cloud (*.atlassian.net)? Both accepted. Detection is by path shape (/browse/<KEY>-<NUM>), not host. The configured Jira host must match the URL host; mismatched host errors with a clear message.
What about issue-comment anchors (e.g., #issuecomment-12345, Linear #comment-..., Jira ?focusedCommentId=...)? Strip and ignore. The identifier resolves to the parent issue/PR; comment context is not currently used by start/plan.
Should the CLI also accept gh-style shorthand (owner/repo#1234)? Out of scope for this enhancement — keep the surface area to URLs the user copies from a browser. Can be added later.
Should existing numeric/identifier inputs (1234, WEB-2423) keep working unchanged? Yes. URL parsing is additive: if input matches a URL pattern it is parsed; otherwise it falls through to the existing identifier resolution path. No behavior change for existing inputs.
Telemetry: should we track URL-vs-identifier input form? Yes — track an enum (identifier_source: 'url' | 'identifier') plus parsed tracker_type on loom.created and the equivalent plan event. No URL content, owner, repo, or issue key in properties.

Problem Summary
Users frequently start work from a browser tab, Slack message, or email containing a full tracker URL. Today they must manually parse the URL down to a numeric or alphanumeric identifier (e.g., extract WEB-2423 from a Linear URL) before invoking il start or il plan, which is error-prone and adds friction.

User Impact
All iloom users on every supported tracker (GitHub issues + PRs, Linear, Jira). The friction is highest for Linear/Jira users whose identifiers include letters and dashes that are easy to mistype.

Enhancement Goal
Allow users to paste a full tracker URL anywhere il start or il plan accepts an identifier. The CLI parses the URL, infers the intended tracker provider, validates it against the project's configured tracker, and resolves the underlying issue/PR — with clear errors when the URL is malformed, points at a different repo than the current workspace, or targets a different provider than the one configured.

Next Steps

  • Technical analysis to design the URL parser surface and where in the resolve-identifier path it slots in
  • Implementation across il start, il plan, and any shared identifier-parsing utility
  • Tests covering each URL shape, malformed input, cross-repo, and provider-mismatch cases

Complete Context and Details (click to expand)

User Stories

  1. As a developer triaging Linear, I want to copy a Linear issue URL from my browser and paste it into il start <url> so I can begin work without manually extracting the issue identifier.
  2. As a developer reviewing GitHub PRs, I want to paste a GitHub PR URL into il start <url> to spin up a worktree against that PR's branch.
  3. As an architect planning an epic, I want to paste a GitHub issue URL (potentially in another repo) into il plan <url> and have iloom resolve the right tracker context for decomposition.
  4. As a Jira user, I want to paste a https://<host>/browse/PROJ-99 URL into il start and have it resolve correctly whether my org is on Jira Cloud or self-hosted.
  5. As a user with a misconfigured project, I want a clear error when I paste a URL whose provider does not match my configured tracker, including the name of the setting I need to change.

Supported URL Shapes

Provider Shape Identifier Extracted
GitHub issue https://github.com/<owner>/<repo>/issues/<n> <n> (with <owner>/<repo> context)
GitHub PR https://github.com/<owner>/<repo>/pull/<n> <n> (PR, with repo context)
Linear issue https://linear.app/<workspace>/issue/<TEAM-NUM>[/<slug>] <TEAM-NUM>
Jira issue https://<host>/browse/<KEY>-<NUM> <KEY>-<NUM>

All shapes must tolerate: trailing slashes, query strings (?foo=bar), fragments/anchors (#issuecomment-..., #comment-...), and mixed case in path segments where the underlying tracker treats them case-insensitively.

Acceptance Criteria

  1. il start and il plan accept any of the four URL shapes above as the identifier argument.
  2. Existing identifier inputs (1234, WEB-2423, PROJ-99) continue to work with no behavior change.
  3. Malformed URLs (unrecognized host, missing identifier segment, truncated) produce a clear, actionable error that names what was missing.
  4. Trailing slashes, query strings, and fragment anchors are stripped before parsing.
  5. Cross-repo GitHub URLs:
    • il plan accepts a URL whose owner/repo differs from the current cwd repo and uses the URL's repo as the tracker context.
    • il start rejects cross-repo GitHub URLs with an error pointing the user to either il contribute or running the command from the correct repository.
  6. Provider mismatch: When the URL's tracker provider does not match the project's configured tracker, the command errors with a message that names both providers and the setting controlling the configured tracker. No silent provider switching.
  7. Jira host mismatch: When a Jira URL's host differs from the configured Jira host, the command errors with a message naming both hosts.
  8. The PR URL form is valid for il start but rejected for il plan with a hint to use the underlying issue URL.
  9. Identifier comparisons remain case-safe per the project rule (Linear WEB-2423 vs web-2423 must resolve to the same loom; URL-extracted identifiers are normalized via IssueTracker.normalizeIdentifier()).
  10. Telemetry on loom.created (and the equivalent plan event) includes an identifier_source: 'url' | 'identifier' enum and a tracker_type enum. No URL strings, owner names, repo names, issue titles, or issue keys are sent.
  11. Unit tests cover every URL shape, all four edge cases (trailing slash, query, fragment, mixed case), malformed URLs, cross-repo and provider-mismatch error paths.
  12. docs/iloom-commands.md is updated with examples of pasting URLs into il start and il plan.

Edge Cases

  • Malformed URLs: Missing path segment (e.g., https://github.com/owner/repo/issues/), unknown host (e.g., https://gitlab.com/...), partially typed URLs.
  • URL with query and/or fragment: ?foo=bar, #issuecomment-12345, #comment-..., Jira ?focusedCommentId=....
  • Trailing slashes: .../issues/123/ should resolve identically to .../issues/123.
  • Mixed case in path: GitHub Issues vs issues, Linear Issue vs issue. Match case-insensitively for path keywords; preserve case in extracted identifier and normalize via IssueTracker.normalizeIdentifier().
  • Cross-repo GitHub URL: URL points at iloom-ai/other-repo while cwd is iloom-ai/iloom-cli. Behavior differs between start (reject) and plan (accept).
  • Provider mismatch vs configured tracker: Linear URL pasted in a project configured for GitHub, etc. Error with both provider names.
  • Linear mobile/app links: linear.app/<workspace>/issue/TEAM-123/some-slug-text — slug is ignored; identifier extraction depends only on the TEAM-NUM segment.
  • Jira Cloud vs self-hosted: Both supported. The configured Jira host must match the URL host; mismatched host errors clearly.
  • Issue-comment anchors: Always stripped; behavior resolves the parent issue/PR.
  • GitHub Enterprise (github.<company>.com): Out of scope unless trivially supported by the existing GitHub provider; otherwise document as future work.
  • Wrapped URLs from chat clients: Some clients wrap URLs in angle brackets (<https://...>) or with trailing punctuation (https://...,). Trim surrounding angle brackets and trailing punctuation defensively.
  • URL with auth credentials: https://user:[email protected]/... — strip auth and parse normally; do not log credentials.

Out of Scope

  • gh-style shorthand (owner/repo#123) and other non-URL shorthand forms.
  • GitHub Enterprise host support beyond what the existing GitHub provider already handles.
  • Auto-switching the configured tracker provider based on a pasted URL.
  • Accepting URLs to entities other than issues/PRs (e.g., GitHub discussions, Linear projects/cycles, Jira boards).
  • Pasting URLs into commands that operate on existing looms by directory rather than identifier (shell, vscode, etc.) — these can adopt the parsing later if useful.

Author

Reporter: @acreeger.

@acreeger
Copy link
Copy Markdown
Collaborator Author

acreeger commented May 1, 2026

Issue #1009 Analysis — URL-paste support for il start / il plan

Executive Summary

il start and il plan both gate identifier handling on a single shared utility — matchIssueIdentifier() in src/utils/IdentifierParser.ts:30 — and only accept numeric (123, #123) or project-key (ABC-123) shapes today. Adding URL-paste support is a two-call-site change with one new utility: a URL detector slotted in front of matchIssueIdentifier() in src/commands/start.ts:450 (parseInput) and in src/commands/plan.ts:204 (the looksLikeIssueIdentifier block). The detector must yield a normalized identifier, optional repo (owner/repo), and a parsed-provider tag — the latter two are then threaded down to IssueTracker.detectInputType(), fetchIssue(), MCP-config generation, and the auto-swarm StartCommand.execute() call.

Questions and Key Decisions

Question Answer
Does the spec's "provider mismatch errors loudly" rule extend to GitHub-PR URLs pasted in a Linear/Jira-configured project? Today il start already routes any numeric input to GitHubService for PR detection regardless of the configured tracker (src/commands/start.ts:537-557), so a pasted GitHub PR URL on a Linear project is the spec's "ambiguous case." The PR URL form already has a privileged path through GitHubService. Recommend treating GitHub PR URLs as always valid for il start regardless of configured tracker (no provider-mismatch error), and only enforcing provider-mismatch on GitHub issue URLs vs. Linear/Jira-configured projects. Otherwise the new rule contradicts existing PR-detection behaviour for Linear users.
Should the URL parser run in cli.ts's argument parser (so it normalizes before reaching commands), or only inside start.ts / plan.ts? Recommend keeping it inside the commands so each command can apply its own policy (il plan accepts cross-repo, il start rejects, il plan rejects PR URLs). A shared utility is fine; the policy is per-command.
il plan currently calls issueTracker.detectInputType(prompt) and fetchIssue(detection.identifier) with no repo argument (plan.ts:224, :228). The spec requires il plan to accept cross-repo GitHub URLs. Is threading repo through these two calls (plus the MCP provider construction at plan.ts:236/:253 and the auto-swarm StartCommand.execute() at :715/:743) the intended design? Yes — that's the only way the URL's owner/repo reaches the GitHub API. The MCP provider construction also needs the repo (otherwise GitHub MCP calls fall back to the cwd remote and silently fetch the wrong issue).

HIGH/CRITICAL Risks

  • Identifier case-sensitivity (CLAUDE.md rule): URL paths on linear.app are lowercase-tolerant. LinearService.normalizeIdentifier() (src/lib/LinearService.ts:221) and JiraIssueTracker.normalizeIdentifier() (src/lib/providers/jira/JiraIssueTracker.ts:90) both uppercase. Any URL-extracted Linear/Jira key MUST be normalized before lookup, comparison, or worktree resolution — otherwise an existing loom registered as WEB-2423 won't be found from a pasted web-2423 URL.
  • Cross-repo repo plumbing in il plan: A URL pointing at iloom-ai/other-repo while cwd is iloom-ai/iloom-cli requires the parsed repo to flow into four call sites: detectInputType, fetchIssue, IssueManagementProviderFactory.create() / mcpProvider.getIssue(), and the auto-swarm StartCommand.execute(). Missing any silently falls back to the cwd remote and corrupts the MCP/decomposition context.
  • Provider mismatch carve-out for GitHub PR URLs on Linear/Jira projects: il start's existing PR fallback (start.ts:537-557) routes numeric PRs to GitHubService regardless of configured tracker. A blanket "URL provider must match configured tracker" rule would regress this. The spec needs an explicit carve-out, or the implementation has to treat GitHub PR URLs as a special case independent of tracker.

Impact Summary

  • 3 files for primary modification:
    • src/commands/start.ts (~6 places: parseInput, validation paths, telemetry, repo plumbing)
    • src/commands/plan.ts (~5 places: identifier detection, repo threading to detectInputType/fetchIssue/MCP/StartCommand)
    • src/utils/IdentifierParser.ts (add parseTrackerUrl() alongside matchIssueIdentifier())
  • 2 test files to extend: src/utils/IdentifierParser.test.ts, src/commands/start.test.ts (input parsing block at line 203), and a new src/commands/plan.test.ts block.
  • 1 type file to extend: src/types/telemetry.ts adds identifier_source: 'url' | 'identifier' to LoomCreatedProperties.
  • 1 doc file: docs/iloom-commands.md per CLAUDE.md documentation requirement.

📋 Complete Technical Reference (click to expand for implementation details)

Problem Space Research

Problem Understanding

Users frequently start work from a browser tab, Slack message, or email and currently must hand-extract the identifier (especially Linear TEAM-NUM or Jira KEY-NUM) before invoking il start / il plan. Tracker URLs come in four shapes (GitHub issue, GitHub PR, Linear, Jira self-hosted + cloud) plus chat-client and shell artifacts (angle-bracket-wrapped, trailing punctuation, query/fragment). The spec assumes URL parsing is additive — existing numeric/project-key inputs must continue to work unchanged.

Architectural Context

The codebase already has a strict separation between shape detection (regex on a string, matchIssueIdentifier()) and existence validation (provider-specific IssueTracker.detectInputType()). URL parsing slots in as a third detection layer before shape detection, normalizing a URL into the same { identifier, repo? } tuple that the existing flow consumes. The IssueTracker interface (src/lib/IssueTracker.ts:16) already exposes normalizeIdentifier() (line 48) — the key contract URL-extracted identifiers must flow through.

Cross-repo support in il plan was added in commit 8873e1a (docs(plan): add cross-repo epic guidance to plan command prompt) — but that commit is prompt-only: it added "One Epic Per Repo for Cross-Repo Work" guidance to templates/prompts/plan-prompt.txt:154-164. The il plan CLI command itself has no --repo flag (src/cli.ts:1622-1647) and threads no repo into provider calls today. Cross-repo URL support in il plan is therefore the first code-level cross-repo affordance, not just a docs change.

Edge Cases Identified

  • Wrapped URLs: chat clients output <https://...> and trailing-comma artifacts; spec requires defensive trimming.
  • Auth-credentialed URLs: https://user:[email protected]/...URL.parse() strips auth into .username/.password; treat as parse-only and never log the raw URL (telemetry / debug logger).
  • Mixed case: Issues vs issues in path segments; tracker keywords are case-insensitive but the extracted identifier must be normalized via IssueTracker.normalizeIdentifier() (uppercase for Linear/Jira).
  • Slug suffixes: Linear's "copy as link" produces linear.app/<workspace>/issue/TEAM-123/<title-slug> — slug must be discarded.
  • Query / fragment: ?focusedCommentId=..., #issuecomment-12345, #comment-... — strip wholesale.
  • Trailing slash: .../issues/123/ should resolve identically.
  • Self-hosted Jira host validation: detection by path shape (/browse/<KEY>-<NUM>), not host. Configured Jira host (settings.issueManagement.jira.host) must match URL host or error loudly with both hosts named.
  • Empty / partial URLs: https://github.com/owner/repo/issues/ (missing number) should produce a clear error naming what was missing.
  • GitHub Enterprise (github.<company>.com): out of scope per spec — error like an unknown host.

Codebase Research Findings

Affected Area 1: Shared identifier matcher

Entry Point: src/utils/IdentifierParser.ts:30 (matchIssueIdentifier())
Used By:

  • src/commands/start.ts:487 — sole gate before IssueTracker.detectInputType() for il start
  • src/commands/plan.ts:204 — sole gate for looksLikeIssueIdentifier in il plan
  • src/utils/IdentifierParser.ts:81IdentifierParser class for cleanup/finish (worktree-pattern matching, NOT URL parsing — different concern; do not extend it)

Note: The class IdentifierParser at line 81 operates on existing worktrees and matches branch-name patterns. It is unrelated to URL parsing and should not be extended for this feature. The new helper should be a standalone exported function in the same file (alongside matchIssueIdentifier) or a sibling file.

Affected Area 2: il start parseInput

Entry Point: src/commands/start.ts:450 (parseInput)
Flow:

  1. Line 453-471: empty/description short-circuits
  2. Line 474-482: explicit PR format (pr/123, PR-123)
  3. Line 487: calls matchIssueIdentifier()URL parser slots in BEFORE this line
  4. Line 489-508: project-key path → issueTracker.detectInputType(input, repo)
  5. Line 511-558: numeric path → issueTracker.detectInputType() if supports PRs, else GitHubService fallback for PR + tracker for issue
  6. Line 561-565: branch-name fallback

Cross-repo policy enforcement for il start lives in validateInput() at src/commands/start.ts:571 — the URL-derived repo must be compared against the configured-remote repo (already loaded at line 161 via getConfiguredRepoFromSettings()) and rejected if different (per spec).

repo parameter is already plumbed through parseInput → detectInputType (line 491, 519, 540). Implementation just needs to populate it from the URL parse instead of leaving it undefined.

Affected Area 3: il plan decomposition path

Entry Point: src/commands/plan.ts:204 (identifierMatch = prompt ? matchIssueIdentifier(prompt) : ...)
Flow:

  1. Line 204-205: matchIssueIdentifier() shape-only check — URL parser slots in BEFORE this
  2. Line 219: IssueTrackerFactory.create(settings)
  3. Line 224: issueTracker.detectInputType(prompt)NO repo argument today
  4. Line 228: issueTracker.fetchIssue(detection.identifier)NO repo argument today
  5. Line 236: IssueManagementProviderFactory.create(provider, settings) — provider-only, no repo
  6. Line 253: mcpProvider.getIssue({ number, includeComments: true }) — relies on cwd remote
  7. Line 304-313: mcpProvider.getChildIssues(), mcpProvider.getDependencies() — same fallback
  8. Line 715, 743: auto-swarm calls StartCommand.execute({ identifier: String(epicIssueNumber), options })identifier is the bare number; cross-repo would need the URL form preserved or repo plumbed into options

Cross-repo plumbing gap: cross-repo GitHub URL support requires repo (owner/repo string) reaching all of (3), (4), (5)/(6)/(7), and the auto-swarm child loom creation. The current code does NOT support this; this is the largest cross-cutting impact of the spec.

Affected Area 4: Provider-specific identifier detection

Files:

  • src/lib/GitHubService.ts:54-80detectInputType(): matches ^#?(\d+)$ only
  • src/lib/LinearService.ts:61-92detectInputType(): matches /^([A-Z]{2,}-\d+)$/i
  • src/lib/providers/jira/JiraIssueTracker.ts:98-121detectInputType(): matches /^([A-Z][A-Z0-9]+)-(\d+)$/i

None of these accept URL strings. The URL parser must extract the identifier before these are called. They do not need modification.

Affected Area 5: IssueTracker.normalizeIdentifier()

Files:

  • src/lib/GitHubService.ts:392String(identifier) (numeric)
  • src/lib/LinearService.ts:221.toUpperCase()
  • src/lib/providers/jira/JiraIssueTracker.ts:90.toUpperCase()

URL-extracted identifiers from Linear/Jira URLs must be passed through normalizeIdentifier() before any downstream comparison/lookup (CLAUDE.md identifier-comparisons rule).

Affected Area 6: Repo URL precedent

Reference: src/commands/contribute.ts:73-108 (parseGitHubRepoUrl()) and src/utils/remote.ts:60-89 (extractOwnerRepoFromUrl())

Both already handle GitHub HTTPS / SSH / shorthand for repo URLs (not issue/PR URLs). The new tracker-URL parser should not subsume these but can borrow their hostname-pattern style.

Similar Patterns Found

  • src/utils/jira.ts:20, :69 — Jira issue URL construction ({host}/browse/{key}); use as the inverse pattern for parsing.
  • src/utils/linear.ts:51 — Linear issue URL construction (linear.app/issue/{identifier}); same.
  • src/mcp/JiraIssueManagementProvider.ts:264, 281, 359, 398, 409, 464 — Jira browse URL construction sites.
  • src/lib/LoomManager.ts:676 — synthesizes a GitHub issue URL from a PR URL by replacing /pull/N with /issues/M — confirms the existing in-tree assumption that GitHub PR URL ↔ GitHub issue URL share the /owner/repo/<type>/N shape.

Historical Context (regression scope)

Issue #1009 is a feature-add, not a regression. Recent commit 8873e1a (Apr 28 2026) added cross-repo guidance to plan-prompt.txt but no code-level cross-repo affordance — so cross-repo URL support in il plan is net-new code, not a fix for a regression.

Architectural Flow Analysis

Data Flow: parsed URL → resolved identifier (il start)

User input: "https://github.com/iloom-ai/iloom-cli/issues/1009"
  → src/commands/start.ts:172  StartCommand.parseInput(input.identifier, repo, options)
    → [NEW] parseTrackerUrl(input.identifier)
        returns { provider: 'github', kind: 'issue', identifier: '1009', repo: 'iloom-ai/iloom-cli' }
    → cross-repo validation against configured repo (start.ts:161)
    → IssueTracker.normalizeIdentifier('1009')  ← MANDATORY pass-through
    → existing parseInput flow with identifier='1009', repo='iloom-ai/iloom-cli'
        → IssueTracker.detectInputType(identifier, repo)  ← repo already plumbed
        → IssueTracker.fetchIssue(identifier, repo)        ← repo already plumbed
  → src/commands/start.ts:357  loomManager.createIloom({ ... })
  → src/commands/start.ts:399  TelemetryService.track('loom.created', {
      ..., identifier_source: 'url', tracker_type: 'github'  ← NEW props
    })

Data Flow: parsed URL → resolved identifier (il plan)

User input: "https://github.com/iloom-ai/other-repo/issues/42"  (cross-repo)
  → src/commands/plan.ts:204  identifierMatch
    → [NEW] parseTrackerUrl(prompt)
        returns { provider: 'github', kind: 'issue', identifier: '42', repo: 'iloom-ai/other-repo' }
    → reject if kind === 'pr' (per spec: PR URL invalid for plan)
    → provider-mismatch check vs IssueTrackerFactory.getProviderName(settings)
    → cross-repo: no rejection (per spec, plan accepts cross-repo)
  → IssueTracker.normalizeIdentifier(identifier)
  → src/commands/plan.ts:224  issueTracker.detectInputType(identifier, repo)   ← repo NEW
  → src/commands/plan.ts:228  issueTracker.fetchIssue(detection.identifier, repo) ← repo NEW
  → src/commands/plan.ts:236  IssueManagementProviderFactory.create(provider, settings, repo?)  ← repo NEW (gap)
  → src/commands/plan.ts:253  mcpProvider.getIssue({ number, includeComments: true, repo? })   ← repo NEW (gap)
  → src/commands/plan.ts:304  mcpProvider.getChildIssues({ number, repo? })                    ← repo NEW (gap)
  → src/commands/plan.ts:312  mcpProvider.getDependencies({ number, direction, repo? })        ← repo NEW (gap)
  → [auto-swarm path] src/commands/plan.ts:715/743  StartCommand.execute({ identifier, options: { ..., repo? } }) ← repo NEW

Critical Implementation Note: Cross-repo URL support in il plan is a cross-cutting change touching at least 5 method signatures (detectInputType, fetchIssue, IssueManagementProviderFactory.create, IssueProvider.getIssue/getChildIssues/getDependencies, StartCommand.execute). The MCP IssueProvider interface (src/mcp/types.ts) and the GitHub MCP provider (src/mcp/GitHubIssueManagementProvider.ts) may not currently accept a repo argument on getIssue/getChildIssues/getDependencies — implementation needs to verify and potentially extend those signatures (out-of-scope to confirm during analysis but flagged here).

Affected Files

  • src/utils/IdentifierParser.ts:30matchIssueIdentifier() is the existing shape detector. Add a new exported parseTrackerUrl() here (or a sibling src/utils/UrlParser.ts).
  • src/commands/start.ts:450-566parseInput() — insert URL detection before line 487 (matchIssueIdentifier()); enforce il start cross-repo rejection in validateInput() at :571.
  • src/commands/start.ts:172 — call site receives URL-input via input.identifier.
  • src/commands/start.ts:399-409 — telemetry block needs new identifier_source property.
  • src/commands/plan.ts:204identifierMatch block — insert URL detection before matchIssueIdentifier(); reject PR URLs (per spec).
  • src/commands/plan.ts:224, 228, 236, 253, 304, 312 — repo plumbing for cross-repo URL support.
  • src/commands/plan.ts:715, 743 — auto-swarm StartCommand.execute() calls; cross-repo identifier should preserve URL or pass repo in options.
  • src/types/telemetry.ts:25-32LoomCreatedProperties add identifier_source: 'url' | 'identifier' (note: existing tracker field already covers spec's tracker_type ask — flag this overlap).
  • src/utils/IdentifierParser.test.ts — add parseTrackerUrl() unit tests covering all shapes, edge cases.
  • src/commands/start.test.ts:203 (input parsing block) — extend with URL-paste cases for each provider, malformed URLs, cross-repo rejection.
  • src/commands/plan.test.ts — add a new describe('URL paste support') block covering cross-repo accept and PR-URL rejection.
  • docs/iloom-commands.md — examples per project documentation requirement (CLAUDE.md).

Integration Points

  • IssueTracker.normalizeIdentifier()mandatory pass-through after URL extraction; case-sensitivity rule (CLAUDE.md).
  • IssueTrackerFactory.getProviderName(settings) (src/lib/IssueTrackerFactory.ts:73) — used to detect provider mismatch.
  • getConfiguredRepoFromSettings(settings) (src/utils/remote.ts:115) — used in start.ts:161 to load the configured repo for cross-repo comparison.
  • IssueManagementProviderFactory (src/mcp/IssueManagementProviderFactory.ts) and IssueProvider (src/mcp/types.ts) — MCP provider seam; may need repo parameter extension for il plan cross-repo.

Medium Severity Risks

  • Auth credential URL log leakage: https://user:[email protected]/... URLs must never appear in logger.debug() / logger.info() output. Use URL API to strip username/password before any log call. The codebase's existing logger does not auto-redact URL credentials.
  • MCP provider signatures: IssueProvider.getIssue/getChildIssues/getDependencies may not currently accept repo. If extending these is out of scope, cross-repo il plan will read MCP context from cwd remote and silently mismatch — better to either extend or explicitly skip MCP calls when repo !== cwd-repo (with a logged warning).
  • Slug containing dashes that look like project keys: linear.app/team/issue/WEB-2423/fix-bug-in-PROJ-99 — the URL parser must extract WEB-2423 from the path segment immediately after /issue/, NOT regex-search the whole URL for KEY-NUM patterns (would match the slug).
  • Telemetry overlap: spec asks for tracker_type enum but existing LoomCreatedProperties.tracker (src/types/telemetry.ts:27) already provides this. Implementation should NOT duplicate; just add identifier_source.
  • il plan with no plan.started event today: URL-vs-identifier telemetry for plan has no existing event to extend. Either extend epic.planned (plan.ts:772) — but only fires for decomposition mode — or add a new plan.started event. Spec says "the equivalent plan event" but none exists.

Related Context

  • React Contexts: N/A (CLI, not web).
  • Test config (vitest.config.ts): mockReset: true, clearMocks: true, restoreMocks: true — do not add redundant afterEach cleanup in new tests (per project CLAUDE.md).
  • Identifier-case rule: project CLAUDE.md "Identifier comparisons are case-sensitive risks" — non-negotiable for this feature.

@acreeger
Copy link
Copy Markdown
Collaborator Author

acreeger commented May 1, 2026

Implementation Plan for Issue #1009

Summary

Add a new parseTrackerUrl() helper in src/utils/TrackerUrlParser.ts that recognizes GitHub issue/PR, Linear, and Jira URLs and returns a normalized { provider, kind, identifier, repo? } tuple. Wire it into src/commands/start.ts:parseInput() (before line 487) and src/commands/plan.ts:204 (before matchIssueIdentifier()), enforcing per-command policy (cross-repo: reject in start, accept in plan; PR URLs: accept in start, reject in plan; provider mismatch: error with carve-out for GitHub-PR-on-Linear/Jira). Add identifier_source: 'url' \| 'identifier' to LoomCreatedProperties.

Questions and Key Decisions

Question Answer Rationale
Where does the parser live? New file src/utils/TrackerUrlParser.ts (sibling of IdentifierParser.ts). The existing IdentifierParser class is worktree-pattern-matching, a different concern. Keep URL parsing separate.
Cross-repo plumbing in il plan for MCP getIssue/getChildIssues/getDependencies? Defer. Thread repo only through IssueTracker.detectInputType / fetchIssue / IssueManagementProviderFactory.create. For MCP calls, emit a logger.warn if URL repo differs from cwd remote and skip the MCP fetches (fall back to issueTracker.fetchIssue body). Analyzer flagged that IssueProvider.getIssue/... likely don't accept repo today; extending the MCP interface is a separate cross-cutting change. Logged warning surfaces the limitation without silent corruption.
GitHub PR URL on a Linear/Jira-configured project — provider mismatch error? No. PR URLs always route through GitHubService regardless of configured tracker (matches existing start.ts:537-557 behavior). Provider-mismatch only applies to GitHub issue URLs vs Linear/Jira-configured projects. Otherwise the new rule regresses existing PR-detection behavior for Linear users.
Telemetry: add tracker_type enum? No — reuse existing LoomCreatedProperties.tracker (src/types/telemetry.ts:27). Only identifier_source is genuinely new. Avoid duplicate fields.
Telemetry on il plan? Add identifier_source to existing epic.planned and auto_swarm.started events (or skip plan-side telemetry since no plan.started event exists). Defer creating a new event; reusing existing events is the lighter touch.
Auto-swarm StartCommand.execute() from il plan (plan.ts:715, 743) — how does it inherit the URL's repo? The auto-swarm path uses epicIssueNumber (created by the plan agent itself) as identifier, not the original URL. The URL-parsed repo only matters for the initial fetch in plan.ts:204-336; the auto-swarm StartCommand.execute operates on the freshly created epic in the cwd repo. No change needed at lines 715/743. The URL only affects the read path of plan, not the write path.
https://user:[email protected]/... URL with auth credentials? Parse via URL API which auto-strips username/password into separate fields; never log the raw URL. Add a defensive logger.debug('parseTrackerUrl received URL with credentials, stripped') if url.username is non-empty. Privacy / CLAUDE.md telemetry rules.

High-Level Execution Phases

  1. URL Parser Helper (Step 1): New src/utils/TrackerUrlParser.ts + unit tests covering all 4 URL shapes and edge cases.
  2. il start Integration (Step 2, parallel with Step 3): Slot parser into parseInput, enforce cross-repo rejection + provider mismatch (with PR carve-out) in validateInput, normalize via IssueTracker.normalizeIdentifier(), add identifier_source telemetry, extend start.test.ts.
  3. il plan Integration (Step 3, parallel with Step 2): Slot parser into the identifierMatch block, reject PR URLs, accept cross-repo, thread repo to detectInputType/fetchIssue, log warning + skip MCP fetches on cross-repo, extend plan.test.ts. Adds identifier_source to telemetry events.
  4. Type + Docs (Step 4, parallel with Steps 2/3): Extend LoomCreatedProperties, update docs/iloom-commands.md, update src/commands/CLAUDE.md and src/utils/ docs as needed.

Quick Stats

  • 3 files to modify: src/commands/start.ts, src/commands/plan.ts, src/types/telemetry.ts
  • 1 new file: src/utils/TrackerUrlParser.ts
  • 3 test files: 1 new (src/utils/TrackerUrlParser.test.ts), 2 extended (start.test.ts, plan.test.ts)
  • 2 docs: docs/iloom-commands.md, src/commands/CLAUDE.md
  • Dependencies: None (uses built-in URL)
  • Estimated complexity: Medium

Potential Risks (HIGH/CRITICAL only)

  • Identifier case-sensitivity (CLAUDE.md rule): URL-extracted Linear/Jira identifiers MUST be passed through IssueTracker.normalizeIdentifier() before any Map.get/Set.has/=== comparison. A user pasting linear.app/.../issue/web-2423/... must resolve to the same loom as one created from WEB-2423.
  • Slug containing dashes that look like project keys: Linear's slug linear.app/team/issue/WEB-2423/fix-bug-in-PROJ-99 — extractor must take the path segment immediately after /issue/, NOT regex-search the whole URL.
  • Cross-repo repo plumbing in il plan: Threading repo into detectInputType/fetchIssue is straightforward (interface already accepts repo?), but MCP provider calls (getIssue/getChildIssues/getDependencies) read from cwd remote. Plan defers full MCP plumbing and logs a warning when URL repo differs from cwd repo.

Complete Implementation Guide (click to expand for step-by-step details)

Shared Contract (defined in Step 1, consumed by Steps 2/3)

// src/utils/TrackerUrlParser.ts
export type TrackerUrlProvider = 'github' | 'linear' | 'jira'
export type TrackerUrlKind = 'issue' | 'pr'

export interface TrackerUrlParseResult {
  provider: TrackerUrlProvider
  kind: TrackerUrlKind
  /** Raw extracted identifier (Linear/Jira pre-normalization, GitHub numeric) */
  identifier: string
  /** owner/repo for GitHub URLs only */
  repo?: string
  /** URL host — used for Jira host-mismatch validation */
  host?: string
}

/**
 * Returns null if input is not a tracker URL.
 * Throws TrackerUrlError ONLY for "looks like a URL but malformed" cases
 * (e.g., known host with missing identifier segment).
 */
export function parseTrackerUrl(input: string): TrackerUrlParseResult | null

export class TrackerUrlError extends Error {
  constructor(message: string, public readonly host?: string) { super(message) }
}

Behavior contract consumed by Steps 2 and 3:

  • Plain identifiers (123, WEB-2423) → returns null (fall through to matchIssueIdentifier).
  • Non-tracker URLs (gitlab.com/..., github.com/owner/repo without /issues|/pull/N) → throws TrackerUrlError.
  • Wrapped URLs (<https://...>, trailing , . ;) → trimmed before parsing.
  • Auth credentials (user:token@host) → stripped via URL API; never logged.
  • Trailing slashes, query (?...), fragment (#...) → stripped; identifier extraction unaffected.
  • Linear slug suffix (/issue/WEB-2423/some-slug) → slug ignored.
  • Mixed-case path keywords (/Issues/, /Issue/) → matched case-insensitively.
  • Identifier in result is NOT uppercased — caller must run IssueTracker.normalizeIdentifier().

Files to Modify

1. src/utils/TrackerUrlParser.ts (NEW)

Purpose: Standalone URL detector + extractor for the 4 supported tracker URL shapes.

Click to expand pseudocode (~80 lines) - URL parser helper
// Pseudocode — implementer fills in body
export function parseTrackerUrl(input: string): TrackerUrlParseResult | null {
  // 1. Normalize: trim whitespace, strip wrapping <...>, strip trailing punctuation [,.;]
  const cleaned = stripWrapping(input)

  // 2. Quick fail: must start with http(s)://
  if (!cleaned.match(/^https?:\/\//i)) return null

  // 3. Parse via URL constructor; on parse-throw, return null
  let url: URL
  try { url = new URL(cleaned) } catch { return null }

  // 4. Strip auth: url.username/url.password are auto-extracted; do not log raw URL
  if (url.username) logger.debug('parseTrackerUrl: URL had credentials, stripped')

  // 5. Dispatch by host
  const host = url.hostname.toLowerCase()
  if (host === 'github.com') return parseGitHub(url)        // /owner/repo/(issues|pull)/N(/...)?
  if (host === 'linear.app') return parseLinear(url)         // /<workspace>/issue/TEAM-NUM(/slug)?
  // Jira: detect by path shape /browse/KEY-NUM (any host)
  if (/^\/browse\/[A-Z][A-Z0-9]+-\d+\/?$/i.test(url.pathname))
    return parseJira(url)

  // 6. Looks like a URL but unknown host/shape → null (caller falls through to matchIssueIdentifier)
  // Exception: github.com with non-issue/non-pull path → throw TrackerUrlError
  if (host === 'github.com') {
    throw new TrackerUrlError(
      `Unrecognized GitHub URL shape: ${url.pathname}. Expected /<owner>/<repo>/issues/<n> or /<owner>/<repo>/pull/<n>.`
    )
  }
  return null
}

function parseGitHub(url: URL): TrackerUrlParseResult {
  // Path: /<owner>/<repo>/(issues|pull)/<n>(/...)? — case-insensitive on keyword
  const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i)
  if (!m) throw new TrackerUrlError(`Malformed GitHub URL: missing issue/PR number in ${url.pathname}`)
  return {
    provider: 'github',
    kind: m[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
    identifier: m[4],
    repo: `${m[1]}/${m[2]}`,
    host: url.hostname,
  }
}

function parseLinear(url: URL): TrackerUrlParseResult {
  // Path: /<workspace>/issue/<TEAM-NUM>(/<slug>)? — keyword case-insensitive
  // CRITICAL: extract identifier from segment immediately after /issue/, do NOT regex-scan whole URL
  const segments = url.pathname.split('/').filter(Boolean)
  const issueIdx = segments.findIndex(s => s.toLowerCase() === 'issue')
  if (issueIdx === -1 || !segments[issueIdx + 1])
    throw new TrackerUrlError(`Malformed Linear URL: missing issue identifier in ${url.pathname}`)
  const idSegment = segments[issueIdx + 1]
  const idMatch = idSegment.match(/^([A-Z]{2,}-\d+)$/i)
  if (!idMatch) throw new TrackerUrlError(`Malformed Linear URL: '${idSegment}' is not a valid identifier`)
  return { provider: 'linear', kind: 'issue', identifier: idMatch[1], host: url.hostname }
}

function parseJira(url: URL): TrackerUrlParseResult {
  const m = url.pathname.match(/^\/browse\/([A-Z][A-Z0-9]+-\d+)\/?$/i)
  if (!m) throw new TrackerUrlError(`Malformed Jira URL: ${url.pathname}`)
  return { provider: 'jira', kind: 'issue', identifier: m[1], host: url.hostname }
}

function stripWrapping(s: string): string {
  let t = s.trim()
  if (t.startsWith('<') && t.endsWith('>')) t = t.slice(1, -1)
  t = t.replace(/[,;.]+$/, '')
  return t
}

2. src/utils/TrackerUrlParser.test.ts (NEW)

Purpose: Unit-test all URL shapes + edge cases (~40 cases via parameterized tests).

Click to expand test structure (~60 lines)
describe('parseTrackerUrl', () => {
  describe('GitHub URLs', () => {
    it.each([
      ['https://github.com/owner/repo/issues/123', { provider: 'github', kind: 'issue', identifier: '123', repo: 'owner/repo' }],
      ['https://github.com/owner/repo/pull/456', { provider: 'github', kind: 'pr', identifier: '456', repo: 'owner/repo' }],
      ['https://github.com/owner/repo/issues/123/', /* trailing slash */],
      ['https://github.com/owner/repo/issues/123?ref=foo', /* query */],
      ['https://github.com/owner/repo/issues/123#issuecomment-789', /* fragment */],
      ['https://github.com/owner/repo/Issues/123', /* mixed case keyword */],
      ['<https://github.com/owner/repo/issues/123>', /* wrapped */],
      ['https://github.com/owner/repo/issues/123,', /* trailing comma */],
      ['https://user:[email protected]/owner/repo/issues/123', /* auth stripped */],
    ])('parses %s correctly', (input, expected) => { /* ... */ })

    it('throws TrackerUrlError on missing number', () => {
      expect(() => parseTrackerUrl('https://github.com/owner/repo/issues/')).toThrow(TrackerUrlError)
    })

    it('throws on unrecognized GitHub path', () => {
      expect(() => parseTrackerUrl('https://github.com/owner/repo/discussions/123')).toThrow(TrackerUrlError)
    })
  })

  describe('Linear URLs', () => {
    it.each([
      ['https://linear.app/team/issue/WEB-2423', { provider: 'linear', identifier: 'WEB-2423' }],
      ['https://linear.app/team/issue/WEB-2423/some-title-slug', /* slug stripped */],
      ['https://linear.app/team/issue/web-2423', /* lowercase identifier preserved as-is */],
      ['https://linear.app/team/Issue/WEB-2423', /* keyword case-insensitive */],
    ])('parses %s', () => { /* ... */ })

    it('does NOT extract project key from slug (CRITICAL — slug may contain dashes)', () => {
      const r = parseTrackerUrl('https://linear.app/team/issue/WEB-2423/fix-PROJ-99-bug')!
      expect(r.identifier).toBe('WEB-2423')
    })

    it('throws on missing identifier', () => {
      expect(() => parseTrackerUrl('https://linear.app/team/issue/')).toThrow(TrackerUrlError)
    })
  })

  describe('Jira URLs', () => {
    it.each([
      ['https://myco.atlassian.net/browse/PROJ-99', { provider: 'jira', identifier: 'PROJ-99', host: 'myco.atlassian.net' }],
      ['https://jira.example.com/browse/PROJ-99', /* self-hosted */],
      ['https://myco.atlassian.net/browse/PROJ-99?focusedCommentId=12345', /* query stripped */],
    ])('parses %s', () => { /* ... */ })
  })

  describe('non-URL / pass-through inputs', () => {
    it.each(['123', '#456', 'WEB-2423', 'feat/branch', '   ', ''])(
      'returns null for plain identifier %s',
      (input) => expect(parseTrackerUrl(input)).toBeNull()
    )
    it('returns null for unknown host', () => {
      expect(parseTrackerUrl('https://gitlab.com/owner/repo/-/issues/1')).toBeNull()
    })
  })
})

3. src/commands/start.ts:447-566 (MODIFY — parseInput)

Change: Insert URL detection before matchIssueIdentifier() call at line 487. Honor cross-repo policy (reject) and provider-mismatch (with GitHub-PR carve-out). Normalize identifier via IssueTracker.normalizeIdentifier(). Track whether input was a URL for telemetry.

Specific edits:

  • Line 450 signature — extend return type to optionally carry repo + identifier_source:

    // Update ParsedInput (in src/commands/start.ts type) to include:
    //   repoOverride?: string
    //   identifierSource?: 'url' | 'identifier'
  • After line 482 (after PR-format check, before matchIssueIdentifier): call parseTrackerUrl(trimmedIdentifier).

  • If URL parse returns non-null:

    1. Provider-mismatch check (GitHub-PR carve-out): If parsed.provider !== this.issueTracker.providerName AND NOT (parsed.provider === 'github' && parsed.kind === 'pr') → throw with both provider names + setting path (issueManagement.provider).
    2. Cross-repo rejection (GitHub only): If parsed.repo is set AND repo (configured cwd repo) is set AND they differ (case-insensitive compare) → throw cross-repo URL not supported by 'il start' — use 'il contribute' or run from the correct repository.
    3. Jira host-mismatch: If parsed.provider === 'jira', compare parsed.host against settings.issueManagement.jira.host; on mismatch, throw with both hosts.
    4. Normalize: const normalized = this.issueTracker.normalizeIdentifier(parsed.identifier).
    5. Replace trimmedIdentifier for downstream calls with normalized and populate repo from parsed.repo when present (so existing detectInputType(trimmedIdentifier, repo) calls get the right repo).
    6. Mark parsed.identifierSource = 'url' for later telemetry.
  • Catch TrackerUrlError: re-throw with the parser's message (already actionable). Other input shapes (plain numbers, project keys) continue to flow through matchIssueIdentifier.

  • Line 399-409 telemetry block — add identifier_source: parsed.identifierSource ?? 'identifier' to the track('loom.created', { ... }) call.

4. src/commands/plan.ts:200-337 (MODIFY)

Change: Insert URL detection before matchIssueIdentifier() at line 204. Reject PR URLs. Accept cross-repo. Thread repo to detectInputType / fetchIssue. Skip MCP calls (with logged warning) when URL repo differs from cwd repo.

Specific edits:

  • Before line 204: parse prompt via parseTrackerUrl(). If non-null:

    1. Reject PR URLs: If parsed.kind === 'pr' → throw 'il plan' decomposes issues, not PRs. Use the underlying issue URL or number, e.g. <suggested issue URL>.
    2. Provider-mismatch check (no PR carve-out needed since plan rejects PRs): error if parsed.provider !== providerName(settings).
    3. Jira host-mismatch: same as start.ts.
    4. Cross-repo accept: do NOT reject. Capture repo = parsed.repo (may differ from cwd remote).
    5. Normalize: const normalized = issueTracker.normalizeIdentifier(parsed.identifier).
    6. Replace prompt-derived identifier with normalized; set looksLikeIssueIdentifier = true; mark identifierSource = 'url' for telemetry.
  • Line 224: change to await issueTracker.detectInputType(normalized, repo).

  • Line 228: change to await issueTracker.fetchIssue(detection.identifier, repo).

  • Around line 250-254 (MCP getIssue): if repo is set AND differs from getConfiguredRepoFromSettings(settings), log:

    logger.warn(`Cross-repo URL detected (${maskRepo(repo)}). Skipping MCP body/comments fetch — using issueTracker.fetchIssue body instead. Full MCP cross-repo support is tracked separately.`)
    

    and skip the MCP block (lines 251-274). The fallback at lines 279-289 already runs processMarkdownImages directly. (Use a helper to mask repo in logs — do not log full owner/repo per privacy.)

  • Around line 304/312 (MCP getChildIssues/getDependencies): same cross-repo skip.

  • Telemetry (around line 460 auto_swarm.started and line 772 epic.planned): add identifier_source: identifierSource ?? 'identifier' to both track() calls. Requires extending AutoSwarmStartedProperties and EpicPlannedProperties interfaces (Step 4).

5. src/types/telemetry.ts (MODIFY)

Change: Extend property interfaces with identifier_source enum.

  • Line 25-32 LoomCreatedProperties: add identifier_source: 'url' | 'identifier'.
  • Line 44-47 EpicPlannedProperties: add identifier_source?: 'url' | 'identifier' (optional — only set when planning was initiated from a recognized identifier; absent for freeform planning).
  • Line 99-102 AutoSwarmStartedProperties: add identifier_source?: 'url' | 'identifier' (same optionality reasoning).

6. src/commands/start.test.ts:203 (describe('input parsing') block) (MODIFY)

Add new describe('URL paste support') sub-block with cases:

  • GitHub issue URL → resolves to issue with correct repo plumbing.
  • GitHub PR URL → resolves to PR.
  • GitHub PR URL on Linear-configured project → resolves via GitHubService fallback (no provider-mismatch error).
  • Linear URL → identifier extracted, normalized to uppercase, project-key flow.
  • Linear URL with slug → slug ignored.
  • Linear URL lowercase → uppercased via normalizeIdentifier().
  • Jira URL → identifier extracted, host validated against settings.
  • Cross-repo GitHub URL → throws actionable error.
  • Provider mismatch (GitHub issue URL on Linear-configured project) → throws.
  • Jira host mismatch → throws.
  • Malformed URL → throws TrackerUrlError.
  • Trailing slash / query / fragment / wrapped / auth credentials → all parse correctly.
  • Telemetry property identifier_source: 'url' is included on loom.created.

7. src/commands/plan.test.ts:80 (describe('PlanCommand') block) (MODIFY)

Add new describe('URL paste support') sub-block with cases:

  • GitHub issue URL with cross-repo → accepted; detectInputType and fetchIssue called with repo argument.
  • GitHub PR URL → throws actionable error directing to issue URL.
  • Linear URL → identifier extracted + normalized.
  • Provider mismatch → throws.
  • Cross-repo URL with MCP available → MCP calls are SKIPPED with logger.warn; falls back to issueTracker.fetchIssue body.
  • identifier_source: 'url' on auto_swarm.started / epic.planned.

8. docs/iloom-commands.md (MODIFY)

il start section (around line 44-160) — add new examples:

# Paste full URLs from your browser
il start https://github.com/iloom-ai/iloom-cli/issues/1009
il start https://github.com/iloom-ai/iloom-cli/pull/1010
il start https://linear.app/myteam/issue/WEB-2423/some-slug
il start https://myco.atlassian.net/browse/PROJ-99

Add notes on:

  • Cross-repo GitHub URLs are rejected (use il contribute or cd into the right repo).
  • Provider mismatch errors with the issueManagement.provider setting path.
  • GitHub PR URLs always work even on Linear/Jira-configured projects.

il plan section — add similar examples + notes:

  • Cross-repo GitHub issue URLs are accepted (URL's repo is used for issue context).
  • PR URLs are rejected — paste the underlying issue URL instead.

9. src/commands/CLAUDE.md (MODIFY)

In the start and plan rows of the command tables, add , parses tracker URLs to the Key Purpose column (one-line touch).

Detailed Execution Order

Phase 1 (sequential — single foundation step)

Step 1: URL parser helper + unit tests

Files:

  • src/utils/TrackerUrlParser.ts (new)
  • src/utils/TrackerUrlParser.test.ts (new)

Contract (consumed by Steps 2 and 3):

export interface TrackerUrlParseResult {
  provider: 'github' | 'linear' | 'jira'
  kind: 'issue' | 'pr'
  identifier: string
  repo?: string
  host?: string
}
export function parseTrackerUrl(input: string): TrackerUrlParseResult | null
export class TrackerUrlError extends Error { constructor(msg: string, host?: string) }
  1. Create src/utils/TrackerUrlParser.ts with parseTrackerUrl(), TrackerUrlError, and parser per provider per pseudocode above → Verify: pnpm compile passes.
  2. Create src/utils/TrackerUrlParser.test.ts covering all 4 URL shapes + edge cases → Verify: all tests pass via pnpm run test:single -- src/utils/TrackerUrlParser.test.ts.

Phase 2 (parallel — Steps 2, 3, 4 can run concurrently after Step 1)

Step 2: il start integration + telemetry + tests

Files:

  • src/commands/start.ts (modify parseInput 447-566; telemetry block 393-409)
  • src/commands/start.test.ts (extend describe('input parsing') at line 203 + telemetry at 1623)
  • src/types/telemetry.ts (add identifier_source to LoomCreatedProperties)

Contract used: imports parseTrackerUrl, TrackerUrlError, TrackerUrlParseResult from src/utils/TrackerUrlParser.ts.

  1. Extend LoomCreatedProperties (src/types/telemetry.ts:25-32) with identifier_source: 'url' | 'identifier' → Verify: tsc passes.
  2. In src/commands/start.ts, slot parseTrackerUrl() before line 487. Implement provider-mismatch (with PR carve-out at parsed.provider === 'github' && parsed.kind === 'pr'), cross-repo rejection (start.ts:159-163 already loads cwd repo), Jira host check, normalize via IssueTracker.normalizeIdentifier(), mark identifierSource: 'url' → Verify: pnpm compile passes.
  3. Update telemetry call at start.ts:399 to include identifier_source: parsed.identifierSource ?? 'identifier' → Verify: tsc passes.
  4. Extend start.test.ts:203 with describe('URL paste support') cases listed in Section 6 above → Verify: pnpm run test:single -- src/commands/start.test.ts passes.

Step 3: il plan integration + cross-repo plumbing + telemetry + tests

Files:

  • src/commands/plan.ts (modify lines 200-337, 460, 772)
  • src/commands/plan.test.ts (extend with URL tests)
  • src/types/telemetry.ts (add optional identifier_source to EpicPlannedProperties + AutoSwarmStartedProperties)

Contract used: imports parseTrackerUrl, TrackerUrlError, TrackerUrlParseResult from src/utils/TrackerUrlParser.ts.

  1. Extend EpicPlannedProperties and AutoSwarmStartedProperties (src/types/telemetry.ts) with optional identifier_source → Verify: tsc passes.
  2. In src/commands/plan.ts:200-217, slot parseTrackerUrl() before line 204. Reject PR URLs, provider mismatch, Jira host mismatch (no cross-repo rejection), normalize, capture repo → Verify: tsc passes.
  3. Thread repo argument into issueTracker.detectInputType(normalized, repo) (line 224) and issueTracker.fetchIssue(detection.identifier, repo) (line 228) → Verify: tsc passes.
  4. Around lines 251-274 / 304 / 312 — guard MCP calls with cross-repo check; on mismatch, logger.warn (no PII — log only 'cross-repo' boolean, not the repo string) and skip MCP fetches → Verify: existing fallback path at 279-289 still runs.
  5. Update telemetry: auto_swarm.started (line 460-463) and epic.planned (line 772-775) include identifier_source when set → Verify: tsc passes.
  6. Extend plan.test.ts with describe('URL paste support') cases listed in Section 7 → Verify: pnpm run test:single -- src/commands/plan.test.ts passes.

Step 4: Documentation

Files:

  • docs/iloom-commands.md (il start and il plan sections)
  • src/commands/CLAUDE.md (Command Reference table)
  1. Add URL paste examples + cross-repo / provider-mismatch / PR-URL notes to both command sections in docs/iloom-commands.md → Verify: markdown renders cleanly.
  2. Update src/commands/CLAUDE.md to mention URL parsing on start and plan rows → Verify: file diff is small (one-line edits).

Phase 3 (sequential — final integration check)

Step 5: Build verification

Files: none (build/test only)

  1. pnpm build → Verify: TypeScript compiles cleanly.
  2. pnpm test → Verify: full test suite passes.
  3. pnpm lint → Verify: no new lint errors.

Execution Plan

This section tells the orchestrator how to execute the implementation steps.

1. Run Step 1 (sequential — foundation: URL parser helper + unit tests)
2. Run Steps 2, 3, 4 in parallel
   (contract: TrackerUrlParseResult { provider, kind, identifier, repo?, host? } + parseTrackerUrl + TrackerUrlError)
   - Step 2: il start integration (src/commands/start.ts, src/commands/start.test.ts, src/types/telemetry.ts:25-32)
   - Step 3: il plan integration (src/commands/plan.ts, src/commands/plan.test.ts, src/types/telemetry.ts:44-47, 99-102)
   - Step 4: docs (docs/iloom-commands.md, src/commands/CLAUDE.md)
   NOTE: Steps 2 and 3 both edit src/types/telemetry.ts — assign one step to perform ALL three telemetry edits (LoomCreatedProperties + EpicPlannedProperties + AutoSwarmStartedProperties) up-front to avoid concurrent edits, OR sequence Step 3 immediately after Step 2 if the orchestrator cannot coordinate shared file edits.
3. Run Step 5 (sequential — pnpm build + test + lint)

File-conflict mitigation for Phase 2:

  • src/types/telemetry.ts is touched by both Step 2 (line 25-32) and Step 3 (line 44-47, 99-102). Recommend the orchestrator instructs Step 2 to perform all three telemetry interface edits up-front, then Step 3 imports and uses the already-extended interfaces. This converts a potential file-edit conflict into a single coordinated edit.
  • All other files in Phase 2 are disjoint: Step 2 only edits src/commands/start*; Step 3 only edits src/commands/plan*; Step 4 only edits docs/ and src/commands/CLAUDE.md.

Dependencies and Configuration

  • None. Uses built-in URL API and existing IssueTracker.normalizeIdentifier().

Status: Plan complete and ready for orchestrator dispatch.

Comment URL: #1010 (comment)

@acreeger
Copy link
Copy Markdown
Collaborator Author

acreeger commented May 1, 2026

Implementation Complete

Summary

il start and il plan now accept full tracker URLs (GitHub issues/PRs, Linear, Jira Cloud and self-hosted) in addition to bare identifiers. URL inputs are normalized through IssueTracker.normalizeIdentifier() before any case-sensitive comparisons. il start rejects cross-repo URLs and provider mismatches (with a carve-out for GitHub PR URLs); il plan rejects PR URLs and provider mismatches and accepts cross-repo GitHub URLs while skipping MCP-backed features (child-issues, dependencies) with a logger.warn for the deferred cross-repo MCP path.

Changes Made

  • src/utils/TrackerUrlParser.ts (new): parseTrackerUrl() + TrackerUrlError + TrackerUrlParseResult. Handles GitHub issue/PR, Linear, Jira (cloud + self-hosted), trailing slashes, query strings, fragments, mixed-case hosts, <...> wrappers, trailing punctuation, and credential stripping.
  • src/utils/TrackerUrlParser.test.ts (new): 46 unit tests covering each URL shape, the null-vs-throw distinction, and edge cases.
  • src/types/telemetry.ts: added identifier_source?: 'url' | 'identifier' to LoomCreatedProperties, EpicPlannedProperties, AutoSwarmStartedProperties; added url_provider?: 'github' | 'linear' | 'jira' to LoomCreatedProperties. Privacy-safe enums only — never the URL itself.
  • src/commands/start.ts: integrated URL parsing into parseInput(); added GitHub PR carve-out, provider-mismatch rejection, cross-repo rejection (case-insensitive); telemetry adds identifier_source and url_provider.
  • src/commands/start.test.ts: new tracker URL input block + telemetry assertions.
  • src/commands/plan.ts: integrated URL parsing; rejects PR URLs; provider/Jira-host mismatch rejection; threads repo into detectInputType/fetchIssue; cross-repo skips MCP getIssue/getChildIssues/getDependencies with warning; telemetry adds identifier_source to auto_swarm.started and epic.planned.
  • src/commands/plan.test.ts: new URL paste support describe with 11 tests covering same/cross-repo, PR rejection, Linear/Jira variants, host mismatch, malformed URLs, normalization edge cases, telemetry.
  • src/lib/LinearService.ts / src/lib/providers/jira/JiraIssueTracker.ts: surfaced (and ignored) the repo parameter; warns at debug level if provided since cross-repo doesn't apply outside GitHub.
  • docs/iloom-commands.md: documented supported URL forms, policies, and examples for both il start and il plan.
  • src/commands/CLAUDE.md: noted URL acceptance on start.ts and plan.ts rows with a pointer to TrackerUrlParser.ts.

Validation Results

  • ✅ Build: pnpm build clean
  • ✅ Tests: 5260 passed / 1 skipped / 0 failed (149 files)
  • ✅ Lint: 0 errors, 0 warnings

Detailed Changes by File (click to expand)

src/utils/TrackerUrlParser.ts (new)

Public contract: parseTrackerUrl(input) returns canonical-cased identifier or null for non-URL inputs; throws TrackerUrlError only when the host clearly belongs to a known tracker but the path is malformed. Linear/Jira identifiers are emitted in uppercase form.

src/utils/TrackerUrlParser.test.ts (new)

46 Vitest tests across all four URL shapes plus the slug-disambiguation case (Linear slug containing PROJ-99 must not be mistaken for the issue id).

src/types/telemetry.ts

  • LoomCreatedProperties: + identifier_source?, + url_provider?
  • EpicPlannedProperties: + identifier_source?
  • AutoSwarmStartedProperties: + identifier_source?

src/commands/start.ts

  • New tryParseTrackerUrl() helper invoked early in parseInput() (before the bare-identifier path).
  • Policy: GH PR URL valid for any tracker (carve-out), other URLs require provider match, cross-repo always rejected.
  • Telemetry block updated with identifier_source (always) and url_provider (URL inputs only).

src/commands/start.test.ts

New tracker URL input block + two telemetry tests asserting the new properties.

src/commands/plan.ts

  • URL detection runs before looksLikeIssueIdentifier.
  • PR URL → reject; provider mismatch → reject; Jira host mismatch → reject.
  • Cross-repo handling: repo threaded into detectInputType/fetchIssue; MCP getIssue/getChildIssues/getDependencies skipped with logger.warn when URL repo ≠ cwd repo.
  • Telemetry on auto_swarm.started and epic.planned.

src/commands/plan.test.ts

New URL paste support describe with 11 tests.

src/lib/LinearService.ts, src/lib/providers/jira/JiraIssueTracker.ts

Accept and ignore the new repo? parameter consistently with the IssueTracker contract.

docs/iloom-commands.md, src/commands/CLAUDE.md

Documentation for supported URL shapes, policy distinctions between il start and il plan, and a pointer to the parser.

Adds a TrackerUrlParser helper that converts pasted GitHub issue/PR,
Linear, and Jira URLs (cloud + self-hosted) into the canonical
identifier expected by il start and il plan. Identifiers are
normalized through IssueTracker.normalizeIdentifier() to keep
case-sensitive comparisons safe.

Policy:
- il start: rejects cross-repo URLs and provider mismatches, with a
  carve-out for GitHub PR URLs (matches the existing PR fallback);
  validates Jira host against the configured one.
- il plan: rejects PR URLs and provider mismatches; accepts cross-repo
  GitHub URLs but skips MCP getIssue/getChildIssues/getDependencies
  with a logger.warn (full cross-repo MCP plumbing is deferred).

Telemetry adds identifier_source ('url' | 'identifier') and
url_provider ('github' | 'linear' | 'jira') as enums only — URLs are
never logged. Includes unit tests for the parser, validator, and
both commands plus docs in docs/iloom-commands.md.
@acreeger acreeger force-pushed the feat/issue-1009__parse-tracker-urls branch from 2705b9e to 5878f48 Compare May 1, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Implement changes to "il plan" and "il start" to support pasting in urls to Github issue + PR, linear, JIRA urls

1 participant