Skip to content

Commit 9649a58

Browse files
authored
Merge pull request #182 from MetaCell/feature/ILEX-160
Some missing features for release 1
2 parents 588da6d + 5ce944f commit 9649a58

46 files changed

Lines changed: 2542 additions & 473 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eslintrc.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,10 @@ module.exports = {
2626
files: ['test/**/*.js'],
2727
env: { jest: true, node: true, commonjs: true },
2828
},
29+
{
30+
// Playwright suite: config + specs run in node, not the browser.
31+
files: ['tests/**/*.js'],
32+
env: { node: true },
33+
},
2934
],
3035
}

nginx/default.conf

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ server {
9898
add_header Access-Control-Expose-Headers X-Redirect-Location always;
9999
}
100100

101+
# Merge request creation: JSON body in, 303 → 200 + JSON out (same as entity-new).
102+
# Must come before the generic /priv/ location so it wins the regex match.
103+
location ~ ^/([^/]+)/priv/pull-new$ {
104+
proxy_pass https://uri.olympiangods.org;
105+
proxy_set_header Host uri.olympiangods.org;
106+
proxy_set_header X-Real-IP $remote_addr;
107+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
108+
proxy_set_header X-Forwarded-Proto $scheme;
109+
proxy_set_header Content-Type application/json;
110+
proxy_set_header Authorization $http_authorization;
111+
proxy_set_header Cookie $http_cookie;
112+
proxy_ssl_verify off;
113+
114+
proxy_intercept_errors on;
115+
error_page 303 = @handle_entity_redirect;
116+
117+
add_header Access-Control-Allow-Origin $http_origin always;
118+
add_header Access-Control-Allow-Credentials true always;
119+
add_header Access-Control-Expose-Headers X-Redirect-Location always;
120+
}
121+
101122
location ~ ^/([^/]+)/priv/(.*) {
102123
proxy_pass https://uri.olympiangods.org;
103124
proxy_set_header Host uri.olympiangods.org;
@@ -223,6 +244,23 @@ server {
223244
add_header Access-Control-Allow-Credentials true always;
224245
}
225246

247+
# Merge request listing / single record / merge op: /<group>/pulls[/<id>[/ops/merge]]
248+
location ~ ^/[^/]+/pulls(/.*)?$ {
249+
proxy_pass https://uri.olympiangods.org;
250+
proxy_set_header Host uri.olympiangods.org;
251+
proxy_set_header X-Real-IP $remote_addr;
252+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
253+
proxy_set_header X-Forwarded-Proto $scheme;
254+
proxy_set_header Authorization $http_authorization;
255+
proxy_set_header Cookie $http_cookie;
256+
proxy_ssl_verify off;
257+
258+
# CORS headers
259+
add_header Access-Control-Allow-Origin $http_origin always;
260+
add_header Access-Control-Allow-Credentials true always;
261+
add_header Access-Control-Expose-Headers X-Redirect-Location always;
262+
}
263+
226264
location ~ ^/[^/]+/ontologies$ {
227265
proxy_pass https://uri.olympiangods.org;
228266
proxy_set_header Host uri.olympiangods.org;

src/App.jsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import CurieEditor from "./components/CurieEditor";
2121
import SearchResults from "./components/SearchResults";
2222
import Organizations from "./components/organizations";
2323
import SingleTermView from "./components/SingleTermView";
24+
import PullRequestView from "./components/PullRequest";
2425
import OntologyPage from "./components/CellCards/OntologyPage";
2526
import OntologyGridPage from "./components/CellCards/OntologyGridPage";
2627
import OntologyBrowsePage from "./components/CellCards/OntologyBrowsePage";
@@ -234,6 +235,16 @@ function MainContent() {
234235
</ProtectedRoute>
235236
}
236237
/>
238+
{/* /<group>/pulls/<id> is the backend's own (proxied) address for the
239+
record, so the in-app view lives alongside it at /pull-requests/. */}
240+
<Route
241+
path="/:group/pull-requests/:pullId"
242+
element={
243+
<PageContainer>
244+
<PullRequestView />
245+
</PageContainer>
246+
}
247+
/>
237248
<Route
238249
path="/:group/:term/versions/:versionHash"
239250
element={

src/api/endpoints/apiService.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { API_CONFIG } from "../../config";
33
import termParser from "../../parsers/termParser";
44
import { jsonldToTriplesAndEdges, PART_OF_IRI } from '../../parsers/hierarchies-parser'
55
import { buildPredicateGroupsForFocus } from "../../parsers/predicateParser";
6+
import versionToTerm from "../../parsers/variantParser";
67
import { 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

629630
export 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(/\/pulls\/(\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
};
Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import ListItem from "./ListItem";
22
import PropTypes from "prop-types";
3+
import { useNavigate } from "react-router-dom";
34
import { Box, List } from "@mui/material";
45

56
import { vars } from "../../../theme/variables";
67
const { gray50 } = vars;
78

8-
const ListTerms = ({ entries }) => {
9+
const ListTerms = ({ entries, viewerGroup }) => {
10+
const navigate = useNavigate();
11+
12+
// The record's own url doubles as the in-app route (/<group>/pulls/<id>), which renders the
13+
// delta rather than the raw JSON the backend serves at that address.
914
const onRequestClick = (e, entry) => {
10-
console.log("Opening change request + ", entry);
15+
if (entry?.path) navigate(entry.path);
1116
};
17+
// Full width: the rows line up with the pagination below them, whose "Previous"/"Next"
18+
// sit 1.5rem inside the section on either side (1rem root + 0.5rem item padding).
1219
return (
13-
<List disablePadding width={1} sx={{ maxWidth: "80%" }}>
20+
<List disablePadding sx={{ width: 1 }}>
1421
{entries.map((entry, index) => (
1522
<Box
16-
key={`${entry.author}_${index}`}
23+
key={entry.id || index}
1724
sx={{
1825
paddingLeft: "1rem",
1926
borderRadius: "0.375rem",
@@ -25,7 +32,7 @@ const ListTerms = ({ entries }) => {
2532
},
2633
}}
2734
>
28-
<ListItem entry={entry} onRequestClick={onRequestClick} />
35+
<ListItem entry={entry} onRequestClick={onRequestClick} viewerGroup={viewerGroup} />
2936
</Box>
3037
))}
3138
</List>
@@ -34,6 +41,7 @@ const ListTerms = ({ entries }) => {
3441

3542
ListTerms.propTypes = {
3643
entries: PropTypes.array,
44+
viewerGroup: PropTypes.string,
3745
};
3846

3947
export default ListTerms;

0 commit comments

Comments
 (0)