@goodtek/vibeops 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +8 -4
- package/dist/cli.js +15 -5
- package/dist/commands/pull.js +79 -0
- package/dist/commands/status.js +5 -3
- package/dist/commands/task-merge.js +17 -16
- package/dist/commands/task-reship.js +3 -5
- package/dist/commands/task-sync.js +7 -8
- package/dist/lib/pr-create.js +93 -0
- package/dist/lib/task-context.js +2 -2
- package/dist/lib/task-effective-status.js +86 -23
- package/dist/lib/task-push-pr.js +20 -24
- package/dist/lib/task-reship.js +123 -46
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.2.0 - 2026-06-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`vibeops pull`** — one command: `git fetch`, switch to integration branch (e.g. `develop`), fast-forward pull.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`vibeops task reship`**: no longer blocks on a dirty working tree — uncommitted edits (including on `develop`) are carried onto the task branch. When the task branch is missing locally and on the remote (e.g. after `task sync`), it is **created automatically** from the integration branch. Removed `--allow-dirty` (behavior is now default).
|
|
16
|
+
|
|
17
|
+
## 2.1.3 - 2026-06-08
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **MR/PR resolution:** `task merge`, `status`, and `task sync` resolve the open PR by **`(taskBranch, baseBranch)`** via `gh` / `glab` (host is source of truth).
|
|
22
|
+
- **`task ship` / `task reship`:** no longer write `Merge Request` or `Pushed At` into TASK md after PR creation — **single push, clean working tree**, CI once per PR.
|
|
23
|
+
- **`task reship`:** archives the current open PR URL into `Previous Merge Requests` (from host lookup) in the reship metadata commit.
|
|
24
|
+
- **Legacy TASK files** that still have `Merge Request:` in Git Context remain supported as a fallback.
|
|
25
|
+
|
|
7
26
|
## 2.1.2 - 2026-06-08
|
|
8
27
|
|
|
9
28
|
### Changed
|
package/README.md
CHANGED
|
@@ -14,10 +14,11 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
|
|
|
14
14
|
| `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
|
|
15
15
|
| `vibeops task add` | New `TASK-NNN` file + task branch |
|
|
16
16
|
| `vibeops task ship` | LLM summary, commit, push, open MR/PR (Status → **Shipped**) |
|
|
17
|
-
| `vibeops task reship` | Shipped
|
|
17
|
+
| `vibeops task reship` | Shipped follow-up: move uncommitted work to task branch, commit, new MR/PR (Status stays Shipped) |
|
|
18
18
|
| `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
|
|
19
19
|
| `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
|
|
20
20
|
| `vibeops task release` | Release PR: integration → production (GitFlow) |
|
|
21
|
+
| `vibeops pull` | Fetch remote + update integration branch (e.g. develop) |
|
|
21
22
|
| `vibeops status` | Briefing: TASK, Git, LLM, clients |
|
|
22
23
|
| `vibeops llm` | Connect LLM providers (`connect` · `status` · `use`) |
|
|
23
24
|
|
|
@@ -53,6 +54,7 @@ vibeops task add
|
|
|
53
54
|
|
|
54
55
|
vibeops task ship
|
|
55
56
|
vibeops task merge
|
|
57
|
+
vibeops pull
|
|
56
58
|
# Follow-up on same TASK (after merge):
|
|
57
59
|
vibeops task reship TASK-NNN
|
|
58
60
|
vibeops task merge
|
|
@@ -96,20 +98,22 @@ pnpm install && pnpm build && pnpm smoke
|
|
|
96
98
|
- **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
|
|
97
99
|
- **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
|
|
98
100
|
- **`task ship`**: `--dry-run`, `--no-pr`
|
|
99
|
-
- **`task reship`**: `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr
|
|
101
|
+
- **`task reship`**: `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr`
|
|
100
102
|
- **`task merge`**: `--dry-run`, `--merge`, `--rebase`
|
|
101
103
|
- **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
|
|
102
104
|
- **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
|
|
105
|
+
- **`pull`**: `--dry-run`
|
|
103
106
|
- **`status`**: `--json`
|
|
104
107
|
|
|
105
108
|
## Git
|
|
106
109
|
|
|
107
110
|
- **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
|
|
108
111
|
- **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
|
|
109
|
-
- **`task ship`**: commit implementation + ship metadata (Status **Shipped**) → push once → MR/PR.
|
|
110
|
-
- **`task reship`**: Shipped TASK
|
|
112
|
+
- **`task ship`**: commit implementation + ship metadata (Status **Shipped**) → push once → MR/PR (URL on host; `task merge` resolves by branch).
|
|
113
|
+
- **`task reship`**: Shipped TASK follow-up — carries uncommitted work onto the task branch (creates it from integration when missing after sync), integrates `develop`, commit → push → **new** PR.
|
|
111
114
|
- **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
|
|
112
115
|
- **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
|
|
116
|
+
- **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
|
|
113
117
|
- **`task release`**: integration → production PR + merge (skipped on trunk policy).
|
|
114
118
|
- No force-push to shared branches.
|
|
115
119
|
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { pullCommand } from "./commands/pull.js";
|
|
3
4
|
import { initCommand } from "./commands/init.js";
|
|
4
5
|
import { llmConnectCommand, llmStatusCommand, llmUseCommand } from "./commands/llm.js";
|
|
5
6
|
import { statusCommand } from "./commands/status.js";
|
|
@@ -14,7 +15,7 @@ import { VERSION } from "./version.js";
|
|
|
14
15
|
const program = new Command();
|
|
15
16
|
program
|
|
16
17
|
.name("vibeops")
|
|
17
|
-
.description("VibeOps — TASK workflow (init · task
|
|
18
|
+
.description("VibeOps — TASK workflow (init · task · pull · status · llm)")
|
|
18
19
|
.version(VERSION, "-v, --version", "Print the VibeOps version");
|
|
19
20
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
20
21
|
const opts = actionCommand.opts();
|
|
@@ -88,14 +89,13 @@ task
|
|
|
88
89
|
});
|
|
89
90
|
task
|
|
90
91
|
.command("reship [taskRef]")
|
|
91
|
-
.description("Follow-up on Shipped TASK:
|
|
92
|
+
.description("Follow-up on Shipped TASK: move uncommitted work to task branch, commit, new MR/PR")
|
|
92
93
|
.option("--dry-run", "Plan only")
|
|
93
94
|
.option("--no-pr", "Push only; skip creating MR/PR")
|
|
94
95
|
.option("--no-integrate", "Skip merging integration branch into task branch")
|
|
95
|
-
.option("--recreate-branch", "
|
|
96
|
+
.option("--recreate-branch", "Create task branch from integration instead of reusing local/remote ref")
|
|
96
97
|
.option("--skip-llm", "Do not run LLM for Result / Test Result")
|
|
97
98
|
.option("--allow-open-mr", "Allow reship while the recorded MR is still open")
|
|
98
|
-
.option("--allow-dirty", "Proceed when only governance paths are dirty")
|
|
99
99
|
.option("--cwd <path>", "Target directory")
|
|
100
100
|
.action(async (taskRef, opts) => {
|
|
101
101
|
await taskReshipCommand(taskRef, {
|
|
@@ -105,7 +105,6 @@ task
|
|
|
105
105
|
recreateBranch: Boolean(opts.recreateBranch),
|
|
106
106
|
skipLlm: Boolean(opts.skipLlm),
|
|
107
107
|
allowOpenMr: Boolean(opts.allowOpenMr),
|
|
108
|
-
allowDirty: Boolean(opts.allowDirty),
|
|
109
108
|
cwd: opts.cwd,
|
|
110
109
|
});
|
|
111
110
|
});
|
|
@@ -156,6 +155,17 @@ task
|
|
|
156
155
|
cwd: opts.cwd,
|
|
157
156
|
});
|
|
158
157
|
});
|
|
158
|
+
program
|
|
159
|
+
.command("pull")
|
|
160
|
+
.description("Fetch remote and update integration branch (e.g. develop)")
|
|
161
|
+
.option("--dry-run", "Plan only")
|
|
162
|
+
.option("--cwd <path>", "Target directory")
|
|
163
|
+
.action(async (opts) => {
|
|
164
|
+
await pullCommand({
|
|
165
|
+
dryRun: Boolean(opts.dryRun),
|
|
166
|
+
cwd: opts.cwd,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
159
169
|
program
|
|
160
170
|
.command("status")
|
|
161
171
|
.description("Briefing: active TASK, Git, LLM, next hint")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
|
|
4
|
+
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* One-step remote sync: fetch, switch to integration branch, fast-forward pull.
|
|
7
|
+
*/
|
|
8
|
+
export async function pullCommand(options = {}) {
|
|
9
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
10
|
+
const dryRun = options.dryRun === true;
|
|
11
|
+
let gitCfg;
|
|
12
|
+
try {
|
|
13
|
+
gitCfg = await requireGitConfig(cwd);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (e instanceof GitConfigError) {
|
|
17
|
+
log.error(e.message);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
const { remote, integrationBranch } = gitCfg;
|
|
24
|
+
log.info(bold("vibeops pull"));
|
|
25
|
+
log.info(` ${dim("remote")} ${remote}`);
|
|
26
|
+
log.info(` ${dim("integration")} ${cyan(integrationBranch)}`);
|
|
27
|
+
log.blank();
|
|
28
|
+
const git = await readGitInfo(cwd);
|
|
29
|
+
if (!git.isRepo) {
|
|
30
|
+
log.error("Not a git repository.");
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const needsSwitch = git.branch !== integrationBranch;
|
|
35
|
+
if (dryRun) {
|
|
36
|
+
log.info(bold("dry-run — would:"));
|
|
37
|
+
log.info(` · git fetch ${remote} --prune`);
|
|
38
|
+
if (needsSwitch) {
|
|
39
|
+
log.info(` · git switch ${integrationBranch}`);
|
|
40
|
+
}
|
|
41
|
+
log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if ((await gitRemoteUrl(cwd, remote)) === null) {
|
|
45
|
+
log.error(`No remote "${remote}". Add a remote or re-run vibeops init.`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
await gitFetchRemote(cwd, remote);
|
|
51
|
+
log.ok(`Fetched ${remote}`);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
55
|
+
log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
|
|
56
|
+
}
|
|
57
|
+
if (needsSwitch) {
|
|
58
|
+
const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
59
|
+
if (!switched) {
|
|
60
|
+
log.error(`Branch "${integrationBranch}" not found locally or on ${remote}. Push integration first or check the remote URL.`);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
log.ok(`On ${integrationBranch}`);
|
|
65
|
+
}
|
|
66
|
+
if (!(await gitRemoteBranchExists(cwd, remote, integrationBranch))) {
|
|
67
|
+
log.warn(`No ${remote}/${integrationBranch} on the remote yet — fetch only.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
72
|
+
log.ok(`Up to date with ${remote}/${integrationBranch}`);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Commit or stash local changes, then rerun \`vibeops pull\`.`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -8,7 +8,7 @@ import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
|
8
8
|
import { projectPaths } from "../lib/paths.js";
|
|
9
9
|
import { VERSION } from "../version.js";
|
|
10
10
|
import { computeNextHint, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
|
|
11
|
-
import { isMergeRequestMerged, taskNeedsSync } from "../lib/task-effective-status.js";
|
|
11
|
+
import { hasOpenTaskMergeRequest, isMergeRequestMerged, resolveTaskMergeRequestUrl, taskNeedsSync, } from "../lib/task-effective-status.js";
|
|
12
12
|
import { countTasks, hasNonEmptySection, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay, } from "../lib/task.js";
|
|
13
13
|
import { summarizeGoal } from "../lib/task-summary.js";
|
|
14
14
|
async function buildReport(cwd) {
|
|
@@ -42,7 +42,7 @@ async function buildReport(cwd) {
|
|
|
42
42
|
testFilled = hasNonEmptySection(body, "Test Result");
|
|
43
43
|
const ctx = await readGitContext(focus.filePath);
|
|
44
44
|
taskBranch = ctx?.taskBranch ?? null;
|
|
45
|
-
mergeRequestUrl =
|
|
45
|
+
mergeRequestUrl = await resolveTaskMergeRequestUrl(cwd, focus.filePath);
|
|
46
46
|
onTaskBranch = isOnTaskBranch(git.branch, ctx);
|
|
47
47
|
branchExists = await taskBranchExistsFor(cwd, focus);
|
|
48
48
|
mergeRequestMerged = await isMergeRequestMerged(cwd, focus);
|
|
@@ -67,7 +67,9 @@ async function buildReport(cwd) {
|
|
|
67
67
|
resultFilled,
|
|
68
68
|
testFilled,
|
|
69
69
|
onTaskBranch,
|
|
70
|
-
hasMergeRequest:
|
|
70
|
+
hasMergeRequest: focus !== null
|
|
71
|
+
? await hasOpenTaskMergeRequest(cwd, focus.filePath)
|
|
72
|
+
: false,
|
|
71
73
|
mergeRequestMerged,
|
|
72
74
|
needsSync,
|
|
73
75
|
hasLocalChanges: git.dirty === true,
|
|
@@ -8,6 +8,7 @@ import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
|
8
8
|
import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
9
9
|
import { relPath } from "../lib/task-context.js";
|
|
10
10
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
11
|
+
import { resolveOpenTaskMergeRequest } from "../lib/task-effective-status.js";
|
|
11
12
|
import { readGitContext } from "../lib/task.js";
|
|
12
13
|
function resolveMergeMethod(opts) {
|
|
13
14
|
if (opts.rebase === true)
|
|
@@ -49,26 +50,26 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
49
50
|
process.exitCode = 1;
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
log.error("No Merge Request URL on TASK — run task ship first.");
|
|
53
|
+
const resolvedMr = dryRun
|
|
54
|
+
? null
|
|
55
|
+
: await resolveOpenTaskMergeRequest(cwd, target.taskFile);
|
|
56
|
+
if (!dryRun && resolvedMr === null) {
|
|
57
|
+
log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship or task reship first.`);
|
|
60
58
|
process.exitCode = 1;
|
|
61
59
|
return;
|
|
62
60
|
}
|
|
61
|
+
const mergeRequestUrl = resolvedMr?.url ??
|
|
62
|
+
`(dry-run: would resolve open MR for ${ctx.taskBranch} → ${ctx.baseBranch})`;
|
|
63
63
|
const remoteUrl = await gitRemoteUrl(cwd, gitCfg.remote);
|
|
64
|
-
const host =
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const host = resolvedMr?.host ??
|
|
65
|
+
(remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
66
|
+
? detectGitHost(remoteUrl)
|
|
67
|
+
: gitCfg.host);
|
|
67
68
|
const method = resolveMergeMethod(options);
|
|
68
69
|
const label = mergeRequestLabel(host);
|
|
69
70
|
log.info(bold(`vibeops task merge ${target.taskId}`));
|
|
70
71
|
log.info(` ${dim("file")} ${relPath(cwd, target.taskFile)}`);
|
|
71
|
-
log.info(` ${dim("MR/PR")} ${
|
|
72
|
+
log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
|
|
72
73
|
log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
|
|
73
74
|
log.info(` ${dim("method")} ${method}`);
|
|
74
75
|
log.blank();
|
|
@@ -76,7 +77,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
76
77
|
await mergeMergeRequest({
|
|
77
78
|
cwd,
|
|
78
79
|
host,
|
|
79
|
-
url:
|
|
80
|
+
url: mergeRequestUrl,
|
|
80
81
|
method,
|
|
81
82
|
dryRun: true,
|
|
82
83
|
});
|
|
@@ -84,7 +85,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
84
85
|
log.info(`Next: ${cyan("vibeops task sync")}`);
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
|
-
const state = await getMergeRequestState(cwd, host,
|
|
88
|
+
const state = await getMergeRequestState(cwd, host, mergeRequestUrl);
|
|
88
89
|
if (state === "merged") {
|
|
89
90
|
log.ok(`${label} already merged.`);
|
|
90
91
|
log.blank();
|
|
@@ -100,7 +101,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
103
|
try {
|
|
103
|
-
await mergeMergeRequest({ cwd, host, url:
|
|
104
|
+
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
|
|
104
105
|
log.ok(`${label} merged into ${ctx.baseBranch}.`);
|
|
105
106
|
}
|
|
106
107
|
catch (e) {
|
|
@@ -119,7 +120,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
119
120
|
else {
|
|
120
121
|
log.warn(`Could not read ${label} state — attempting merge anyway.`);
|
|
121
122
|
try {
|
|
122
|
-
await mergeMergeRequest({ cwd, host, url:
|
|
123
|
+
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
|
|
123
124
|
log.ok(`${label} merge command completed.`);
|
|
124
125
|
}
|
|
125
126
|
catch (e) {
|
|
@@ -7,7 +7,7 @@ import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } f
|
|
|
7
7
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
8
8
|
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
9
9
|
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "../lib/task-reship.js";
|
|
10
|
-
import { hasNonEmptySection, parseTaskFilename, readGitContext,
|
|
10
|
+
import { hasNonEmptySection, parseTaskFilename, readGitContext, } from "../lib/task.js";
|
|
11
11
|
import { listWorkingTreeRelPaths } from "../lib/git.js";
|
|
12
12
|
async function commitReshipMetadata(opts) {
|
|
13
13
|
if (opts.dryRun) {
|
|
@@ -56,7 +56,6 @@ export async function taskReshipCommand(taskRef, options = {}) {
|
|
|
56
56
|
remote,
|
|
57
57
|
recreateBranch: options.recreateBranch === true,
|
|
58
58
|
dryRun,
|
|
59
|
-
allowDirty: options.allowDirty === true,
|
|
60
59
|
});
|
|
61
60
|
if (!branchOk) {
|
|
62
61
|
process.exitCode = 1;
|
|
@@ -126,7 +125,7 @@ export async function taskReshipCommand(taskRef, options = {}) {
|
|
|
126
125
|
}
|
|
127
126
|
const dirtyBefore = await listWorkingTreeRelPaths(cwd);
|
|
128
127
|
if (dirtyBefore.length === 0) {
|
|
129
|
-
log.error("No changes to reship. Edit files on
|
|
128
|
+
log.error("No changes to reship. Edit files (e.g. on develop), then rerun.");
|
|
130
129
|
process.exitCode = 1;
|
|
131
130
|
return;
|
|
132
131
|
}
|
|
@@ -139,8 +138,7 @@ export async function taskReshipCommand(taskRef, options = {}) {
|
|
|
139
138
|
return;
|
|
140
139
|
}
|
|
141
140
|
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
|
|
142
|
-
gitCtx = await prepareGitContextForReship(taskFile, gitCtx);
|
|
143
|
-
await upsertGitContext(taskFile, gitCtx);
|
|
141
|
+
gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
|
|
144
142
|
await commitReshipMetadata({
|
|
145
143
|
cwd,
|
|
146
144
|
taskFile,
|
|
@@ -7,9 +7,9 @@ import { probeMergeRequestCli } from "../lib/pr-create.js";
|
|
|
7
7
|
import { projectPaths } from "../lib/paths.js";
|
|
8
8
|
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
9
9
|
import { relPath } from "../lib/task-context.js";
|
|
10
|
-
import {
|
|
10
|
+
import { getTaskMergeRequestLifecycle, isMergeRequestMerged, } from "../lib/task-effective-status.js";
|
|
11
11
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
12
|
-
import {
|
|
12
|
+
import { readTaskFile } from "../lib/task.js";
|
|
13
13
|
function isProtectedBranch(name, integration, production) {
|
|
14
14
|
return name === integration || name === production || name === "main" || name === "master";
|
|
15
15
|
}
|
|
@@ -42,7 +42,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
const { integrationBranch, productionBranch, remote } = gitCfg;
|
|
44
44
|
const { taskId, taskBranch, taskFile } = target;
|
|
45
|
-
const ctx = await readGitContext(taskFile);
|
|
46
45
|
if (isProtectedBranch(taskBranch, integrationBranch, productionBranch)) {
|
|
47
46
|
log.error(`Refusing to delete protected branch: ${taskBranch}`);
|
|
48
47
|
process.exitCode = 1;
|
|
@@ -55,18 +54,18 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
55
54
|
log.info(` ${dim("note")} ${dim("Git cleanup only — TASK md stays Shipped")}`);
|
|
56
55
|
log.blank();
|
|
57
56
|
const meta = await readTaskFile(taskFile);
|
|
58
|
-
if (
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
57
|
+
if (!dryRun) {
|
|
58
|
+
const lifecycle = await getTaskMergeRequestLifecycle(cwd, taskFile);
|
|
59
|
+
if (lifecycle !== "none") {
|
|
61
60
|
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
62
61
|
const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
63
62
|
? detectGitHost(remoteUrl)
|
|
64
63
|
: gitCfg.host;
|
|
65
64
|
const label = mergeRequestLabel(host);
|
|
66
|
-
if (
|
|
65
|
+
if (lifecycle === "open") {
|
|
67
66
|
log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
|
|
68
67
|
}
|
|
69
|
-
else if (
|
|
68
|
+
else if (lifecycle === "merged") {
|
|
70
69
|
log.ok(`${label} merged.`);
|
|
71
70
|
}
|
|
72
71
|
}
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -64,6 +64,99 @@ export async function probeMergeRequestCli(host) {
|
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
function normalizeGhMergeRequestState(raw) {
|
|
68
|
+
const state = raw.trim().toUpperCase();
|
|
69
|
+
if (state === "MERGED")
|
|
70
|
+
return "merged";
|
|
71
|
+
if (state === "OPEN")
|
|
72
|
+
return "open";
|
|
73
|
+
if (state === "CLOSED")
|
|
74
|
+
return "closed";
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
function normalizeGlabMergeRequestState(raw) {
|
|
78
|
+
const state = raw.trim().toLowerCase();
|
|
79
|
+
if (state === "merged")
|
|
80
|
+
return "merged";
|
|
81
|
+
if (state === "opened" || state === "open")
|
|
82
|
+
return "open";
|
|
83
|
+
if (state === "closed")
|
|
84
|
+
return "closed";
|
|
85
|
+
return "unknown";
|
|
86
|
+
}
|
|
87
|
+
async function ghFindMergeRequestByBranches(opts) {
|
|
88
|
+
const state = opts.state ?? "open";
|
|
89
|
+
const { stdout } = await execFileAsync("gh", [
|
|
90
|
+
"pr",
|
|
91
|
+
"list",
|
|
92
|
+
"--head",
|
|
93
|
+
opts.headBranch,
|
|
94
|
+
"--base",
|
|
95
|
+
opts.baseBranch,
|
|
96
|
+
"--state",
|
|
97
|
+
state,
|
|
98
|
+
"--limit",
|
|
99
|
+
"1",
|
|
100
|
+
"--json",
|
|
101
|
+
"url,state",
|
|
102
|
+
], { cwd: opts.cwd, maxBuffer: 1024 * 1024 });
|
|
103
|
+
const trimmed = stdout.trim();
|
|
104
|
+
if (trimmed.length === 0 || trimmed === "[]")
|
|
105
|
+
return null;
|
|
106
|
+
const rows = JSON.parse(trimmed);
|
|
107
|
+
const row = rows[0];
|
|
108
|
+
if (row?.url === undefined || row.url.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
return {
|
|
111
|
+
url: row.url,
|
|
112
|
+
state: normalizeGhMergeRequestState(row.state ?? ""),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function glabFindMergeRequestByBranches(opts) {
|
|
116
|
+
const state = opts.state ?? "open";
|
|
117
|
+
const glabState = state === "open" ? "opened" : state === "merged" ? "merged" : state === "closed" ? "closed" : "all";
|
|
118
|
+
const { stdout } = await execFileAsync("glab", [
|
|
119
|
+
"mr",
|
|
120
|
+
"list",
|
|
121
|
+
"--source-branch",
|
|
122
|
+
opts.headBranch,
|
|
123
|
+
"--target-branch",
|
|
124
|
+
opts.baseBranch,
|
|
125
|
+
"-S",
|
|
126
|
+
glabState,
|
|
127
|
+
"-P",
|
|
128
|
+
"1",
|
|
129
|
+
"--output",
|
|
130
|
+
"json",
|
|
131
|
+
"web_url,state",
|
|
132
|
+
], { cwd: opts.cwd, maxBuffer: 1024 * 1024 });
|
|
133
|
+
const trimmed = stdout.trim();
|
|
134
|
+
if (trimmed.length === 0 || trimmed === "[]")
|
|
135
|
+
return null;
|
|
136
|
+
const rows = JSON.parse(trimmed);
|
|
137
|
+
const row = rows[0];
|
|
138
|
+
const url = row?.web_url?.trim();
|
|
139
|
+
if (url === undefined || url.length === 0)
|
|
140
|
+
return null;
|
|
141
|
+
return {
|
|
142
|
+
url,
|
|
143
|
+
state: normalizeGlabMergeRequestState(row.state ?? ""),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Resolve MR/PR by `(headBranch, baseBranch)` — source of truth for ship/reship (no TASK URL). */
|
|
147
|
+
export async function findMergeRequestByBranches(opts) {
|
|
148
|
+
try {
|
|
149
|
+
if (!(await probeMergeRequestCli(opts.host)))
|
|
150
|
+
return null;
|
|
151
|
+
if (opts.host === "gitlab") {
|
|
152
|
+
return glabFindMergeRequestByBranches(opts);
|
|
153
|
+
}
|
|
154
|
+
return ghFindMergeRequestByBranches(opts);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
67
160
|
function prRefFromUrl(url) {
|
|
68
161
|
const trimmed = url.trim();
|
|
69
162
|
const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
|
package/dist/lib/task-context.js
CHANGED
|
@@ -48,7 +48,7 @@ export function computeNextHint(input) {
|
|
|
48
48
|
return "task-sync";
|
|
49
49
|
if (input.hasMergeRequest && !input.mergeRequestMerged)
|
|
50
50
|
return "task-merge";
|
|
51
|
-
if (input.hasLocalChanges
|
|
51
|
+
if (input.hasLocalChanges)
|
|
52
52
|
return "task-reship";
|
|
53
53
|
return "task-add";
|
|
54
54
|
}
|
|
@@ -67,7 +67,7 @@ export function hintToText(hint, cwd, task) {
|
|
|
67
67
|
return task ? `Run \`vibeops task ship ${task.id}\`.` : "Run `vibeops task ship`.";
|
|
68
68
|
case "task-reship":
|
|
69
69
|
return task
|
|
70
|
-
? `Follow-up on ${task.id}? Edit
|
|
70
|
+
? `Follow-up on ${task.id}? Edit files (develop is fine), then \`vibeops task reship ${task.id}\`.`
|
|
71
71
|
: "Run `vibeops task reship TASK-NNN` for a Shipped follow-up.";
|
|
72
72
|
case "task-merge":
|
|
73
73
|
return task
|
|
@@ -1,37 +1,103 @@
|
|
|
1
1
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
2
2
|
import { detectGitHost } from "./git-host.js";
|
|
3
3
|
import { gitBranchExists, gitRemoteBranchExists, gitRemoteUrl, } from "./git.js";
|
|
4
|
-
import { getMergeRequestState, probeMergeRequestCli } from "./pr-create.js";
|
|
4
|
+
import { findMergeRequestByBranches, getMergeRequestState, probeMergeRequestCli, } from "./pr-create.js";
|
|
5
5
|
import { readGitContext } from "./task.js";
|
|
6
|
-
|
|
7
|
-
export async function getMergeRequestStateForTask(cwd, taskFile) {
|
|
8
|
-
const ctx = await readGitContext(taskFile);
|
|
9
|
-
const url = ctx?.mergeRequestUrl?.trim();
|
|
10
|
-
if (url === undefined || url.length === 0)
|
|
11
|
-
return null;
|
|
12
|
-
let host;
|
|
6
|
+
export async function resolveGitHostForCwd(cwd) {
|
|
13
7
|
try {
|
|
14
8
|
const gitCfg = await requireGitConfig(cwd);
|
|
15
9
|
const remoteUrl = await gitRemoteUrl(cwd, gitCfg.remote);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
if (remoteUrl !== null && detectGitHost(remoteUrl) !== null) {
|
|
11
|
+
return detectGitHost(remoteUrl);
|
|
12
|
+
}
|
|
13
|
+
return gitCfg.host;
|
|
20
14
|
}
|
|
21
15
|
catch (e) {
|
|
22
16
|
if (e instanceof GitConfigError)
|
|
23
17
|
return null;
|
|
24
18
|
throw e;
|
|
25
19
|
}
|
|
26
|
-
|
|
20
|
+
}
|
|
21
|
+
async function readTaskGitContext(taskFile) {
|
|
22
|
+
return readGitContext(taskFile);
|
|
23
|
+
}
|
|
24
|
+
async function resolveFromBranches(cwd, ctx, state) {
|
|
25
|
+
try {
|
|
26
|
+
const host = await resolveGitHostForCwd(cwd);
|
|
27
|
+
if (host === null)
|
|
28
|
+
return null;
|
|
29
|
+
const found = await findMergeRequestByBranches({
|
|
30
|
+
cwd,
|
|
31
|
+
host,
|
|
32
|
+
headBranch: ctx.taskBranch,
|
|
33
|
+
baseBranch: ctx.baseBranch,
|
|
34
|
+
state,
|
|
35
|
+
});
|
|
36
|
+
if (found === null)
|
|
37
|
+
return null;
|
|
38
|
+
return { url: found.url, host, state: found.state, source: "branch" };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function resolveFromTaskFileUrl(cwd, ctx) {
|
|
45
|
+
const url = ctx.mergeRequestUrl?.trim();
|
|
46
|
+
if (url === undefined || url.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const host = await resolveGitHostForCwd(cwd);
|
|
49
|
+
if (host === null || !(await probeMergeRequestCli(host)))
|
|
27
50
|
return null;
|
|
28
|
-
|
|
51
|
+
const state = await getMergeRequestState(cwd, host, url);
|
|
52
|
+
return { url, host, state, source: "task-file" };
|
|
53
|
+
}
|
|
54
|
+
/** Open MR/PR for a TASK — branch lookup first, legacy TASK URL fallback. */
|
|
55
|
+
export async function resolveOpenTaskMergeRequest(cwd, taskFile) {
|
|
56
|
+
const ctx = await readTaskGitContext(taskFile);
|
|
57
|
+
if (ctx === null)
|
|
58
|
+
return null;
|
|
59
|
+
const fromBranch = await resolveFromBranches(cwd, ctx, "open");
|
|
60
|
+
if (fromBranch !== null)
|
|
61
|
+
return fromBranch;
|
|
62
|
+
const legacy = await resolveFromTaskFileUrl(cwd, ctx);
|
|
63
|
+
if (legacy !== null && legacy.state === "open")
|
|
64
|
+
return legacy;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
|
|
68
|
+
export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
|
|
69
|
+
const ctx = await readTaskGitContext(taskFile);
|
|
70
|
+
if (ctx === null)
|
|
71
|
+
return "none";
|
|
72
|
+
const open = await resolveFromBranches(cwd, ctx, "open");
|
|
73
|
+
if (open !== null)
|
|
74
|
+
return "open";
|
|
75
|
+
const merged = await resolveFromBranches(cwd, ctx, "merged");
|
|
76
|
+
if (merged !== null)
|
|
77
|
+
return "merged";
|
|
78
|
+
const legacy = await resolveFromTaskFileUrl(cwd, ctx);
|
|
79
|
+
if (legacy !== null)
|
|
80
|
+
return legacy.state;
|
|
81
|
+
return "none";
|
|
82
|
+
}
|
|
83
|
+
/** @deprecated Use {@link getTaskMergeRequestLifecycle} or {@link resolveOpenTaskMergeRequest}. */
|
|
84
|
+
export async function getMergeRequestStateForTask(cwd, taskFile) {
|
|
85
|
+
const lifecycle = await getTaskMergeRequestLifecycle(cwd, taskFile);
|
|
86
|
+
if (lifecycle === "none")
|
|
87
|
+
return null;
|
|
88
|
+
return lifecycle;
|
|
89
|
+
}
|
|
90
|
+
export async function hasOpenTaskMergeRequest(cwd, taskFile) {
|
|
91
|
+
return (await getTaskMergeRequestLifecycle(cwd, taskFile)) === "open";
|
|
92
|
+
}
|
|
93
|
+
export async function resolveTaskMergeRequestUrl(cwd, taskFile) {
|
|
94
|
+
const open = await resolveOpenTaskMergeRequest(cwd, taskFile);
|
|
95
|
+
return open?.url ?? null;
|
|
29
96
|
}
|
|
30
97
|
export async function isMergeRequestMerged(cwd, task) {
|
|
31
98
|
if (task.status !== "shipped")
|
|
32
99
|
return false;
|
|
33
|
-
|
|
34
|
-
return state === "merged";
|
|
100
|
+
return (await getTaskMergeRequestLifecycle(cwd, task.filePath)) === "merged";
|
|
35
101
|
}
|
|
36
102
|
/**
|
|
37
103
|
* After ship: optional `task sync` when MR is merged (or no MR) but `task/*` still exists.
|
|
@@ -39,15 +105,12 @@ export async function isMergeRequestMerged(cwd, task) {
|
|
|
39
105
|
export async function taskNeedsSync(cwd, task) {
|
|
40
106
|
if (task.status !== "shipped")
|
|
41
107
|
return false;
|
|
42
|
-
const ctx = await
|
|
108
|
+
const ctx = await readTaskGitContext(task.filePath);
|
|
43
109
|
if (ctx === null)
|
|
44
110
|
return false;
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
if (state !== "merged")
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
111
|
+
const lifecycle = await getTaskMergeRequestLifecycle(cwd, task.filePath);
|
|
112
|
+
if (lifecycle === "open")
|
|
113
|
+
return false;
|
|
51
114
|
if (await gitBranchExists(cwd, ctx.taskBranch))
|
|
52
115
|
return true;
|
|
53
116
|
try {
|
package/dist/lib/task-push-pr.js
CHANGED
|
@@ -4,9 +4,9 @@ import { detectGitHost, mergeRequestLabel } from "./git-host.js";
|
|
|
4
4
|
import { requireGitConfig } from "./git-config.js";
|
|
5
5
|
import { gitCommitsAhead, gitDiffNameOnly, gitLogOneline, gitPush, gitRemoteUrl, readGitInfo, } from "./git.js";
|
|
6
6
|
import { dim, log } from "./logger.js";
|
|
7
|
-
import { createMergeRequest, probeMergeRequestCli } from "./pr-create.js";
|
|
7
|
+
import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
8
8
|
import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
|
|
9
|
-
import { readGitContext, readTaskFile
|
|
9
|
+
import { readGitContext, readTaskFile } from "./task.js";
|
|
10
10
|
async function buildDiffSummary(cwd, gitCtx) {
|
|
11
11
|
const files = await gitDiffNameOnly(cwd, `${gitCtx.baseCommit}..HEAD`);
|
|
12
12
|
const commits = await gitLogOneline(cwd, `${gitCtx.baseCommit}..HEAD`);
|
|
@@ -28,12 +28,7 @@ async function buildDiffSummary(cwd, gitCtx) {
|
|
|
28
28
|
}
|
|
29
29
|
return lines.join("\n") || "(no commits since task start)";
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
if (typeof url !== "string" || url.length === 0)
|
|
33
|
-
return false;
|
|
34
|
-
return url.startsWith("http://") || url.startsWith("https://");
|
|
35
|
-
}
|
|
36
|
-
/** Push task branch and open MR/PR (idempotent when URL already in Git Context). */
|
|
31
|
+
/** Push task branch and open MR/PR. Does not write MR URL into TASK md (host is source of truth). */
|
|
37
32
|
export async function finishTaskWithPullRequest(opts) {
|
|
38
33
|
let gitCfg = await requireGitConfig(opts.cwd);
|
|
39
34
|
const gitCtx = await readGitContext(opts.taskFile);
|
|
@@ -41,8 +36,9 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
41
36
|
log.warn("No Git Context on TASK — push and merge request skipped.");
|
|
42
37
|
return { ok: true };
|
|
43
38
|
}
|
|
39
|
+
const label = mergeRequestLabel(gitCfg.host);
|
|
44
40
|
if (opts.dryRun) {
|
|
45
|
-
log.info(dim(`would git push -u ${gitCfg.remote} ${gitCtx.taskBranch} and open ${
|
|
41
|
+
log.info(dim(`would git push -u ${gitCfg.remote} ${gitCtx.taskBranch} and open ${label} → ${gitCtx.baseBranch}`));
|
|
46
42
|
return { ok: true, mergeRequestUrl: "(dry-run)" };
|
|
47
43
|
}
|
|
48
44
|
const remoteUrl = await gitRemoteUrl(opts.cwd, gitCfg.remote);
|
|
@@ -59,10 +55,18 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
59
55
|
if (git.branch !== gitCtx.taskBranch) {
|
|
60
56
|
log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
|
|
61
57
|
}
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
if (opts.skipPr !== true && opts.forceNewMergeRequest !== true) {
|
|
59
|
+
const existing = await findMergeRequestByBranches({
|
|
60
|
+
cwd: opts.cwd,
|
|
61
|
+
host: gitCfg.host,
|
|
62
|
+
headBranch: gitCtx.taskBranch,
|
|
63
|
+
baseBranch: gitCtx.baseBranch,
|
|
64
|
+
state: "open",
|
|
65
|
+
});
|
|
66
|
+
if (existing !== null) {
|
|
67
|
+
log.info(dim(`Open ${label} already exists: ${existing.url}`));
|
|
68
|
+
return { ok: true, mergeRequestUrl: existing.url, pushed: true };
|
|
69
|
+
}
|
|
66
70
|
}
|
|
67
71
|
const ahead = await gitCommitsAhead(opts.cwd, gitCtx.baseCommit, "HEAD");
|
|
68
72
|
if (ahead === 0) {
|
|
@@ -77,17 +81,14 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
77
81
|
log.error(`git push failed: ${msg}`);
|
|
78
82
|
return { ok: false };
|
|
79
83
|
}
|
|
80
|
-
const pushedAt = new Date().toISOString();
|
|
81
84
|
if (opts.skipPr === true) {
|
|
82
|
-
await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
|
|
83
85
|
log.info(dim("Merge request creation skipped (--no-pr)."));
|
|
84
86
|
return { ok: true, pushed: true };
|
|
85
87
|
}
|
|
86
88
|
const cliOk = await probeMergeRequestCli(gitCfg.host);
|
|
87
89
|
if (!cliOk) {
|
|
88
90
|
const tool = gitCfg.host === "gitlab" ? "glab" : "gh";
|
|
89
|
-
|
|
90
|
-
log.warn(`${tool} CLI not found — create the ${mergeRequestLabel(gitCfg.host)} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
|
|
91
|
+
log.warn(`${tool} CLI not found — create the ${label} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`);
|
|
91
92
|
return { ok: true, pushed: true };
|
|
92
93
|
}
|
|
93
94
|
const config = await readConfig(opts.cwd);
|
|
@@ -114,18 +115,13 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
114
115
|
title: prTitle,
|
|
115
116
|
body: prBody,
|
|
116
117
|
});
|
|
117
|
-
log.ok(`${
|
|
118
|
-
|
|
119
|
-
...gitCtx,
|
|
120
|
-
mergeRequestUrl: url,
|
|
121
|
-
pushedAt,
|
|
122
|
-
});
|
|
118
|
+
log.ok(`${label}: ${url}`);
|
|
119
|
+
log.info(dim("MR/PR URL is on the host — not written to TASK md (use task merge)."));
|
|
123
120
|
return { ok: true, mergeRequestUrl: url, pushed: true };
|
|
124
121
|
}
|
|
125
122
|
catch (e) {
|
|
126
123
|
const msg = e instanceof Error ? e.message : String(e);
|
|
127
124
|
log.error(`Could not create merge request: ${msg}`);
|
|
128
|
-
await upsertGitContext(opts.taskFile, { ...gitCtx, pushedAt });
|
|
129
125
|
log.info(dim(`Branch was pushed. Create MR manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
|
|
130
126
|
return { ok: false, pushed: true };
|
|
131
127
|
}
|
package/dist/lib/task-reship.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
3
|
-
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch,
|
|
3
|
+
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitHeadCommit, gitMergeRemoteBranch, gitMergeInProgress, gitPullFastForwardOnly, gitRemoteBranchExists, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, } from "./git.js";
|
|
4
4
|
import { dim, log } from "./logger.js";
|
|
5
|
-
import {
|
|
5
|
+
import { findMergeRequestByBranches } from "./pr-create.js";
|
|
6
6
|
import { normalizeTaskRef } from "./resolve-task.js";
|
|
7
|
-
import {
|
|
7
|
+
import { getTaskMergeRequestLifecycle, resolveGitHostForCwd, } from "./task-effective-status.js";
|
|
8
|
+
import { findTaskFile, loadActionableTasks, pickLatestShippedTask, readGitContext, readTaskFile, upsertGitContext, } from "./task.js";
|
|
8
9
|
export async function resolveReshipTarget(paths, cwd, taskRef) {
|
|
9
10
|
const trimmed = taskRef?.trim();
|
|
10
11
|
let taskId;
|
|
@@ -44,52 +45,44 @@ export function reshipNotFoundMessage(taskRef) {
|
|
|
44
45
|
}
|
|
45
46
|
return "No Shipped TASK found. Pass `vibeops task reship TASK-NNN`.";
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const git = await readGitInfo(cwd);
|
|
52
|
-
if (!git.isRepo) {
|
|
53
|
-
log.error("Not a git repository.");
|
|
54
|
-
return false;
|
|
48
|
+
async function switchToTaskBranch(cwd, taskBranch) {
|
|
49
|
+
try {
|
|
50
|
+
await gitCheckout(cwd, taskBranch);
|
|
51
|
+
return true;
|
|
55
52
|
}
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
log.warn("Only governance paths are dirty — proceeding.");
|
|
53
|
+
catch (e) {
|
|
54
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
55
|
+
log.error(`Could not switch to ${taskBranch} with uncommitted changes. Commit, stash, or resolve conflicts, then rerun.`);
|
|
56
|
+
if (msg.length > 0)
|
|
57
|
+
log.info(dim(msg));
|
|
58
|
+
return false;
|
|
63
59
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else {
|
|
74
|
-
log.error(`Local branch ${taskBranch} missing. Rerun with --recreate-branch.`);
|
|
60
|
+
}
|
|
61
|
+
async function removeLocalTaskBranch(cwd, taskBranch, integrationBranch, remote) {
|
|
62
|
+
if (!(await gitBranchExists(cwd, taskBranch)))
|
|
63
|
+
return true;
|
|
64
|
+
const git = await readGitInfo(cwd);
|
|
65
|
+
if (git.branch === taskBranch) {
|
|
66
|
+
const ok = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
67
|
+
if (!ok) {
|
|
68
|
+
log.error(`Integration branch "${integrationBranch}" not found.`);
|
|
75
69
|
return false;
|
|
76
70
|
}
|
|
77
|
-
return true;
|
|
78
71
|
}
|
|
79
|
-
|
|
80
|
-
await
|
|
81
|
-
log.
|
|
82
|
-
return true;
|
|
72
|
+
try {
|
|
73
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
74
|
+
log.info(dim(`Removed local ${taskBranch}.`));
|
|
83
75
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
log.error(`Branch ${taskBranch} not found locally or on ${opts.remote}. Use --recreate-branch.`);
|
|
90
|
-
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
78
|
+
log.error(`Could not remove local ${taskBranch}: ${msg}`);
|
|
91
79
|
return false;
|
|
92
80
|
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
async function recreateTaskBranchFromIntegration(opts) {
|
|
84
|
+
const cwd = resolve(opts.cwd);
|
|
85
|
+
const { taskBranch } = opts.gitCtx;
|
|
93
86
|
const ok = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
|
|
94
87
|
if (!ok) {
|
|
95
88
|
log.error(`Integration branch "${opts.integrationBranch}" not found.`);
|
|
@@ -110,15 +103,75 @@ export async function ensureTaskBranchForReship(opts) {
|
|
|
110
103
|
log.error("Integration branch has no commits.");
|
|
111
104
|
return false;
|
|
112
105
|
}
|
|
113
|
-
|
|
106
|
+
try {
|
|
107
|
+
await gitCheckoutNewBranch(cwd, taskBranch, opts.integrationBranch);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
111
|
+
log.error(`Could not create ${taskBranch} from ${opts.integrationBranch} with uncommitted changes.`);
|
|
112
|
+
if (msg.length > 0)
|
|
113
|
+
log.info(dim(msg));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
114
116
|
await upsertGitContext(opts.taskFile, {
|
|
115
117
|
...opts.gitCtx,
|
|
116
118
|
baseBranch: opts.integrationBranch,
|
|
117
119
|
baseCommit: head,
|
|
118
120
|
});
|
|
119
|
-
log.ok(`
|
|
121
|
+
log.ok(`Created ${taskBranch} from ${opts.integrationBranch}`);
|
|
120
122
|
return true;
|
|
121
123
|
}
|
|
124
|
+
/** Checkout or recreate the task branch without changing TASK Status. */
|
|
125
|
+
export async function ensureTaskBranchForReship(opts) {
|
|
126
|
+
const cwd = resolve(opts.cwd);
|
|
127
|
+
const { taskBranch } = opts.gitCtx;
|
|
128
|
+
const git = await readGitInfo(cwd);
|
|
129
|
+
if (!git.isRepo) {
|
|
130
|
+
log.error("Not a git repository.");
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (git.dirty === true) {
|
|
134
|
+
log.info(dim("Uncommitted changes will be carried onto the task branch."));
|
|
135
|
+
}
|
|
136
|
+
const localExists = await gitBranchExists(cwd, taskBranch);
|
|
137
|
+
const remoteExists = await gitRemoteBranchExists(cwd, opts.remote, taskBranch);
|
|
138
|
+
const recreateFromIntegration = opts.recreateBranch === true || (!localExists && !remoteExists);
|
|
139
|
+
if (opts.dryRun) {
|
|
140
|
+
if (localExists && !opts.recreateBranch) {
|
|
141
|
+
log.info(dim(` would git switch ${taskBranch}`));
|
|
142
|
+
}
|
|
143
|
+
else if (!recreateFromIntegration && remoteExists) {
|
|
144
|
+
log.info(dim(` would checkout ${taskBranch} from ${opts.remote}`));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (localExists && opts.recreateBranch) {
|
|
148
|
+
log.info(dim(` would remove local ${taskBranch}`));
|
|
149
|
+
}
|
|
150
|
+
log.info(dim(` would create ${taskBranch} from ${opts.integrationBranch}`));
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (localExists && !opts.recreateBranch) {
|
|
155
|
+
if (!(await switchToTaskBranch(cwd, taskBranch)))
|
|
156
|
+
return false;
|
|
157
|
+
log.ok(`On ${taskBranch}`);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (!recreateFromIntegration && remoteExists) {
|
|
161
|
+
const checkedOut = await gitSwitchToBranch(cwd, taskBranch, opts.remote);
|
|
162
|
+
if (checkedOut) {
|
|
163
|
+
log.ok(`Checked out ${taskBranch} from ${opts.remote}`);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
log.warn(`Could not checkout ${opts.remote}/${taskBranch}; creating from integration.`);
|
|
167
|
+
}
|
|
168
|
+
if (localExists) {
|
|
169
|
+
const removed = await removeLocalTaskBranch(cwd, taskBranch, opts.integrationBranch, opts.remote);
|
|
170
|
+
if (!removed)
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return recreateTaskBranchFromIntegration(opts);
|
|
174
|
+
}
|
|
122
175
|
/** Merge latest integration into the current task branch. */
|
|
123
176
|
export async function integrateIntegrationIntoTaskBranch(opts) {
|
|
124
177
|
const cwd = resolve(opts.cwd);
|
|
@@ -160,7 +213,7 @@ export async function integrateIntegrationIntoTaskBranch(opts) {
|
|
|
160
213
|
export async function assertMergeRequestNotOpen(cwd, taskFile, allowOpenMr) {
|
|
161
214
|
if (allowOpenMr)
|
|
162
215
|
return true;
|
|
163
|
-
const state = await
|
|
216
|
+
const state = await getTaskMergeRequestLifecycle(cwd, taskFile);
|
|
164
217
|
if (state !== "open")
|
|
165
218
|
return true;
|
|
166
219
|
log.error("Current MR/PR is still open. Merge or close it first, or rerun with --allow-open-mr.");
|
|
@@ -172,8 +225,32 @@ export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd) {
|
|
|
172
225
|
return gitCtx;
|
|
173
226
|
return { ...gitCtx, baseCommit: head };
|
|
174
227
|
}
|
|
175
|
-
export async function prepareGitContextForReship(taskFile, gitCtx) {
|
|
176
|
-
const
|
|
228
|
+
export async function prepareGitContextForReship(cwd, taskFile, gitCtx) {
|
|
229
|
+
const previous = [...(gitCtx.previousMergeRequestUrls ?? [])];
|
|
230
|
+
const host = await resolveGitHostForCwd(cwd);
|
|
231
|
+
if (host !== null) {
|
|
232
|
+
const open = await findMergeRequestByBranches({
|
|
233
|
+
cwd,
|
|
234
|
+
host,
|
|
235
|
+
headBranch: gitCtx.taskBranch,
|
|
236
|
+
baseBranch: gitCtx.baseBranch,
|
|
237
|
+
state: "open",
|
|
238
|
+
});
|
|
239
|
+
if (open !== null) {
|
|
240
|
+
previous.push(open.url);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const legacy = gitCtx.mergeRequestUrl?.trim();
|
|
244
|
+
if (legacy !== undefined && legacy.length > 0 && !previous.includes(legacy)) {
|
|
245
|
+
previous.push(legacy);
|
|
246
|
+
}
|
|
247
|
+
const archived = {
|
|
248
|
+
...gitCtx,
|
|
249
|
+
previousMergeRequestUrls: previous.length > 0 ? previous : undefined,
|
|
250
|
+
mergeRequestUrl: undefined,
|
|
251
|
+
reshipCount: (gitCtx.reshipCount ?? 0) + 1,
|
|
252
|
+
lastReshipAt: new Date().toISOString(),
|
|
253
|
+
};
|
|
177
254
|
await upsertGitContext(taskFile, archived);
|
|
178
255
|
return archived;
|
|
179
256
|
}
|