@aigne/afs-github 1.11.0-beta.6 → 1.11.0-beta.7

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/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { throttling } from "@octokit/plugin-throttling";
3
3
  import { Octokit } from "@octokit/rest";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { AFSNotFoundError, AFSReadonlyError } from "@aigne/afs";
6
7
  import { MappingCompiler } from "@aigne/afs-mapping";
7
8
  import { WorldMappingCore } from "@aigne/afs-world-mapping";
8
9
  import { z } from "zod";
@@ -380,7 +381,8 @@ var AFSGitHub = class {
380
381
  path: "/{owner}/{repo}/issues/{$.number}",
381
382
  summary: "$.title",
382
383
  content: "$.body",
383
- metadata: {
384
+ meta: {
385
+ kind: "github:issue",
384
386
  type: "issue",
385
387
  state: "$.state",
386
388
  author: "$.user.login"
@@ -401,7 +403,7 @@ var AFSGitHub = class {
401
403
  path: "/{owner}/{repo}/issues/{$.number}",
402
404
  summary: "$.title",
403
405
  content: "$.body",
404
- metadata: {
406
+ meta: {
405
407
  type: "issue",
406
408
  state: "$.state",
407
409
  author: "$.user.login"
@@ -424,7 +426,8 @@ var AFSGitHub = class {
424
426
  path: "/{owner}/{repo}/pulls/{$.number}",
425
427
  summary: "$.title",
426
428
  content: "$.body",
427
- metadata: {
429
+ meta: {
430
+ kind: "github:pull-request",
428
431
  type: "pull_request",
429
432
  state: "$.state",
430
433
  author: "$.user.login"
@@ -445,7 +448,7 @@ var AFSGitHub = class {
445
448
  path: "/{owner}/{repo}/pulls/{$.number}",
446
449
  summary: "$.title",
447
450
  content: "$.body",
448
- metadata: {
451
+ meta: {
449
452
  type: "pull_request",
450
453
  state: "$.state",
451
454
  author: "$.user.login"
@@ -567,7 +570,8 @@ var AFSGitHub = class {
567
570
  id: "issues",
568
571
  path: "/issues",
569
572
  summary: "Repository Issues",
570
- metadata: {
573
+ meta: {
574
+ kind: "github:directory",
571
575
  childrenCount: -1,
572
576
  description: "List and read repository issues"
573
577
  }
@@ -576,7 +580,8 @@ var AFSGitHub = class {
576
580
  id: "pulls",
577
581
  path: "/pulls",
578
582
  summary: "Pull Requests",
579
- metadata: {
583
+ meta: {
584
+ kind: "github:directory",
580
585
  childrenCount: -1,
581
586
  description: "List and read pull requests"
582
587
  }
@@ -585,7 +590,8 @@ var AFSGitHub = class {
585
590
  id: "repo",
586
591
  path: "/repo",
587
592
  summary: "Repository Code",
588
- metadata: {
593
+ meta: {
594
+ kind: "github:directory",
589
595
  childrenCount: -1,
590
596
  description: "Repository source code (via Contents API)"
591
597
  }
@@ -593,6 +599,19 @@ var AFSGitHub = class {
593
599
  ];
594
600
  }
595
601
  /**
602
+ * Check if path points to a file (leaf node) like /issues/{n} or /pulls/{n}
603
+ * These are individual issues/PRs that don't have children.
604
+ */
605
+ isFilePath(path) {
606
+ const segments = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
607
+ if (this.options.mode === "single-repo") {
608
+ if (segments.length === 2 && (segments[0] === "issues" || segments[0] === "pulls")) return /^\d+$/.test(segments[1]);
609
+ } else if (this.options.mode === "org") {
610
+ if (segments.length === 3 && (segments[1] === "issues" || segments[1] === "pulls")) return /^\d+$/.test(segments[2]);
611
+ }
612
+ return false;
613
+ }
614
+ /**
596
615
  * Check if path is root or empty
597
616
  */
598
617
  isRootPath(path) {
@@ -612,13 +631,13 @@ var AFSGitHub = class {
612
631
  childrenCount = 3;
613
632
  } else {
614
633
  summary = this.description ?? "GitHub";
615
- childrenCount = -1;
634
+ childrenCount = void 0;
616
635
  }
617
636
  return {
618
637
  id: this.name,
619
638
  path: "/",
620
639
  summary,
621
- metadata: {
640
+ meta: {
622
641
  childrenCount,
623
642
  description: summary
624
643
  }
@@ -633,7 +652,7 @@ var AFSGitHub = class {
633
652
  id: `${repoPath}/issues`,
634
653
  path: `${repoPath}/issues`,
635
654
  summary: "Repository Issues",
636
- metadata: {
655
+ meta: {
637
656
  childrenCount: -1,
638
657
  description: "List and read repository issues"
639
658
  }
@@ -642,7 +661,7 @@ var AFSGitHub = class {
642
661
  id: `${repoPath}/pulls`,
643
662
  path: `${repoPath}/pulls`,
644
663
  summary: "Pull Requests",
645
- metadata: {
664
+ meta: {
646
665
  childrenCount: -1,
647
666
  description: "List and read pull requests"
648
667
  }
@@ -651,7 +670,7 @@ var AFSGitHub = class {
651
670
  id: `${repoPath}/repo`,
652
671
  path: `${repoPath}/repo`,
653
672
  summary: "Repository Code",
654
- metadata: {
673
+ meta: {
655
674
  childrenCount: -1,
656
675
  description: "Repository source code (via Contents API)"
657
676
  }
@@ -742,6 +761,8 @@ var AFSGitHub = class {
742
761
  * - /repo -> list all branches
743
762
  * - /repo/{branch} -> list branch root
744
763
  * - /repo/{branch}/path -> list contents at path
764
+ *
765
+ * @throws AFSNotFoundError if path does not exist
745
766
  */
746
767
  async listViaContentsAPI(path, _options) {
747
768
  const { owner, repo, branch, filePath } = this.parseRepoPath(path);
@@ -753,7 +774,8 @@ var AFSGitHub = class {
753
774
  id: `${repoPrefix}/${b.name}`,
754
775
  path: `${repoPrefix}/${b.name}`,
755
776
  summary: b.name + (b.name === defaultBranch ? " (default)" : ""),
756
- metadata: {
777
+ meta: {
778
+ kind: "github:branch",
757
779
  type: "branch",
758
780
  sha: b.commit.sha,
759
781
  protected: b.protected,
@@ -762,24 +784,23 @@ var AFSGitHub = class {
762
784
  }
763
785
  })) };
764
786
  } catch (error) {
765
- return {
766
- data: [],
767
- message: this.formatErrorMessage(error, `branches for ${owner}/${repo}`)
768
- };
787
+ if (error?.status === 404) throw new AFSNotFoundError(path);
788
+ throw error;
769
789
  }
770
790
  try {
771
791
  const contents = await this.client.getContents(owner, repo, filePath, branch);
772
792
  if (!Array.isArray(contents)) {
793
+ if (contents.type === "file") return { data: [] };
773
794
  const entryPath = filePath ? `${repoPrefix}/${branch}/${contents.path}` : `${repoPrefix}/${branch}/${contents.name}`;
774
795
  return { data: [{
775
796
  id: entryPath,
776
797
  path: entryPath,
777
798
  summary: contents.name,
778
- metadata: {
799
+ meta: {
800
+ kind: contents.type === "dir" ? "github:directory" : "github:file",
779
801
  type: contents.type,
780
802
  size: contents.size,
781
- sha: contents.sha,
782
- childrenCount: contents.type === "dir" ? -1 : void 0
803
+ sha: contents.sha
783
804
  }
784
805
  }] };
785
806
  }
@@ -789,50 +810,94 @@ var AFSGitHub = class {
789
810
  id: entryPath,
790
811
  path: entryPath,
791
812
  summary: item.name,
792
- metadata: {
813
+ meta: {
814
+ kind: item.type === "dir" ? "github:directory" : "github:file",
793
815
  type: item.type,
794
816
  size: item.size,
795
- sha: item.sha,
796
- childrenCount: item.type === "dir" ? -1 : void 0
817
+ sha: item.sha
797
818
  }
798
819
  };
799
820
  }) };
800
821
  } catch (error) {
801
- if (error?.status === 404) return {
802
- data: [],
803
- message: `Path not found: ${branch}/${filePath || ""}`
804
- };
805
- return {
806
- data: [],
807
- message: this.formatErrorMessage(error, `repository contents for ${owner}/${repo}`)
808
- };
822
+ if (error?.status === 404) throw new AFSNotFoundError(path, `Path not found: ${branch}/${filePath || ""}`);
823
+ throw error;
809
824
  }
810
825
  }
811
826
  /**
812
- * Read file content via GitHub Contents API
813
- * Path format: /repo/{branch}/{filePath}
827
+ * Read via GitHub Contents API
828
+ * Supports:
829
+ * - /repo - return repo virtual directory
830
+ * - /repo/{branch} - return branch directory entry
831
+ * - /repo/{branch}/{path} - return file/directory entry
832
+ *
833
+ * @throws AFSNotFoundError if path does not exist
814
834
  */
815
835
  async readViaContentsAPI(path, _options) {
816
836
  const { owner, repo, branch, filePath } = this.parseRepoPath(path);
817
837
  const repoPrefix = this.getRepoPathPrefix(repo);
818
- if (!branch) return {
819
- data: void 0,
820
- message: "Branch is required to read files"
821
- };
822
- if (!filePath) return {
823
- data: void 0,
824
- message: "Cannot read branch root as file"
825
- };
838
+ if (!branch) return { data: {
839
+ id: `${repoPrefix}`,
840
+ path: repoPrefix,
841
+ summary: "Repository Code",
842
+ meta: { description: "Repository source code (via Contents API)" }
843
+ } };
844
+ if (!filePath) try {
845
+ const branches = await this.client.getBranches(owner, repo);
846
+ const defaultBranch = await this.client.getDefaultBranch(owner, repo);
847
+ const branchInfo = branches.find((b) => b.name === branch);
848
+ if (!branchInfo) throw new AFSNotFoundError(path, `Branch not found: ${branch}`);
849
+ let childrenCount;
850
+ try {
851
+ const contents = await this.client.getContents(owner, repo, "", branch);
852
+ if (Array.isArray(contents)) childrenCount = contents.length;
853
+ } catch {}
854
+ const entryPath = `${repoPrefix}/${branch}`;
855
+ return { data: {
856
+ id: entryPath,
857
+ path: entryPath,
858
+ summary: branch + (branch === defaultBranch ? " (default)" : ""),
859
+ meta: {
860
+ type: "branch",
861
+ sha: branchInfo.commit.sha,
862
+ protected: branchInfo.protected,
863
+ isDefault: branch === defaultBranch,
864
+ childrenCount
865
+ }
866
+ } };
867
+ } catch (error) {
868
+ if (error instanceof AFSNotFoundError) throw error;
869
+ if (error?.status === 404) throw new AFSNotFoundError(path);
870
+ throw error;
871
+ }
826
872
  try {
827
873
  const contents = await this.client.getContents(owner, repo, filePath, branch);
828
- if (Array.isArray(contents)) return {
829
- data: void 0,
830
- message: "Path is a directory, not a file"
831
- };
832
- if (contents.type !== "file") return {
833
- data: void 0,
834
- message: `Path is a ${contents.type}, not a file`
835
- };
874
+ if (Array.isArray(contents)) {
875
+ const entryPath$1 = `${repoPrefix}/${branch}/${filePath}`;
876
+ return { data: {
877
+ id: entryPath$1,
878
+ path: entryPath$1,
879
+ summary: filePath.split("/").pop() || filePath,
880
+ meta: {
881
+ type: "dir",
882
+ childrenCount: contents.length,
883
+ branch
884
+ }
885
+ } };
886
+ }
887
+ if (contents.type === "dir") {
888
+ const entryPath$1 = `${repoPrefix}/${branch}/${filePath}`;
889
+ return { data: {
890
+ id: entryPath$1,
891
+ path: entryPath$1,
892
+ summary: contents.name,
893
+ meta: {
894
+ type: "dir",
895
+ sha: contents.sha,
896
+ branch
897
+ }
898
+ } };
899
+ }
900
+ if (contents.type !== "file") throw new AFSNotFoundError(path, `Unsupported type: ${contents.type}`);
836
901
  let content;
837
902
  if (contents.content) content = Buffer.from(contents.content, "base64").toString("utf-8");
838
903
  else content = await this.client.getBlob(owner, repo, contents.sha);
@@ -842,7 +907,7 @@ var AFSGitHub = class {
842
907
  path: entryPath,
843
908
  summary: contents.name,
844
909
  content,
845
- metadata: {
910
+ meta: {
846
911
  type: "file",
847
912
  size: contents.size,
848
913
  sha: contents.sha,
@@ -850,14 +915,9 @@ var AFSGitHub = class {
850
915
  }
851
916
  } };
852
917
  } catch (error) {
853
- if (error?.status === 404) return {
854
- data: void 0,
855
- message: `File not found: ${branch}/${filePath}`
856
- };
857
- return {
858
- data: void 0,
859
- message: this.formatErrorMessage(error, `file ${filePath} in ${owner}/${repo}@${branch}`)
860
- };
918
+ if (error instanceof AFSNotFoundError) throw error;
919
+ if (error?.status === 404) throw new AFSNotFoundError(path, `Path not found: ${branch}/${filePath}`);
920
+ throw error;
861
921
  }
862
922
  }
863
923
  /**
@@ -901,7 +961,7 @@ var AFSGitHub = class {
901
961
  id: repo.name,
902
962
  path: `/${repo.name}`,
903
963
  summary: repo.description || repo.name,
904
- metadata: {
964
+ meta: {
905
965
  childrenCount: 3,
906
966
  description: repo.description,
907
967
  private: repo.private
@@ -937,41 +997,34 @@ var AFSGitHub = class {
937
997
  return allRepos;
938
998
  }
939
999
  /**
940
- * List entries at a path
1000
+ * Apply limit to entries array
941
1001
  */
942
- async list(path, options) {
943
- const maxDepth = options?.maxDepth ?? 1;
1002
+ applyLimit(entries, limit) {
1003
+ if (limit !== void 0 && limit > 0 && entries.length > limit) return entries.slice(0, limit);
1004
+ return entries;
1005
+ }
1006
+ /**
1007
+ * List immediate children at a path (maxDepth=1 only)
1008
+ */
1009
+ async listImmediate(path, options) {
1010
+ const actionsResult = this.listActions(path);
1011
+ if (actionsResult) return actionsResult;
1012
+ if (this.isFilePath(path)) return { data: [] };
944
1013
  if (this.isRepoPath(path)) return this.listViaContentsAPI(path, options);
945
1014
  if (this.isRootPath(path)) {
946
- if (maxDepth === 0) return { data: [this.getRootEntry()] };
947
1015
  if (this.options.mode === "org") return this.listOrgRepos(!!this.options.auth?.token);
948
1016
  return { data: this.getVirtualDirectories() };
949
1017
  }
950
1018
  if (this.options.mode === "org" && this.isRepoRootPath(path)) {
951
1019
  const repoName = path.replace(/^\/+|\/+$/g, "");
952
- if (maxDepth === 0) return { data: [{
953
- id: repoName,
954
- path: `/${repoName}`,
955
- summary: `Repository: ${this.options.owner}/${repoName}`,
956
- metadata: { childrenCount: 2 }
957
- }] };
958
1020
  return { data: this.getRepoVirtualDirectories(`/${repoName}`) };
959
1021
  }
960
1022
  const fullPath = this.resolvePath(path);
961
- if (!this.compiled) return {
962
- data: [],
963
- message: "Mapping not loaded"
964
- };
1023
+ if (!this.compiled) throw new AFSNotFoundError(path, "Mapping not loaded");
965
1024
  const resolved = this.compiled.resolve(fullPath);
966
- if (!resolved || !resolved.operations.list) return {
967
- data: [],
968
- message: `No list operation for path: ${fullPath}`
969
- };
1025
+ if (!resolved || !resolved.operations.list) throw new AFSNotFoundError(path, `No list operation for path: ${fullPath}`);
970
1026
  const request = this.compiled.buildRequest(fullPath, "list", { query: options?.filter });
971
- if (!request) return {
972
- data: [],
973
- message: "Failed to build request"
974
- };
1027
+ if (!request) throw new AFSNotFoundError(path, "Failed to build request");
975
1028
  try {
976
1029
  let responseData = (await this.client.request(`${request.method} ${request.path}`, request.params)).data;
977
1030
  if (Array.isArray(responseData) && fullPath.endsWith("/issues")) responseData = responseData.filter((item) => !item.pull_request);
@@ -985,16 +1038,132 @@ var AFSGitHub = class {
985
1038
  }
986
1039
  return { data: entries };
987
1040
  } catch (error) {
988
- return {
989
- data: [],
990
- message: this.formatErrorMessage(error, `path "${path}"`)
1041
+ if (error?.status === 404) throw new AFSNotFoundError(path);
1042
+ throw error;
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Check if a path represents a directory (has children)
1047
+ */
1048
+ isDirectoryPath(path) {
1049
+ if (this.isRootPath(path)) return true;
1050
+ if (this.isFilePath(path)) return false;
1051
+ if (this.getVirtualDirectoryEntry(path)) return true;
1052
+ if (this.isRepoPath(path)) return true;
1053
+ const fullPath = this.resolvePath(path);
1054
+ if (this.compiled) {
1055
+ if (this.compiled.resolve(fullPath)?.operations.list) return true;
1056
+ }
1057
+ return false;
1058
+ }
1059
+ /**
1060
+ * List entries at a path
1061
+ *
1062
+ * Per AFS semantics:
1063
+ * - maxDepth=0: return empty array (no children)
1064
+ * - maxDepth=1: return immediate children
1065
+ * - maxDepth>1: recursively include deeper levels
1066
+ * - limit: maximum number of entries to return
1067
+ *
1068
+ * @throws AFSNotFoundError if path does not exist
1069
+ */
1070
+ async list(path, options) {
1071
+ const maxDepth = options?.maxDepth ?? 1;
1072
+ const limit = options?.limit;
1073
+ if (maxDepth === 0) return { data: [] };
1074
+ const result = await this.listImmediate(path, options);
1075
+ let entries = result.data;
1076
+ if (maxDepth > 1) {
1077
+ const childEntries = [];
1078
+ for (const entry of entries) if (this.isDirectoryPath(entry.path)) try {
1079
+ const childResult = await this.listImmediate(entry.path, options);
1080
+ childEntries.push(...childResult.data);
1081
+ } catch {}
1082
+ entries = [...entries, ...childEntries];
1083
+ }
1084
+ return {
1085
+ data: this.applyLimit(entries, limit),
1086
+ message: result.message
1087
+ };
1088
+ }
1089
+ /**
1090
+ * Check if path is a .meta path and extract the target path
1091
+ */
1092
+ isMetaPath(path) {
1093
+ const normalized = path.replace(/\/+$/, "");
1094
+ if (normalized.endsWith("/.meta")) return {
1095
+ isMeta: true,
1096
+ targetPath: normalized.slice(0, -6) || "/"
1097
+ };
1098
+ return {
1099
+ isMeta: false,
1100
+ targetPath: path
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Get entry info for a virtual directory path
1105
+ */
1106
+ getVirtualDirectoryEntry(path) {
1107
+ const segments = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
1108
+ if (segments.length === 0) return this.getRootEntry();
1109
+ if (this.options.mode === "single-repo") {
1110
+ if (segments.length === 1) {
1111
+ const dir = this.getVirtualDirectories().find((d) => d.path === `/${segments[0]}`);
1112
+ if (dir) return dir;
1113
+ }
1114
+ }
1115
+ if (this.options.mode === "org") {
1116
+ if (segments.length === 1) return {
1117
+ id: segments[0],
1118
+ path: `/${segments[0]}`,
1119
+ summary: `Repository: ${this.options.owner}/${segments[0]}`,
1120
+ meta: { childrenCount: 3 }
991
1121
  };
1122
+ if (segments.length === 2) {
1123
+ const repoName = segments[0];
1124
+ const dir = this.getRepoVirtualDirectories(`/${repoName}`).find((d) => d.path === `/${repoName}/${segments[1]}`);
1125
+ if (dir) return dir;
1126
+ }
992
1127
  }
993
1128
  }
994
1129
  /**
995
1130
  * Read a single entry
1131
+ *
1132
+ * Supports:
1133
+ * - Directory paths (returns entry info)
1134
+ * - .meta paths (returns metadata)
1135
+ * - Issue/PR paths (returns content via API)
1136
+ * - Repo file paths (returns content via Contents API)
1137
+ *
1138
+ * @throws AFSNotFoundError if path does not exist
996
1139
  */
997
1140
  async read(path, options) {
1141
+ if (path === "/.meta/.capabilities") return this.readCapabilities();
1142
+ const actionsMatch = path.match(/^(.*)\/\.actions\/([^/]+)$/);
1143
+ if (actionsMatch) {
1144
+ const actionsListPath = `${actionsMatch[1] || ""}/.actions`;
1145
+ const actionName = actionsMatch[2];
1146
+ const actionsResult = this.listActions(actionsListPath);
1147
+ if (actionsResult) {
1148
+ const actionEntry = actionsResult.data.find((entry) => entry.id === actionName);
1149
+ if (actionEntry) return { data: actionEntry };
1150
+ }
1151
+ throw new AFSNotFoundError(path);
1152
+ }
1153
+ const { isMeta, targetPath } = this.isMetaPath(path);
1154
+ if (isMeta) return this.readMeta(targetPath);
1155
+ const virtualEntry = this.getVirtualDirectoryEntry(path);
1156
+ if (virtualEntry) {
1157
+ const cc = virtualEntry.meta?.childrenCount;
1158
+ if ((cc === void 0 || cc === -1) && this.isDirectoryPath(path)) try {
1159
+ const children = await this.listImmediate(path);
1160
+ virtualEntry.meta = {
1161
+ ...virtualEntry.meta,
1162
+ childrenCount: children.data.length
1163
+ };
1164
+ } catch {}
1165
+ return { data: virtualEntry };
1166
+ }
998
1167
  if (this.isRepoPath(path)) return this.readViaContentsAPI(path, options);
999
1168
  if (this.options.mode === "org" || this.options.mode === "single-repo") {
1000
1169
  const segments = path.replace(/^\/+|\/+$/g, "").split("/");
@@ -1002,41 +1171,23 @@ var AFSGitHub = class {
1002
1171
  const pullsIdx = segments.indexOf("pulls");
1003
1172
  if (issuesIdx !== -1 && segments[issuesIdx + 1]) {
1004
1173
  const issueNum = segments[issuesIdx + 1];
1005
- if (!/^\d+$/.test(issueNum)) return {
1006
- data: void 0,
1007
- message: "Invalid issue number"
1008
- };
1174
+ if (!/^\d+$/.test(issueNum)) throw new AFSNotFoundError(path, "Invalid issue number");
1009
1175
  }
1010
1176
  if (pullsIdx !== -1 && segments[pullsIdx + 1]) {
1011
1177
  const prNum = segments[pullsIdx + 1];
1012
- if (!/^\d+$/.test(prNum)) return {
1013
- data: void 0,
1014
- message: "Invalid pull request number"
1015
- };
1178
+ if (!/^\d+$/.test(prNum)) throw new AFSNotFoundError(path, "Invalid pull request number");
1016
1179
  }
1017
1180
  }
1018
1181
  const fullPath = this.resolvePath(path);
1019
- if (!this.compiled) return {
1020
- data: void 0,
1021
- message: "Mapping not loaded"
1022
- };
1182
+ if (!this.compiled) throw new AFSNotFoundError(path, "Mapping not loaded");
1023
1183
  const resolved = this.compiled.resolve(fullPath);
1024
- if (!resolved || !resolved.operations.read) return {
1025
- data: void 0,
1026
- message: `No read operation for path: ${fullPath}`
1027
- };
1184
+ if (!resolved || !resolved.operations.read) throw new AFSNotFoundError(path, `No read operation for path: ${fullPath}`);
1028
1185
  const request = this.compiled.buildRequest(fullPath, "read", {});
1029
- if (!request) return {
1030
- data: void 0,
1031
- message: "Failed to build request"
1032
- };
1186
+ if (!request) throw new AFSNotFoundError(path, "Failed to build request");
1033
1187
  try {
1034
1188
  const response = await this.client.request(`${request.method} ${request.path}`, request.params);
1035
1189
  const entries = this.compiled.projectResponse(fullPath, "read", response.data);
1036
- if (entries.length === 0) return {
1037
- data: void 0,
1038
- message: "No data returned"
1039
- };
1190
+ if (entries.length === 0) throw new AFSNotFoundError(path, "No data returned");
1040
1191
  const entry = entries[0];
1041
1192
  if (this.options.mode === "single-repo") {
1042
1193
  const prefix = `/${this.options.owner}/${this.options.repo}`;
@@ -1047,12 +1198,41 @@ var AFSGitHub = class {
1047
1198
  }
1048
1199
  return { data: entry };
1049
1200
  } catch (error) {
1050
- return {
1051
- data: void 0,
1052
- message: this.formatErrorMessage(error, `path "${path}"`)
1053
- };
1201
+ if (error instanceof AFSNotFoundError) throw error;
1202
+ if (error?.status === 404) throw new AFSNotFoundError(path);
1203
+ throw error;
1054
1204
  }
1055
1205
  }
1206
+ /**
1207
+ * Read metadata for a path
1208
+ * Returns an entry with meta field containing the metadata
1209
+ *
1210
+ * @throws AFSNotFoundError if path does not exist
1211
+ */
1212
+ async readMeta(targetPath) {
1213
+ const metaPath = targetPath === "/" ? "/.meta" : `${targetPath}/.meta`;
1214
+ const virtualEntry = this.getVirtualDirectoryEntry(targetPath);
1215
+ if (virtualEntry) return { data: {
1216
+ id: `${virtualEntry.id}/.meta`,
1217
+ path: metaPath,
1218
+ summary: `Metadata for ${targetPath}`,
1219
+ meta: virtualEntry.meta ?? {}
1220
+ } };
1221
+ const enrichedMeta = await this.getEnrichedMeta(targetPath);
1222
+ if (enrichedMeta) return { data: {
1223
+ id: `${targetPath}/.meta`,
1224
+ path: metaPath,
1225
+ summary: `Metadata for ${targetPath}`,
1226
+ meta: enrichedMeta
1227
+ } };
1228
+ const readResult = await this.read(targetPath);
1229
+ return { data: {
1230
+ id: `${readResult.data.id}/.meta`,
1231
+ path: metaPath,
1232
+ summary: `Metadata for ${targetPath}`,
1233
+ meta: readResult.data.meta ?? {}
1234
+ } };
1235
+ }
1056
1236
  async loadMapping(mappingPath) {
1057
1237
  const compiler = new MappingCompiler();
1058
1238
  try {
@@ -1118,6 +1298,1044 @@ var AFSGitHub = class {
1118
1298
  error: "Write operations not yet implemented"
1119
1299
  };
1120
1300
  }
1301
+ /**
1302
+ * Get metadata for a path without content.
1303
+ * Supports issues, PRs, and virtual directories.
1304
+ */
1305
+ async stat(path, _options) {
1306
+ const parsed = this.parseIssuePRPath(path);
1307
+ if (parsed?.type === "issue") return this.statIssue(parsed.number);
1308
+ if (parsed?.type === "pull") return this.statPR(parsed.number);
1309
+ const virtualEntry = this.getVirtualDirectoryEntry(path);
1310
+ if (virtualEntry) return { data: virtualEntry };
1311
+ throw new AFSNotFoundError(path);
1312
+ }
1313
+ async statIssue(issueNumber) {
1314
+ const owner = this.options.owner;
1315
+ const repo = this.options.repo;
1316
+ try {
1317
+ const issue = (await this.client.request("GET /repos/{owner}/{repo}/issues/{issue_number}", {
1318
+ owner,
1319
+ repo,
1320
+ issue_number: issueNumber
1321
+ })).data;
1322
+ return { data: {
1323
+ id: String(issue.number),
1324
+ path: `/issues/${issue.number}`,
1325
+ summary: issue.title,
1326
+ meta: {
1327
+ type: "issue",
1328
+ state: issue.state,
1329
+ title: issue.title,
1330
+ author: issue.user?.login,
1331
+ commentCount: issue.comments,
1332
+ labels: (issue.labels ?? []).map((l) => l.name),
1333
+ created_at: issue.created_at,
1334
+ updated_at: issue.updated_at
1335
+ }
1336
+ } };
1337
+ } catch (error) {
1338
+ if (error?.status === 404) throw new AFSNotFoundError(`/issues/${issueNumber}`);
1339
+ throw error;
1340
+ }
1341
+ }
1342
+ async statPR(prNumber) {
1343
+ const owner = this.options.owner;
1344
+ const repo = this.options.repo;
1345
+ try {
1346
+ const pr = (await this.client.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
1347
+ owner,
1348
+ repo,
1349
+ pull_number: prNumber
1350
+ })).data;
1351
+ return { data: {
1352
+ id: String(pr.number),
1353
+ path: `/pulls/${pr.number}`,
1354
+ summary: pr.title,
1355
+ meta: {
1356
+ type: "pull_request",
1357
+ state: pr.state,
1358
+ title: pr.title,
1359
+ author: pr.user?.login,
1360
+ draft: pr.draft,
1361
+ reviewers: (pr.requested_reviewers ?? []).map((r) => r.login),
1362
+ labels: (pr.labels ?? []).map((l) => l.name),
1363
+ created_at: pr.created_at,
1364
+ updated_at: pr.updated_at,
1365
+ merged_at: pr.merged_at
1366
+ }
1367
+ } };
1368
+ } catch (error) {
1369
+ if (error?.status === 404) throw new AFSNotFoundError(`/pulls/${prNumber}`);
1370
+ throw error;
1371
+ }
1372
+ }
1373
+ /**
1374
+ * Search issues and PRs using the GitHub Search API.
1375
+ * Path determines scope: /issues searches issues, /pulls searches PRs, / searches both.
1376
+ */
1377
+ async search(path, query, options) {
1378
+ const owner = this.options.owner;
1379
+ const repo = this.options.repo;
1380
+ const sanitizedQuery = query.replace(/[\r\n]+/g, " ").replace(/ghp_[a-zA-Z0-9]+/g, "");
1381
+ const normalized = path.replace(/^\/+|\/+$/g, "");
1382
+ const isPulls = normalized === "pulls" || normalized.endsWith("/pulls");
1383
+ const isIssues = normalized === "issues" || normalized.endsWith("/issues");
1384
+ let q = sanitizedQuery.trim();
1385
+ q += ` repo:${owner}/${repo}`;
1386
+ if (isPulls) q += " is:pr";
1387
+ else if (isIssues) q += " is:issue";
1388
+ try {
1389
+ return { data: (await this.client.request("GET /search/issues", {
1390
+ q: q.trim(),
1391
+ per_page: options?.limit ?? 30
1392
+ })).data.items.map((item) => {
1393
+ const hasPR = "pull_request" in item;
1394
+ const type = hasPR ? "pulls" : "issues";
1395
+ return {
1396
+ id: String(item.number),
1397
+ path: `/${type}/${item.number}`,
1398
+ summary: item.title,
1399
+ meta: {
1400
+ type: hasPR ? "pull_request" : "issue",
1401
+ state: item.state,
1402
+ author: item.user?.login
1403
+ }
1404
+ };
1405
+ }) };
1406
+ } catch (error) {
1407
+ if (error?.status === 422) return {
1408
+ data: [],
1409
+ message: "Invalid search query"
1410
+ };
1411
+ throw error;
1412
+ }
1413
+ }
1414
+ /**
1415
+ * Provide human/LLM-readable explanation of a path.
1416
+ */
1417
+ async explain(path, _options) {
1418
+ const normalized = path.replace(/^\/+|\/+$/g, "");
1419
+ if (normalized === "") return this.explainRoot();
1420
+ if (normalized === "issues") return this.explainIssuesDir();
1421
+ if (normalized === "pulls") return this.explainPullsDir();
1422
+ const parsed = this.parseIssuePRPath(path);
1423
+ if (parsed?.type === "issue") return this.explainIssue(parsed.number);
1424
+ if (parsed?.type === "pull") return this.explainPR(parsed.number);
1425
+ throw new AFSNotFoundError(path);
1426
+ }
1427
+ async explainRoot() {
1428
+ const owner = this.options.owner;
1429
+ const repo = this.options.repo;
1430
+ const data = (await this.client.request("GET /repos/{owner}/{repo}", {
1431
+ owner,
1432
+ repo
1433
+ })).data;
1434
+ const lines = [
1435
+ `# ${data.full_name}`,
1436
+ "",
1437
+ data.description ? `${data.description}` : "",
1438
+ "",
1439
+ `- **Language**: ${data.language ?? "N/A"}`,
1440
+ `- **Stars**: ${data.stargazers_count ?? 0} ⭐`,
1441
+ `- **Forks**: ${data.forks_count ?? 0}`,
1442
+ `- **Open Issues**: ${data.open_issues_count ?? 0}`,
1443
+ `- **Default Branch**: ${data.default_branch ?? "main"}`,
1444
+ `- **License**: ${data.license?.spdx_id ?? "N/A"}`,
1445
+ `- **Visibility**: ${data.private ? "Private" : "Public"}`,
1446
+ "",
1447
+ "## Structure",
1448
+ "",
1449
+ "- `/issues` — Repository issues",
1450
+ "- `/pulls` — Pull requests",
1451
+ "- `/repo` — Repository source code"
1452
+ ];
1453
+ const topics = data.topics;
1454
+ if (topics && topics.length > 0) lines.push("", `**Topics**: ${topics.join(", ")}`);
1455
+ return {
1456
+ format: "markdown",
1457
+ content: lines.filter((l) => l !== void 0).join("\n")
1458
+ };
1459
+ }
1460
+ async explainIssuesDir() {
1461
+ const owner = this.options.owner;
1462
+ const repo = this.options.repo;
1463
+ const [openResult, closedResult] = await Promise.all([this.client.request("GET /search/issues", {
1464
+ q: `repo:${owner}/${repo} is:issue is:open`,
1465
+ per_page: 1
1466
+ }), this.client.request("GET /search/issues", {
1467
+ q: `repo:${owner}/${repo} is:issue is:closed`,
1468
+ per_page: 1
1469
+ })]);
1470
+ const openCount = openResult.data.total_count;
1471
+ const closedCount = closedResult.data.total_count;
1472
+ return {
1473
+ format: "markdown",
1474
+ content: [
1475
+ `# Issues — ${owner}/${repo}`,
1476
+ "",
1477
+ `- **Open**: ${openCount}`,
1478
+ `- **Closed**: ${closedCount}`,
1479
+ `- **Total**: ${openCount + closedCount}`,
1480
+ "",
1481
+ "Use `list /issues` to browse issues.",
1482
+ "Use `search /issues <query>` to find specific issues."
1483
+ ].join("\n")
1484
+ };
1485
+ }
1486
+ async explainPullsDir() {
1487
+ const owner = this.options.owner;
1488
+ const repo = this.options.repo;
1489
+ const [openResult, closedResult, mergedResult] = await Promise.all([
1490
+ this.client.request("GET /search/issues", {
1491
+ q: `repo:${owner}/${repo} is:pr is:open`,
1492
+ per_page: 1
1493
+ }),
1494
+ this.client.request("GET /search/issues", {
1495
+ q: `repo:${owner}/${repo} is:pr is:closed`,
1496
+ per_page: 1
1497
+ }),
1498
+ this.client.request("GET /search/issues", {
1499
+ q: `repo:${owner}/${repo} is:pr is:merged`,
1500
+ per_page: 1
1501
+ })
1502
+ ]);
1503
+ const openCount = openResult.data.total_count;
1504
+ const closedCount = closedResult.data.total_count;
1505
+ const mergedCount = mergedResult.data.total_count;
1506
+ return {
1507
+ format: "markdown",
1508
+ content: [
1509
+ `# Pull Requests — ${owner}/${repo}`,
1510
+ "",
1511
+ `- **Open**: ${openCount}`,
1512
+ `- **Closed**: ${closedCount}`,
1513
+ `- **Merged**: ${mergedCount}`,
1514
+ "",
1515
+ "Use `list /pulls` to browse pull requests.",
1516
+ "Use `search /pulls <query>` to find specific PRs."
1517
+ ].join("\n")
1518
+ };
1519
+ }
1520
+ async explainIssue(issueNumber) {
1521
+ const owner = this.options.owner;
1522
+ const repo = this.options.repo;
1523
+ try {
1524
+ const issue = (await this.client.request("GET /repos/{owner}/{repo}/issues/{issue_number}", {
1525
+ owner,
1526
+ repo,
1527
+ issue_number: issueNumber
1528
+ })).data;
1529
+ const labels = (issue.labels ?? []).map((l) => l.name);
1530
+ const assignees = (issue.assignees ?? []).map((a) => a.login);
1531
+ return {
1532
+ format: "markdown",
1533
+ content: [
1534
+ `# Issue #${issue.number}: ${issue.title}`,
1535
+ "",
1536
+ `- **State**: ${issue.state}`,
1537
+ `- **Author**: ${issue.user?.login}`,
1538
+ `- **Created**: ${issue.created_at}`,
1539
+ `- **Updated**: ${issue.updated_at}`,
1540
+ labels.length > 0 ? `- **Labels**: ${labels.join(", ")}` : null,
1541
+ assignees.length > 0 ? `- **Assignees**: ${assignees.join(", ")}` : null,
1542
+ `- **Comments**: ${issue.comments}`
1543
+ ].filter((l) => l !== null).join("\n")
1544
+ };
1545
+ } catch (error) {
1546
+ if (error?.status === 404) throw new AFSNotFoundError(`/issues/${issueNumber}`);
1547
+ throw error;
1548
+ }
1549
+ }
1550
+ async explainPR(prNumber) {
1551
+ const owner = this.options.owner;
1552
+ const repo = this.options.repo;
1553
+ try {
1554
+ const pr = (await this.client.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
1555
+ owner,
1556
+ repo,
1557
+ pull_number: prNumber
1558
+ })).data;
1559
+ const labels = (pr.labels ?? []).map((l) => l.name);
1560
+ const reviewers = (pr.requested_reviewers ?? []).map((r) => r.login);
1561
+ return {
1562
+ format: "markdown",
1563
+ content: [
1564
+ `# PR #${pr.number}: ${pr.title}`,
1565
+ "",
1566
+ `- **State**: ${pr.state}${pr.draft ? " (draft)" : ""}`,
1567
+ `- **Author**: ${pr.user?.login}`,
1568
+ `- **Created**: ${pr.created_at}`,
1569
+ `- **Updated**: ${pr.updated_at}`,
1570
+ pr.merged_at ? `- **Merged**: ${pr.merged_at}` : null,
1571
+ labels.length > 0 ? `- **Labels**: ${labels.join(", ")}` : null,
1572
+ reviewers.length > 0 ? `- **Reviewers**: ${reviewers.join(", ")}` : null,
1573
+ `- **Head**: ${pr.head?.ref} → **Base**: ${pr.base?.ref}`
1574
+ ].filter((l) => l !== null).join("\n")
1575
+ };
1576
+ } catch (error) {
1577
+ if (error?.status === 404) throw new AFSNotFoundError(`/pulls/${prNumber}`);
1578
+ throw error;
1579
+ }
1580
+ }
1581
+ async readCapabilities() {
1582
+ return { data: {
1583
+ id: "/.meta/.capabilities",
1584
+ path: "/.meta/.capabilities",
1585
+ summary: "Provider capabilities manifest",
1586
+ content: {
1587
+ schemaVersion: 1,
1588
+ provider: this.name,
1589
+ description: this.description ?? `GitHub: ${this.options.owner}/${this.options.repo}`,
1590
+ tools: [],
1591
+ operations: {
1592
+ read: true,
1593
+ list: true,
1594
+ write: false,
1595
+ delete: false,
1596
+ search: true,
1597
+ exec: true,
1598
+ stat: true,
1599
+ explain: true
1600
+ },
1601
+ actions: [
1602
+ {
1603
+ kind: "github:issue",
1604
+ description: "Issue lifecycle actions",
1605
+ catalog: [
1606
+ {
1607
+ name: "close-issue",
1608
+ description: "Close an issue"
1609
+ },
1610
+ {
1611
+ name: "reopen-issue",
1612
+ description: "Reopen a closed issue"
1613
+ },
1614
+ {
1615
+ name: "add-comment",
1616
+ description: "Add a comment to an issue"
1617
+ },
1618
+ {
1619
+ name: "add-label",
1620
+ description: "Add a label to an issue"
1621
+ },
1622
+ {
1623
+ name: "remove-label",
1624
+ description: "Remove a label from an issue"
1625
+ }
1626
+ ],
1627
+ discovery: { pathTemplate: "/issues/:number/.actions" }
1628
+ },
1629
+ {
1630
+ kind: "github:pull_request",
1631
+ description: "Pull request lifecycle actions",
1632
+ catalog: [
1633
+ {
1634
+ name: "merge-pr",
1635
+ description: "Merge a pull request"
1636
+ },
1637
+ {
1638
+ name: "approve-pr",
1639
+ description: "Approve a pull request"
1640
+ },
1641
+ {
1642
+ name: "request-changes",
1643
+ description: "Request changes on a pull request"
1644
+ },
1645
+ {
1646
+ name: "add-comment",
1647
+ description: "Add a comment to a pull request"
1648
+ },
1649
+ {
1650
+ name: "add-label",
1651
+ description: "Add a label to a pull request"
1652
+ },
1653
+ {
1654
+ name: "remove-label",
1655
+ description: "Remove a label from a pull request"
1656
+ }
1657
+ ],
1658
+ discovery: { pathTemplate: "/pulls/:number/.actions" }
1659
+ },
1660
+ {
1661
+ kind: "github:repo",
1662
+ description: "Repository file operations",
1663
+ catalog: [
1664
+ {
1665
+ name: "read-file",
1666
+ description: "Read a file from the repository"
1667
+ },
1668
+ {
1669
+ name: "write-file",
1670
+ description: "Write a file to the repository"
1671
+ },
1672
+ {
1673
+ name: "create-branch",
1674
+ description: "Create a new branch"
1675
+ }
1676
+ ],
1677
+ discovery: { pathTemplate: "/repo/:branch/.actions" }
1678
+ }
1679
+ ]
1680
+ }
1681
+ } };
1682
+ }
1683
+ /**
1684
+ * Get enriched metadata for an issue or PR path.
1685
+ * Returns labels, assignees, milestone, reactions from GitHub API.
1686
+ */
1687
+ async getEnrichedMeta(path) {
1688
+ const parsed = this.parseIssuePRPath(path);
1689
+ if (!parsed) return null;
1690
+ const owner = this.options.owner;
1691
+ const repo = this.options.repo;
1692
+ if (parsed.type === "issue") try {
1693
+ const issue = (await this.client.request("GET /repos/{owner}/{repo}/issues/{issue_number}", {
1694
+ owner,
1695
+ repo,
1696
+ issue_number: parsed.number
1697
+ })).data;
1698
+ return {
1699
+ type: "issue",
1700
+ state: issue.state,
1701
+ author: issue.user?.login,
1702
+ labels: (issue.labels ?? []).map((l) => l.name),
1703
+ assignees: (issue.assignees ?? []).map((a) => a.login),
1704
+ milestone: issue.milestone?.title ?? null,
1705
+ reactions: issue.reactions ? this.extractReactions(issue.reactions) : this.zeroReactions(),
1706
+ commentCount: issue.comments,
1707
+ created_at: issue.created_at,
1708
+ updated_at: issue.updated_at
1709
+ };
1710
+ } catch (error) {
1711
+ if (error?.status === 404) throw new AFSNotFoundError(path);
1712
+ throw error;
1713
+ }
1714
+ if (parsed.type === "pull") try {
1715
+ const pr = (await this.client.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
1716
+ owner,
1717
+ repo,
1718
+ pull_number: parsed.number
1719
+ })).data;
1720
+ return {
1721
+ type: "pull_request",
1722
+ state: pr.state,
1723
+ author: pr.user?.login,
1724
+ draft: pr.draft,
1725
+ labels: (pr.labels ?? []).map((l) => l.name),
1726
+ assignees: (pr.assignees ?? []).map((a) => a.login),
1727
+ milestone: pr.milestone?.title ?? null,
1728
+ reactions: pr.reactions ? this.extractReactions(pr.reactions) : this.zeroReactions(),
1729
+ reviewers: (pr.requested_reviewers ?? []).map((r) => r.login),
1730
+ commentCount: pr.comments,
1731
+ merged_at: pr.merged_at,
1732
+ created_at: pr.created_at,
1733
+ updated_at: pr.updated_at
1734
+ };
1735
+ } catch (error) {
1736
+ if (error?.status === 404) throw new AFSNotFoundError(path);
1737
+ throw error;
1738
+ }
1739
+ return null;
1740
+ }
1741
+ extractReactions(reactions) {
1742
+ return {
1743
+ "+1": reactions["+1"] ?? 0,
1744
+ "-1": reactions["-1"] ?? 0,
1745
+ laugh: reactions.laugh ?? 0,
1746
+ hooray: reactions.hooray ?? 0,
1747
+ confused: reactions.confused ?? 0,
1748
+ heart: reactions.heart ?? 0,
1749
+ rocket: reactions.rocket ?? 0,
1750
+ eyes: reactions.eyes ?? 0
1751
+ };
1752
+ }
1753
+ zeroReactions() {
1754
+ return {
1755
+ "+1": 0,
1756
+ "-1": 0,
1757
+ laugh: 0,
1758
+ hooray: 0,
1759
+ confused: 0,
1760
+ heart: 0,
1761
+ rocket: 0,
1762
+ eyes: 0
1763
+ };
1764
+ }
1765
+ /**
1766
+ * Parse a path to determine if it's an issue or PR path.
1767
+ * Returns { type, number } or null.
1768
+ */
1769
+ parseIssuePRPath(path) {
1770
+ const segments = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
1771
+ if (this.options.mode === "single-repo") {
1772
+ if (segments.length === 2 && segments[0] === "issues" && /^\d+$/.test(segments[1])) return {
1773
+ type: "issue",
1774
+ number: Number.parseInt(segments[1], 10)
1775
+ };
1776
+ if (segments.length === 2 && segments[0] === "pulls" && /^\d+$/.test(segments[1])) return {
1777
+ type: "pull",
1778
+ number: Number.parseInt(segments[1], 10)
1779
+ };
1780
+ } else if (this.options.mode === "org") {
1781
+ if (segments.length === 3 && segments[1] === "issues" && /^\d+$/.test(segments[2])) return {
1782
+ type: "issue",
1783
+ number: Number.parseInt(segments[2], 10)
1784
+ };
1785
+ if (segments.length === 3 && segments[1] === "pulls" && /^\d+$/.test(segments[2])) return {
1786
+ type: "pull",
1787
+ number: Number.parseInt(segments[2], 10)
1788
+ };
1789
+ }
1790
+ return null;
1791
+ }
1792
+ /**
1793
+ * List available actions at a path.
1794
+ * Returns null if the path is not an .actions path.
1795
+ */
1796
+ listActions(path) {
1797
+ const normalized = path.replace(/^\/+|\/+$/g, "");
1798
+ if (normalized === ".actions") return { data: [
1799
+ {
1800
+ id: "create-issue",
1801
+ path: "/.actions/create-issue",
1802
+ summary: "Create a new issue",
1803
+ meta: {
1804
+ kind: "afs:executable",
1805
+ name: "create-issue"
1806
+ }
1807
+ },
1808
+ {
1809
+ id: "create-pr",
1810
+ path: "/.actions/create-pr",
1811
+ summary: "Create a new pull request",
1812
+ meta: {
1813
+ kind: "afs:executable",
1814
+ name: "create-pr"
1815
+ }
1816
+ },
1817
+ {
1818
+ id: "create-file",
1819
+ path: "/.actions/create-file",
1820
+ summary: "Create a file in the repository",
1821
+ meta: {
1822
+ kind: "afs:executable",
1823
+ name: "create-file"
1824
+ }
1825
+ },
1826
+ {
1827
+ id: "update-file",
1828
+ path: "/.actions/update-file",
1829
+ summary: "Update a file in the repository",
1830
+ meta: {
1831
+ kind: "afs:executable",
1832
+ name: "update-file"
1833
+ }
1834
+ }
1835
+ ] };
1836
+ const issueActionsMatch = normalized.match(/^issues\/(\d+)\/\.actions$/);
1837
+ if (issueActionsMatch) {
1838
+ const num = issueActionsMatch[1];
1839
+ return { data: [
1840
+ {
1841
+ id: "close-issue",
1842
+ path: `/issues/${num}/.actions/close-issue`,
1843
+ summary: "Close this issue",
1844
+ meta: {
1845
+ kind: "afs:executable",
1846
+ name: "close-issue"
1847
+ }
1848
+ },
1849
+ {
1850
+ id: "reopen-issue",
1851
+ path: `/issues/${num}/.actions/reopen-issue`,
1852
+ summary: "Reopen this issue",
1853
+ meta: {
1854
+ kind: "afs:executable",
1855
+ name: "reopen-issue"
1856
+ }
1857
+ },
1858
+ {
1859
+ id: "add-comment",
1860
+ path: `/issues/${num}/.actions/add-comment`,
1861
+ summary: "Add a comment",
1862
+ meta: {
1863
+ kind: "afs:executable",
1864
+ name: "add-comment"
1865
+ }
1866
+ },
1867
+ {
1868
+ id: "add-label",
1869
+ path: `/issues/${num}/.actions/add-label`,
1870
+ summary: "Add a label",
1871
+ meta: {
1872
+ kind: "afs:executable",
1873
+ name: "add-label"
1874
+ }
1875
+ },
1876
+ {
1877
+ id: "remove-label",
1878
+ path: `/issues/${num}/.actions/remove-label`,
1879
+ summary: "Remove a label",
1880
+ meta: {
1881
+ kind: "afs:executable",
1882
+ name: "remove-label"
1883
+ }
1884
+ }
1885
+ ] };
1886
+ }
1887
+ const prActionsMatch = normalized.match(/^pulls\/(\d+)\/\.actions$/);
1888
+ if (prActionsMatch) {
1889
+ const num = prActionsMatch[1];
1890
+ return { data: [
1891
+ {
1892
+ id: "merge-pr",
1893
+ path: `/pulls/${num}/.actions/merge-pr`,
1894
+ summary: "Merge this pull request",
1895
+ meta: {
1896
+ kind: "afs:executable",
1897
+ name: "merge-pr"
1898
+ }
1899
+ },
1900
+ {
1901
+ id: "request-review",
1902
+ path: `/pulls/${num}/.actions/request-review`,
1903
+ summary: "Request a review",
1904
+ meta: {
1905
+ kind: "afs:executable",
1906
+ name: "request-review"
1907
+ }
1908
+ },
1909
+ {
1910
+ id: "add-comment",
1911
+ path: `/pulls/${num}/.actions/add-comment`,
1912
+ summary: "Add a comment",
1913
+ meta: {
1914
+ kind: "afs:executable",
1915
+ name: "add-comment"
1916
+ }
1917
+ },
1918
+ {
1919
+ id: "add-label",
1920
+ path: `/pulls/${num}/.actions/add-label`,
1921
+ summary: "Add a label",
1922
+ meta: {
1923
+ kind: "afs:executable",
1924
+ name: "add-label"
1925
+ }
1926
+ },
1927
+ {
1928
+ id: "remove-label",
1929
+ path: `/pulls/${num}/.actions/remove-label`,
1930
+ summary: "Remove a label",
1931
+ meta: {
1932
+ kind: "afs:executable",
1933
+ name: "remove-label"
1934
+ }
1935
+ }
1936
+ ] };
1937
+ }
1938
+ return null;
1939
+ }
1940
+ /**
1941
+ * Execute an action at a path.
1942
+ * Path format: /{resource}/.actions/{action-name}
1943
+ */
1944
+ async exec(path, args, _options) {
1945
+ if (this.accessMode === "readonly") throw new AFSReadonlyError("Cannot exec on a readonly provider");
1946
+ const actionMatch = path.replace(/^\/+|\/+$/g, "").match(/^(?:(.+)\/)?\.actions\/([^/]+)$/);
1947
+ if (!actionMatch) return {
1948
+ success: false,
1949
+ error: {
1950
+ code: "INVALID_PATH",
1951
+ message: `Invalid action path: ${path}`
1952
+ }
1953
+ };
1954
+ const resourcePath = actionMatch[1] || "";
1955
+ const actionName = actionMatch[2];
1956
+ try {
1957
+ if (resourcePath === "") return await this.execRootAction(actionName, args);
1958
+ const issueMatch = resourcePath.match(/^issues\/(\d+)$/);
1959
+ if (issueMatch) {
1960
+ const issueNumber = Number.parseInt(issueMatch[1], 10);
1961
+ return await this.execIssueAction(issueNumber, actionName, args);
1962
+ }
1963
+ const prMatch = resourcePath.match(/^pulls\/(\d+)$/);
1964
+ if (prMatch) {
1965
+ const prNumber = Number.parseInt(prMatch[1], 10);
1966
+ return await this.execPRAction(prNumber, actionName, args);
1967
+ }
1968
+ return {
1969
+ success: false,
1970
+ error: {
1971
+ code: "NOT_FOUND",
1972
+ message: `No actions for path: /${resourcePath}`
1973
+ }
1974
+ };
1975
+ } catch (error) {
1976
+ return this.handleExecError(error);
1977
+ }
1978
+ }
1979
+ async execRootAction(actionName, args) {
1980
+ const owner = this.options.owner;
1981
+ const repo = this.options.repo;
1982
+ switch (actionName) {
1983
+ case "create-issue": {
1984
+ const title = args.title;
1985
+ if (!title) return {
1986
+ success: false,
1987
+ error: {
1988
+ code: "VALIDATION_ERROR",
1989
+ message: "title is required"
1990
+ }
1991
+ };
1992
+ const issue = (await this.client.request("POST /repos/{owner}/{repo}/issues", {
1993
+ owner,
1994
+ repo,
1995
+ title,
1996
+ body: args.body ?? void 0,
1997
+ labels: args.labels ?? void 0,
1998
+ assignees: args.assignees ?? void 0
1999
+ })).data;
2000
+ return {
2001
+ success: true,
2002
+ data: {
2003
+ number: issue.number,
2004
+ url: issue.html_url
2005
+ }
2006
+ };
2007
+ }
2008
+ case "create-pr": {
2009
+ const title = args.title;
2010
+ const head = args.head;
2011
+ const base = args.base;
2012
+ if (!title || !head || !base) return {
2013
+ success: false,
2014
+ error: {
2015
+ code: "VALIDATION_ERROR",
2016
+ message: "title, head, and base are required"
2017
+ }
2018
+ };
2019
+ if (head === base) return {
2020
+ success: false,
2021
+ error: {
2022
+ code: "VALIDATION_ERROR",
2023
+ message: "head and base must be different branches"
2024
+ }
2025
+ };
2026
+ const pr = (await this.client.request("POST /repos/{owner}/{repo}/pulls", {
2027
+ owner,
2028
+ repo,
2029
+ title,
2030
+ body: args.body ?? void 0,
2031
+ head,
2032
+ base,
2033
+ draft: args.draft ?? false
2034
+ })).data;
2035
+ return {
2036
+ success: true,
2037
+ data: {
2038
+ number: pr.number,
2039
+ url: pr.html_url
2040
+ }
2041
+ };
2042
+ }
2043
+ case "create-file": {
2044
+ const filePath = args.path;
2045
+ if (!filePath) return {
2046
+ success: false,
2047
+ error: {
2048
+ code: "VALIDATION_ERROR",
2049
+ message: "path is required"
2050
+ }
2051
+ };
2052
+ if (filePath.includes("..") || filePath.startsWith("/")) return {
2053
+ success: false,
2054
+ error: {
2055
+ code: "VALIDATION_ERROR",
2056
+ message: "path must not contain '..' or start with '/'"
2057
+ }
2058
+ };
2059
+ const content = args.content ?? "";
2060
+ const message = args.message ?? `Create ${filePath}`;
2061
+ const result = (await this.client.request("PUT /repos/{owner}/{repo}/contents/{path}", {
2062
+ owner,
2063
+ repo,
2064
+ path: filePath,
2065
+ message,
2066
+ content: Buffer.from(content).toString("base64"),
2067
+ branch: args.branch ?? void 0
2068
+ })).data;
2069
+ const fileInfo = result.content;
2070
+ const commitInfo = result.commit;
2071
+ return {
2072
+ success: true,
2073
+ data: {
2074
+ sha: fileInfo?.sha,
2075
+ commitSha: commitInfo?.sha
2076
+ }
2077
+ };
2078
+ }
2079
+ case "update-file": {
2080
+ const filePath = args.path;
2081
+ const sha = args.sha;
2082
+ if (!filePath) return {
2083
+ success: false,
2084
+ error: {
2085
+ code: "VALIDATION_ERROR",
2086
+ message: "path is required"
2087
+ }
2088
+ };
2089
+ if (!sha) return {
2090
+ success: false,
2091
+ error: {
2092
+ code: "VALIDATION_ERROR",
2093
+ message: "sha is required (optimistic lock)"
2094
+ }
2095
+ };
2096
+ if (filePath.includes("..") || filePath.startsWith("/")) return {
2097
+ success: false,
2098
+ error: {
2099
+ code: "VALIDATION_ERROR",
2100
+ message: "path must not contain '..' or start with '/'"
2101
+ }
2102
+ };
2103
+ const content = args.content ?? "";
2104
+ const message = args.message ?? `Update ${filePath}`;
2105
+ const result = (await this.client.request("PUT /repos/{owner}/{repo}/contents/{path}", {
2106
+ owner,
2107
+ repo,
2108
+ path: filePath,
2109
+ message,
2110
+ content: Buffer.from(content).toString("base64"),
2111
+ sha,
2112
+ branch: args.branch ?? void 0
2113
+ })).data;
2114
+ const fileInfo = result.content;
2115
+ const commitInfo = result.commit;
2116
+ return {
2117
+ success: true,
2118
+ data: {
2119
+ sha: fileInfo?.sha,
2120
+ commitSha: commitInfo?.sha
2121
+ }
2122
+ };
2123
+ }
2124
+ default: return {
2125
+ success: false,
2126
+ error: {
2127
+ code: "NOT_FOUND",
2128
+ message: `Unknown action: ${actionName}`
2129
+ }
2130
+ };
2131
+ }
2132
+ }
2133
+ async execIssueAction(issueNumber, actionName, args) {
2134
+ const owner = this.options.owner;
2135
+ const repo = this.options.repo;
2136
+ switch (actionName) {
2137
+ case "close-issue": return {
2138
+ success: true,
2139
+ data: { state: (await this.client.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
2140
+ owner,
2141
+ repo,
2142
+ issue_number: issueNumber,
2143
+ state: "closed",
2144
+ state_reason: args.reason ?? void 0
2145
+ })).data.state }
2146
+ };
2147
+ case "reopen-issue": return {
2148
+ success: true,
2149
+ data: { state: (await this.client.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
2150
+ owner,
2151
+ repo,
2152
+ issue_number: issueNumber,
2153
+ state: "open"
2154
+ })).data.state }
2155
+ };
2156
+ case "add-comment": {
2157
+ const body = args.body;
2158
+ if (!body) return {
2159
+ success: false,
2160
+ error: {
2161
+ code: "VALIDATION_ERROR",
2162
+ message: "body is required"
2163
+ }
2164
+ };
2165
+ return {
2166
+ success: true,
2167
+ data: { id: (await this.client.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
2168
+ owner,
2169
+ repo,
2170
+ issue_number: issueNumber,
2171
+ body
2172
+ })).data.id }
2173
+ };
2174
+ }
2175
+ case "add-label": {
2176
+ const label = args.label;
2177
+ if (!label) return {
2178
+ success: false,
2179
+ error: {
2180
+ code: "VALIDATION_ERROR",
2181
+ message: "label is required"
2182
+ }
2183
+ };
2184
+ if (label.length > 200) return {
2185
+ success: false,
2186
+ error: {
2187
+ code: "VALIDATION_ERROR",
2188
+ message: "label name exceeds maximum length (200)"
2189
+ }
2190
+ };
2191
+ await this.client.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", {
2192
+ owner,
2193
+ repo,
2194
+ issue_number: issueNumber,
2195
+ labels: [label]
2196
+ });
2197
+ return {
2198
+ success: true,
2199
+ data: { label }
2200
+ };
2201
+ }
2202
+ case "remove-label": {
2203
+ const label = args.label;
2204
+ if (!label) return {
2205
+ success: false,
2206
+ error: {
2207
+ code: "VALIDATION_ERROR",
2208
+ message: "label is required"
2209
+ }
2210
+ };
2211
+ try {
2212
+ await this.client.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", {
2213
+ owner,
2214
+ repo,
2215
+ issue_number: issueNumber,
2216
+ name: label
2217
+ });
2218
+ return {
2219
+ success: true,
2220
+ data: { label }
2221
+ };
2222
+ } catch (error) {
2223
+ if (error?.status === 404) return {
2224
+ success: false,
2225
+ error: {
2226
+ code: "NOT_FOUND",
2227
+ message: `Label "${label}" not found`
2228
+ }
2229
+ };
2230
+ throw error;
2231
+ }
2232
+ }
2233
+ default: return {
2234
+ success: false,
2235
+ error: {
2236
+ code: "NOT_FOUND",
2237
+ message: `Unknown issue action: ${actionName}`
2238
+ }
2239
+ };
2240
+ }
2241
+ }
2242
+ async execPRAction(prNumber, actionName, args) {
2243
+ const owner = this.options.owner;
2244
+ const repo = this.options.repo;
2245
+ switch (actionName) {
2246
+ case "merge-pr": {
2247
+ const method = args.method ?? "merge";
2248
+ const result = (await this.client.request("PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge", {
2249
+ owner,
2250
+ repo,
2251
+ pull_number: prNumber,
2252
+ merge_method: method,
2253
+ commit_title: args.commitTitle ?? void 0,
2254
+ commit_message: args.commitMessage ?? void 0
2255
+ })).data;
2256
+ return {
2257
+ success: true,
2258
+ data: {
2259
+ merged: result.merged,
2260
+ sha: result.sha
2261
+ }
2262
+ };
2263
+ }
2264
+ case "request-review": {
2265
+ const reviewers = args.reviewers;
2266
+ if (!reviewers || reviewers.length === 0) return {
2267
+ success: false,
2268
+ error: {
2269
+ code: "VALIDATION_ERROR",
2270
+ message: "reviewers is required"
2271
+ }
2272
+ };
2273
+ await this.client.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", {
2274
+ owner,
2275
+ repo,
2276
+ pull_number: prNumber,
2277
+ reviewers
2278
+ });
2279
+ return {
2280
+ success: true,
2281
+ data: { reviewers }
2282
+ };
2283
+ }
2284
+ case "add-comment": return this.execIssueAction(prNumber, "add-comment", args);
2285
+ case "add-label": return this.execIssueAction(prNumber, "add-label", args);
2286
+ case "remove-label": return this.execIssueAction(prNumber, "remove-label", args);
2287
+ default: return {
2288
+ success: false,
2289
+ error: {
2290
+ code: "NOT_FOUND",
2291
+ message: `Unknown PR action: ${actionName}`
2292
+ }
2293
+ };
2294
+ }
2295
+ }
2296
+ /**
2297
+ * Handle errors from action execution.
2298
+ * Sanitizes error messages to prevent token leaks.
2299
+ */
2300
+ handleExecError(error) {
2301
+ const status = error?.status;
2302
+ const sanitized = (error instanceof Error ? error.message : String(error)).replace(/ghp_[a-zA-Z0-9]+/g, "[REDACTED]");
2303
+ if (status === 403) return {
2304
+ success: false,
2305
+ error: {
2306
+ code: "FORBIDDEN",
2307
+ message: `Insufficient permissions. Check that your token has the required access.`
2308
+ }
2309
+ };
2310
+ if (status === 404) return {
2311
+ success: false,
2312
+ error: {
2313
+ code: "NOT_FOUND",
2314
+ message: `Resource not found: ${sanitized}`
2315
+ }
2316
+ };
2317
+ if (status === 405 || status === 409) return {
2318
+ success: false,
2319
+ error: {
2320
+ code: "CONFLICT",
2321
+ message: sanitized
2322
+ }
2323
+ };
2324
+ if (status === 422) return {
2325
+ success: false,
2326
+ error: {
2327
+ code: "VALIDATION_ERROR",
2328
+ message: sanitized
2329
+ }
2330
+ };
2331
+ return {
2332
+ success: false,
2333
+ error: {
2334
+ code: "INTERNAL_ERROR",
2335
+ message: sanitized
2336
+ }
2337
+ };
2338
+ }
1121
2339
  };
1122
2340
 
1123
2341
  //#endregion