From 83ead76ff2deb1d9208ac3b96dc6dd00c95cfadd Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Tue, 7 Jul 2026 11:29:41 -0700 Subject: [PATCH 1/6] chore: docs and plan reconcilliation --- plans/PLAN.md | 22 +++--- .../6.1-tui-repo-creation-from-template.md | 10 +-- plans/github/6.2-cli-repo-creation.md | 38 ++++------ plans/github/6.3-repo-rules.md | 69 ++++++++++--------- ...4-local-clone-and-wtc-project-bootstrap.md | 63 +++++++++++++++++ plans/github/6.4-teamwork-project-linking.md | 42 ----------- .../6.5-local-clone-and-final-features.md | 39 ----------- ...amwork-project-linking-and-final-polish.md | 48 +++++++++++++ plans/github/README.md | 23 +++---- 9 files changed, 182 insertions(+), 172 deletions(-) create mode 100644 plans/github/6.4-local-clone-and-wtc-project-bootstrap.md delete mode 100644 plans/github/6.4-teamwork-project-linking.md delete mode 100644 plans/github/6.5-local-clone-and-final-features.md create mode 100644 plans/github/6.5-teamwork-project-linking-and-final-polish.md diff --git a/plans/PLAN.md b/plans/PLAN.md index a32afa6..83cdfc4 100644 --- a/plans/PLAN.md +++ b/plans/PLAN.md @@ -2,7 +2,7 @@ A terminal UI tool for developers to manage GitHub repos, AWS Amplify projects, and Teamwork tasks. -- **Status:** Phase 6 GitHub Repo Creation (6.1 planned) +- **Status:** Phase 6.4 Local Clone + WTC Project Bootstrap planned - **Package Manager:** Bun - **Runtime:** Bun (standalone binary distribution) - **TUI:** @opentui/solid + solid-js @@ -155,25 +155,25 @@ Create branches and draft PRs from Teamwork tasks with timer prompts and useful GitHub repo creation work is split into detailed subphase plans under [`plans/github/`](github/README.md). -#### Phase 6.1 — [TUI Repo Creation From Template](github/6.1-tui-repo-creation-from-template.md) +#### Phase 6.1 — [TUI Repo Creation From Template](github/6.1-tui-repo-creation-from-template.md) ✅ -Create a GitHub repo under the company org from an approved organization template through the TUI. No CLI, branch protection, Teamwork linking, or local clone yet. +Create a GitHub repo under the company org from an approved organization template through the TUI, with template selection, repo name/description/visibility input, repo rules preset selection, and success dialog. -#### Phase 6.2 — [CLI Repo Creation](github/6.2-cli-repo-creation.md) +#### Phase 6.2 — [CLI Repo Creation](github/6.2-cli-repo-creation.md) ✅ -Add the CLI equivalent of Phase 6.1. +CLI `wtc repo create` command with `--template`, `--visibility`, `--description`, `--rules-preset`, and `--json` flags. Creates repos under the company org from templates or as blank repos initialized with an MIT license. -#### Phase 6.3 — [Repo Rules](github/6.3-repo-rules.md) +#### Phase 6.3 — [Repo Rules](github/6.3-repo-rules.md) ✅ -Add branch protection, PR rules, and repository settings to the creation flow. +Branch protection rulesets, repository settings, vulnerability alerts, and senior-reviewers bypass for newly created repos. Rules preset selection (`standard`/`none`) available in both TUI and CLI. Best-effort post-create setup with warnings for token permission issues. -#### Phase 6.4 — [Teamwork Project Linking](github/6.4-teamwork-project-linking.md) +#### Phase 6.4 — [Local Clone + WTC Project Bootstrap](github/6.4-local-clone-and-wtc-project-bootstrap.md) -Link created GitHub repos into WTC project config and update CLI parity if Phase 6.3 introduces CLI-relevant options. +Clone the newly created repo, write a `.wtc.yaml` into the cloned repo with project links and Teamwork project ID, and optionally commit and push the config so all devs get a ready-to-use WTC project. -#### Phase 6.5 — [Local Clone And Final Features](github/6.5-local-clone-and-final-features.md) +#### Phase 6.5 — [Teamwork Project Linking + Final Polish](github/6.5-teamwork-project-linking-and-final-polish.md) -Add optional local clone and final repo creation workflow conveniences. +Link the created repo into WTC project config from the old working directory. Add any remaining repo creation workflow conveniences and CLI/TUI parity gaps. ### Phase 7 — AWS Amplify Hosting diff --git a/plans/github/6.1-tui-repo-creation-from-template.md b/plans/github/6.1-tui-repo-creation-from-template.md index 5d9a847..7608c28 100644 --- a/plans/github/6.1-tui-repo-creation-from-template.md +++ b/plans/github/6.1-tui-repo-creation-from-template.md @@ -1,10 +1,8 @@ -# 6.1 TUI Repo Creation From Template +# 6.1 TUI Repo Creation From Template ✅ Complete ## Goal -Create a new GitHub repository from an approved organization template, or as a blank repository initialized with an MIT license, through the TUI. - -This phase is intentionally narrow: TUI first, no CLI, no local clone, no branch protection, and no Teamwork/project linking. +Create a new GitHub repository from an approved organization template, or as a blank repository initialized with an MIT license, through the TUI. Includes rules preset selection (`standard`/`none`), repo settings, and post-create setup warnings. ## Decisions @@ -136,15 +134,11 @@ Command palette: ## Out Of Scope -- CLI commands. -- Branch protection. -- PR/review/status check rules. - Teamwork/project linking. - Local clone. - Manual template entry. - Personal account repo creation. - GitHub Enterprise `internal` visibility. -- Template/repo list caching. ## Definition Of Done diff --git a/plans/github/6.2-cli-repo-creation.md b/plans/github/6.2-cli-repo-creation.md index 50b0298..e0830fa 100644 --- a/plans/github/6.2-cli-repo-creation.md +++ b/plans/github/6.2-cli-repo-creation.md @@ -1,43 +1,29 @@ -# 6.2 CLI Repo Creation +# 6.2 CLI Repo Creation ✅ Complete ## Goal -Add the CLI equivalent of Phase 6.1 repo creation. +CLI `wtc repo create` command that mirrors the full TUI creation flow. -This phase mirrors only the basic TUI workflow: create a repo from an approved owner template or as a blank repo. It does not include branch protection, Teamwork linking, or local clone. - -## Scope - -- Add a `repo` CLI command group. -- Add `wtc repo create`. -- Use the code-owned company GitHub org constant `wethegit`. -- Reuse API functions from `src/api/github/repos.ts`. -- Keep the command scriptable first. - -## Proposed Command +## Actual Command ```bash -wtc repo create --template --visibility private --description "Optional description" +wtc repo create --template --visibility private --description "Optional description" --rules-preset standard --json ``` -Flags: +## Flags - `--template `: optional template repo name under `wethegit`. Omit for a blank repo. - `--visibility `: defaults to `private`. - `--description `: optional. -- `--json`: print machine-readable output. +- `--rules-preset `: repo rules preset. Defaults to `standard`. `none` skips rulesets. +- `--json`: print machine-readable output with `name`, `fullName`, `url`, `cloneUrl`, `sshUrl`, and `warnings`. -Behavior: +## Behavior +- Uses `createGitHubRepoWithSetup()` from `src/api/github/repos.ts`, matching the TUI flow. - If `--template` is provided, verifies the requested template exists and has `is_template === true`. - If `--template` is omitted, creates a blank repo initialized with an MIT license. - Creates the repo under `wethegit`. -- Prints the created repo URL. - -## Deferred - -- Interactive TTY prompts. -- Opening the created repo in a browser. -- Branch protection flags. -- Teamwork/project linking flags. -- Clone flags. +- Applies repo settings, vulnerability alerts, and optional rulesets after creation. +- Prints any setup warnings (token permission issues, team lookup failures). +- Prints the created repo URL (or JSON output with `--json`). diff --git a/plans/github/6.3-repo-rules.md b/plans/github/6.3-repo-rules.md index 8adcb93..c7ce831 100644 --- a/plans/github/6.3-repo-rules.md +++ b/plans/github/6.3-repo-rules.md @@ -1,61 +1,64 @@ -# 6.3 Repo Rules +# 6.3 Repo Rules ✅ Complete ## Goal -Add branch protection, PR rules, and repository settings to the repo creation flow after basic creation is stable. +Branch protection rulesets, repository settings, and vulnerability alerts applied to newly created GitHub repos. -## Scope Candidates +## Implemented Scope - Protect the default branch plus `release/*` and `release-candidate/*`. -- Require pull requests before merging. -- Require approving reviews. -- Set required approving review count. +- Require pull requests before merging with 1 required approving review. - Dismiss stale approvals when new commits are pushed. -- Block force pushes, with `wethegit/senior-reviewers` allowed to bypass the ruleset. +- Require review thread resolution. +- Block force pushes; `wethegit/senior-reviewers` team can bypass the ruleset. - Block branch deletion. -- Configure allowed merge strategies. +- Allow merge, squash, and rebase. - Enable delete branch on merge. - Enable vulnerability alerts. - Disable wikis, discussions, and repository projects. - Initialize blank repos with an MIT license. -## TUI Flow - -Repo rules should appear as an optional setup step in the TUI creation flow after visibility selection and before final confirmation. +## Presets -Possible shape: +Two presets selectable during creation: -1. Select template. -2. Enter repo name. -3. Optional description. -4. Select visibility. -5. Select repo rules preset. -6. Confirm creation. -7. Create repo. -8. Apply repository settings, vulnerability alerts, and rules. -9. Show success dialog with warnings if post-create setup is incomplete. +| Preset | Behavior | +| ---------- | --------------------------------------------------------------- | +| `standard` | Applies all rulesets, settings, and vulnerability alerts. | +| `none` | Applies settings and vulnerability alerts only; skips rulesets. | -## API Scope +## API -Likely add or extend `src/api/github/repos.ts` with focused functions such as: +- `createGitHubRepoWithSetup()` in `src/api/github/repos.ts` — single call for create + setup. +- `applyGitHubRepoSetup()` — applies repository settings, vulnerability alerts, and optional rulesets. +- `updateGitHubRepoSettings()` — disables wikis, discussions, and projects; enables delete branch on merge. +- `enableGitHubVulnerabilityAlerts()` — enables Dependabot alerts. +- `applyGitHubRepoRulesetSetup()` — resolves the bypass team ID and creates the ruleset. +- `GITHUB_REPO_RULESET_PRESETS` in `src/api/github/repo-rules.ts` — preset definitions kept separate from Octokit call sites. -- `updateGitHubRepoSettings(input)` -- `applyGitHubRepoSetup(input)` -- GitHub repository rulesets for branch rules. +## TUI Flow -Keep Octokit-specific request details in the GitHub API module, not in TUI components. +The TUI creation flow includes a rules preset step after visibility selection and before confirmation, matching the implemented behavior. -## Deferred +## CLI Parity -- CLI parity until Phase 6.4.1. -- Teamwork/project linking until Phase 6.4.2. -- Local clone until Phase 6.5. +`--rules-preset standard|none` is available on `wtc repo create`. ## Decisions -- Rules are a `standard` preset with a `none` escape hatch in the TUI. +- Rules are a `standard` preset with a `none` escape hatch. - Required approvals: 1. - Dismiss stale approvals and require review thread resolution. -- CODEOWNERS reviews and required status checks are disabled for now. +- CODEOWNERS reviews and required status checks are disabled. - Merge, squash, and rebase are all allowed. - Only the `senior-reviewers` team bypasses the ruleset. +- Post-create setup failures are returned as warnings (repo creation is not rolled back). + +## Token Requirements + +The GitHub token needs the following permissions for full setup: + +- `Administration: read/write` (repository settings) +- `Dependabot alerts: read/write` (vulnerability alerts) +- `Repository rules: read/write` (rulesets) +- Organization `member: read` (team lookup for bypass actor) diff --git a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md new file mode 100644 index 0000000..803dad0 --- /dev/null +++ b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md @@ -0,0 +1,63 @@ +# 6.4 Local Clone + WTC Project Bootstrap + +## Goal + +Clone the newly created repo locally, then write a `.wtc.yaml` into it with project links and the Teamwork project ID. The cloned repo should be ready to use in WTC for every developer who clones it. + +## Motivation + +Previous phases create the repo, configure rules, and accept the Teamwork project ID but never persist it. Writing `.wtc.yaml` into the cloned repo and pushing it means every dev who clones gets a ready-to-use WTC project with: + +- A link to the GitHub repo under `project.links`. +- The Teamwork project ID under `teamwork.projectId`. +- Empty `reviewTaskId` and `pinnedTaskLists` ready to be populated by workflow use. + +## Scope + +- Prompt whether to clone after successful repo creation. +- Select clone protocol: SSH default, HTTPS optional. +- Select clone parent directory (default to a sensible location). +- Run `git clone`. +- Write `.wtc.yaml` into the cloned repo with: + - `project.links`: GitHub repo link. + - `teamwork.projectId`: from current `.wtc.yaml` or prompted. + - `teamwork.reviewTaskId`: left empty. + - `teamwork.pinnedTaskLists`: left empty. +- Commit `.wtc.yaml` on a branch or directly to default branch. +- Push the config commit so all devs get it. +- Surface clone/config/commit/push warnings without pretending repo creation failed. + +## Where Teamwork Project ID Comes From + +Recommended priority: + +1. If the current working directory has `.wtc.yaml` with `teamwork.projectId`, use that as default. +2. If missing, prompt for Teamwork project ID (or offer to skip). +3. Allow skip — the cloned repo still gets `teamwork.projectId: ""` and can be filled in later. + +## TUI Flow + +After repo creation success: + +1. `ConfirmDialog`: "Clone to local directory?" +2. If yes, `DialogSelect`: "Protocol" → SSH / HTTPS. +3. `DialogInput`: "Parent directory" (default: `~/dev/github/wethegit`). +4. Show `LoadingDialog` during `git clone`. +5. `ConfirmDialog`: "Write Teamwork project ID?" with default from parent `.wtc.yaml`. +6. Show `LoadingDialog` during `.wtc.yaml` write + commit + push. +7. Show success dialog with paths and commit info, or warnings. + +## CLI Scope + +Potential flags on `wtc repo create`: + +- `--clone`: clone after creation. +- `--clone-dir `: clone destination parent directory. +- `--clone-protocol `: defaults to `ssh`. +- `--teamwork-project-id `: defaults to parent `.wtc.yaml` value. + +## Deferred + +- Interactive TTY prompts for clone (non-`--json` mode). +- Opening the cloned directory in the TUI. +- Opening the cloned directory in the shell or editor. diff --git a/plans/github/6.4-teamwork-project-linking.md b/plans/github/6.4-teamwork-project-linking.md deleted file mode 100644 index a6a1daa..0000000 --- a/plans/github/6.4-teamwork-project-linking.md +++ /dev/null @@ -1,42 +0,0 @@ -# 6.4 Teamwork Project Linking - -## Goal - -Link a created GitHub repository into WTC project context after repo creation rules are settled. - -This phase may also include CLI parity for anything added in Phase 6.3 if needed. If Phase 6.3 adds no CLI-relevant options, CLI parity can be a no-op. - -## Scope - -- Add optional project linking after repo creation. -- Write the created repo URL into the nearest `.wtc.yaml` project config. -- Reuse existing project links shape. - -Likely config result: - -```yaml -project: - links: - - name: GitHub - url: https://github.com/wethegit/example-repo -``` - -## TUI Flow - -Potential placement: - -- After successful repo creation, ask whether to link the repo to the current project. -- If accepted, write the project link and show a success message. - -## API/Config Scope - -- Reuse `loadProjectConfig` and `saveProjectConfig`. -- Preserve existing links. -- Avoid duplicating the same repo URL if already linked. - -## Open Questions - -- Should linking be automatic or optional? -- Should WTC create `.wtc.yaml` if none exists? -- What should the link name be: `GitHub`, `Repository`, or the repo name? -- Should a created repo be linked to Teamwork itself, or only to WTC project config? diff --git a/plans/github/6.5-local-clone-and-final-features.md b/plans/github/6.5-local-clone-and-final-features.md deleted file mode 100644 index d7c6055..0000000 --- a/plans/github/6.5-local-clone-and-final-features.md +++ /dev/null @@ -1,39 +0,0 @@ -# 6.5 Local Clone And Final Features - -## Goal - -Add local clone and final repo creation workflow conveniences after basic creation, repo rules, CLI parity, and project linking are stable. - -## Scope Candidates - -- Prompt to clone after repo creation. -- Choose clone destination directory. -- Choose SSH or HTTPS clone URL. -- Open the local directory after cloning if useful. -- Surface clone progress and failures clearly. -- Add any final workflow shortcuts discovered in previous phases. - -## TUI Flow - -Potential flow after repo creation/linking: - -1. Ask whether to clone locally. -2. Select SSH or HTTPS. -3. Enter local destination path. -4. Run `git clone`. -5. Show success or failure. - -## CLI Scope - -Potential flags once behavior is proven: - -- `--clone` -- `--clone-dir ` -- `--clone-protocol ` - -## Open Questions - -- Should clone default to SSH or HTTPS? -- What default parent directory should be used? -- Should WTC create the parent directory if missing? -- Should clone be available in TUI first, CLI first, or both together? diff --git a/plans/github/6.5-teamwork-project-linking-and-final-polish.md b/plans/github/6.5-teamwork-project-linking-and-final-polish.md new file mode 100644 index 0000000..1654414 --- /dev/null +++ b/plans/github/6.5-teamwork-project-linking-and-final-polish.md @@ -0,0 +1,48 @@ +# 6.5 Teamwork Project Linking + Final Polish + +## Goal + +After the repo is created, cloned, and bootstrapped with `.wtc.yaml` (6.4), link the created repo into the old working directory's `.wtc.yaml` project config and add any remaining repo creation workflow conveniences. + +## Scope + +- Add optional project linking after repo creation (in the old working directory). +- Write the created repo URL into the nearest `.wtc.yaml` project config under `project.links`. +- Reuse existing project links shape. +- Preserve existing links; avoid duplicating the same repo URL. +- Create `.wtc.yaml` in the old working directory if none exists. + +## TUI Flow (Old Working Directory) + +- After successful repo creation + clone (6.4), ask: "Link this repo to your current project?" +- If accepted, write the project link and show a success message. + +## Config Result + +```yaml +project: + links: + - name: "GitHub" + url: "https://github.com/wethegit/example-repo" +``` + +## Final Polish Candidates + +- Interactive TTY prompts for CLI `wtc repo create`. +- Opening the created repo in the browser after creation. +- Opening the cloned directory in the TUI or shell. +- Any remaining CLI/TUI parity gaps discovered in earlier phases. + +## Open Questions (From Old 6.4 Plan) + +- Should linking be automatic or optional? +- Should WTC create `.wtc.yaml` if none exists? +- What should the link name be: `GitHub`, `Repository`, or the repo name? +- Should a created repo be linked to Teamwork itself, or only to WTC project config? + +## Decisions + +- Linking is optional (user confirms). +- WTC creates `.wtc.yaml` if none exists to write the link. +- Link name is `GitHub` (matching the source). +- Created repo is linked to WTC project config only, not Teamwork itself (Teamwork linking would require an API call and is deferred). diff --git a/plans/github/README.md b/plans/github/README.md index fe85114..0304d12 100644 --- a/plans/github/README.md +++ b/plans/github/README.md @@ -4,20 +4,19 @@ GitHub repo creation is split into focused subphase documents so `plans/PLAN.md` ## Subphases -- [6.1 TUI Repo Creation From Template](6.1-tui-repo-creation-from-template.md): create a GitHub repo from an approved org template through the TUI. -- [6.2 CLI Repo Creation](6.2-cli-repo-creation.md): add a CLI equivalent of the 6.1 TUI flow. -- [6.3 Repo Rules](6.3-repo-rules.md): add branch protection, PR rules, and repository settings to the creation flow. -- [6.4 Teamwork Project Linking](6.4-teamwork-project-linking.md): link created GitHub repos into WTC project config. -- [6.5 Local Clone and Final Features](6.5-local-clone-and-final-features.md): clone locally and add final repo creation workflow conveniences. +- [6.1 TUI Repo Creation From Template](6.1-tui-repo-creation-from-template.md) ✅: create a GitHub repo from an approved org template through the TUI with rules preset selection. +- [6.2 CLI Repo Creation](6.2-cli-repo-creation.md) ✅: CLI `wtc repo create` command with `--template`, `--visibility`, `--description`, `--rules-preset`, and `--json`. +- [6.3 Repo Rules](6.3-repo-rules.md) ✅: branch protection rulesets, repository settings, vulnerability alerts, and senior-reviewers bypass. +- [6.4 Local Clone + WTC Project Bootstrap](6.4-local-clone-and-wtc-project-bootstrap.md): clone the repo and write `.wtc.yaml` into it with project links and Teamwork project ID. +- [6.5 Teamwork Project Linking + Final Polish](6.5-teamwork-project-linking-and-final-polish.md): link the created repo into WTC project config from the old working directory and add remaining workflow conveniences. ## Implementation Order -1. Add the minimal config and API needed for TUI repo creation from templates. -2. Build the TUI workflow on the GitHub page. -3. Add CLI parity after the TUI workflow proves the data shape and user interaction. -4. Add repo rules only after basic creation is stable. -5. Add Teamwork/project linking after repo rules are clear. -6. Add cloning and final workflow polish last. +1. ✅ TUI repo creation from templates (6.1). +2. ✅ CLI repo creation parity (6.2). +3. ✅ Repo rules, repository settings, vulnerability alerts, and rulesets (6.3). +4. Clone the newly created repo, write `.wtc.yaml` with Teamwork context, and push the config commit (6.4). +5. Link the created repo into the old working directory's `.wtc.yaml` and add remaining workflow polish (6.5). ## Shared Rules @@ -28,6 +27,4 @@ GitHub repo creation is split into focused subphase documents so `plans/PLAN.md` - Template lists include only repositories with `is_template === true`. - Template discovery is cached for 24 hours because org templates rarely change; clearing the WTC cache forces a refresh. - Repo creation from templates uses `include_all_branches: false`. -- Do not add CLI commands until Phase 6.2. -- Do not add cloning until Phase 6.5. - Do not add tests. From d5a8d18b89b18af109a512fd8b9b541fe4568e71 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Tue, 7 Jul 2026 13:26:55 -0700 Subject: [PATCH 2/6] feature: refactor preflight for repo creation and adds local clonning --- ...4-local-clone-and-wtc-project-bootstrap.md | 140 ++++--- src/api/github/repo-bootstrap.ts | 89 +++++ src/api/github/repo-creation-workflow.ts | 117 ++++++ src/api/teamwork/task-lists.ts | 194 ++++++++++ src/api/teamwork/tasks.ts | 37 ++ src/cli/commands/repo-command.ts | 38 ++ src/cli/commands/repo.ts | 135 +++++++ src/tui/pages/github.tsx | 342 +++++++++++++++++- src/utils/git.ts | 48 +++ 9 files changed, 1081 insertions(+), 59 deletions(-) create mode 100644 src/api/github/repo-bootstrap.ts create mode 100644 src/api/github/repo-creation-workflow.ts create mode 100644 src/api/teamwork/task-lists.ts diff --git a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md index 803dad0..0ac1731 100644 --- a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md +++ b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md @@ -2,62 +2,118 @@ ## Goal -Clone the newly created repo locally, then write a `.wtc.yaml` into it with project links and the Teamwork project ID. The cloned repo should be ready to use in WTC for every developer who clones it. +Optionally bootstrap a newly created GitHub repo as a ready-to-use WTC project by cloning it locally, writing `.wtc.yaml` into the clone, committing the config, and pushing it before repository rules are applied. -## Motivation +If the user skips Teamwork setup, repo creation keeps the existing behavior: create repo, apply repo setup/rules, and show the success dialog. -Previous phases create the repo, configure rules, and accept the Teamwork project ID but never persist it. Writing `.wtc.yaml` into the cloned repo and pushing it means every dev who clones gets a ready-to-use WTC project with: +## Decisions -- A link to the GitHub repo under `project.links`. -- The Teamwork project ID under `teamwork.projectId`. -- Empty `reviewTaskId` and `pinnedTaskLists` ready to be populated by workflow use. +- Teamwork bootstrap is optional. +- Cloning uses SSH only; there is no HTTPS protocol choice. +- The clone parent directory defaults to the directory where WTC was started. +- Repo rules are applied after clone/bootstrap when Teamwork setup is enabled so the config commit can be pushed before branch protection is active. +- Do not add the GitHub repo URL to `project.links`; the `.wtc.yaml` lives inside the repo, so that link is redundant. +- Bootstrap should auto-discover values from our internal Teamwork project template before asking the user. -## Scope +## Internal Teamwork Template Names -- Prompt whether to clone after successful repo creation. -- Select clone protocol: SSH default, HTTPS optional. -- Select clone parent directory (default to a sensible location). -- Run `git clone`. -- Write `.wtc.yaml` into the cloned repo with: - - `project.links`: GitHub repo link. - - `teamwork.projectId`: from current `.wtc.yaml` or prompted. - - `teamwork.reviewTaskId`: left empty. - - `teamwork.pinnedTaskLists`: left empty. -- Commit `.wtc.yaml` on a branch or directly to default branch. -- Push the config commit so all devs get it. -- Surface clone/config/commit/push warnings without pretending repo creation failed. +- General task list: `Internal | Billable Projects | General Tasks` +- Preferred code review task: `Dev | Code Review - Forever` +- Fallback code review task: `Dev | Code Review` -## Where Teamwork Project ID Comes From +The code review task is expected to be a child task in the General task list. -Recommended priority: +This phase only needs the internal template path where Code Review is a task in the General task list. A future reusable picker can support IDs that represent either task lists or parent tasks with subtasks, matching the Project tab behavior. -1. If the current working directory has `.wtc.yaml` with `teamwork.projectId`, use that as default. -2. If missing, prompt for Teamwork project ID (or offer to skip). -3. Allow skip — the cloned repo still gets `teamwork.projectId: ""` and can be filled in later. +## Bootstrap Config -## TUI Flow +Generated `.wtc.yaml` in the cloned repo: + +```yaml +version: 1 + +project: + links: [] + +teamwork: + projectId: 12345 + reviewTaskId: 67890 + pinnedTaskLists: + - name: "General Tasks" + id: 1699936 +``` + +If the CLI user explicitly passes `--general-tasks -1`, WTC skips the pinned General Tasks list and writes `pinnedTaskLists: []`. If the CLI user explicitly passes `--review-task -1`, WTC skips `reviewTaskId` and writes it empty. + +## Discovery Flow -After repo creation success: +1. Resolve Teamwork project ID. + - Prefer explicit CLI/TUI input. + - Otherwise use nearest current `.wtc.yaml` `teamwork.projectId`. + - If still missing in TUI, ask user. + - If still missing in CLI, fail before creating the repo. +2. Search for the General task list in that Teamwork project using `searchTerm`, then exact-match the returned task-list name. +3. If the General task list is found, search for the code review task using `searchTerm=Dev | Code Review`, `projectIds`, and `tasklistIds`, then exact-match returned task names in priority order: `Dev | Code Review - Forever`, then `Dev | Code Review`. +4. If both are found, skip the remaining Teamwork questions. +5. If the General task list is not found, ask for General task-list ID or URL. +6. After a General task list is known, try again to find the code review task inside it. +7. If the code review task is still not found, ask for code review task ID or URL. -1. `ConfirmDialog`: "Clone to local directory?" -2. If yes, `DialogSelect`: "Protocol" → SSH / HTTPS. -3. `DialogInput`: "Parent directory" (default: `~/dev/github/wethegit`). -4. Show `LoadingDialog` during `git clone`. -5. `ConfirmDialog`: "Write Teamwork project ID?" with default from parent `.wtc.yaml`. -6. Show `LoadingDialog` during `.wtc.yaml` write + commit + push. -7. Show success dialog with paths and commit info, or warnings. +## TUI Flow -## CLI Scope +Existing repo creation steps remain unchanged until repo rules are selected. + +1. Ask: `Would you like to setup Teamwork linking now?` +2. If no: + - Continue to final confirmation without bootstrap. +3. If yes: + - Resolve or ask for Teamwork project ID. + - Auto-discover General task list and code review task. + - Ask only for values that could not be discovered. + - Ask for clone parent directory, defaulting to process start directory. + - Validate the clone target does not exist. +4. Show final confirmation with repo and Teamwork setup details. +5. If confirmed: + - Create the GitHub repo. + - Clone with `repo.sshUrl`. + - Write `.wtc.yaml` in the cloned repo. + - Commit `chore: add WTC project config`. + - Push the config commit. + - Apply repo settings/rules. + - Show success with any bootstrap/setup warnings. + +## CLI Flow + +Existing default remains unchanged unless Teamwork setup is requested. Potential flags on `wtc repo create`: -- `--clone`: clone after creation. -- `--clone-dir `: clone destination parent directory. -- `--clone-protocol `: defaults to `ssh`. -- `--teamwork-project-id `: defaults to parent `.wtc.yaml` value. +- `--setup-teamwork`: enable clone + `.wtc.yaml` bootstrap. +- `--clone-dir `: clone parent directory; defaults to process start directory. +- `--teamwork-project-id `: optional override for current config value. +- `--general-tasks `: optional override for auto-discovery. `-1` skips the pinned General Tasks list. +- `--review-task `: optional override for auto-discovery. `-1` skips the Code Review task. + +When `--setup-teamwork` is present, CLI should fail before repo creation if it cannot resolve all required Teamwork values from flags, current config, and auto-discovery. Passing `--general-tasks -1` or `--review-task -1` explicitly opts out of those optional config values. + +## API Scope + +- Add task-list reference parsing next to the existing task reference parser. +- Add targeted Teamwork task-list and task discovery using `searchTerm` and exact-match filtering. +- Add Git helpers for clone, commit, and push config. +- Add a repo creation workflow that can delay repo setup/rules until after optional bootstrap. + +## Error Handling + +- If repo creation fails, stop normally. +- If bootstrap fails after repo creation, still attempt repo setup/rules and return warnings. +- If repo setup/rules fail, return warnings as current 6.3 behavior does. +- Do not leave errors only in logs; TUI/CLI must show user-facing warnings. -## Deferred +## Verification -- Interactive TTY prompts for clone (non-`--json` mode). -- Opening the cloned directory in the TUI. -- Opening the cloned directory in the shell or editor. +- `bun run check` +- `bun run lint` +- Manual TUI happy path with internal Teamwork template project. +- Manual CLI happy path with `--setup-teamwork`. +- Manual skip path to confirm existing repo creation still works unchanged. diff --git a/src/api/github/repo-bootstrap.ts b/src/api/github/repo-bootstrap.ts new file mode 100644 index 0000000..82fa544 --- /dev/null +++ b/src/api/github/repo-bootstrap.ts @@ -0,0 +1,89 @@ +import { logError, logInfo } from "../logs/manager.ts"; +import { saveProjectConfig } from "../config/manager.ts"; +import { PROJECT_CONFIG_VERSION, type ProjectConfig } from "../config/schema.ts"; +import { cloneRepo, commitFile, pushCurrentBranch } from "../../utils/git.ts"; + +export interface BootstrapGitHubRepoProjectInput { + repoName: string; + sshUrl: string; + cloneParentDir: string; + teamworkProjectId: number; + reviewTaskId?: number | null; + generalTaskList?: { + id: number; + name: string; + } | null; +} + +export interface BootstrapGitHubRepoProjectResult { + cloneDir: string; + configPath: string; +} + +export async function bootstrapGitHubRepoProject( + input: BootstrapGitHubRepoProjectInput, +): Promise { + if (!input.repoName.trim()) throw new Error("GitHub repository name is required."); + if (!input.sshUrl.trim()) throw new Error("GitHub SSH clone URL is required."); + if (!Number.isInteger(input.teamworkProjectId) || input.teamworkProjectId <= 0) { + throw new Error("Teamwork project ID must be a positive integer."); + } + if ( + input.reviewTaskId !== null && + input.reviewTaskId !== undefined && + (!Number.isInteger(input.reviewTaskId) || input.reviewTaskId <= 0) + ) { + throw new Error("Teamwork review task ID must be a positive integer."); + } + if ( + input.generalTaskList && + (!Number.isInteger(input.generalTaskList.id) || input.generalTaskList.id <= 0) + ) { + throw new Error("Teamwork general task-list ID must be a positive integer."); + } + if (input.generalTaskList && !input.generalTaskList.name.trim()) { + throw new Error("Teamwork general task-list name is required."); + } + + try { + const cloneDir = await cloneRepo({ + remoteUrl: input.sshUrl, + parentDir: input.cloneParentDir, + repoName: input.repoName, + }); + const projectConfig: ProjectConfig = { + version: PROJECT_CONFIG_VERSION, + project: { links: [] }, + teamwork: { + projectId: input.teamworkProjectId, + reviewTaskId: input.reviewTaskId ?? null, + pinnedTaskLists: input.generalTaskList + ? [ + { + id: input.generalTaskList.id, + name: input.generalTaskList.name, + }, + ] + : [], + }, + }; + const configPath = await saveProjectConfig(projectConfig, cloneDir); + await commitFile(".wtc.yaml", "chore: add WTC project config", cloneDir); + await pushCurrentBranch(cloneDir); + + logInfo("github", "repos.bootstrap.success", "Bootstrapped WTC project config", { + repoName: input.repoName, + cloneDir, + configPath, + }); + + return { cloneDir, configPath }; + } catch (error) { + logError("github", "repos.bootstrap.error", "Failed to bootstrap WTC project config", { + repoName: input.repoName, + cloneParentDir: input.cloneParentDir, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/src/api/github/repo-creation-workflow.ts b/src/api/github/repo-creation-workflow.ts new file mode 100644 index 0000000..5b4dfab --- /dev/null +++ b/src/api/github/repo-creation-workflow.ts @@ -0,0 +1,117 @@ +import { logError, logInfo } from "../logs/manager.ts"; + +import { + applyGitHubRepoSetup, + createGitHubRepo, + createGitHubRepoFromTemplate, + type CreatedGitHubRepo, + type GitHubRepoRulesPreset, +} from "./repos.ts"; +import { + bootstrapGitHubRepoProject, + type BootstrapGitHubRepoProjectResult, +} from "./repo-bootstrap.ts"; + +export interface CreateGitHubRepoWorkflowInput { + owner: string; + name: string; + description?: string; + private: boolean; + templateOwner?: string; + templateRepo?: string; + rulesPreset: GitHubRepoRulesPreset; + bootstrap?: { + cloneParentDir: string; + teamworkProjectId: number; + reviewTaskId?: number | null; + generalTaskList?: { + id: number; + name: string; + } | null; + }; +} + +export interface CreateGitHubRepoWorkflowResult { + repo: CreatedGitHubRepo; + bootstrap: BootstrapGitHubRepoProjectResult | null; + warnings: string[]; +} + +export async function createGitHubRepoWorkflow( + input: CreateGitHubRepoWorkflowInput, +): Promise { + if ( + (input.templateOwner && !input.templateRepo) || + (!input.templateOwner && input.templateRepo) + ) { + throw new Error("Both templateOwner and templateRepo must be provided together."); + } + + const repo = await (input.templateOwner && input.templateRepo + ? createGitHubRepoFromTemplate({ + templateOwner: input.templateOwner, + templateRepo: input.templateRepo, + owner: input.owner, + name: input.name, + description: input.description, + private: input.private, + }) + : createGitHubRepo({ + owner: input.owner, + name: input.name, + description: input.description, + private: input.private, + })); + + const warnings: string[] = []; + let bootstrap: BootstrapGitHubRepoProjectResult | null = null; + + if (input.bootstrap) { + try { + bootstrap = await bootstrapGitHubRepoProject({ + repoName: repo.name, + sshUrl: repo.sshUrl, + cloneParentDir: input.bootstrap.cloneParentDir, + teamworkProjectId: input.bootstrap.teamworkProjectId, + reviewTaskId: input.bootstrap.reviewTaskId, + generalTaskList: input.bootstrap.generalTaskList, + }); + } catch (error) { + logError("github", "repos.workflow.bootstrap.error", "Repository bootstrap failed", { + owner: input.owner, + repo: repo.name, + error: error instanceof Error ? error.message : String(error), + }); + warnings.push( + `Repository bootstrap failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + try { + const setupResult = await applyGitHubRepoSetup({ + owner: input.owner, + repo: repo.name, + rulesPreset: input.rulesPreset, + }); + warnings.push(...setupResult.warnings); + } catch (error) { + logError("github", "repos.workflow.setup.error", "Repository setup failed", { + owner: input.owner, + repo: repo.name, + error: error instanceof Error ? error.message : String(error), + }); + warnings.push( + `Repository setup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + logInfo("github", "repos.workflow.success", "Repository creation workflow completed", { + owner: input.owner, + repo: repo.name, + bootstrapped: bootstrap !== null, + warningCount: warnings.length, + }); + + return { repo, bootstrap, warnings }; +} diff --git a/src/api/teamwork/task-lists.ts b/src/api/teamwork/task-lists.ts new file mode 100644 index 0000000..d460780 --- /dev/null +++ b/src/api/teamwork/task-lists.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +import { logError } from "../logs/manager.ts"; + +import { fetchTeamworkApiJson } from "./client.ts"; + +export const TEAMWORK_GENERAL_TASK_LIST_NAME = "Internal | Billable Projects | General Tasks"; +export const TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME = "General Tasks"; + +const TEAMWORK_CODE_REVIEW_TASK_SEARCH_TERM = "Dev | Code Review"; +const TEAMWORK_CODE_REVIEW_TASK_NAMES = ["Dev | Code Review - Forever", "Dev | Code Review"]; + +const TeamworkIdSchema = z.union([ + z.number().int().positive(), + z.string().regex(/^\d+$/).transform(Number), +]); + +const TeamworkRelationshipSchema = z.looseObject({ + id: TeamworkIdSchema.optional(), +}); + +const TeamworkTaskListApiSchema = z.looseObject({ + id: TeamworkIdSchema, + name: z.string().optional(), + title: z.string().optional(), + projectId: TeamworkIdSchema.optional(), + project: TeamworkRelationshipSchema.nullish(), +}); + +const TeamworkProjectTaskListsResponseSchema = z.looseObject({ + tasklists: z.array(TeamworkTaskListApiSchema).optional(), + taskLists: z.array(TeamworkTaskListApiSchema).optional(), +}); + +const TeamworkTaskApiSchema = z.looseObject({ + id: TeamworkIdSchema, + name: z.string().optional(), + title: z.string().optional(), + content: z.string().optional(), + tasklistId: TeamworkIdSchema.optional(), + tasklist: TeamworkRelationshipSchema.nullish(), +}); + +const TeamworkTasksResponseSchema = z.looseObject({ + tasks: z.array(TeamworkTaskApiSchema), +}); + +export interface TeamworkTaskList { + id: number; + name: string; + projectId: number | null; +} + +export interface TeamworkProjectBootstrapDefaults { + generalTaskList: TeamworkTaskList | null; + codeReviewTask: TeamworkTaskListTaskSummary | null; +} + +export interface TeamworkTaskListTaskSummary { + id: number; + name: string; + taskListId: number | null; +} + +export async function getTeamworkProjectTaskListByName( + projectId: number, + name: string, +): Promise { + if (!Number.isInteger(projectId) || projectId <= 0) { + throw new Error("Teamwork project ID must be a positive integer."); + } + const searchTerm = name.trim(); + if (!searchTerm) throw new Error("Teamwork task-list search term is required."); + + try { + const searchParams = new URLSearchParams({ + searchTerm, + page: "1", + pageSize: "10", + skipCounts: "true", + "fields[tasklists]": "id,name,projectId", + }); + const parsed = TeamworkProjectTaskListsResponseSchema.parse( + await fetchTeamworkApiJson(`/projects/${projectId}/tasklists.json?${searchParams}`), + ); + + for (const taskList of parsed.tasklists ?? parsed.taskLists ?? []) { + const name = taskList.name ?? taskList.title; + if (!name) throw new Error("Teamwork task-list response did not include a name."); + if (name !== searchTerm) continue; + + return { + id: taskList.id, + name, + projectId: taskList.projectId ?? taskList.project?.id ?? null, + }; + } + + return null; + } catch (error) { + logError("teamwork", "taskLists.project.search.error", "Failed to search project task lists", { + projectId, + searchTerm, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export async function getTeamworkTaskInTaskListByNames(input: { + projectId: number; + taskListId: number; + searchTerm: string; + names: readonly string[]; +}): Promise { + const searchTerm = input.searchTerm.trim(); + const names = input.names.map((name) => name.trim()).filter(Boolean); + if (!Number.isInteger(input.projectId) || input.projectId <= 0) { + throw new Error("Teamwork project ID must be a positive integer."); + } + if (!Number.isInteger(input.taskListId) || input.taskListId <= 0) { + throw new Error("Teamwork task-list ID must be a positive integer."); + } + if (!searchTerm) throw new Error("Teamwork task search term is required."); + if (names.length === 0) throw new Error("Teamwork task names are required."); + + try { + const searchParams = new URLSearchParams({ + searchTerm, + projectIds: input.projectId.toString(), + tasklistIds: input.taskListId.toString(), + taskFilter: "all", + page: "1", + pageSize: "10", + skipCounts: "true", + "fields[tasks]": "id,name,tasklistId,status", + }); + const parsed = TeamworkTasksResponseSchema.parse( + await fetchTeamworkApiJson(`/tasks.json?${searchParams}`), + ); + + const candidates: TeamworkTaskListTaskSummary[] = []; + for (const task of parsed.tasks) { + const name = task.name ?? task.title ?? task.content; + if (!name) throw new Error("Teamwork task response did not include a name."); + const taskListId = task.tasklistId ?? task.tasklist?.id ?? null; + if (!names.includes(name) || taskListId !== input.taskListId) continue; + candidates.push({ id: task.id, name, taskListId }); + } + + for (const name of names) { + const candidate = candidates.find((task) => task.name === name); + if (candidate) return candidate; + } + + return null; + } catch (error) { + logError("teamwork", "taskList.task.search.error", "Failed to search task-list tasks", { + projectId: input.projectId, + taskListId: input.taskListId, + searchTerm, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export async function getTeamworkCodeReviewTaskInTaskList(input: { + projectId: number; + taskListId: number; +}): Promise { + return await getTeamworkTaskInTaskListByNames({ + projectId: input.projectId, + taskListId: input.taskListId, + searchTerm: TEAMWORK_CODE_REVIEW_TASK_SEARCH_TERM, + names: TEAMWORK_CODE_REVIEW_TASK_NAMES, + }); +} + +export async function getTeamworkProjectBootstrapDefaults( + projectId: number, +): Promise { + const generalTaskList = await getTeamworkProjectTaskListByName( + projectId, + TEAMWORK_GENERAL_TASK_LIST_NAME, + ); + if (!generalTaskList) return { generalTaskList: null, codeReviewTask: null }; + + const codeReviewTask = await getTeamworkCodeReviewTaskInTaskList({ + projectId, + taskListId: generalTaskList.id, + }); + return { generalTaskList, codeReviewTask }; +} diff --git a/src/api/teamwork/tasks.ts b/src/api/teamwork/tasks.ts index f1e391b..c22b8b7 100644 --- a/src/api/teamwork/tasks.ts +++ b/src/api/teamwork/tasks.ts @@ -8,6 +8,14 @@ export interface TeamworkTaskReference { url: string; } +/** A Teamwork task list parsed from a user-provided ID or URL. */ +export interface TeamworkTaskListReference { + /** Numeric Teamwork task-list ID. */ + id: number; + /** Browser URL for the Teamwork task list. */ + url: string; +} + /** Parses a user-provided task ID (e.g. "12345") or Teamwork URL into a task reference. */ export function getTeamworkTaskReference(value: string): TeamworkTaskReference { const trimmed = value.trim(); @@ -36,3 +44,32 @@ export function getTeamworkTaskReference(value: string): TeamworkTaskReference { throw new Error("Teamwork task must be a numeric ID or Teamwork task URL."); } } + +/** Parses a user-provided task-list ID or Teamwork task-list URL into a task-list reference. */ +export function getTeamworkTaskListReference(value: string): TeamworkTaskListReference { + const trimmed = value.trim(); + if (!trimmed) throw new Error("Teamwork task-list ID or URL is required."); + + if (/^\d+$/.test(trimmed)) { + const id = Number(trimmed); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error("Teamwork task-list ID is invalid."); + + return { id, url: `${TEAMWORK_BASE_URL}/app/tasklists/${id}/list` }; + } + + try { + const url = new URL(trimmed); + const teamworkUrl = new URL(TEAMWORK_BASE_URL); + if (url.hostname !== teamworkUrl.hostname) { + throw new Error("Teamwork task-list URL must use the configured Teamwork site."); + } + + const match = url.pathname.match(/\/tasklists\/(\d+)/); + if (!match?.[1]) throw new Error("Teamwork task-list URL must include a task-list ID."); + + return { id: Number(match[1]), url: url.toString() }; + } catch (error) { + if (error instanceof Error && error.message.startsWith("Teamwork")) throw error; + throw new Error("Teamwork task list must be a numeric ID or Teamwork task-list URL."); + } +} diff --git a/src/cli/commands/repo-command.ts b/src/cli/commands/repo-command.ts index 96996e6..7431662 100644 --- a/src/cli/commands/repo-command.ts +++ b/src/cli/commands/repo-command.ts @@ -12,6 +12,11 @@ const repoCreateCommand: CommandModule< description?: string; json?: boolean; rulesPreset?: string; + setupTeamwork?: boolean; + cloneDir?: string; + teamworkProjectId?: number; + generalTasks?: string; + reviewTask?: string; } > = { command: "create ", @@ -40,6 +45,29 @@ const repoCreateCommand: CommandModule< describe: "Repository rules preset (standard or none)", default: "standard" as const, }) + .option("setup-teamwork", { + type: "boolean", + describe: "Clone with SSH and write .wtc.yaml Teamwork project config before repo rules", + default: false, + }) + .option("clone-dir", { + type: "string", + describe: "Parent directory for the cloned repo. Defaults to the current directory.", + }) + .option("teamwork-project-id", { + type: "number", + describe: "Teamwork project ID for the generated .wtc.yaml", + }) + .option("general-tasks", { + type: "string", + describe: + "General Tasks task-list ID or URL. Defaults to Teamwork template discovery. Pass -1 to skip.", + }) + .option("review-task", { + type: "string", + describe: + "Code Review task ID or URL. Defaults to Teamwork template discovery. Pass -1 to skip.", + }) .option("json", { type: "boolean", describe: "Print JSON output", @@ -51,6 +79,11 @@ const repoCreateCommand: CommandModule< description?: string; json?: boolean; rulesPreset?: string; + setupTeamwork?: boolean; + cloneDir?: string; + teamworkProjectId?: number; + generalTasks?: string; + reviewTask?: string; }>, handler: (argv) => repoCreate({ @@ -60,6 +93,11 @@ const repoCreateCommand: CommandModule< description: argv.description, json: argv.json ?? false, rulesPreset: argv.rulesPreset ?? "standard", + setupTeamwork: argv.setupTeamwork ?? false, + cloneDir: argv.cloneDir, + teamworkProjectId: argv.teamworkProjectId, + generalTasks: argv.generalTasks, + reviewTask: argv.reviewTask, }), }; diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 49572be..156407f 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -1,10 +1,28 @@ import { z } from "zod"; import { GITHUB_REPO_OWNER } from "../../api/github/consts.ts"; +import { createGitHubRepoWorkflow } from "../../api/github/repo-creation-workflow.ts"; import { logError, logInfo, logWarn } from "../../api/logs/manager.ts"; import { createGitHubRepoWithSetup, getGitHubTemplateRepo } from "../../api/github/repos.ts"; +import { loadProjectConfig } from "../../api/config/manager.ts"; +import { + getTeamworkCodeReviewTaskInTaskList, + getTeamworkProjectBootstrapDefaults, + TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, +} from "../../api/teamwork/task-lists.ts"; +import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; +import { + getTeamworkTaskListReference, + getTeamworkTaskReference, +} from "../../api/teamwork/tasks.ts"; +import { assertCloneTargetAvailable } from "../../utils/git.ts"; const rulesPresetSchema = z.enum(["standard", "none"]); +const positiveIntegerSchema = z.number().int().positive(); + +function isExplicitSkip(value: string | undefined): boolean { + return value?.trim() === "-1"; +} export type RepoVisibility = "private" | "public"; @@ -15,6 +33,11 @@ export async function repoCreate(args: { description?: string; json: boolean; rulesPreset?: string; + setupTeamwork?: boolean; + cloneDir?: string; + teamworkProjectId?: number; + generalTasks?: string; + reviewTask?: string; startDir?: string; }): Promise { const name = args.name.trim(); @@ -25,12 +48,14 @@ export async function repoCreate(args: { const templateName = args.template?.trim(); const rulesPreset = rulesPresetSchema.parse(args.rulesPreset ?? "standard"); + const startDir = args.startDir ?? process.cwd(); logInfo("cli.repo", "repo.create.start", "Starting repo creation", { name, template: templateName ?? null, visibility: args.visibility, rulesPreset, + setupTeamwork: Boolean(args.setupTeamwork), }); let templateOwner: string | undefined; @@ -47,6 +72,116 @@ export async function repoCreate(args: { templateRepo = template.name; } + if (args.setupTeamwork) { + const config = await loadProjectConfig(startDir); + const rawProjectId = args.teamworkProjectId ?? config?.teamwork.projectId ?? null; + if (rawProjectId === null) { + throw new Error( + "Teamwork project ID is required for --setup-teamwork. Pass --teamwork-project-id or run from a configured WTC project.", + ); + } + const teamworkProjectId = positiveIntegerSchema.parse(rawProjectId); + const skipGeneralTasks = isExplicitSkip(args.generalTasks); + const skipReviewTask = isExplicitSkip(args.reviewTask); + const hasGeneralTasksOverride = args.generalTasks !== undefined && !skipGeneralTasks; + const discovered = hasGeneralTasksOverride + ? { generalTaskList: null, codeReviewTask: null } + : await getTeamworkProjectBootstrapDefaults(teamworkProjectId); + const generalTaskList = skipGeneralTasks + ? null + : hasGeneralTasksOverride + ? { + id: getTeamworkTaskListReference(args.generalTasks ?? "").id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + } + : discovered.generalTaskList + ? { + id: discovered.generalTaskList.id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + } + : null; + + if (!generalTaskList && !skipGeneralTasks) { + throw new Error( + "General Tasks list could not be discovered. Pass --general-tasks with a task-list ID/URL, or pass --general-tasks -1 to skip it.", + ); + } + + const reviewTask = skipReviewTask + ? null + : args.reviewTask + ? await getTeamworkTaskById(getTeamworkTaskReference(args.reviewTask).id) + : generalTaskList + ? (discovered.codeReviewTask ?? + (await getTeamworkCodeReviewTaskInTaskList({ + projectId: teamworkProjectId, + taskListId: generalTaskList.id, + }))) + : null; + + if (!reviewTask && !skipReviewTask && !skipGeneralTasks) { + throw new Error( + "Code Review task could not be discovered. Pass --review-task with a task ID/URL, or pass --review-task -1 to skip it.", + ); + } + + const cloneParentDir = args.cloneDir?.trim() || startDir; + await assertCloneTargetAvailable(cloneParentDir, name); + + const { repo, bootstrap, warnings } = await createGitHubRepoWorkflow({ + owner: GITHUB_REPO_OWNER, + name, + description: args.description?.trim() || undefined, + private: args.visibility === "private", + templateOwner, + templateRepo, + rulesPreset, + bootstrap: { + cloneParentDir, + teamworkProjectId, + reviewTaskId: reviewTask?.id ?? null, + generalTaskList, + }, + }); + + logInfo("cli.repo", "repo.create.success", "Repo created", { fullName: repo.fullName }); + + if (args.json) { + console.log( + JSON.stringify({ + name: repo.name, + fullName: repo.fullName, + url: repo.htmlUrl, + cloneUrl: repo.cloneUrl, + sshUrl: repo.sshUrl, + bootstrap, + warnings, + }), + ); + return; + } + + console.log(`Created GitHub repo: ${repo.htmlUrl}`); + if (bootstrap) console.log(`Bootstrapped WTC config: ${bootstrap.configPath}`); + if (!generalTaskList) { + logInfo("cli.repo", "repo.bootstrap.generalTasks.skipped", "Skipped General Tasks config"); + console.log("Skipped General Tasks list config (--general-tasks -1)."); + } + if (!reviewTask) { + logInfo("cli.repo", "repo.bootstrap.reviewTask.skipped", "Skipped Code Review task config"); + console.log("Skipped Code Review task config (--review-task -1)."); + } + if (warnings.length) { + for (const warning of warnings) { + logWarn("cli.repo", "repo.setup.warning", warning); + console.warn(` Warning: ${warning}`); + } + } else { + logInfo("cli.repo", "repo.setup.success", "Setup completed"); + } + return; + } + const { repo, warnings } = await createGitHubRepoWithSetup({ owner: GITHUB_REPO_OWNER, name, diff --git a/src/tui/pages/github.tsx b/src/tui/pages/github.tsx index fa04105..a19c44e 100644 --- a/src/tui/pages/github.tsx +++ b/src/tui/pages/github.tsx @@ -4,6 +4,7 @@ import { useBindings } from "@opentui/keymap/solid"; import { getGitHubAuthStatus, type GitHubAuthStatus } from "../../api/github/auth.ts"; import { GITHUB_REPO_OWNER } from "../../api/github/consts.ts"; +import { createGitHubRepoWorkflow } from "../../api/github/repo-creation-workflow.ts"; import { createGitHubRepoWithSetup, getGitHubTemplateRepos, @@ -11,8 +12,20 @@ import { type GitHubRepoRulesPreset, type GitHubTemplateRepo, } from "../../api/github/repos.ts"; +import { loadProjectConfig } from "../../api/config/manager.ts"; +import { + getTeamworkCodeReviewTaskInTaskList, + getTeamworkProjectBootstrapDefaults, + TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, +} from "../../api/teamwork/task-lists.ts"; +import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; +import { + getTeamworkTaskListReference, + getTeamworkTaskReference, +} from "../../api/teamwork/tasks.ts"; import { logInfo, logWarn, logError } from "../../api/logs/manager.ts"; import { openUrlInBrowser } from "../../utils/browser.ts"; +import { assertCloneTargetAvailable } from "../../utils/git.ts"; import { ActionButton } from "../components/forms/action-button.tsx"; import { Card } from "../components/layout/card.tsx"; import { Page } from "../components/layout/page.tsx"; @@ -31,6 +44,20 @@ interface RepoCreationDraft { description: string; private: boolean; rulesPreset: GitHubRepoRulesPreset; + bootstrap: RepoBootstrapDraft | null; +} + +interface RepoBootstrapDraft { + cloneParentDir: string; + teamworkProjectId: number; + generalTaskList: { + id: number; + name: string; + }; + reviewTask: { + id: number; + name: string; + }; } /** GitHub route for creating repositories from approved organization templates. */ @@ -139,6 +166,7 @@ export function GitHubPage() { description: "", private: true, rulesPreset: "standard", + bootstrap: null, }) } onCancel={() => { @@ -198,14 +226,14 @@ export function GitHubPage() { description: "WTC repo settings, vulnerability alerts, and protected branch rules", value: "standard", category: "Repo Rules", - onSelect: () => showConfirmStep({ ...draft, rulesPreset: "standard" }), + onSelect: () => showTeamworkSetupStep({ ...draft, rulesPreset: "standard" }), }, { title: "none", description: "Create the repo without protected branch rules", value: "none", category: "Repo Rules", - onSelect: () => showConfirmStep({ ...draft, rulesPreset: "none" }), + onSelect: () => showTeamworkSetupStep({ ...draft, rulesPreset: "none" }), }, ]; @@ -218,28 +246,279 @@ export function GitHubPage() { )); }; + const showTeamworkSetupStep = (draft: RepoCreationDraft) => { + dialog.replace(() => ( + startTeamworkSetup(draft)} + onCancel={() => showConfirmStep({ ...draft, bootstrap: null })} + /> + )); + }; + + const startTeamworkSetup = async (draft: RepoCreationDraft) => { + dialog.replace(() => ); + + try { + const config = await loadProjectConfig(process.cwd()); + const projectId = config?.teamwork.projectId ?? null; + if (projectId) { + await resolveTeamworkDefaults(draft, projectId); + } else { + showTeamworkProjectInput(draft, ""); + } + } catch (error) { + showTeamworkSetupError( + "Teamwork Setup Failed", + error instanceof Error ? error.message : "Failed to load Teamwork setup defaults.", + () => showTeamworkSetupStep(draft), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + } + }; + + const showTeamworkProjectInput = (draft: RepoCreationDraft, initialValue: string) => { + dialog.replace(() => ( + { + const projectId = Number(value); + if (!Number.isInteger(projectId) || projectId <= 0) { + showTeamworkSetupError( + "Invalid Project ID", + "Teamwork project ID must be a positive integer.", + () => showTeamworkProjectInput(draft, value), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + return; + } + void resolveTeamworkDefaults(draft, projectId); + }} + onCancel={() => showTeamworkSetupStep(draft)} + /> + )); + }; + + const resolveTeamworkDefaults = async (draft: RepoCreationDraft, teamworkProjectId: number) => { + dialog.replace(() => ); + + try { + const defaults = await getTeamworkProjectBootstrapDefaults(teamworkProjectId); + if (!defaults.generalTaskList) { + showGeneralTasksInput(draft, teamworkProjectId, ""); + return; + } + + const generalTaskList = { + id: defaults.generalTaskList.id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }; + if (defaults.codeReviewTask) { + showCloneDirInput(draft, { + teamworkProjectId, + generalTaskList, + reviewTask: defaults.codeReviewTask, + }); + return; + } + + showReviewTaskInput(draft, { + teamworkProjectId, + generalTaskList, + }); + } catch (error) { + showTeamworkSetupError( + "Teamwork Discovery Failed", + error instanceof Error ? error.message : "Failed to discover Teamwork defaults.", + () => showTeamworkProjectInput(draft, teamworkProjectId.toString()), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + } + }; + + const showGeneralTasksInput = ( + draft: RepoCreationDraft, + teamworkProjectId: number, + initialValue: string, + ) => { + dialog.replace(() => ( + { + try { + const ref = getTeamworkTaskListReference(value); + void resolveCodeReviewTask(draft, teamworkProjectId, { + id: ref.id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }); + } catch (error) { + showTeamworkSetupError( + "Invalid Task List", + error instanceof Error ? error.message : "Invalid Teamwork task-list ID or URL.", + () => showGeneralTasksInput(draft, teamworkProjectId, value), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + } + }} + onCancel={() => showTeamworkProjectInput(draft, teamworkProjectId.toString())} + /> + )); + }; + + const resolveCodeReviewTask = async ( + draft: RepoCreationDraft, + teamworkProjectId: number, + generalTaskList: RepoBootstrapDraft["generalTaskList"], + ) => { + dialog.replace(() => ); + + try { + const reviewTask = await getTeamworkCodeReviewTaskInTaskList({ + projectId: teamworkProjectId, + taskListId: generalTaskList.id, + }); + if (reviewTask) { + showCloneDirInput(draft, { teamworkProjectId, generalTaskList, reviewTask }); + return; + } + showReviewTaskInput(draft, { teamworkProjectId, generalTaskList }); + } catch (error) { + showTeamworkSetupError( + "Code Review Search Failed", + error instanceof Error ? error.message : "Failed to search for the Code Review task.", + () => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList }), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + } + }; + + const showReviewTaskInput = ( + draft: RepoCreationDraft, + base: Pick, + ) => { + dialog.replace(() => ( + { + try { + const ref = getTeamworkTaskReference(value); + getTeamworkTaskById(ref.id) + .then((reviewTask) => showCloneDirInput(draft, { ...base, reviewTask })) + .catch((error: unknown) => { + showTeamworkSetupError( + "Invalid Code Review Task", + error instanceof Error ? error.message : "Failed to load Code Review task.", + () => showReviewTaskInput(draft, base), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + }); + } catch (error) { + showTeamworkSetupError( + "Invalid Code Review Task", + error instanceof Error ? error.message : "Invalid Teamwork task ID or URL.", + () => showReviewTaskInput(draft, base), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + } + }} + onCancel={() => + showGeneralTasksInput(draft, base.teamworkProjectId, base.generalTaskList.id.toString()) + } + /> + )); + }; + + const showCloneDirInput = ( + draft: RepoCreationDraft, + base: Omit, + ) => { + const defaultDir = process.cwd(); + dialog.replace(() => ( + { + const cloneParentDir = value.trim() || defaultDir; + assertCloneTargetAvailable(cloneParentDir, draft.name) + .then(() => showConfirmStep({ ...draft, bootstrap: { ...base, cloneParentDir } })) + .catch((error: unknown) => { + showTeamworkSetupError( + "Clone Directory Unavailable", + error instanceof Error ? error.message : "Clone target is not available.", + () => showCloneDirInput(draft, base), + () => showConfirmStep({ ...draft, bootstrap: null }), + ); + }); + }} + onCancel={() => showReviewTaskInput(draft, base)} + /> + )); + }; + + const showTeamworkSetupError = ( + title: string, + message: string, + onBack: () => void, + onSkip?: () => void, + ) => { + dialog.replace(() => ( + (onSkip ? onSkip() : dialog.clear())} + /> + )); + }; + const showConfirmStep = (draft: RepoCreationDraft) => { const description = draft.description ? `\nDescription: ${draft.description}` : ""; const source = draft.template ? `from ${draft.template.fullName}` : "as a blank repo"; + const bootstrap = draft.bootstrap + ? `\nTeamwork project: ${draft.bootstrap.teamworkProjectId}\nGeneral Tasks: ${draft.bootstrap.generalTaskList.id}\nCode Review: ${draft.bootstrap.reviewTask.name} (#${draft.bootstrap.reviewTask.id})\nClone parent: ${draft.bootstrap.cloneParentDir}` + : "\nTeamwork setup: skipped"; dialog.replace(() => ( createRepo(draft)} - onCancel={() => showRulesStep(draft)} + onCancel={() => showTeamworkSetupStep(draft)} /> )); }; - const showSuccessDialog = (repo: CreatedGitHubRepo, warnings: string[]) => { + const showSuccessDialog = ( + repo: CreatedGitHubRepo, + warnings: string[], + configPath: string | null, + ) => { const warningMessage = warnings.length ? `\n\nSetup warnings:\n${warnings.join("\n")}` : ""; + const bootstrapMessage = configPath ? `\nWTC config: ${configPath}` : ""; dialog.replace(() => ( { @@ -268,15 +547,42 @@ export function GitHubPage() { }); try { - const { repo, warnings } = await createGitHubRepoWithSetup({ - owner: GITHUB_REPO_OWNER, - name: draft.name, - description: draft.description.trim() || undefined, - private: draft.private, - templateOwner: draft.template ? GITHUB_REPO_OWNER : undefined, - templateRepo: draft.template?.name, - rulesPreset: draft.rulesPreset, - }); + let repo: CreatedGitHubRepo; + let warnings: string[]; + let configPath: string | null = null; + + if (draft.bootstrap) { + const result = await createGitHubRepoWorkflow({ + owner: GITHUB_REPO_OWNER, + name: draft.name, + description: draft.description.trim() || undefined, + private: draft.private, + templateOwner: draft.template ? GITHUB_REPO_OWNER : undefined, + templateRepo: draft.template?.name, + rulesPreset: draft.rulesPreset, + bootstrap: { + cloneParentDir: draft.bootstrap.cloneParentDir, + teamworkProjectId: draft.bootstrap.teamworkProjectId, + reviewTaskId: draft.bootstrap.reviewTask.id, + generalTaskList: draft.bootstrap.generalTaskList, + }, + }); + repo = result.repo; + warnings = result.warnings; + configPath = result.bootstrap?.configPath ?? null; + } else { + const result = await createGitHubRepoWithSetup({ + owner: GITHUB_REPO_OWNER, + name: draft.name, + description: draft.description.trim() || undefined, + private: draft.private, + templateOwner: draft.template ? GITHUB_REPO_OWNER : undefined, + templateRepo: draft.template?.name, + rulesPreset: draft.rulesPreset, + }); + repo = result.repo; + warnings = result.warnings; + } logInfo("tui.github", "github.repo.create.success", "Repo created", { fullName: repo.fullName, @@ -291,9 +597,11 @@ export function GitHubPage() { setMessage( warnings.length ? `Created GitHub repo with setup warnings: ${repo.fullName}` - : `Created GitHub repo: ${repo.fullName}`, + : draft.bootstrap + ? `Created and bootstrapped GitHub repo: ${repo.fullName}` + : `Created GitHub repo: ${repo.fullName}`, ); - showSuccessDialog(repo, warnings); + showSuccessDialog(repo, warnings, configPath); } catch (error) { logError("tui.github", "github.repo.create.error", "Repo creation failed", { error: error instanceof Error ? error.message : String(error), diff --git a/src/utils/git.ts b/src/utils/git.ts index 2263396..dc8d577 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,3 +1,6 @@ +import { mkdir, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + export interface GitHubRemote { owner: string; repo: string; @@ -53,3 +56,48 @@ export async function branchExists(name: string, projectDir = process.cwd()): Pr return false; } } + +export async function cloneRepo(input: { + remoteUrl: string; + parentDir: string; + repoName: string; +}): Promise { + const parentDir = resolve(input.parentDir); + const cloneDir = await assertCloneTargetAvailable(parentDir, input.repoName); + await mkdir(parentDir, { recursive: true }); + await Bun.$`git clone ${input.remoteUrl} ${cloneDir}`.quiet(); + return cloneDir; +} + +export function getCloneDir(parentDir: string, repoName: string): string { + return join(resolve(parentDir), repoName); +} + +export async function assertCloneTargetAvailable( + parentDir: string, + repoName: string, +): Promise { + const cloneDir = getCloneDir(parentDir, repoName); + try { + await stat(cloneDir); + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { + return cloneDir; + } + throw error; + } + throw new Error(`Clone target already exists: ${cloneDir}`); +} + +export async function commitFile( + relativePath: string, + message: string, + projectDir = process.cwd(), +): Promise { + await Bun.$`git add ${relativePath}`.cwd(projectDir).quiet(); + await Bun.$`git commit -m ${message}`.cwd(projectDir).quiet(); +} + +export async function pushCurrentBranch(projectDir = process.cwd()): Promise { + await Bun.$`git push`.cwd(projectDir).quiet(); +} From 24e5381f7e2fe1e7973e66c8cbddcdc0a6995489 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Fri, 10 Jul 2026 16:42:35 -0700 Subject: [PATCH 3/6] fix: repo creation doesnt wait for completion --- .nvmrc | 1 + ...4-local-clone-and-wtc-project-bootstrap.md | 14 ++-- src/api/github/repos.ts | 9 +-- src/api/teamwork/task.ts | 63 ++++++++++++---- src/cli/commands/repo.ts | 20 ++---- src/tui/pages/github.tsx | 71 +++++++++++++------ src/utils/git.ts | 70 ++++++++++++++++-- 7 files changed, 181 insertions(+), 67 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md index 0ac1731..8343e1c 100644 --- a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md +++ b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md @@ -43,7 +43,7 @@ teamwork: id: 1699936 ``` -If the CLI user explicitly passes `--general-tasks -1`, WTC skips the pinned General Tasks list and writes `pinnedTaskLists: []`. If the CLI user explicitly passes `--review-task -1`, WTC skips `reviewTaskId` and writes it empty. +The Teamwork project ID is the only required Teamwork value for bootstrap. If General Tasks or Code Review cannot be discovered, or the user skips them, WTC still writes the config with `pinnedTaskLists: []` and/or an empty `reviewTaskId`. ## Discovery Flow @@ -55,9 +55,9 @@ If the CLI user explicitly passes `--general-tasks -1`, WTC skips the pinned Gen 2. Search for the General task list in that Teamwork project using `searchTerm`, then exact-match the returned task-list name. 3. If the General task list is found, search for the code review task using `searchTerm=Dev | Code Review`, `projectIds`, and `tasklistIds`, then exact-match returned task names in priority order: `Dev | Code Review - Forever`, then `Dev | Code Review`. 4. If both are found, skip the remaining Teamwork questions. -5. If the General task list is not found, ask for General task-list ID or URL. +5. If the General task list is not found, ask for General task-list ID or URL and allow skipping only that field. 6. After a General task list is known, try again to find the code review task inside it. -7. If the code review task is still not found, ask for code review task ID or URL. +7. If the code review task is still not found, ask for code review task ID or URL and allow skipping only that field. ## TUI Flow @@ -69,7 +69,7 @@ Existing repo creation steps remain unchanged until repo rules are selected. 3. If yes: - Resolve or ask for Teamwork project ID. - Auto-discover General task list and code review task. - - Ask only for values that could not be discovered. + - Ask only for values that could not be discovered; General Tasks and Code Review can be skipped independently. - Ask for clone parent directory, defaulting to process start directory. - Validate the clone target does not exist. 4. Show final confirmation with repo and Teamwork setup details. @@ -91,10 +91,10 @@ Potential flags on `wtc repo create`: - `--setup-teamwork`: enable clone + `.wtc.yaml` bootstrap. - `--clone-dir `: clone parent directory; defaults to process start directory. - `--teamwork-project-id `: optional override for current config value. -- `--general-tasks `: optional override for auto-discovery. `-1` skips the pinned General Tasks list. -- `--review-task `: optional override for auto-discovery. `-1` skips the Code Review task. +- `--general-tasks `: optional override for auto-discovery. `-1` explicitly skips the pinned General Tasks list. +- `--review-task `: optional override for auto-discovery. `-1` explicitly skips the Code Review task. -When `--setup-teamwork` is present, CLI should fail before repo creation if it cannot resolve all required Teamwork values from flags, current config, and auto-discovery. Passing `--general-tasks -1` or `--review-task -1` explicitly opts out of those optional config values. +When `--setup-teamwork` is present, CLI should fail before repo creation only if it cannot resolve the required Teamwork project ID or clone target. General Tasks and Code Review are optional config values; if discovery does not find them, CLI proceeds and writes empty values. ## API Scope diff --git a/src/api/github/repos.ts b/src/api/github/repos.ts index ad76178..1f743cb 100644 --- a/src/api/github/repos.ts +++ b/src/api/github/repos.ts @@ -9,6 +9,7 @@ import { GITHUB_REPO_RULESET_PRESETS, type GitHubRepoRulesPreset } from "./repo- export type { GitHubRepoRulesPreset } from "./repo-rules.ts"; const TEMPLATE_REPOS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const TEMPLATE_REPOS_CACHE_VERSION = 2; // Applied during blank repo creation and again after creation so template-based // repos converge on the same baseline settings. @@ -32,7 +33,7 @@ const GitHubTemplateRepoSchema = z.object({ }); const GitHubTemplateReposCacheFileSchema = z.object({ - version: z.literal(1), + version: z.literal(TEMPLATE_REPOS_CACHE_VERSION), owners: z.record( z.string(), z.object({ @@ -113,7 +114,7 @@ export async function getGitHubTemplateRepos(owner: string): Promise repo.is_template === true) + .filter((repo) => repo.is_template === true && !repo.archived) .map((repo) => ({ owner: repo.owner.login, name: repo.name, @@ -166,7 +167,7 @@ export async function getGitHubTemplateRepo( repo: repoName, }); - if (data.is_template !== true) return null; + if (data.is_template !== true || data.archived) return null; return { owner: data.owner.login, diff --git a/src/api/teamwork/task.ts b/src/api/teamwork/task.ts index 6de407a..421c812 100644 --- a/src/api/teamwork/task.ts +++ b/src/api/teamwork/task.ts @@ -3,36 +3,69 @@ import { z } from "zod"; import { logError } from "../logs/manager.ts"; import { fetchTeamworkApiJson } from "./client.ts"; -const TeamworkTaskByIdResponseSchema = z.object({ - task: z.object({ - id: z.union([z.number().int().positive(), z.string().regex(/^\d+$/).transform(Number)]), - projectId: z - .union([z.number().int().positive(), z.string().regex(/^\d+$/).transform(Number)]) - .optional(), +const TeamworkIdSchema = z.union([ + z.number().int().positive(), + z.string().regex(/^\d+$/).transform(Number), +]); + +const TeamworkRelationshipSchema = z.looseObject({ + id: TeamworkIdSchema.optional(), +}); + +const TeamworkTaskByIdResponseSchema = z.looseObject({ + task: z.looseObject({ + id: TeamworkIdSchema, + projectId: TeamworkIdSchema.optional(), + project: TeamworkRelationshipSchema.nullish(), name: z.string().optional(), title: z.string().optional(), content: z.string().optional(), }), }); -/** Fetches a single Teamwork task by ID and returns its id and name. */ -export async function getTeamworkTaskById( - taskId: number, -): Promise<{ id: number; projectId: number; name: string }> { +export interface TeamworkTaskSummary { + id: number; + projectId: number | null; + name: string; +} + +/** Fetches a single Teamwork task by ID without requiring project metadata. */ +export async function getTeamworkTaskSummaryById(taskId: number): Promise { try { + const searchParams = new URLSearchParams({ + "fields[tasks]": "id,name,title,content,projectId,project", + }); const parsed = TeamworkTaskByIdResponseSchema.parse( - await fetchTeamworkApiJson(`/tasks/${taskId}.json`), + await fetchTeamworkApiJson(`/tasks/${taskId}.json?${searchParams}`), ); const name = parsed.task.name ?? parsed.task.title ?? parsed.task.content; if (!name) throw new Error("Teamwork task response did not include a task name."); - if (!parsed.task.projectId) - throw new Error("Teamwork task response did not include a project ID."); - return { id: parsed.task.id, projectId: parsed.task.projectId, name }; + return { + id: parsed.task.id, + projectId: parsed.task.projectId ?? parsed.task.project?.id ?? null, + name, + }; } catch (error) { - logError("teamwork", "task.getById.error", `Failed to fetch task ${taskId}`, { + logError("teamwork", "task.getSummaryById.error", `Failed to fetch task ${taskId}`, { error: error instanceof Error ? error.message : String(error), }); throw error; } } + +/** Fetches a single Teamwork task by ID and returns its id and name. */ +export async function getTeamworkTaskById( + taskId: number, +): Promise<{ id: number; projectId: number; name: string }> { + const task = await getTeamworkTaskSummaryById(taskId); + if (!task.projectId) { + const error = new Error("Teamwork task response did not include a project ID."); + logError("teamwork", "task.getById.error", `Failed to fetch task ${taskId}`, { + error: error.message, + }); + throw error; + } + + return { id: task.id, projectId: task.projectId, name: task.name }; +} diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 156407f..a569d4b 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -10,7 +10,7 @@ import { getTeamworkProjectBootstrapDefaults, TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, } from "../../api/teamwork/task-lists.ts"; -import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; +import { getTeamworkTaskSummaryById } from "../../api/teamwork/task.ts"; import { getTeamworkTaskListReference, getTeamworkTaskReference, @@ -101,16 +101,10 @@ export async function repoCreate(args: { } : null; - if (!generalTaskList && !skipGeneralTasks) { - throw new Error( - "General Tasks list could not be discovered. Pass --general-tasks with a task-list ID/URL, or pass --general-tasks -1 to skip it.", - ); - } - const reviewTask = skipReviewTask ? null : args.reviewTask - ? await getTeamworkTaskById(getTeamworkTaskReference(args.reviewTask).id) + ? await getTeamworkTaskSummaryById(getTeamworkTaskReference(args.reviewTask).id) : generalTaskList ? (discovered.codeReviewTask ?? (await getTeamworkCodeReviewTaskInTaskList({ @@ -119,12 +113,6 @@ export async function repoCreate(args: { }))) : null; - if (!reviewTask && !skipReviewTask && !skipGeneralTasks) { - throw new Error( - "Code Review task could not be discovered. Pass --review-task with a task ID/URL, or pass --review-task -1 to skip it.", - ); - } - const cloneParentDir = args.cloneDir?.trim() || startDir; await assertCloneTargetAvailable(cloneParentDir, name); @@ -165,11 +153,11 @@ export async function repoCreate(args: { if (bootstrap) console.log(`Bootstrapped WTC config: ${bootstrap.configPath}`); if (!generalTaskList) { logInfo("cli.repo", "repo.bootstrap.generalTasks.skipped", "Skipped General Tasks config"); - console.log("Skipped General Tasks list config (--general-tasks -1)."); + console.log("Skipped General Tasks list config."); } if (!reviewTask) { logInfo("cli.repo", "repo.bootstrap.reviewTask.skipped", "Skipped Code Review task config"); - console.log("Skipped Code Review task config (--review-task -1)."); + console.log("Skipped Code Review task config."); } if (warnings.length) { for (const warning of warnings) { diff --git a/src/tui/pages/github.tsx b/src/tui/pages/github.tsx index a19c44e..b3793ca 100644 --- a/src/tui/pages/github.tsx +++ b/src/tui/pages/github.tsx @@ -18,7 +18,7 @@ import { getTeamworkProjectBootstrapDefaults, TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, } from "../../api/teamwork/task-lists.ts"; -import { getTeamworkTaskById } from "../../api/teamwork/task.ts"; +import { getTeamworkTaskSummaryById } from "../../api/teamwork/task.ts"; import { getTeamworkTaskListReference, getTeamworkTaskReference, @@ -53,11 +53,11 @@ interface RepoBootstrapDraft { generalTaskList: { id: number; name: string; - }; + } | null; reviewTask: { id: number; name: string; - }; + } | null; } /** GitHub route for creating repositories from approved organization templates. */ @@ -337,8 +337,15 @@ export function GitHubPage() { showTeamworkSetupError( "Teamwork Discovery Failed", error instanceof Error ? error.message : "Failed to discover Teamwork defaults.", - () => showTeamworkProjectInput(draft, teamworkProjectId.toString()), - () => showConfirmStep({ ...draft, bootstrap: null }), + () => showGeneralTasksInput(draft, teamworkProjectId, ""), + () => + showCloneDirInput(draft, { + teamworkProjectId, + generalTaskList: null, + reviewTask: null, + }), + "Skip Optional Fields", + "Enter Manually", ); } }; @@ -351,10 +358,16 @@ export function GitHubPage() { dialog.replace(() => ( { + if (!value) { + showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null }); + return; + } try { const ref = getTeamworkTaskListReference(value); void resolveCodeReviewTask(draft, teamworkProjectId, { @@ -366,11 +379,12 @@ export function GitHubPage() { "Invalid Task List", error instanceof Error ? error.message : "Invalid Teamwork task-list ID or URL.", () => showGeneralTasksInput(draft, teamworkProjectId, value), - () => showConfirmStep({ ...draft, bootstrap: null }), + () => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null }), + "Skip General Tasks", ); } }} - onCancel={() => showTeamworkProjectInput(draft, teamworkProjectId.toString())} + onCancel={() => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null })} /> )); }; @@ -378,7 +392,7 @@ export function GitHubPage() { const resolveCodeReviewTask = async ( draft: RepoCreationDraft, teamworkProjectId: number, - generalTaskList: RepoBootstrapDraft["generalTaskList"], + generalTaskList: NonNullable, ) => { dialog.replace(() => ); @@ -397,7 +411,8 @@ export function GitHubPage() { "Code Review Search Failed", error instanceof Error ? error.message : "Failed to search for the Code Review task.", () => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList }), - () => showConfirmStep({ ...draft, bootstrap: null }), + () => showCloneDirInput(draft, { teamworkProjectId, generalTaskList, reviewTask: null }), + "Skip Code Review", ); } }; @@ -409,20 +424,27 @@ export function GitHubPage() { dialog.replace(() => ( { + if (!value) { + showCloneDirInput(draft, { ...base, reviewTask: null }); + return; + } try { const ref = getTeamworkTaskReference(value); - getTeamworkTaskById(ref.id) + getTeamworkTaskSummaryById(ref.id) .then((reviewTask) => showCloneDirInput(draft, { ...base, reviewTask })) .catch((error: unknown) => { showTeamworkSetupError( "Invalid Code Review Task", error instanceof Error ? error.message : "Failed to load Code Review task.", () => showReviewTaskInput(draft, base), - () => showConfirmStep({ ...draft, bootstrap: null }), + () => showCloneDirInput(draft, { ...base, reviewTask: null }), + "Skip Code Review", ); }); } catch (error) { @@ -430,13 +452,12 @@ export function GitHubPage() { "Invalid Code Review Task", error instanceof Error ? error.message : "Invalid Teamwork task ID or URL.", () => showReviewTaskInput(draft, base), - () => showConfirmStep({ ...draft, bootstrap: null }), + () => showCloneDirInput(draft, { ...base, reviewTask: null }), + "Skip Code Review", ); } }} - onCancel={() => - showGeneralTasksInput(draft, base.teamworkProjectId, base.generalTaskList.id.toString()) - } + onCancel={() => showCloneDirInput(draft, { ...base, reviewTask: null })} /> )); }; @@ -475,13 +496,15 @@ export function GitHubPage() { message: string, onBack: () => void, onSkip?: () => void, + skipLabel = "Skip Setup", + confirmLabel = "Go Back", ) => { dialog.replace(() => ( (onSkip ? onSkip() : dialog.clear())} @@ -492,8 +515,14 @@ export function GitHubPage() { const showConfirmStep = (draft: RepoCreationDraft) => { const description = draft.description ? `\nDescription: ${draft.description}` : ""; const source = draft.template ? `from ${draft.template.fullName}` : "as a blank repo"; + const generalTasks = draft.bootstrap?.generalTaskList + ? draft.bootstrap.generalTaskList.id.toString() + : "skipped"; + const codeReview = draft.bootstrap?.reviewTask + ? `${draft.bootstrap.reviewTask.name} (#${draft.bootstrap.reviewTask.id})` + : "skipped"; const bootstrap = draft.bootstrap - ? `\nTeamwork project: ${draft.bootstrap.teamworkProjectId}\nGeneral Tasks: ${draft.bootstrap.generalTaskList.id}\nCode Review: ${draft.bootstrap.reviewTask.name} (#${draft.bootstrap.reviewTask.id})\nClone parent: ${draft.bootstrap.cloneParentDir}` + ? `\nTeamwork project: ${draft.bootstrap.teamworkProjectId}\nGeneral Tasks: ${generalTasks}\nCode Review: ${codeReview}\nClone parent: ${draft.bootstrap.cloneParentDir}` : "\nTeamwork setup: skipped"; dialog.replace(() => ( detail !== null); + + return new Error(`${action} failed: ${details.join("\n")}`); +} + +async function runGitStep(action: string, command: () => Promise): Promise { + try { + return await command(); + } catch (error) { + throw formatGitError(action, error); + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForRemoteHead(remoteUrl: string): Promise { + let lastError: string | null = null; + for (let attempt = 1; attempt <= REMOTE_HEAD_WAIT_ATTEMPTS; attempt += 1) { + try { + const output = await Bun.$`git ls-remote --symref ${remoteUrl} HEAD`.quiet().text(); + if (output.includes("refs/heads/")) return; + lastError = "remote HEAD was not available yet"; + } catch (error) { + lastError = formatGitError("Git remote readiness check", error).message; + } + + if (attempt < REMOTE_HEAD_WAIT_ATTEMPTS) await sleep(REMOTE_HEAD_WAIT_DELAY_MS); + } + + throw new Error( + `GitHub repository branch was not ready after ${REMOTE_HEAD_WAIT_ATTEMPTS}s.${lastError ? ` Last check: ${lastError}` : ""}`, + ); +} function matchGitHubRemote(url: string, pattern: RegExp): GitHubRemote | null { const match = url.match(pattern); @@ -65,7 +126,8 @@ export async function cloneRepo(input: { const parentDir = resolve(input.parentDir); const cloneDir = await assertCloneTargetAvailable(parentDir, input.repoName); await mkdir(parentDir, { recursive: true }); - await Bun.$`git clone ${input.remoteUrl} ${cloneDir}`.quiet(); + await waitForRemoteHead(input.remoteUrl); + await runGitStep("Git clone", () => Bun.$`git clone ${input.remoteUrl} ${cloneDir}`.quiet()); return cloneDir; } @@ -94,10 +156,10 @@ export async function commitFile( message: string, projectDir = process.cwd(), ): Promise { - await Bun.$`git add ${relativePath}`.cwd(projectDir).quiet(); - await Bun.$`git commit -m ${message}`.cwd(projectDir).quiet(); + await runGitStep("Git add", () => Bun.$`git add ${relativePath}`.cwd(projectDir).quiet()); + await runGitStep("Git commit", () => Bun.$`git commit -m ${message}`.cwd(projectDir).quiet()); } export async function pushCurrentBranch(projectDir = process.cwd()): Promise { - await Bun.$`git push`.cwd(projectDir).quiet(); + await runGitStep("Git push", () => Bun.$`git push`.cwd(projectDir).quiet()); } From 66f20c0a0548cff565d7ac6537bfa8ed65c0fe4e Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Fri, 10 Jul 2026 16:58:34 -0700 Subject: [PATCH 4/6] fix: CLI flag for boolean --- plans/PLAN.md | 6 +- ...4-local-clone-and-wtc-project-bootstrap.md | 2 +- plans/github/README.md | 4 +- src/cli/commands/repo.ts | 59 +++++++++++++------ src/tui/pages/github.tsx | 47 ++++++++++++--- 5 files changed, 87 insertions(+), 31 deletions(-) diff --git a/plans/PLAN.md b/plans/PLAN.md index 83cdfc4..638dbd2 100644 --- a/plans/PLAN.md +++ b/plans/PLAN.md @@ -2,7 +2,7 @@ A terminal UI tool for developers to manage GitHub repos, AWS Amplify projects, and Teamwork tasks. -- **Status:** Phase 6.4 Local Clone + WTC Project Bootstrap planned +- **Status:** Phase 6.4 Local Clone + WTC Project Bootstrap completed - **Package Manager:** Bun - **Runtime:** Bun (standalone binary distribution) - **TUI:** @opentui/solid + solid-js @@ -167,9 +167,9 @@ CLI `wtc repo create` command with `--template`, `--visibility`, `--description` Branch protection rulesets, repository settings, vulnerability alerts, and senior-reviewers bypass for newly created repos. Rules preset selection (`standard`/`none`) available in both TUI and CLI. Best-effort post-create setup with warnings for token permission issues. -#### Phase 6.4 — [Local Clone + WTC Project Bootstrap](github/6.4-local-clone-and-wtc-project-bootstrap.md) +#### Phase 6.4 — [Local Clone + WTC Project Bootstrap](github/6.4-local-clone-and-wtc-project-bootstrap.md) ✅ -Clone the newly created repo, write a `.wtc.yaml` into the cloned repo with project links and Teamwork project ID, and optionally commit and push the config so all devs get a ready-to-use WTC project. +Clone the newly created repo, write a `.wtc.yaml` into the cloned repo with Teamwork project ID and optional Teamwork references, then commit and push the config so all devs get a ready-to-use WTC project. #### Phase 6.5 — [Teamwork Project Linking + Final Polish](github/6.5-teamwork-project-linking-and-final-polish.md) diff --git a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md index 8343e1c..c2f41ea 100644 --- a/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md +++ b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md @@ -1,4 +1,4 @@ -# 6.4 Local Clone + WTC Project Bootstrap +# 6.4 Local Clone + WTC Project Bootstrap ✅ Complete ## Goal diff --git a/plans/github/README.md b/plans/github/README.md index 0304d12..6d185a0 100644 --- a/plans/github/README.md +++ b/plans/github/README.md @@ -7,7 +7,7 @@ GitHub repo creation is split into focused subphase documents so `plans/PLAN.md` - [6.1 TUI Repo Creation From Template](6.1-tui-repo-creation-from-template.md) ✅: create a GitHub repo from an approved org template through the TUI with rules preset selection. - [6.2 CLI Repo Creation](6.2-cli-repo-creation.md) ✅: CLI `wtc repo create` command with `--template`, `--visibility`, `--description`, `--rules-preset`, and `--json`. - [6.3 Repo Rules](6.3-repo-rules.md) ✅: branch protection rulesets, repository settings, vulnerability alerts, and senior-reviewers bypass. -- [6.4 Local Clone + WTC Project Bootstrap](6.4-local-clone-and-wtc-project-bootstrap.md): clone the repo and write `.wtc.yaml` into it with project links and Teamwork project ID. +- [6.4 Local Clone + WTC Project Bootstrap](6.4-local-clone-and-wtc-project-bootstrap.md) ✅: clone the repo and write `.wtc.yaml` into it with Teamwork project ID and optional Teamwork references. - [6.5 Teamwork Project Linking + Final Polish](6.5-teamwork-project-linking-and-final-polish.md): link the created repo into WTC project config from the old working directory and add remaining workflow conveniences. ## Implementation Order @@ -15,7 +15,7 @@ GitHub repo creation is split into focused subphase documents so `plans/PLAN.md` 1. ✅ TUI repo creation from templates (6.1). 2. ✅ CLI repo creation parity (6.2). 3. ✅ Repo rules, repository settings, vulnerability alerts, and rulesets (6.3). -4. Clone the newly created repo, write `.wtc.yaml` with Teamwork context, and push the config commit (6.4). +4. ✅ Clone the newly created repo, write `.wtc.yaml` with Teamwork context, and push the config commit (6.4). 5. Link the created repo into the old working directory's `.wtc.yaml` and add remaining workflow polish (6.5). ## Shared Rules diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index a569d4b..1a9bc05 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { GITHUB_REPO_OWNER } from "../../api/github/consts.ts"; import { createGitHubRepoWorkflow } from "../../api/github/repo-creation-workflow.ts"; -import { logError, logInfo, logWarn } from "../../api/logs/manager.ts"; +import { logError, logInfo } from "../../api/logs/manager.ts"; import { createGitHubRepoWithSetup, getGitHubTemplateRepo } from "../../api/github/repos.ts"; import { loadProjectConfig } from "../../api/config/manager.ts"; import { @@ -24,6 +24,10 @@ function isExplicitSkip(value: string | undefined): boolean { return value?.trim() === "-1"; } +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export type RepoVisibility = "private" | "public"; export async function repoCreate(args: { @@ -84,9 +88,21 @@ export async function repoCreate(args: { const skipGeneralTasks = isExplicitSkip(args.generalTasks); const skipReviewTask = isExplicitSkip(args.reviewTask); const hasGeneralTasksOverride = args.generalTasks !== undefined && !skipGeneralTasks; - const discovered = hasGeneralTasksOverride - ? { generalTaskList: null, codeReviewTask: null } - : await getTeamworkProjectBootstrapDefaults(teamworkProjectId); + const preflightWarnings: string[] = []; + let discovered: Awaited> = { + generalTaskList: null, + codeReviewTask: null, + }; + + if (!hasGeneralTasksOverride && !skipGeneralTasks) { + try { + discovered = await getTeamworkProjectBootstrapDefaults(teamworkProjectId); + } catch (error) { + preflightWarnings.push( + `Teamwork optional defaults discovery skipped: ${getErrorMessage(error)}`, + ); + } + } const generalTaskList = skipGeneralTasks ? null : hasGeneralTasksOverride @@ -101,17 +117,25 @@ export async function repoCreate(args: { } : null; - const reviewTask = skipReviewTask - ? null - : args.reviewTask - ? await getTeamworkTaskSummaryById(getTeamworkTaskReference(args.reviewTask).id) - : generalTaskList - ? (discovered.codeReviewTask ?? - (await getTeamworkCodeReviewTaskInTaskList({ + let reviewTask: { id: number; name: string } | null = null; + if (!skipReviewTask) { + if (args.reviewTask) { + reviewTask = await getTeamworkTaskSummaryById(getTeamworkTaskReference(args.reviewTask).id); + } else if (generalTaskList) { + if (discovered.codeReviewTask) { + reviewTask = discovered.codeReviewTask; + } else { + try { + reviewTask = await getTeamworkCodeReviewTaskInTaskList({ projectId: teamworkProjectId, taskListId: generalTaskList.id, - }))) - : null; + }); + } catch (error) { + preflightWarnings.push(`Code Review task discovery skipped: ${getErrorMessage(error)}`); + } + } + } + } const cloneParentDir = args.cloneDir?.trim() || startDir; await assertCloneTargetAvailable(cloneParentDir, name); @@ -131,6 +155,7 @@ export async function repoCreate(args: { generalTaskList, }, }); + const allWarnings = [...preflightWarnings, ...warnings]; logInfo("cli.repo", "repo.create.success", "Repo created", { fullName: repo.fullName }); @@ -143,7 +168,7 @@ export async function repoCreate(args: { cloneUrl: repo.cloneUrl, sshUrl: repo.sshUrl, bootstrap, - warnings, + warnings: allWarnings, }), ); return; @@ -159,9 +184,8 @@ export async function repoCreate(args: { logInfo("cli.repo", "repo.bootstrap.reviewTask.skipped", "Skipped Code Review task config"); console.log("Skipped Code Review task config."); } - if (warnings.length) { - for (const warning of warnings) { - logWarn("cli.repo", "repo.setup.warning", warning); + if (allWarnings.length) { + for (const warning of allWarnings) { console.warn(` Warning: ${warning}`); } } else { @@ -200,7 +224,6 @@ export async function repoCreate(args: { if (warnings.length) { for (const warning of warnings) { - logWarn("cli.repo", "repo.setup.warning", warning); console.warn(` Warning: ${warning}`); } } else { diff --git a/src/tui/pages/github.tsx b/src/tui/pages/github.tsx index b3793ca..9b59dc1 100644 --- a/src/tui/pages/github.tsx +++ b/src/tui/pages/github.tsx @@ -119,7 +119,9 @@ export function GitHubPage() { const triggerCreateRepo = () => { if (dialog.active() || isCreating()) return; - void startCreateRepo(); + void startCreateRepo().catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : "Failed to start repo creation."); + }); }; const showTemplateStep = (templates: GitHubTemplateRepo[]) => { @@ -299,7 +301,20 @@ export function GitHubPage() { ); return; } - void resolveTeamworkDefaults(draft, projectId); + void resolveTeamworkDefaults(draft, projectId).catch((error: unknown) => { + showTeamworkSetupError( + "Teamwork Discovery Failed", + error instanceof Error ? error.message : "Failed to discover Teamwork defaults.", + () => showTeamworkProjectInput(draft, value), + () => + showCloneDirInput(draft, { + teamworkProjectId: projectId, + generalTaskList: null, + reviewTask: null, + }), + "Skip Optional Fields", + ); + }); }} onCancel={() => showTeamworkSetupStep(draft)} /> @@ -373,6 +388,24 @@ export function GitHubPage() { void resolveCodeReviewTask(draft, teamworkProjectId, { id: ref.id, name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }).catch((error: unknown) => { + showTeamworkSetupError( + "Code Review Search Failed", + error instanceof Error + ? error.message + : "Failed to search for the Code Review task.", + () => showGeneralTasksInput(draft, teamworkProjectId, value), + () => + showCloneDirInput(draft, { + teamworkProjectId, + generalTaskList: { + id: ref.id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }, + reviewTask: null, + }), + "Skip Code Review", + ); }); } catch (error) { showTeamworkSetupError( @@ -532,7 +565,7 @@ export function GitHubPage() { cancelLabel="Back" autoClose={false} onConfirm={() => createRepo(draft)} - onCancel={() => showTeamworkSetupStep(draft)} + onCancel={() => showRulesStep(draft)} /> )); }; @@ -617,9 +650,7 @@ export function GitHubPage() { fullName: repo.fullName, }); - if (warnings.length) { - logWarn("tui.github", "github.repo.setup.warnings", "Setup warnings", { warnings }); - } else { + if (!warnings.length) { logInfo("tui.github", "github.repo.setup.success", "Setup completed"); } @@ -669,7 +700,9 @@ export function GitHubPage() { })); onMount(() => { - void loadGitHubContext(); + void loadGitHubContext().catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : "Failed to load GitHub context."); + }); setHints([ { key: "↵", label: "create" }, { key: "^N", label: "new repo" }, From ea950874c520c1df05ebdeeb5f63da9bbd0b33cf Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Fri, 10 Jul 2026 17:02:24 -0700 Subject: [PATCH 5/6] chore: changeset --- .changeset/hip-bags-laugh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hip-bags-laugh.md diff --git a/.changeset/hip-bags-laugh.md b/.changeset/hip-bags-laugh.md new file mode 100644 index 0000000..630fed9 --- /dev/null +++ b/.changeset/hip-bags-laugh.md @@ -0,0 +1,5 @@ +--- +"wtc": patch +--- + +feature: adds cli cloning and teamwork setup From 184c51c5b3a4b32e86cad7e62c06cc163dcf1d63 Mon Sep 17 00:00:00 2001 From: Marlon Marcello Date: Fri, 10 Jul 2026 17:19:41 -0700 Subject: [PATCH 6/6] chore: better logs --- src/api/github/repos.ts | 8 ++++++- src/api/teamwork/task-lists.ts | 8 +++---- src/cli/commands/repo.ts | 44 +++++++++++++++++++--------------- src/utils/git.ts | 4 ++-- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/api/github/repos.ts b/src/api/github/repos.ts index 1f743cb..ec89f3e 100644 --- a/src/api/github/repos.ts +++ b/src/api/github/repos.ts @@ -367,7 +367,13 @@ async function applyGitHubRepoRulesetSetup( if (teamWarnings.length) return teamWarnings; const resolvedBypassTeamId = bypassTeamId; if (resolvedBypassTeamId === null) { - return ["Repository rules bypass team lookup: GitHub did not return a team ID."]; + const warning = "Repository rules bypass team lookup: GitHub did not return a team ID."; + logError("github", "repos.setup.warning", "Repository rules bypass team lookup", { + owner, + repo, + warning, + }); + return [warning]; } return collectGitHubSetupWarnings(owner, repo, "Repository ruleset creation", async () => { diff --git a/src/api/teamwork/task-lists.ts b/src/api/teamwork/task-lists.ts index d460780..53797c2 100644 --- a/src/api/teamwork/task-lists.ts +++ b/src/api/teamwork/task-lists.ts @@ -85,13 +85,13 @@ export async function getTeamworkProjectTaskListByName( ); for (const taskList of parsed.tasklists ?? parsed.taskLists ?? []) { - const name = taskList.name ?? taskList.title; - if (!name) throw new Error("Teamwork task-list response did not include a name."); - if (name !== searchTerm) continue; + const taskListName = taskList.name ?? taskList.title; + if (!taskListName) throw new Error("Teamwork task-list response did not include a name."); + if (taskListName !== searchTerm) continue; return { id: taskList.id, - name, + name: taskListName, projectId: taskList.projectId ?? taskList.project?.id ?? null, }; } diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 1a9bc05..f28eaa6 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { GITHUB_REPO_OWNER } from "../../api/github/consts.ts"; import { createGitHubRepoWorkflow } from "../../api/github/repo-creation-workflow.ts"; -import { logError, logInfo } from "../../api/logs/manager.ts"; +import { logError, logInfo, logWarn } from "../../api/logs/manager.ts"; import { createGitHubRepoWithSetup, getGitHubTemplateRepo } from "../../api/github/repos.ts"; import { loadProjectConfig } from "../../api/config/manager.ts"; import { @@ -85,9 +85,11 @@ export async function repoCreate(args: { ); } const teamworkProjectId = positiveIntegerSchema.parse(rawProjectId); - const skipGeneralTasks = isExplicitSkip(args.generalTasks); - const skipReviewTask = isExplicitSkip(args.reviewTask); - const hasGeneralTasksOverride = args.generalTasks !== undefined && !skipGeneralTasks; + const generalTasksValue = args.generalTasks?.trim() || undefined; + const reviewTaskValue = args.reviewTask?.trim() || undefined; + const skipGeneralTasks = isExplicitSkip(generalTasksValue); + const skipReviewTask = isExplicitSkip(reviewTaskValue); + const hasGeneralTasksOverride = generalTasksValue !== undefined && !skipGeneralTasks; const preflightWarnings: string[] = []; let discovered: Awaited> = { generalTaskList: null, @@ -103,24 +105,25 @@ export async function repoCreate(args: { ); } } - const generalTaskList = skipGeneralTasks - ? null - : hasGeneralTasksOverride - ? { - id: getTeamworkTaskListReference(args.generalTasks ?? "").id, - name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, - } - : discovered.generalTaskList - ? { - id: discovered.generalTaskList.id, - name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, - } - : null; + let generalTaskList: { id: number; name: string } | null = null; + if (!skipGeneralTasks) { + if (hasGeneralTasksOverride) { + generalTaskList = { + id: getTeamworkTaskListReference(generalTasksValue).id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }; + } else if (discovered.generalTaskList) { + generalTaskList = { + id: discovered.generalTaskList.id, + name: TEAMWORK_GENERAL_TASK_LIST_DISPLAY_NAME, + }; + } + } let reviewTask: { id: number; name: string } | null = null; if (!skipReviewTask) { - if (args.reviewTask) { - reviewTask = await getTeamworkTaskSummaryById(getTeamworkTaskReference(args.reviewTask).id); + if (reviewTaskValue) { + reviewTask = await getTeamworkTaskSummaryById(getTeamworkTaskReference(reviewTaskValue).id); } else if (generalTaskList) { if (discovered.codeReviewTask) { reviewTask = discovered.codeReviewTask; @@ -158,6 +161,9 @@ export async function repoCreate(args: { const allWarnings = [...preflightWarnings, ...warnings]; logInfo("cli.repo", "repo.create.success", "Repo created", { fullName: repo.fullName }); + for (const warning of preflightWarnings) { + logWarn("cli.repo", "repo.preflight.warning", warning); + } if (args.json) { console.log( diff --git a/src/utils/git.ts b/src/utils/git.ts index 2dbb5f7..ed46b1c 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -70,7 +70,7 @@ async function waitForRemoteHead(remoteUrl: string): Promise { } throw new Error( - `GitHub repository branch was not ready after ${REMOTE_HEAD_WAIT_ATTEMPTS}s.${lastError ? ` Last check: ${lastError}` : ""}`, + `GitHub repository branch was not ready after ${REMOTE_HEAD_WAIT_ATTEMPTS} attempts (${REMOTE_HEAD_WAIT_DELAY_MS}ms delay).${lastError ? ` Last check: ${lastError}` : ""}`, ); } @@ -156,7 +156,7 @@ export async function commitFile( message: string, projectDir = process.cwd(), ): Promise { - await runGitStep("Git add", () => Bun.$`git add ${relativePath}`.cwd(projectDir).quiet()); + await runGitStep("Git add", () => Bun.$`git add -- ${relativePath}`.cwd(projectDir).quiet()); await runGitStep("Git commit", () => Bun.$`git commit -m ${message}`.cwd(projectDir).quiet()); }