review.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { Command } from "commander";
  2. import { StateManager } from "@actalk/inkos-core";
  3. import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
  4. export const reviewCommand = new Command("review")
  5. .description("Review and approve chapters");
  6. reviewCommand
  7. .command("list")
  8. .description("List chapters pending review")
  9. .argument("[book-id]", "Book ID (optional, lists all books if omitted)")
  10. .option("--json", "Output JSON")
  11. .action(async (bookId: string | undefined, opts) => {
  12. try {
  13. const root = findProjectRoot();
  14. const state = new StateManager(root);
  15. const bookIds = bookId ? [bookId] : await state.listBooks();
  16. const allPending: Array<{
  17. readonly bookId: string;
  18. readonly title: string;
  19. readonly chapter: number;
  20. readonly chapterTitle: string;
  21. readonly wordCount: number;
  22. readonly status: string;
  23. readonly issues: ReadonlyArray<string>;
  24. }> = [];
  25. for (const id of bookIds) {
  26. const index = await state.loadChapterIndex(id);
  27. const pending = index.filter(
  28. (ch) =>
  29. ch.status === "ready-for-review" || ch.status === "audit-failed",
  30. );
  31. if (pending.length === 0) continue;
  32. const book = await state.loadBookConfig(id);
  33. if (!opts.json) {
  34. log(`\n${book.title} (${id}):`);
  35. }
  36. for (const ch of pending) {
  37. allPending.push({
  38. bookId: id,
  39. title: book.title,
  40. chapter: ch.number,
  41. chapterTitle: ch.title,
  42. wordCount: ch.wordCount,
  43. status: ch.status,
  44. issues: ch.auditIssues,
  45. });
  46. if (!opts.json) {
  47. log(
  48. ` Ch.${ch.number} "${ch.title}" | ${ch.wordCount}字 | ${ch.status}`,
  49. );
  50. if (ch.auditIssues.length > 0) {
  51. for (const issue of ch.auditIssues) {
  52. log(` - ${issue}`);
  53. }
  54. }
  55. }
  56. }
  57. }
  58. if (opts.json) {
  59. log(JSON.stringify({ pending: allPending }, null, 2));
  60. } else if (allPending.length === 0) {
  61. log("No chapters pending review.");
  62. }
  63. } catch (e) {
  64. if (opts.json) {
  65. log(JSON.stringify({ error: String(e) }));
  66. } else {
  67. logError(`Failed to list reviews: ${e}`);
  68. }
  69. process.exit(1);
  70. }
  71. });
  72. /**
  73. * Parse "[book-id] <chapter>" style arguments from variadic args.
  74. * Supports: "3" (auto-detect book) or "my-book 3"
  75. */
  76. function parseBookAndChapter(
  77. args: ReadonlyArray<string>,
  78. ): { readonly bookIdArg: string | undefined; readonly chapterNum: number } {
  79. if (args.length === 1) {
  80. const num = parseInt(args[0]!, 10);
  81. if (isNaN(num)) {
  82. throw new Error(`Expected chapter number, got "${args[0]}"`);
  83. }
  84. return { bookIdArg: undefined, chapterNum: num };
  85. }
  86. if (args.length === 2) {
  87. const num = parseInt(args[1]!, 10);
  88. if (isNaN(num)) {
  89. throw new Error(`Expected chapter number as second argument, got "${args[1]}"`);
  90. }
  91. return { bookIdArg: args[0], chapterNum: num };
  92. }
  93. throw new Error("Usage: inkos review approve [book-id] <chapter>");
  94. }
  95. reviewCommand
  96. .command("approve")
  97. .description("Approve a chapter: approve [book-id] <chapter>")
  98. .argument("<args...>", "Book ID (optional) and chapter number")
  99. .option("--json", "Output JSON")
  100. .action(async (args: ReadonlyArray<string>, opts) => {
  101. try {
  102. const root = findProjectRoot();
  103. const { bookIdArg, chapterNum } = parseBookAndChapter(args);
  104. const bookId = await resolveBookId(bookIdArg, root);
  105. const state = new StateManager(root);
  106. const index = [...(await state.loadChapterIndex(bookId))];
  107. const idx = index.findIndex((ch) => ch.number === chapterNum);
  108. if (idx === -1) {
  109. throw new Error(`Chapter ${chapterNum} not found in "${bookId}"`);
  110. }
  111. index[idx] = {
  112. ...index[idx]!,
  113. status: "approved",
  114. updatedAt: new Date().toISOString(),
  115. };
  116. await state.saveChapterIndex(bookId, index);
  117. if (opts.json) {
  118. log(JSON.stringify({ bookId, chapter: chapterNum, status: "approved" }));
  119. } else {
  120. log(`Chapter ${chapterNum} approved.`);
  121. }
  122. } catch (e) {
  123. if (opts.json) {
  124. log(JSON.stringify({ error: String(e) }));
  125. } else {
  126. logError(`Failed to approve: ${e}`);
  127. }
  128. process.exit(1);
  129. }
  130. });
  131. reviewCommand
  132. .command("approve-all")
  133. .description("Approve all pending chapters for a book")
  134. .argument("[book-id]", "Book ID (auto-detected if only one book)")
  135. .option("--json", "Output JSON")
  136. .action(async (bookIdArg: string | undefined, opts) => {
  137. try {
  138. const root = findProjectRoot();
  139. const bookId = await resolveBookId(bookIdArg, root);
  140. const state = new StateManager(root);
  141. const index = [...(await state.loadChapterIndex(bookId))];
  142. let count = 0;
  143. const now = new Date().toISOString();
  144. const updated = index.map((ch) => {
  145. if (ch.status === "ready-for-review" || ch.status === "audit-failed") {
  146. count++;
  147. return { ...ch, status: "approved" as const, updatedAt: now };
  148. }
  149. return ch;
  150. });
  151. await state.saveChapterIndex(bookId, updated);
  152. if (opts.json) {
  153. log(JSON.stringify({ bookId, approvedCount: count }));
  154. } else {
  155. log(`${count} chapter(s) approved.`);
  156. }
  157. } catch (e) {
  158. if (opts.json) {
  159. log(JSON.stringify({ error: String(e) }));
  160. } else {
  161. logError(`Failed to approve: ${e}`);
  162. }
  163. process.exit(1);
  164. }
  165. });
  166. reviewCommand
  167. .command("reject")
  168. .description("Reject a chapter: reject [book-id] <chapter>")
  169. .argument("<args...>", "Book ID (optional) and chapter number")
  170. .option("--reason <reason>", "Rejection reason")
  171. .option("--json", "Output JSON")
  172. .action(async (args: ReadonlyArray<string>, opts) => {
  173. try {
  174. const root = findProjectRoot();
  175. const { bookIdArg, chapterNum } = parseBookAndChapter(args);
  176. const bookId = await resolveBookId(bookIdArg, root);
  177. const state = new StateManager(root);
  178. const index = [...(await state.loadChapterIndex(bookId))];
  179. const idx = index.findIndex((ch) => ch.number === chapterNum);
  180. if (idx === -1) {
  181. throw new Error(`Chapter ${chapterNum} not found in "${bookId}"`);
  182. }
  183. index[idx] = {
  184. ...index[idx]!,
  185. status: "rejected",
  186. reviewNote: opts.reason ?? "Rejected without reason",
  187. updatedAt: new Date().toISOString(),
  188. };
  189. await state.saveChapterIndex(bookId, index);
  190. if (opts.json) {
  191. log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected" }));
  192. } else {
  193. log(`Chapter ${chapterNum} rejected.`);
  194. }
  195. } catch (e) {
  196. if (opts.json) {
  197. log(JSON.stringify({ error: String(e) }));
  198. } else {
  199. logError(`Failed to reject: ${e}`);
  200. }
  201. process.exit(1);
  202. }
  203. });