@@ -3,6 +3,7 @@ import { API_CONFIG } from "../../config";
33import termParser from "../../parsers/termParser" ;
44import { jsonldToTriplesAndEdges , PART_OF_IRI } from '../../parsers/hierarchies-parser'
55import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser" ;
6+ import versionToTerm from "../../parsers/variantParser" ;
67import { termUriMappingPath } from "../../components/CellCards/config/gridConfig" ;
78
89// Error enriched with the queried URL + the backend's message, so the UI can
@@ -628,4 +629,222 @@ export const getTermHierarchies = async ({
628629
629630export const checkPotentialMatches = async ( group : string , data : any ) => {
630631 return createPostRequest < any , any > ( `/${ group } ${ API_CONFIG . REAL_API . CHECK_ENTITY } ` , { "Content-Type" : "application/json" } ) ( data ) ;
632+ } ;
633+
634+ /* ------------------------------------------------------------------ *
635+ * Pull requests (variant → curated merge proposals)
636+ * ------------------------------------------------------------------ */
637+
638+ export interface PullRequestResult {
639+ ok : boolean ;
640+ status : number ;
641+ /** URL of the created pull request, e.g. "http://host/base/pulls/3" */
642+ pullUrl ?: string ;
643+ /** Numeric id parsed out of pullUrl */
644+ pullId ?: string ;
645+ /** Human readable failure reason, set when ok === false */
646+ error ?: string ;
647+ }
648+
649+ // The backend answers create with 303 + Location. Dev (vite) and prod (nginx) proxies both
650+ // intercept it: the location arrives either as the X-Redirect-Location header or as a JSON
651+ // body ({location} from the proxies, {redirect} when the backend answers Accept: json itself).
652+ const readRedirectLocation = ( resp : Response , raw : string ) : string => {
653+ const header = resp . headers . get ( 'x-redirect-location' ) ;
654+ if ( header ) return header ;
655+ try {
656+ const json = JSON . parse ( raw ) ;
657+ return json ?. location || json ?. redirect || '' ;
658+ } catch {
659+ return '' ;
660+ }
661+ } ;
662+
663+ // Backend statuses documented for pull-new; anything else falls back to the response body.
664+ const PULL_NEW_ERRORS : Record < number , string > = {
665+ 401 : 'You do not have permission to open a merge request from this fork.' ,
666+ 409 : 'There is nothing to merge: this variant does not differ from the curated term.' ,
667+ 422 : 'The merge request is missing required information or it is invalid.' ,
668+ } ;
669+
670+ /**
671+ * Open a merge request proposing the changes made in `groupFrom`'s variant of `termId`
672+ * against the curated (`groupTo`, normally "base") version.
673+ *
674+ * POST /<group-from>/priv/pull-new — `group-from` must match the group in the path.
675+ */
676+ export const createPullRequest = async ( {
677+ groupFrom,
678+ groupTo = 'base' ,
679+ termId,
680+ perspectiveFrom,
681+ perspectiveTo,
682+ } : {
683+ groupFrom : string ;
684+ groupTo ?: string ;
685+ termId : string ;
686+ perspectiveFrom ?: string ;
687+ perspectiveTo ?: string ;
688+ } ) : Promise < PullRequestResult > => {
689+ const endpoint = `/${ groupFrom } ${ API_CONFIG . REAL_API . PULL_NEW } ` ;
690+ const body : Record < string , string > = {
691+ subject : `${ API_CONFIG . INTERLEX_URL } /${ groupFrom } /${ termId } ` ,
692+ 'group-from' : groupFrom ,
693+ 'group-to' : groupTo ,
694+ } ;
695+ // Optional; the backend defaults them to the group names.
696+ if ( perspectiveFrom ) body [ 'perspective-name-from' ] = perspectiveFrom ;
697+ if ( perspectiveTo ) body [ 'perspective-name-to' ] = perspectiveTo ;
698+
699+ try {
700+ const resp = await fetch ( endpoint , {
701+ method : 'POST' ,
702+ headers : { 'Content-Type' : 'application/json' , Accept : 'application/json' } ,
703+ credentials : 'include' ,
704+ body : JSON . stringify ( body ) ,
705+ } ) ;
706+
707+ let raw = '' ;
708+ try { raw = await resp . text ( ) ; } catch { /* body not readable */ }
709+
710+ const location = readRedirectLocation ( resp , raw ) ;
711+ // 303 is the success path; the proxies rewrite it to 200 + JSON, so accept both.
712+ if ( location ) {
713+ return {
714+ ok : true ,
715+ status : resp . status ,
716+ pullUrl : location ,
717+ pullId : location . match ( / \/ p u l l s \/ ( \d + ) / ) ?. [ 1 ] ,
718+ } ;
719+ }
720+
721+ if ( resp . ok ) return { ok : true , status : resp . status } ;
722+
723+ return {
724+ ok : false ,
725+ status : resp . status ,
726+ error : PULL_NEW_ERRORS [ resp . status ] || raw || `Request failed with HTTP ${ resp . status } .` ,
727+ } ;
728+ } catch ( error : any ) {
729+ return { ok : false , status : 0 , error : error ?. message || String ( error ) } ;
730+ }
731+ } ;
732+
733+ /**
734+ * Fetch one side of a merge request (`from-variant-uri` / `to-variant-uri`) and parse it into
735+ * the Term shape the delta panels render.
736+ *
737+ * The record carries absolute backend URIs; only the path is used so the request goes through
738+ * the app's own origin (and therefore the /versions proxy) instead of cross-origin.
739+ */
740+ export const getVariantTerm = async ( variantUri : string , termId ?: string ) => {
741+ if ( ! variantUri ) return null ;
742+
743+ let path = variantUri ;
744+ try {
745+ path = new URL ( variantUri ) . pathname ;
746+ } catch {
747+ /* already a path */
748+ }
749+
750+ const jsonld = await createGetRequest < any , any > ( path , "application/ld+json" ) ( ) ;
751+ return versionToTerm ( jsonld , termId ) ;
752+ } ;
753+
754+ /**
755+ * GET /<group>/priv/role — the signed-in user's role in `group`.
756+ * Used to decide whether the merge controls apply; returns null when there is no session or
757+ * the user holds no role there (both answer 401).
758+ */
759+ export const getUserRoleForGroup = async ( group : string ) => {
760+ try {
761+ return await createGetRequest < any , any > ( `/${ group } ${ API_CONFIG . REAL_API . USER_ROLE } ` , "application/json" ) ( ) ;
762+ } catch ( error : any ) {
763+ if ( error ?. response ?. status !== 401 ) console . warn ( `getUserRoleForGroup(${ group } ) failed:` , error ) ;
764+ return null ;
765+ }
766+ } ;
767+
768+ /** GET /<group>/pulls — every merge request that group is involved in. */
769+ export const getPullRequests = async ( group : string ) => {
770+ return createGetRequest < any , any > ( `/${ group } ${ API_CONFIG . REAL_API . PULLS } ` , "application/json" ) ( ) ;
771+ } ;
772+
773+ /** GET /<group>/pulls/<pullId> — a single merge request, with its status log. */
774+ export const getPullRequest = async ( group : string , pullId : string ) => {
775+ return createGetRequest < any , any > ( `/${ group } ${ API_CONFIG . REAL_API . PULLS } /${ pullId } ` , "application/json" ) ( ) ;
776+ } ;
777+
778+ /**
779+ * Every merge request the backend holds, found by walking the id sequence.
780+ *
781+ * `/<group>/pulls` only lists requests *into* that group, so a user's own outgoing requests are
782+ * invisible from their group and nothing enumerates them — but a single record is readable from
783+ * any group path (the path group is not cross-checked) and ids are one global sequence, so
784+ * walking it is the only way to see the whole picture.
785+ *
786+ * Walks in batches and stops as soon as a whole batch comes back empty; `truncated` reports
787+ * hitting `maxId` first, so a caller can say so rather than quietly showing a partial list.
788+ */
789+ export const listAllPullRequests = async ( {
790+ group = 'base' ,
791+ maxId = 200 ,
792+ batchSize = 10 ,
793+ } : { group ?: string ; maxId ?: number ; batchSize ?: number } = { } ) : Promise < { records : any [ ] ; truncated : boolean } > => {
794+ const records : any [ ] = [ ] ;
795+
796+ for ( let start = 1 ; start <= maxId ; start += batchSize ) {
797+ const ids = Array . from (
798+ { length : Math . min ( batchSize , maxId - start + 1 ) } ,
799+ ( _ , offset ) => start + offset
800+ ) ;
801+ const batch = await Promise . all (
802+ // A 404 is the end of the sequence (or a gap in it), not a failure.
803+ ids . map ( id => getPullRequest ( group , String ( id ) ) . catch ( ( ) => null ) )
804+ ) ;
805+ const found = batch . filter ( Boolean ) ;
806+ records . push ( ...found ) ;
807+ if ( ! found . length ) return { records, truncated : false } ;
808+ }
809+
810+ return { records, truncated : true } ;
811+ } ;
812+
813+ /**
814+ * POST /<group>/pulls/<pullId>/ops/merge — accept a merge request.
815+ * `<group>` must be the *to* group, and the identities come straight off the GET.
816+ */
817+ export const mergePullRequest = async ( {
818+ group,
819+ pullId,
820+ expectedFromIdentity,
821+ expectedToIdentity,
822+ } : {
823+ group : string ;
824+ pullId : string ;
825+ expectedFromIdentity : string ;
826+ expectedToIdentity : string ;
827+ } ) : Promise < { ok : boolean ; status : number ; error ?: string } > => {
828+ const endpoint = `/${ group } ${ API_CONFIG . REAL_API . PULLS } /${ pullId } /ops/merge` ;
829+ try {
830+ const resp = await fetch ( endpoint , {
831+ method : 'POST' ,
832+ headers : { 'Content-Type' : 'application/json' , Accept : 'application/json' } ,
833+ credentials : 'include' ,
834+ body : JSON . stringify ( {
835+ 'expected-from-identity' : expectedFromIdentity ,
836+ 'expected-to-identity' : expectedToIdentity ,
837+ } ) ,
838+ } ) ;
839+ if ( resp . ok ) return { ok : true , status : resp . status } ;
840+ let raw = '' ;
841+ try { raw = await resp . text ( ) ; } catch { /* body not readable */ }
842+ const messages : Record < number , string > = {
843+ 401 : 'You do not have permission to merge this request.' ,
844+ 422 : 'The merge request is missing the expected identities.' ,
845+ } ;
846+ return { ok : false , status : resp . status , error : messages [ resp . status ] || raw || `HTTP ${ resp . status } ` } ;
847+ } catch ( error : any ) {
848+ return { ok : false , status : 0 , error : error ?. message || String ( error ) } ;
849+ }
631850} ;
0 commit comments