wmdev 0.3.0 → 0.4.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/README.md CHANGED
@@ -62,6 +62,10 @@ wmdev uses two config files in the project root:
62
62
  ### `.wmdev.yaml` schema
63
63
 
64
64
  ```yaml
65
+ # Project name displayed in the sidebar header and browser tab title.
66
+ # Falls back to "Dashboard" if omitted.
67
+ name: string
68
+
65
69
  # Services to monitor — each maps a display name to a port env var.
66
70
  # The dashboard polls these ports and shows health status badges.
67
71
  # When portStart is set, wmdev auto-allocates ports for new worktrees
@@ -112,6 +116,8 @@ linkedRepos: []
112
116
  ### Example
113
117
 
114
118
  ```yaml
119
+ name: My Project
120
+
115
121
  services:
116
122
  - name: BE
117
123
  portEnv: BACKEND_PORT
@@ -149,6 +155,7 @@ linkedRepos:
149
155
 
150
156
  | Parameter | Type | Required | Description |
151
157
  |-----------|------|----------|-------------|
158
+ | `name` | string | no | Project name shown in the sidebar header and browser tab title. Defaults to "Dashboard" |
152
159
  | `services[].name` | string | yes | Display name shown in the dashboard |
153
160
  | `services[].portEnv` | string | yes | Env var containing the service port (read from each worktree's `.env.local`) |
154
161
  | `services[].portStart` | number | no | Base port for slot 0. When set, wmdev auto-allocates ports for new worktrees |
@@ -6941,8 +6941,13 @@ async function readEnvLocal(wtDir) {
6941
6941
  for (const line of text.split(`
6942
6942
  `)) {
6943
6943
  const match = line.match(/^(\w+)=(.*)$/);
6944
- if (match)
6945
- env[match[1]] = match[2];
6944
+ if (match) {
6945
+ let val = match[2];
6946
+ if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
6947
+ val = val.slice(1, -1);
6948
+ }
6949
+ env[match[1]] = val;
6950
+ }
6946
6951
  }
6947
6952
  return env;
6948
6953
  } catch {
@@ -6959,12 +6964,14 @@ async function writeEnvLocal(wtDir, entries) {
6959
6964
  `);
6960
6965
  } catch {}
6961
6966
  for (const [key, value] of Object.entries(entries)) {
6967
+ const needsQuoting = /[{}"\s$`\\!#|;&()<>]/.test(value);
6968
+ const safe = needsQuoting ? `'${value}'` : value;
6962
6969
  const pattern = new RegExp(`^${key}=`);
6963
6970
  const idx = lines.findIndex((l) => pattern.test(l));
6964
6971
  if (idx >= 0) {
6965
- lines[idx] = `${key}=${value}`;
6972
+ lines[idx] = `${key}=${safe}`;
6966
6973
  } else {
6967
- lines.push(`${key}=${value}`);
6974
+ lines.push(`${key}=${safe}`);
6968
6975
  }
6969
6976
  }
6970
6977
  await Bun.write(filePath, lines.join(`
@@ -7098,6 +7105,7 @@ function loadConfig(dir) {
7098
7105
  alias: typeof r.alias === "string" ? r.alias : r.repo.split("/").pop()
7099
7106
  })) : [];
7100
7107
  return {
7108
+ ...typeof parsed.name === "string" ? { name: parsed.name } : {},
7101
7109
  services: Array.isArray(parsed.services) ? parsed.services : DEFAULT_CONFIG.services,
7102
7110
  profiles: {
7103
7111
  default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
@@ -8097,6 +8105,20 @@ function mapChecks(checks) {
8097
8105
  runId: parseRunId(c.detailsUrl)
8098
8106
  }));
8099
8107
  }
8108
+ function parseReviewComments(json) {
8109
+ const raw = JSON.parse(json);
8110
+ const sorted = raw.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
8111
+ return sorted.slice(0, PR_FETCH_LIMIT).map((c) => ({
8112
+ type: "inline",
8113
+ author: c.user?.login ?? "unknown",
8114
+ body: c.body ?? "",
8115
+ createdAt: c.created_at ?? "",
8116
+ path: c.path ?? "",
8117
+ line: c.line ?? null,
8118
+ diffHunk: c.diff_hunk ?? "",
8119
+ isReply: c.in_reply_to_id !== undefined
8120
+ }));
8121
+ }
8100
8122
  function parsePrResponse(json, repoLabel) {
8101
8123
  const prs = new Map;
8102
8124
  const entries = JSON.parse(json);
@@ -8111,6 +8133,7 @@ function parsePrResponse(json, repoLabel) {
8111
8133
  ciStatus: summarizeChecks(entry.statusCheckRollup),
8112
8134
  ciChecks: mapChecks(entry.statusCheckRollup),
8113
8135
  comments: (entry.comments ?? []).map((c) => ({
8136
+ type: "comment",
8114
8137
  author: c.author?.login ?? "unknown",
8115
8138
  body: c.body ?? "",
8116
8139
  createdAt: c.createdAt ?? ""
@@ -8161,6 +8184,45 @@ async function fetchAllPrs(repoSlug, repoLabel, cwd) {
8161
8184
  return { ok: false, error: `failed to parse gh output for ${label}: ${err}` };
8162
8185
  }
8163
8186
  }
8187
+ async function mapWithConcurrency(items, limit, fn) {
8188
+ const results = new Array(items.length);
8189
+ let next = 0;
8190
+ async function worker() {
8191
+ while (next < items.length) {
8192
+ const idx = next++;
8193
+ results[idx] = await fn(items[idx]);
8194
+ }
8195
+ }
8196
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
8197
+ return results;
8198
+ }
8199
+ async function fetchReviewComments(prNumber, repoSlug, cwd) {
8200
+ const repoFlag = repoSlug ? repoSlug : "{owner}/{repo}";
8201
+ const args = [
8202
+ "gh",
8203
+ "api",
8204
+ `repos/${repoFlag}/pulls/${prNumber}/comments`,
8205
+ "--paginate"
8206
+ ];
8207
+ const proc = Bun.spawn(args, {
8208
+ stdout: "pipe",
8209
+ stderr: "pipe",
8210
+ ...cwd ? { cwd } : {}
8211
+ });
8212
+ const timeout = Bun.sleep(GH_TIMEOUT_MS).then(() => {
8213
+ proc.kill();
8214
+ return "timeout";
8215
+ });
8216
+ const raceResult = await Promise.race([proc.exited, timeout]);
8217
+ if (raceResult === "timeout" || raceResult !== 0)
8218
+ return [];
8219
+ try {
8220
+ const json = await new Response(proc.stdout).text();
8221
+ return parseReviewComments(json);
8222
+ } catch {
8223
+ return [];
8224
+ }
8225
+ }
8164
8226
  async function fetchPrState(url) {
8165
8227
  const proc = Bun.spawn(["gh", "pr", "view", url, "--json", "state"], {
8166
8228
  stdout: "pipe",
@@ -8217,6 +8279,22 @@ async function syncPrStatus(getWorktreePaths, linkedRepos, projectDir) {
8217
8279
  branchPrs.set(branch, existing);
8218
8280
  }
8219
8281
  }
8282
+ const reviewTuples = [];
8283
+ for (const entries of branchPrs.values()) {
8284
+ for (const entry of entries) {
8285
+ if (entry.state === "open") {
8286
+ const repoSlug = entry.repo ? linkedRepos.find((lr) => lr.alias === entry.repo)?.repo : undefined;
8287
+ reviewTuples.push({ entry, repoSlug });
8288
+ }
8289
+ }
8290
+ }
8291
+ if (reviewTuples.length > 0) {
8292
+ const reviewResults = await mapWithConcurrency(reviewTuples, 5, (t) => fetchReviewComments(t.entry.number, t.repoSlug, projectDir));
8293
+ for (let i = 0;i < reviewTuples.length; i++) {
8294
+ const entry = reviewTuples[i].entry;
8295
+ entry.comments = [...entry.comments, ...reviewResults[i]].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
8296
+ }
8297
+ }
8220
8298
  const wtPaths = await getWorktreePaths();
8221
8299
  const seen = new Set;
8222
8300
  for (const [branch, entries] of branchPrs) {