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 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/PLAN.md b/plans/PLAN.md index a32afa6..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 GitHub Repo Creation (6.1 planned) +- **Status:** Phase 6.4 Local Clone + WTC Project Bootstrap completed - **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 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 — [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..c2f41ea --- /dev/null +++ b/plans/github/6.4-local-clone-and-wtc-project-bootstrap.md @@ -0,0 +1,119 @@ +# 6.4 Local Clone + WTC Project Bootstrap ✅ Complete + +## Goal + +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. + +If the user skips Teamwork setup, repo creation keeps the existing behavior: create repo, apply repo setup/rules, and show the success dialog. + +## Decisions + +- 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. + +## Internal Teamwork Template Names + +- General task list: `Internal | Billable Projects | General Tasks` +- Preferred code review task: `Dev | Code Review - Forever` +- Fallback code review task: `Dev | Code Review` + +The code review task is expected to be a child task in the General task list. + +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. + +## Bootstrap Config + +Generated `.wtc.yaml` in the cloned repo: + +```yaml +version: 1 + +project: + links: [] + +teamwork: + projectId: 12345 + reviewTaskId: 67890 + pinnedTaskLists: + - name: "General Tasks" + id: 1699936 +``` + +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 + +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 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 and allow skipping only that field. + +## TUI Flow + +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; 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. +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`: + +- `--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` 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 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 + +- 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. + +## Verification + +- `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/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..6d185a0 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 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 -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. 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/github/repos.ts b/src/api/github/repos.ts index ad76178..ec89f3e 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, @@ -366,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 new file mode 100644 index 0000000..53797c2 --- /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 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: taskListName, + 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/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/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..f28eaa6 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -1,10 +1,32 @@ 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 { getTeamworkTaskSummaryById } 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"; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} export type RepoVisibility = "private" | "public"; @@ -15,6 +37,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 +52,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 +76,130 @@ 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 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, + codeReviewTask: null, + }; + + if (!hasGeneralTasksOverride && !skipGeneralTasks) { + try { + discovered = await getTeamworkProjectBootstrapDefaults(teamworkProjectId); + } catch (error) { + preflightWarnings.push( + `Teamwork optional defaults discovery skipped: ${getErrorMessage(error)}`, + ); + } + } + 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 (reviewTaskValue) { + reviewTask = await getTeamworkTaskSummaryById(getTeamworkTaskReference(reviewTaskValue).id); + } else if (generalTaskList) { + if (discovered.codeReviewTask) { + reviewTask = discovered.codeReviewTask; + } else { + try { + reviewTask = await getTeamworkCodeReviewTaskInTaskList({ + projectId: teamworkProjectId, + taskListId: generalTaskList.id, + }); + } catch (error) { + preflightWarnings.push(`Code Review task discovery skipped: ${getErrorMessage(error)}`); + } + } + } + } + + 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, + }, + }); + 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( + JSON.stringify({ + name: repo.name, + fullName: repo.fullName, + url: repo.htmlUrl, + cloneUrl: repo.cloneUrl, + sshUrl: repo.sshUrl, + bootstrap, + warnings: allWarnings, + }), + ); + 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."); + } + if (!reviewTask) { + logInfo("cli.repo", "repo.bootstrap.reviewTask.skipped", "Skipped Code Review task config"); + console.log("Skipped Code Review task config."); + } + if (allWarnings.length) { + for (const warning of allWarnings) { + console.warn(` Warning: ${warning}`); + } + } else { + logInfo("cli.repo", "repo.setup.success", "Setup completed"); + } + return; + } + const { repo, warnings } = await createGitHubRepoWithSetup({ owner: GITHUB_REPO_OWNER, name, @@ -77,7 +230,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 fa04105..9b59dc1 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 { getTeamworkTaskSummaryById } 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; + } | null; + reviewTask: { + id: number; + name: string; + } | null; } /** GitHub route for creating repositories from approved organization templates. */ @@ -92,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[]) => { @@ -139,6 +168,7 @@ export function GitHubPage() { description: "", private: true, rulesPreset: "standard", + bootstrap: null, }) } onCancel={() => { @@ -198,14 +228,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,13 +248,319 @@ 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).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)} + /> + )); + }; + + 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.", + () => showGeneralTasksInput(draft, teamworkProjectId, ""), + () => + showCloneDirInput(draft, { + teamworkProjectId, + generalTaskList: null, + reviewTask: null, + }), + "Skip Optional Fields", + "Enter Manually", + ); + } + }; + + const showGeneralTasksInput = ( + draft: RepoCreationDraft, + teamworkProjectId: number, + initialValue: string, + ) => { + dialog.replace(() => ( + { + if (!value) { + showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null }); + return; + } + try { + const ref = getTeamworkTaskListReference(value); + 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( + "Invalid Task List", + error instanceof Error ? error.message : "Invalid Teamwork task-list ID or URL.", + () => showGeneralTasksInput(draft, teamworkProjectId, value), + () => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null }), + "Skip General Tasks", + ); + } + }} + onCancel={() => showReviewTaskInput(draft, { teamworkProjectId, generalTaskList: null })} + /> + )); + }; + + const resolveCodeReviewTask = async ( + draft: RepoCreationDraft, + teamworkProjectId: number, + generalTaskList: NonNullable, + ) => { + 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 }), + () => showCloneDirInput(draft, { teamworkProjectId, generalTaskList, reviewTask: null }), + "Skip Code Review", + ); + } + }; + + const showReviewTaskInput = ( + draft: RepoCreationDraft, + base: Pick, + ) => { + dialog.replace(() => ( + { + if (!value) { + showCloneDirInput(draft, { ...base, reviewTask: null }); + return; + } + try { + const ref = getTeamworkTaskReference(value); + 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), + () => showCloneDirInput(draft, { ...base, reviewTask: null }), + "Skip Code Review", + ); + }); + } catch (error) { + showTeamworkSetupError( + "Invalid Code Review Task", + error instanceof Error ? error.message : "Invalid Teamwork task ID or URL.", + () => showReviewTaskInput(draft, base), + () => showCloneDirInput(draft, { ...base, reviewTask: null }), + "Skip Code Review", + ); + } + }} + onCancel={() => showCloneDirInput(draft, { ...base, reviewTask: null })} + /> + )); + }; + + 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, + skipLabel = "Skip Setup", + confirmLabel = "Go Back", + ) => { + 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 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: ${generalTasks}\nCode Review: ${codeReview}\nClone parent: ${draft.bootstrap.cloneParentDir}` + : "\nTeamwork setup: skipped"; dialog.replace(() => ( { + 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,32 +609,59 @@ 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 ?? null, + 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, }); - 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"); } 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), @@ -332,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" }, diff --git a/src/utils/git.ts b/src/utils/git.ts index 2263396..ed46b1c 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; @@ -9,6 +12,67 @@ export interface GitHubRemote { const SSH_PATTERN = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)(?:\.git)?\/?$/; const HTTPS_PATTERN = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/; +const REMOTE_HEAD_WAIT_ATTEMPTS = 30; +const REMOTE_HEAD_WAIT_DELAY_MS = 1_000; + +function getErrorProperty(error: unknown, key: string): unknown { + if (typeof error !== "object" || error === null || !(key in error)) return undefined; + return Reflect.get(error, key); +} + +function formatOutput(value: unknown): string | null { + if (typeof value === "string") return value.trim() || null; + if (value instanceof Uint8Array) { + const text = new TextDecoder().decode(value).trim(); + return text || null; + } + return null; +} + +function formatGitError(action: string, error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + const exitCode = getErrorProperty(error, "exitCode"); + const stderr = formatOutput(getErrorProperty(error, "stderr")); + const stdout = formatOutput(getErrorProperty(error, "stdout")); + const details = [ + typeof exitCode === "number" ? `exit code ${exitCode}` : message, + stderr ? `stderr: ${stderr}` : null, + stdout ? `stdout: ${stdout}` : null, + ].filter((detail): detail is string => 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} attempts (${REMOTE_HEAD_WAIT_DELAY_MS}ms delay).${lastError ? ` Last check: ${lastError}` : ""}`, + ); +} function matchGitHubRemote(url: string, pattern: RegExp): GitHubRemote | null { const match = url.match(pattern); @@ -53,3 +117,49 @@ 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 waitForRemoteHead(input.remoteUrl); + await runGitStep("Git clone", () => 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 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 runGitStep("Git push", () => Bun.$`git push`.cwd(projectDir).quiet()); +}